errors.zig

27.9 KB · raw

1const std = @import("std");23// ─── Public error types ───────────────────────────────────────────────────────45pub const NinaError = error{6    PackageNotFound,7    MachineNotFound,8    GenerationNotFound,9    ConfigNotFound,10    ConfigParseError,11    BuildFailed,12    SshFailed,13    SearchFailed,14    NetworkError,15    OutOfMemory,16    ProcessFailed,17    Internal,18    FileNotFound,19    AccessDenied,20    SystemResources,21    Unexpected,22};2324// ─── ErrorInfo (used for non-build errors) ───────────────────────────────────2526pub var error_info: ErrorInfo = .{};2728pub const ErrorInfo = struct {29    message: [512]u8 = undefined,30    message_len: usize = 0,31    detail: [512]u8 = undefined,32    detail_len: usize = 0,33    suggestion: [256]u8 = undefined,34    suggestion_len: usize = 0,3536    pub fn setMessage(self: *ErrorInfo, comptime fmt: []const u8, args: anytype) void {37        const r = std.fmt.bufPrint(&self.message, fmt, args) catch return;38        self.message_len = r.len;39    }4041    pub fn setDetail(self: *ErrorInfo, comptime fmt: []const u8, args: anytype) void {42        const r = std.fmt.bufPrint(&self.detail, fmt, args) catch return;43        self.detail_len = r.len;44    }4546    pub fn setSuggestion(self: *ErrorInfo, comptime fmt: []const u8, args: anytype) void {47        const r = std.fmt.bufPrint(&self.suggestion, fmt, args) catch return;48        self.suggestion_len = r.len;49    }5051    pub fn getMessage(self: *const ErrorInfo) ?[]const u8 {52        if (self.message_len == 0) return null;53        return self.message[0..self.message_len];54    }5556    pub fn getDetail(self: *const ErrorInfo) ?[]const u8 {57        if (self.detail_len == 0) return null;58        return self.detail[0..self.detail_len];59    }6061    pub fn getSuggestion(self: *const ErrorInfo) ?[]const u8 {62        if (self.suggestion_len == 0) return null;63        return self.suggestion[0..self.suggestion_len];64    }6566    pub fn reset(self: *ErrorInfo) void {67        self.message_len = 0;68        self.detail_len = 0;69        self.suggestion_len = 0;70    }71};7273pub fn staticMessage(err: anyerror) []const u8 {74    return switch (err) {75        error.ConfigNotFound => "~/.nina.conf not found",76        error.ConfigParseError => "could not parse ~/.nina.conf",77        error.SearchFailed => "nix search failed",78        error.NetworkError => "network unavailable",79        error.OutOfMemory => "out of memory",80        error.ProcessFailed => "process failed",81        error.SshFailed => "ssh connection failed",82        error.MachineNotFound => "machine not found in config",83        error.PackageNotFound => "package not found",84        error.GenerationNotFound => "generation not found",85        error.BuildFailed => "build failed",86        else => "internal error",87    };88}8990// ─── Build stderr capture ─────────────────────────────────────────────────────91//92// nixosRebuildRun (and home manager commands) capture stderr into this buffer93// before returning so translateError can read it in reportError.9495var build_stderr_buf: [65536]u8 = undefined;96var build_stderr_len: usize = 0;9798pub 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;102}103104pub fn getBuildStderr() []const u8 {105    return build_stderr_buf[0..build_stderr_len];106}107108// ─── TranslatedError ──────────────────────────────────────────────────────────109110pub 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};120121// ─── Helpers ──────────────────────────────────────────────────────────────────122123// Return the single-quoted content at the first occurrence in s.124fn 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}130131// Return the first line in text that contains pattern.132fn 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}139140// Extract the innermost error: last "error: ..." line before "For full logs".141fn 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}151152fn 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}157158fn justFilename(path: []const u8) []const u8 {159    return if (std.mem.lastIndexOf(u8, path, "/")) |i| path[i + 1 ..] else path;160}161162fn 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;170171    // 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    };177178    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}181182pub 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}189190// Find a /nix/store/xxx.drv path in stderr for the "nix log" suggestion.191fn 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}203204// ─── Layer 1: Known pattern matching ─────────────────────────────────────────205//206// 25 nixos-rebuild patterns + home manager patterns. First match wins.207208pub fn matchKnownPattern(allocator: std.mem.Allocator, stderr: []const u8) ?TranslatedError {209    // ── nixos-rebuild patterns ────────────────────────────────────────────────210211    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    }226227    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    }246247    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    }258259    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    }271272    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    }280281    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    }289290    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    }298299    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    }307308    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    }316317    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    }325326    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    }334335    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    }346347    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    }356357    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    }371372    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    }380381    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    }391392    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    }400401    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    }417418    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    }428429    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    }437438    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    }448449    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    }460461    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    }473474    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    }483484    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    }495496    // ── home manager patterns ─────────────────────────────────────────────────497498    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    }509510    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    }519520    return null;521}522523// ─── 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.527528pub fn extractStructured(allocator: std.mem.Allocator, stderr: []const u8) ?TranslatedError {529    if (stderr.len == 0) return null;530531    const inner = innermostError(stderr) orelse return null;532    if (inner.len == 0) return null;533534    // 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;538539    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";545546    return .{547        .title = "build failed",548        .body = body,549        .suggestion = suggestion,550        .is_system = true,551        .location = loc,552    };553}554555fn 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}574575// ─── Layer 3: Graceful fallback ───────────────────────────────────────────────576577pub 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}590591// ─── Entry point ─────────────────────────────────────────────────────────────592593pub 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}598599// ─── Warning extraction ───────────────────────────────────────────────────────600601const 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};607608fn 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}614615// Extract and translate warning lines from captured stderr.616// Caller owns the returned slice and each string within it.617pub 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}630631// ─── Tests ───────────────────────────────────────────────────────────────────632633test "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}638639test "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}645646test "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}651652test "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}656657test "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}662663test "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}667668test "staticMessage" {669    try std.testing.expectEqualStrings("~/.nina.conf not found", staticMessage(error.ConfigNotFound));670}671672test "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]);682}