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
7 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 −0
VERSION+1 −1
@@ -1 +1 @@1-3.2.11+3.2.2

view file

build.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 allows

view file

src/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 

view file

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 }

view file

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 {

view file

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 

view file

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 {

view file