errors.zig

29.4 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    // "warning: the following units failed: svc.service"497    // switch-to-configuration exits 4 when one or more units fail to start.498    if (std.mem.indexOf(u8, stderr, "the following units failed:") != null) {499        const svc = blk: {500            if (lineWith(stderr, "the following units failed:")) |l| {501                if (std.mem.indexOf(u8, l, "failed:")) |p| {502                    const rest = std.mem.trim(u8, l[p + 7 ..], " \t");503                    if (rest.len > 0 and rest.len <= 80) break :blk rest;504                }505            }506            break :blk "a service";507        };508        return .{509            .title = "service failed after upgrade",510            .body = std.fmt.allocPrint(allocator, "{s} failed to start after activation.", .{svc}) catch "a service failed to start after activation.",511            .suggestion = std.fmt.allocPrint(allocator, "systemctl status {s}", .{svc}) catch "systemctl status <service>",512            .is_system = true,513        };514    }515516    // ── home manager patterns ─────────────────────────────────────────────────517518    if (std.mem.indexOf(u8, stderr, "The option") != null and519        std.mem.indexOf(u8, stderr, "does not exist") != null)520    {521        const opt = if (lineWith(stderr, "does not exist")) |l| extractQuoted(l) orelse "this option" else "this option";522        return .{523            .title = "unknown home-manager option",524            .body = std.fmt.allocPrint(allocator, "'{s}' is not a valid home-manager option.", .{opt}) catch "this option doesn't exist in home-manager.",525            .suggestion = "check the home-manager option name",526            .is_system = false,527        };528    }529530    if (std.mem.indexOf(u8, stderr, "is used but not defined") != null) {531        const mod = if (lineWith(stderr, "is used but not defined")) |l| extractQuoted(l) orelse "a module" else "a module";532        return .{533            .title = "module not imported",534            .body = std.fmt.allocPrint(allocator, "'{s}' is used but not imported.", .{mod}) catch "a module is used but not imported.",535            .suggestion = "add the module import to your home.nix",536            .is_system = false,537        };538    }539540    return null;541}542543// ─── Layer 2: Structured extraction ──────────────────────────────────────────544//545// When no pattern matches, extract the innermost error from nix's nested output546// and present it with any available location information.547548pub fn extractStructured(allocator: std.mem.Allocator, stderr: []const u8) ?TranslatedError {549    if (stderr.len == 0) return null;550551    const inner = innermostError(stderr) orelse return null;552    if (inner.len == 0) return null;553554    // Strip "error: " prefix, cap at 3 lines, skip store paths and note: lines.555    const raw_msg = if (std.mem.startsWith(u8, inner, "error: ")) inner[7..] else inner;556    const body = cleanExtractedBody(allocator, raw_msg);557    if (body.len == 0) return null;558559    const loc = extractLocation(allocator, stderr);560    const drv = extractDrvPath(stderr);561    const suggestion = if (drv) |path|562        std.fmt.allocPrint(allocator, "nina doctor  for a full diagnostic\nnix log {s}", .{path}) catch "nina doctor  for a full diagnostic"563    else564        "nina doctor  for a full diagnostic";565566    return .{567        .title = "build failed",568        .body = body,569        .suggestion = suggestion,570        .is_system = true,571        .location = loc,572    };573}574575fn cleanExtractedBody(allocator: std.mem.Allocator, raw: []const u8) []const u8 {576    var parts: [3][]const u8 = undefined;577    var count: usize = 0;578    var it = std.mem.splitScalar(u8, raw, '\n');579    while (it.next()) |line| {580        if (count >= 3) break;581        const t = std.mem.trim(u8, line, " \t");582        if (t.len == 0) continue;583        // Skip pure store paths and builder output lines584        if (std.mem.startsWith(u8, t, "/nix/store/")) continue;585        if (std.mem.startsWith(u8, t, "note:") and std.mem.indexOf(u8, t, "/nix/store/") != null) continue;586        if (std.mem.startsWith(u8, t, "> ")) continue;587        parts[count] = t;588        count += 1;589    }590    if (count == 0) return "";591    if (count == 1) return allocator.dupe(u8, parts[0]) catch "";592    return std.mem.join(allocator, "\n", parts[0..count]) catch "";593}594595// ─── Layer 3: Graceful fallback ───────────────────────────────────────────────596597pub fn fallback(allocator: std.mem.Allocator, stderr: []const u8) TranslatedError {598    const drv = extractDrvPath(stderr);599    const suggestion = if (drv) |path|600        std.fmt.allocPrint(allocator, "nina doctor  for diagnostics\nnix log {s}", .{path}) catch "nina doctor  for diagnostics"601    else602        "nina doctor  for diagnostics";603    return .{604        .title = "build failed",605        .body = "something went wrong that nina couldn't translate.",606        .suggestion = suggestion,607        .is_system = true,608    };609}610611// ─── Entry point ─────────────────────────────────────────────────────────────612613pub fn translateError(allocator: std.mem.Allocator, stderr: []const u8) TranslatedError {614    if (matchKnownPattern(allocator, stderr)) |t| return t;615    if (extractStructured(allocator, stderr)) |t| return t;616    return fallback(allocator, stderr);617}618619// ─── Warning extraction ───────────────────────────────────────────────────────620621const warning_translations = [_][2][]const u8{622    .{ "is too new", "nixpkgs version is ahead of your channel" },623    .{ "unknown setting", "unknown nix setting — check nix.conf" },624    .{ "Git tree", "uncommitted changes in your flake directory" },625    .{ "not writing modified lock file", "flake.lock is out of date — run nina flake update" },626};627628fn translateWarning(msg: []const u8) []const u8 {629    for (warning_translations) |pair| {630        if (std.mem.indexOf(u8, msg, pair[0]) != null) return pair[1];631    }632    return msg;633}634635// Extract and translate warning lines from captured stderr.636// Caller owns the returned slice and each string within it.637pub fn extractWarnings(allocator: std.mem.Allocator, stderr: []const u8) ![][]const u8 {638    var warnings: std.ArrayList([]const u8) = .empty;639    var it = std.mem.splitScalar(u8, stderr, '\n');640    while (it.next()) |line| {641        const t = std.mem.trim(u8, line, " \t");642        if (!std.mem.startsWith(u8, t, "warning:")) continue;643        const msg = std.mem.trim(u8, t[8..], " \t");644        if (msg.len == 0) continue;645        const translated = translateWarning(msg);646        try warnings.append(allocator, try allocator.dupe(u8, translated));647    }648    return warnings.toOwnedSlice(allocator);649}650651// ─── Tests ───────────────────────────────────────────────────────────────────652653test "layer 1: undefined variable" {654    const t = matchKnownPattern(std.testing.allocator, "error: undefined variable 'pkgs.notexist'");655    try std.testing.expect(t != null);656    try std.testing.expect(std.mem.indexOf(u8, t.?.title, "notexist") != null);657}658659test "layer 1: disk full" {660    const t = matchKnownPattern(std.testing.allocator, "error: No space left on device");661    try std.testing.expect(t != null);662    try std.testing.expectEqualStrings("disk is full", t.?.title);663    try std.testing.expect(t.?.is_system);664}665666test "layer 1: unfree" {667    const t = matchKnownPattern(std.testing.allocator, "error: Package 'discord' has an unfree license");668    try std.testing.expect(t != null);669    try std.testing.expectEqualStrings("package has an unfree license", t.?.title);670}671672test "layer 1: no match returns null" {673    const t = matchKnownPattern(std.testing.allocator, "some unrecognized error text");674    try std.testing.expect(t == null);675}676677test "layer 1: service activation failure" {678    const t = matchKnownPattern(std.testing.allocator,679        "warning: the following units failed: rtorrent.service\n" ++680        "Command 'systemd-run ...' returned non-zero exit status 4.");681    try std.testing.expect(t != null);682    try std.testing.expectEqualStrings("service failed after upgrade", t.?.title);683    try std.testing.expect(std.mem.indexOf(u8, t.?.body, "rtorrent.service") != null);684    try std.testing.expect(std.mem.indexOf(u8, t.?.suggestion, "rtorrent.service") != null);685}686687test "layer 3: fallback always returns" {688    const t = fallback(std.testing.allocator, "");689    try std.testing.expectEqualStrings("build failed", t.title);690    try std.testing.expect(t.is_system);691}692693test "translateError: falls through to fallback" {694    const t = translateError(std.testing.allocator, "unrecognized garbage stderr");695    try std.testing.expectEqualStrings("build failed", t.title);696}697698test "staticMessage" {699    try std.testing.expectEqualStrings("~/.nina.conf not found", staticMessage(error.ConfigNotFound));700}701702test "extractWarnings: translates known warnings" {703    const stderr = "warning: Git tree '/home/user/config' is dirty\nwarning: unknown setting 'foo'\n";704    const ws = try extractWarnings(std.testing.allocator, stderr);705    defer {706        for (ws) |w| std.testing.allocator.free(w);707        std.testing.allocator.free(ws);708    }709    try std.testing.expectEqual(@as(usize, 2), ws.len);710    try std.testing.expectEqualStrings("uncommitted changes in your flake directory", ws[0]);711    try std.testing.expectEqualStrings("unknown nix setting — check nix.conf", ws[1]);712}