a04f62111174
errors: expanded translation table (25 patterns + home manager), 3-layer pipeline, warning extraction; output: buildError + buildWarnings; exec: capture nixos-rebuild stderr; main: route BuildFailed through pipeline; bump v3.2.2
- time
- 2026-06-12 18:28 UTC · 4 months ago
- machine
- koh2
- agent
- claude-sonnet-4-6
- session
- error wrapping pipeline
- parent
- 636dfd11f624
↓ nina-a04f6211.tar.gzatlas at this save
files changed7 files
VERSION+1 −1build.zig.zon+1 −1src/commands.zig+9 −10src/errors.zig+593 −28src/exec.zig+38 −8src/main.zig+22 −10src/output.zig+46 −0build.zig.zon+1 −1
@@ -9,7 +9,7 @@99 .name = .nina,1010 // This is a [Semantic Version](https://semver.org/).1111 // In a future version of Zig it will be used for package deduplication.12- .version = "3.2.1",12+ .version = "3.2.2",1313 // Together with name, this represents a globally unique package1414 // identifier. This field is generated by the Zig toolchain when the1515 // package is first created, and then *never changes*. This allowssrc/commands.zig+9 −10
@@ -79,13 +79,11 @@7979 8080 const code = exec.nixosRebuildSwitch(ctx.io, &ctx.machine, ctx.gpa) catch |e| {8181 exec.markApplyFailed(ctx.io, ctx.environ);82- output.applyFailed("");8382 return e;8483 };8584 8685 if (code != 0) {8786 exec.markApplyFailed(ctx.io, ctx.environ);88- output.applyFailed("");8987 return error.BuildFailed;9088 }9189 @@ -94,6 +92,13 @@9492 const gen = exec.getCurrentGeneration(ctx.gpa, ctx.io, &ctx.machine) catch 0;9593 const after_failure = exec.takeApplyFailed(ctx.io, ctx.environ);9694 output.applyDone(gen, elapsed, after_failure);95+ // Show any nix warnings collected during the build (dim yellow, after success line).96+ const warnings = errors.extractWarnings(ctx.gpa, errors.getBuildStderr()) catch &.{};97+ defer {98+ for (warnings) |w| ctx.gpa.free(w);99+ ctx.gpa.free(warnings);100+ }101+ if (warnings.len > 0) output.buildWarnings(warnings);97102 runPostHook(ctx, "post-apply");98103 logAction(ctx, "apply", gen);99104 }@@ -101,10 +106,7 @@101106 pub fn check(ctx: Ctx) !void {102107 output.checkStart(ctx.machine.name);103108 const code = try exec.nixosRebuildBuild(ctx.io, &ctx.machine, ctx.gpa);104- if (code != 0) {105- output.checkFailed("");106- return error.BuildFailed;107- }109+ if (code != 0) return error.BuildFailed;108110 output.checkOk();109111 }110112 @@ -1201,10 +1203,7 @@12011203 fn homeCheck(ctx: Ctx) !void {12021204 output.homeCheckStart(ctx.machine.name);12031205 const code = try exec.homeManagerBuild(ctx.io, &ctx.machine, ctx.gpa);1204- if (code != 0) {1205- output.checkFailed("");1206- return error.BuildFailed;1207- }1206+ if (code != 0) return error.BuildFailed;12081207 output.homeCheckOk();12091208 }12101209 src/errors.zig+593 −28
@@ -1,5 +1,7 @@11 const std = @import("std");22 3+// ─── Public error types ───────────────────────────────────────────────────────4+35 pub const NinaError = error{46 PackageNotFound,57 MachineNotFound,@@ -13,13 +15,14 @@1315 OutOfMemory,1416 ProcessFailed,1517 Internal,16- // common IO errors that bubble up1718 FileNotFound,1819 AccessDenied,1920 SystemResources,2021 Unexpected,2122 };2223 24+// ─── ErrorInfo (used for non-build errors) ───────────────────────────────────25+2326 pub var error_info: ErrorInfo = .{};2427 2528 pub const ErrorInfo = struct {@@ -67,29 +70,6 @@6770 }6871 };6972 70-const BuildPattern = struct { pattern: []const u8, message: []const u8, suggestion: ?[]const u8 = null };71-const build_patterns = [_]BuildPattern{72- .{ .pattern = "undefined variable", .message = "package not found in nixpkgs", .suggestion = "nina search <name>" },73- .{ .pattern = "has been renamed to", .message = "package was renamed — update your config" },74- .{ .pattern = "attribute '", .message = "package doesn't exist in this nixpkgs version" },75- .{ .pattern = "collision between", .message = "two packages are claiming the same file — remove one" },76- .{ .pattern = "infinite recursion", .message = "circular reference in configuration.nix — check recent edits" },77- .{ .pattern = "No space left on device", .message = "disk is full", .suggestion = "nina clean" },78- .{ .pattern = "hash mismatch", .message = "download checksum mismatch — try again" },79- .{ .pattern = "cannot write lock file", .message = "can't write flake.lock", .suggestion = "use --no-write-lock-file" },80- .{ .pattern = "SSL certificate", .message = "ssl error — check your network" },81- .{ .pattern = "permission denied", .message = "permission denied — try with sudo" },82-};83-84-pub fn translateBuildError(stderr: []const u8) struct { message: []const u8, suggestion: ?[]const u8 } {85- for (build_patterns) |p| {86- if (std.mem.indexOf(u8, stderr, p.pattern) != null) {87- return .{ .message = p.message, .suggestion = p.suggestion };88- }89- }90- return .{ .message = stderr, .suggestion = null };91-}92-9373 pub fn staticMessage(err: anyerror) []const u8 {9474 return switch (err) {9575 error.ConfigNotFound => "~/.nina.conf not found",@@ -107,11 +87,596 @@10787 };10888 }10989 110-test "build error translation" {111- const r = translateBuildError("error: undefined variable 'pkgs.notexist'");112- try std.testing.expectEqualStrings("package not found in nixpkgs", r.message);90+// ─── Build stderr capture ─────────────────────────────────────────────────────91+//92+// nixosRebuildRun (and home manager commands) capture stderr into this buffer93+// before returning so translateError can read it in reportError.94+95+var build_stderr_buf: [65536]u8 = undefined;96+var build_stderr_len: usize = 0;97+98+pub fn setBuildStderr(stderr: []const u8) void {99+ const n = @min(stderr.len, build_stderr_buf.len);100+ @memcpy(build_stderr_buf[0..n], stderr[0..n]);101+ build_stderr_len = n;113102 }114103 115-test "static message" {104+pub fn getBuildStderr() []const u8 {105+ return build_stderr_buf[0..build_stderr_len];106+}107+108+// ─── TranslatedError ──────────────────────────────────────────────────────────109+110+pub const TranslatedError = struct {111+ title: []const u8,112+ body: []const u8,113+ // May contain multiple lines separated by '\n'. buildError prints the first114+ // with "-> " and subsequent lines with " " indentation.115+ suggestion: []const u8,116+ is_system: bool = false,117+ // Dim location shown below body: "configuration.nix line 47"118+ location: ?[]const u8 = null,119+};120+121+// ─── Helpers ──────────────────────────────────────────────────────────────────122+123+// Return the single-quoted content at the first occurrence in s.124+fn extractQuoted(s: []const u8) ?[]const u8 {125+ const a = std.mem.indexOf(u8, s, "'") orelse return null;126+ const b = std.mem.indexOfPos(u8, s, a + 1, "'") orelse return null;127+ const v = s[a + 1 .. b];128+ return if (v.len > 0) v else null;129+}130+131+// Return the first line in text that contains pattern.132+fn lineWith(text: []const u8, pattern: []const u8) ?[]const u8 {133+ var it = std.mem.splitScalar(u8, text, '\n');134+ while (it.next()) |line| {135+ if (std.mem.indexOf(u8, line, pattern) != null) return line;136+ }137+ return null;138+}139+140+// Extract the innermost error: last "error: ..." line before "For full logs".141+fn innermostError(text: []const u8) ?[]const u8 {142+ var last: ?[]const u8 = null;143+ var it = std.mem.splitScalar(u8, text, '\n');144+ while (it.next()) |line| {145+ const t = std.mem.trim(u8, line, " \t");146+ if (std.mem.startsWith(u8, t, "error:")) last = t;147+ if (std.mem.startsWith(u8, t, "For full logs")) break;148+ }149+ return last;150+}151+152+fn isUserPath(path: []const u8) bool {153+ return std.mem.startsWith(u8, path, "/etc/nixos/") or154+ std.mem.startsWith(u8, path, "/home/") or155+ std.mem.startsWith(u8, path, "~");156+}157+158+fn justFilename(path: []const u8) []const u8 {159+ return if (std.mem.lastIndexOf(u8, path, "/")) |i| path[i + 1 ..] else path;160+}161+162+fn findLocationInLine(allocator: std.mem.Allocator, line: []const u8) ?[]const u8 {163+ const nix_pos = std.mem.indexOf(u8, line, ".nix:") orelse return null;164+ const before = line[0 .. nix_pos + 4]; // includes ".nix"165+ const rest = line[nix_pos + 5..]; // "47:3" or "47"166+ const colon_or_end = std.mem.indexOf(u8, rest, ":") orelse rest.len;167+ const line_num_str = rest[0..colon_or_end];168+ if (line_num_str.len == 0 or line_num_str.len > 6) return null;169+ for (line_num_str) |ch| if (ch < '0' or ch > '9') return null;170+171+ // Extract the actual path from context (after " /" or after "(")172+ const full_path = blk: {173+ if (std.mem.indexOf(u8, before, " /")) |sp| break :blk before[sp + 1 ..];174+ if (std.mem.indexOf(u8, before, "(")) |paren| break :blk before[paren + 1 ..];175+ break :blk before;176+ };177+178+ const display = if (isUserPath(full_path)) full_path else justFilename(full_path);179+ return std.fmt.allocPrint(allocator, "{s} line {s}", .{ display, line_num_str }) catch null;180+}181+182+pub fn extractLocation(allocator: std.mem.Allocator, text: []const u8) ?[]const u8 {183+ var it = std.mem.splitScalar(u8, text, '\n');184+ while (it.next()) |line| {185+ if (findLocationInLine(allocator, line)) |loc| return loc;186+ }187+ return null;188+}189+190+// Find a /nix/store/xxx.drv path in stderr for the "nix log" suggestion.191+fn extractDrvPath(text: []const u8) ?[]const u8 {192+ var it = std.mem.splitScalar(u8, text, '\n');193+ while (it.next()) |line| {194+ if (std.mem.indexOf(u8, line, "/nix/store/")) |start| {195+ const after = line[start..];196+ if (std.mem.indexOf(u8, after, ".drv")) |end| {197+ return after[0 .. end + 4];198+ }199+ }200+ }201+ return null;202+}203+204+// ─── Layer 1: Known pattern matching ─────────────────────────────────────────205+//206+// 25 nixos-rebuild patterns + home manager patterns. First match wins.207+208+pub fn matchKnownPattern(allocator: std.mem.Allocator, stderr: []const u8) ?TranslatedError {209+ // ── nixos-rebuild patterns ────────────────────────────────────────────────210+211+ if (std.mem.indexOf(u8, stderr, "undefined variable '") != null) {212+ const var_name = blk: {213+ if (lineWith(stderr, "undefined variable '")) |l| {214+ const pos = std.mem.indexOf(u8, l, "undefined variable '") orelse break :blk "this package";215+ break :blk extractQuoted(l[pos..]) orelse "this package";216+ }217+ break :blk "this package";218+ };219+ return .{220+ .title = std.fmt.allocPrint(allocator, "'{s}' is not defined", .{var_name}) catch "package is not defined",221+ .body = std.fmt.allocPrint(allocator, "nixpkgs doesn't know about '{s}'.", .{var_name}) catch "nixpkgs doesn't know about this package.",222+ .suggestion = std.fmt.allocPrint(allocator, "nina search {s}", .{var_name}) catch "nina search",223+ .is_system = false,224+ };225+ }226+227+ if (std.mem.indexOf(u8, stderr, "has been renamed to") != null) {228+ const body = blk: {229+ if (lineWith(stderr, "has been renamed to")) |l| {230+ const old = extractQuoted(l) orelse break :blk "the package has a new name.";231+ if (std.mem.indexOf(u8, l, "renamed to")) |rp| {232+ const new_name = extractQuoted(l[rp..]) orelse break :blk std.fmt.allocPrint(allocator, "'{s}' has a new name.", .{old}) catch "the package has a new name.";233+ break :blk std.fmt.allocPrint(allocator, "'{s}' has been renamed to '{s}'.", .{ old, new_name }) catch "the package has a new name.";234+ }235+ break :blk std.fmt.allocPrint(allocator, "'{s}' has a new name.", .{old}) catch "the package has a new name.";236+ }237+ break :blk "the package has been renamed.";238+ };239+ return .{240+ .title = "package was renamed",241+ .body = body,242+ .suggestion = "update your config to use the new name",243+ .is_system = false,244+ };245+ }246+247+ if (std.mem.indexOf(u8, stderr, "attribute '") != null and248+ std.mem.indexOf(u8, stderr, "' missing") != null)249+ {250+ const attr = if (lineWith(stderr, "' missing")) |l| extractQuoted(l) orelse "this attribute" else "this attribute";251+ return .{252+ .title = std.fmt.allocPrint(allocator, "'{s}' not found", .{attr}) catch "attribute not found",253+ .body = "this attribute doesn't exist in your nixpkgs version.",254+ .suggestion = std.fmt.allocPrint(allocator, "check spelling or try: nina search {s}", .{attr}) catch "nina search",255+ .is_system = false,256+ };257+ }258+259+ if (std.mem.indexOf(u8, stderr, "collision between") != null) {260+ const is_hm = std.mem.indexOf(u8, stderr, "home-manager") != null;261+ return .{262+ .title = if (is_hm) "two home packages share a file" else "two packages share a file",263+ .body = if (is_hm)264+ "two home-manager packages are trying to provide the same file."265+ else266+ "two installed packages are trying to provide the same file.",267+ .suggestion = "remove one of the conflicting packages",268+ .is_system = false,269+ };270+ }271+272+ if (std.mem.indexOf(u8, stderr, "infinite recursion encountered") != null) {273+ return .{274+ .title = "circular reference in config",275+ .body = "two options reference each other indefinitely.",276+ .suggestion = "check your recent config edits",277+ .is_system = false,278+ };279+ }280+281+ if (std.mem.indexOf(u8, stderr, "No space left on device") != null) {282+ return .{283+ .title = "disk is full",284+ .body = "the nix store has no room to build.",285+ .suggestion = "nina clean",286+ .is_system = true,287+ };288+ }289+290+ if (std.mem.indexOf(u8, stderr, "hash mismatch") != null) {291+ return .{292+ .title = "download checksum mismatch",293+ .body = "the downloaded file doesn't match its expected hash.",294+ .suggestion = "try again — this is usually temporary",295+ .is_system = true,296+ };297+ }298+299+ if (std.mem.indexOf(u8, stderr, "cannot write lock file") != null) {300+ return .{301+ .title = "can't write flake.lock",302+ .body = "the lock file can't be updated in this directory.",303+ .suggestion = "run with --no-write-lock-file",304+ .is_system = false,305+ };306+ }307+308+ if (std.mem.indexOf(u8, stderr, "SSL certificate") != null) {309+ return .{310+ .title = "ssl error",311+ .body = "the connection couldn't be verified.",312+ .suggestion = "check your network connection",313+ .is_system = true,314+ };315+ }316+317+ if (std.mem.indexOf(u8, stderr, "permission denied") != null) {318+ return .{319+ .title = "permission denied",320+ .body = "nina needs elevated access for this operation.",321+ .suggestion = "check that sudo is available",322+ .is_system = true,323+ };324+ }325+326+ if (std.mem.indexOf(u8, stderr, "is not allowed by the 'allowed-uris'") != null) {327+ return .{328+ .title = "url not in allowed list",329+ .body = "this fetch is blocked by nix's allowed-uris setting.",330+ .suggestion = "add the url to nix.settings.allowed-uris in your config",331+ .is_system = false,332+ };333+ }334+335+ if (std.mem.indexOf(u8, stderr, "top-level") != null and336+ std.mem.indexOf(u8, stderr, "is not available") != null)337+ {338+ const pkg = if (lineWith(stderr, "is not available")) |l| extractQuoted(l) orelse "this package" else "this package";339+ return .{340+ .title = std.fmt.allocPrint(allocator, "'{s}' is not available", .{pkg}) catch "package is not available",341+ .body = "this package doesn't exist in your current nixpkgs.",342+ .suggestion = std.fmt.allocPrint(allocator, "nina search {s} to find the right name", .{pkg}) catch "nina search",343+ .is_system = false,344+ };345+ }346+347+ if (std.mem.indexOf(u8, stderr, "is marked as broken") != null) {348+ const pkg = if (lineWith(stderr, "is marked as broken")) |l| extractQuoted(l) orelse "this package" else "this package";349+ return .{350+ .title = "package is marked broken",351+ .body = std.fmt.allocPrint(allocator, "'{s}' is marked broken in nixpkgs.", .{pkg}) catch "this package is marked broken in nixpkgs.",352+ .suggestion = std.fmt.allocPrint(allocator, "check if there's a working alternative: nina search {s}", .{pkg}) catch "nina search",353+ .is_system = false,354+ };355+ }356+357+ if (std.mem.indexOf(u8, stderr, "requires unfree license") != null or358+ std.mem.indexOf(u8, stderr, "has an unfree license") != null)359+ {360+ const pkg = blk: {361+ if (lineWith(stderr, "unfree")) |l| break :blk extractQuoted(l) orelse "this package";362+ break :blk "this package";363+ };364+ return .{365+ .title = "package has an unfree license",366+ .body = std.fmt.allocPrint(allocator, "'{s}' uses a license that nix won't install by default.", .{pkg}) catch "this package uses an unfree license.",367+ .suggestion = "add nixpkgs.config.allowUnfree = true; to your config",368+ .is_system = false,369+ };370+ }371+372+ if (std.mem.indexOf(u8, stderr, "sandbox violation") != null) {373+ return .{374+ .title = "sandbox access denied",375+ .body = "a build tried to access something outside its sandbox.",376+ .suggestion = "check the package derivation for network/filesystem access",377+ .is_system = true,378+ };379+ }380+381+ if (std.mem.indexOf(u8, stderr, "output path") != null and382+ std.mem.indexOf(u8, stderr, "already exists") != null)383+ {384+ return .{385+ .title = "output path conflict",386+ .body = "a store path from a previous failed build is in the way.",387+ .suggestion = "nina store gc then try again",388+ .is_system = true,389+ };390+ }391+392+ if (std.mem.indexOf(u8, stderr, "cannot connect to daemon") != null) {393+ return .{394+ .title = "nix daemon is not running",395+ .body = "the nix daemon isn't available.",396+ .suggestion = "sudo systemctl start nix-daemon",397+ .is_system = true,398+ };399+ }400+401+ if (std.mem.indexOf(u8, stderr, "does not provide attribute") != null) {402+ const attr = blk: {403+ if (lineWith(stderr, "does not provide attribute")) |l| {404+ if (std.mem.indexOf(u8, l, "provide attribute")) |pos| {405+ break :blk extractQuoted(l[pos..]) orelse "the requested output";406+ }407+ }408+ break :blk "the requested output";409+ };410+ return .{411+ .title = "flake attribute not found",412+ .body = std.fmt.allocPrint(allocator, "'{s}' doesn't exist in this flake.", .{attr}) catch "the flake attribute doesn't exist.",413+ .suggestion = "run nina flake show to see available outputs",414+ .is_system = false,415+ };416+ }417+418+ if (std.mem.indexOf(u8, stderr, "is not valid") != null and419+ std.mem.indexOf(u8, stderr, "/nix/store/") != null)420+ {421+ return .{422+ .title = "store path is invalid",423+ .body = "a referenced path doesn't exist in the store.",424+ .suggestion = "nina store repair",425+ .is_system = true,426+ };427+ }428+429+ if (std.mem.indexOf(u8, stderr, "missing input 'nixpkgs'") != null) {430+ return .{431+ .title = "nixpkgs input missing",432+ .body = "the flake can't find its nixpkgs input.",433+ .suggestion = "nina flake update",434+ .is_system = false,435+ };436+ }437+438+ if (std.mem.indexOf(u8, stderr, "getting status of") != null and439+ std.mem.indexOf(u8, stderr, "flake.nix") != null)440+ {441+ return .{442+ .title = "flake.nix not found",443+ .body = "no flake.nix in this directory.",444+ .suggestion = "nina flake init to create one",445+ .is_system = false,446+ };447+ }448+449+ if (std.mem.indexOf(u8, stderr, "syntax error") != null and450+ std.mem.indexOf(u8, stderr, "unexpected") != null)451+ {452+ return .{453+ .title = "syntax error in config",454+ .body = "there's a syntax error in your NixOS config.",455+ .suggestion = "run nina edit to fix it",456+ .is_system = false,457+ .location = extractLocation(allocator, stderr),458+ };459+ }460+461+ if (std.mem.indexOf(u8, stderr, "while evaluating") != null and462+ std.mem.indexOf(u8, stderr, "configuration.nix") != null)463+ {464+ const inner = innermostError(stderr) orelse "evaluation failed";465+ const body = if (std.mem.startsWith(u8, inner, "error: ")) inner[7..] else inner;466+ return .{467+ .title = "config evaluation failed",468+ .body = body,469+ .suggestion = "run nina check for details",470+ .is_system = false,471+ };472+ }473+474+ if (std.mem.indexOf(u8, stderr, "type error") != null) {475+ return .{476+ .title = "type mismatch in config",477+ .body = "a config option received a value of the wrong type.",478+ .suggestion = "check the option type in the NixOS manual",479+ .is_system = false,480+ .location = extractLocation(allocator, stderr),481+ };482+ }483+484+ if (std.mem.indexOf(u8, stderr, "does not match") != null and485+ std.mem.indexOf(u8, stderr, "option") != null)486+ {487+ const opt = if (lineWith(stderr, "does not match")) |l| extractQuoted(l) orelse "this option" else "this option";488+ return .{489+ .title = "wrong value for option",490+ .body = std.fmt.allocPrint(allocator, "'{s}' received an unexpected value.", .{opt}) catch "an option received an unexpected value.",491+ .suggestion = "check the option documentation: man configuration.nix",492+ .is_system = false,493+ };494+ }495+496+ // ── home manager patterns ─────────────────────────────────────────────────497+498+ if (std.mem.indexOf(u8, stderr, "The option") != null and499+ std.mem.indexOf(u8, stderr, "does not exist") != null)500+ {501+ const opt = if (lineWith(stderr, "does not exist")) |l| extractQuoted(l) orelse "this option" else "this option";502+ return .{503+ .title = "unknown home-manager option",504+ .body = std.fmt.allocPrint(allocator, "'{s}' is not a valid home-manager option.", .{opt}) catch "this option doesn't exist in home-manager.",505+ .suggestion = "check the home-manager option name",506+ .is_system = false,507+ };508+ }509+510+ if (std.mem.indexOf(u8, stderr, "is used but not defined") != null) {511+ const mod = if (lineWith(stderr, "is used but not defined")) |l| extractQuoted(l) orelse "a module" else "a module";512+ return .{513+ .title = "module not imported",514+ .body = std.fmt.allocPrint(allocator, "'{s}' is used but not imported.", .{mod}) catch "a module is used but not imported.",515+ .suggestion = "add the module import to your home.nix",516+ .is_system = false,517+ };518+ }519+520+ return null;521+}522+523+// ─── Layer 2: Structured extraction ──────────────────────────────────────────524+//525+// When no pattern matches, extract the innermost error from nix's nested output526+// and present it with any available location information.527+528+pub fn extractStructured(allocator: std.mem.Allocator, stderr: []const u8) ?TranslatedError {529+ if (stderr.len == 0) return null;530+531+ const inner = innermostError(stderr) orelse return null;532+ if (inner.len == 0) return null;533+534+ // Strip "error: " prefix, cap at 3 lines, skip store paths and note: lines.535+ const raw_msg = if (std.mem.startsWith(u8, inner, "error: ")) inner[7..] else inner;536+ const body = cleanExtractedBody(allocator, raw_msg);537+ if (body.len == 0) return null;538+539+ const loc = extractLocation(allocator, stderr);540+ const drv = extractDrvPath(stderr);541+ const suggestion = if (drv) |path|542+ std.fmt.allocPrint(allocator, "nina doctor for a full diagnostic\nnix log {s}", .{path}) catch "nina doctor for a full diagnostic"543+ else544+ "nina doctor for a full diagnostic";545+546+ return .{547+ .title = "build failed",548+ .body = body,549+ .suggestion = suggestion,550+ .is_system = true,551+ .location = loc,552+ };553+}554+555+fn cleanExtractedBody(allocator: std.mem.Allocator, raw: []const u8) []const u8 {556+ var parts: [3][]const u8 = undefined;557+ var count: usize = 0;558+ var it = std.mem.splitScalar(u8, raw, '\n');559+ while (it.next()) |line| {560+ if (count >= 3) break;561+ const t = std.mem.trim(u8, line, " \t");562+ if (t.len == 0) continue;563+ // Skip pure store paths and builder output lines564+ if (std.mem.startsWith(u8, t, "/nix/store/")) continue;565+ if (std.mem.startsWith(u8, t, "note:") and std.mem.indexOf(u8, t, "/nix/store/") != null) continue;566+ if (std.mem.startsWith(u8, t, "> ")) continue;567+ parts[count] = t;568+ count += 1;569+ }570+ if (count == 0) return "";571+ if (count == 1) return allocator.dupe(u8, parts[0]) catch "";572+ return std.mem.join(allocator, "\n", parts[0..count]) catch "";573+}574+575+// ─── Layer 3: Graceful fallback ───────────────────────────────────────────────576+577+pub fn fallback(allocator: std.mem.Allocator, stderr: []const u8) TranslatedError {578+ const drv = extractDrvPath(stderr);579+ const suggestion = if (drv) |path|580+ std.fmt.allocPrint(allocator, "nina doctor for diagnostics\nnix log {s}", .{path}) catch "nina doctor for diagnostics"581+ else582+ "nina doctor for diagnostics";583+ return .{584+ .title = "build failed",585+ .body = "something went wrong that nina couldn't translate.",586+ .suggestion = suggestion,587+ .is_system = true,588+ };589+}590+591+// ─── Entry point ─────────────────────────────────────────────────────────────592+593+pub fn translateError(allocator: std.mem.Allocator, stderr: []const u8) TranslatedError {594+ if (matchKnownPattern(allocator, stderr)) |t| return t;595+ if (extractStructured(allocator, stderr)) |t| return t;596+ return fallback(allocator, stderr);597+}598+599+// ─── Warning extraction ───────────────────────────────────────────────────────600+601+const warning_translations = [_][2][]const u8{602+ .{ "is too new", "nixpkgs version is ahead of your channel" },603+ .{ "unknown setting", "unknown nix setting — check nix.conf" },604+ .{ "Git tree", "uncommitted changes in your flake directory" },605+ .{ "not writing modified lock file", "flake.lock is out of date — run nina flake update" },606+};607+608+fn translateWarning(msg: []const u8) []const u8 {609+ for (warning_translations) |pair| {610+ if (std.mem.indexOf(u8, msg, pair[0]) != null) return pair[1];611+ }612+ return msg;613+}614+615+// Extract and translate warning lines from captured stderr.616+// Caller owns the returned slice and each string within it.617+pub fn extractWarnings(allocator: std.mem.Allocator, stderr: []const u8) ![][]const u8 {618+ var warnings: std.ArrayList([]const u8) = .empty;619+ var it = std.mem.splitScalar(u8, stderr, '\n');620+ while (it.next()) |line| {621+ const t = std.mem.trim(u8, line, " \t");622+ if (!std.mem.startsWith(u8, t, "warning:")) continue;623+ const msg = std.mem.trim(u8, t[8..], " \t");624+ if (msg.len == 0) continue;625+ const translated = translateWarning(msg);626+ try warnings.append(allocator, try allocator.dupe(u8, translated));627+ }628+ return warnings.toOwnedSlice(allocator);629+}630+631+// ─── Tests ───────────────────────────────────────────────────────────────────632+633+test "layer 1: undefined variable" {634+ const t = matchKnownPattern(std.testing.allocator, "error: undefined variable 'pkgs.notexist'");635+ try std.testing.expect(t != null);636+ try std.testing.expect(std.mem.indexOf(u8, t.?.title, "notexist") != null);637+}638+639+test "layer 1: disk full" {640+ const t = matchKnownPattern(std.testing.allocator, "error: No space left on device");641+ try std.testing.expect(t != null);642+ try std.testing.expectEqualStrings("disk is full", t.?.title);643+ try std.testing.expect(t.?.is_system);644+}645+646+test "layer 1: unfree" {647+ const t = matchKnownPattern(std.testing.allocator, "error: Package 'discord' has an unfree license");648+ try std.testing.expect(t != null);649+ try std.testing.expectEqualStrings("package has an unfree license", t.?.title);650+}651+652+test "layer 1: no match returns null" {653+ const t = matchKnownPattern(std.testing.allocator, "some unrecognized error text");654+ try std.testing.expect(t == null);655+}656+657+test "layer 3: fallback always returns" {658+ const t = fallback(std.testing.allocator, "");659+ try std.testing.expectEqualStrings("build failed", t.title);660+ try std.testing.expect(t.is_system);661+}662+663+test "translateError: falls through to fallback" {664+ const t = translateError(std.testing.allocator, "unrecognized garbage stderr");665+ try std.testing.expectEqualStrings("build failed", t.title);666+}667+668+test "staticMessage" {116669 try std.testing.expectEqualStrings("~/.nina.conf not found", staticMessage(error.ConfigNotFound));670+}671+672+test "extractWarnings: translates known warnings" {673+ const stderr = "warning: Git tree '/home/user/config' is dirty\nwarning: unknown setting 'foo'\n";674+ const ws = try extractWarnings(std.testing.allocator, stderr);675+ defer {676+ for (ws) |w| std.testing.allocator.free(w);677+ std.testing.allocator.free(ws);678+ }679+ try std.testing.expectEqual(@as(usize, 2), ws.len);680+ try std.testing.expectEqualStrings("uncommitted changes in your flake directory", ws[0]);681+ try std.testing.expectEqualStrings("unknown nix setting — check nix.conf", ws[1]);117682 }src/exec.zig+38 −8
@@ -347,11 +347,21 @@347347 return !std.mem.eql(u8, path, "/etc/nixos");348348 }349349 350+// Capture nixos-rebuild stderr for the translation pipeline. Raw nix build351+// output is suppressed; on failure, reportError() translates stderr via352+// errors.translateError(). The substep line above the call tells the user what353+// is running; silence during the build is intentional per the error-wrapping brief.350354 fn nixosRebuildRun(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, verb: []const u8, flake: bool) !u8 {351- if (flake) {352- return stream(io, machine, gpa, &.{ "sudo", "nixos-rebuild", verb, "--flake", machine.config_path });353- }354- return stream(io, machine, gpa, &.{ "sudo", "nixos-rebuild", verb });355+ const argv: []const []const u8 = if (flake)356+ &.{ "sudo", "nixos-rebuild", verb, "--flake", machine.config_path }357+ else358+ &.{ "sudo", "nixos-rebuild", verb };359+ output.flush();360+ const r = try capture(gpa, io, machine, argv);361+ defer gpa.free(r.stdout);362+ defer gpa.free(r.stderr);363+ errors.setBuildStderr(r.stderr);364+ return r.exit_code;355365 }356366 357367 pub fn nixosRebuildSwitch(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {@@ -1371,17 +1381,32 @@13711381 }13721382 13731383 pub fn homeManagerSwitch(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {1374- return stream(io, machine, gpa, &.{ "home-manager", "switch" });1384+ output.flush();1385+ const r = try capture(gpa, io, machine, &.{ "home-manager", "switch" });1386+ defer gpa.free(r.stdout);1387+ defer gpa.free(r.stderr);1388+ errors.setBuildStderr(r.stderr);1389+ return r.exit_code;13751390 }13761391 13771392 pub fn homeManagerSwitchFlake(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator, flake_ref: []const u8) !u8 {1378- return stream(io, machine, gpa, &.{ "home-manager", "switch", "--flake", flake_ref });1393+ output.flush();1394+ const r = try capture(gpa, io, machine, &.{ "home-manager", "switch", "--flake", flake_ref });1395+ defer gpa.free(r.stdout);1396+ defer gpa.free(r.stderr);1397+ errors.setBuildStderr(r.stderr);1398+ return r.exit_code;13791399 }13801400 13811401 // home-manager has no system-profile rollback; it re-activates the previous13821402 // generation via `switch --rollback`.13831403 pub fn homeManagerRollback(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {1384- return stream(io, machine, gpa, &.{ "home-manager", "switch", "--rollback" });1404+ output.flush();1405+ const r = try capture(gpa, io, machine, &.{ "home-manager", "switch", "--rollback" });1406+ defer gpa.free(r.stdout);1407+ defer gpa.free(r.stderr);1408+ errors.setBuildStderr(r.stderr);1409+ return r.exit_code;13851410 }13861411 13871412 // Build (and validate) the home config without activating it — used by `home@@ -1389,7 +1414,12 @@13891414 // in the cwd, so run it in a throwaway temp dir to avoid littering the directory13901415 // nina was invoked from.13911416 pub fn homeManagerBuild(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {1392- return stream(io, machine, gpa, &.{ "sh", "-c", "d=$(mktemp -d) && cd \"$d\" && home-manager build" });1417+ output.flush();1418+ const r = try capture(gpa, io, machine, &.{ "sh", "-c", "d=$(mktemp -d) && cd \"$d\" && home-manager build" });1419+ defer gpa.free(r.stdout);1420+ defer gpa.free(r.stderr);1421+ errors.setBuildStderr(r.stderr);1422+ return r.exit_code;13931423 }13941424 13951425 pub fn homeGenerations(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {src/main.zig+22 −10
@@ -92,7 +92,7 @@9292 };9393 } else {9494 dispatch(pre_ctx, args.command) catch |err| {95- reportError(err);95+ reportError(err, ca);9696 output.flush();9797 std.process.exit(1);9898 };@@ -163,24 +163,36 @@163163 };164164 165165 dispatch(ctx, args.command) catch |err| {166- reportError(err);166+ reportError(err, ca);167167 output.flush();168168 std.process.exit(1);169169 };170170 }171171 172172 // Network/ssh/search failures are the machine's fault, not the user's — surface173-// them with the sad kaomoji. Everything else (not found, bad config, bad args)174-// is a plain error line.175-fn reportError(err: anyerror) void {176- const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);177- const detail = errors.error_info.getDetail();178- const suggestion = errors.error_info.getSuggestion();173+// them with the sad kaomoji. BuildFailed is routed through the 3-layer174+// translation pipeline. Everything else uses a plain error line.175+fn reportError(err: anyerror, allocator: std.mem.Allocator) void {179176 switch (err) {180177 // abort message already printed by output.aborted() — just exit non-zero181178 error.HookAborted => {},182- error.NetworkError, error.SshFailed, error.SearchFailed => output.printSystemError(msg, detail, suggestion),183- else => output.printError(msg, detail, suggestion),179+ error.BuildFailed => {180+ const stderr = errors.getBuildStderr();181+ const translated = errors.translateError(allocator, stderr);182+ output.buildError(translated);183+ },184+ error.NetworkError, error.SshFailed, error.SearchFailed => {185+ const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);186+ const detail = errors.error_info.getDetail();187+ const suggestion = errors.error_info.getSuggestion();188+ output.printSystemError(msg, detail, suggestion);189+ },190+ else => {191+ const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);192+ const detail = errors.error_info.getDetail();193+ const suggestion = errors.error_info.getSuggestion();194+ output.printError(msg, detail, suggestion);195+ },184196 }185197 }186198 src/output.zig+46 −0
@@ -3,6 +3,7 @@33 const std = @import("std");44 const types = @import("types.zig");55 const nina_version = @import("version.zig");6+const errors = @import("errors.zig");67 const Io = std.Io;78 89 // Color constants — all ANSI codes live here only@@ -132,6 +133,51 @@132133 133134 pub fn printWarning(message: []const u8) void {134135 p("{s}{s}\n", .{ if (color_enabled) WARN_C else WARN_P, message });136+}137+138+// Display a translated build error through the 3-layer pipeline format.139+// is_system = true → (;ω;) kaomoji prefix (machine's fault, not the user's)140+// suggestion may contain '\n'-separated lines; the first gets "-> " and the141+// rest are indented with " " so the secondary "nix log ..." line lines up.142+pub fn buildError(err: errors.TranslatedError) void {143+ if (err.is_system) {144+ // KAO_SAD already includes trailing spaces: "(;ω;) "145+ p("{s}{s}{s}\n\n", .{ KAO_SAD, if (color_enabled) ERR_C else ERR_P, err.title });146+ } else {147+ p("{s}{s}\n\n", .{ if (color_enabled) ERR_C else ERR_P, err.title });148+ }149+150+ var body_lines = std.mem.splitScalar(u8, err.body, '\n');151+ while (body_lines.next()) |line| {152+ if (line.len == 0) continue;153+ p(" {s}\n", .{line});154+ }155+156+ if (err.location) |loc| {157+ p("{s} {s}{s}\n", .{ c(DIM), loc, c(RESET) });158+ }159+160+ p("\n", .{});161+162+ var sug_lines = std.mem.splitScalar(u8, err.suggestion, '\n');163+ var first = true;164+ while (sug_lines.next()) |line| {165+ if (line.len == 0) continue;166+ if (first) {167+ p("{s} -> {s}{s}\n", .{ c(CYAN), line, c(RESET) });168+ first = false;169+ } else {170+ p("{s} {s}{s}\n", .{ c(DIM), line, c(RESET) });171+ }172+ }173+}174+175+// Display post-build warnings collected from captured stderr.176+// Shown after the success line in dim yellow.177+pub fn buildWarnings(warnings: []const []const u8) void {178+ for (warnings) |w| {179+ p("{s} \u{26a0} {s}{s}\n", .{ c(YELLOW), w, c(RESET) });180+ }135181 }136182 137183 pub fn aborted() void {