errors.zig

29.7 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    NotImplemented,23};2425// ─── ErrorInfo (used for non-build errors) ───────────────────────────────────2627pub var error_info: ErrorInfo = .{};2829pub const ErrorInfo = struct {30    message: [512]u8 = undefined,31    message_len: usize = 0,32    detail: [512]u8 = undefined,33    detail_len: usize = 0,34    suggestion: [256]u8 = undefined,35    suggestion_len: usize = 0,3637    pub fn setMessage(self: *ErrorInfo, comptime fmt: []const u8, args: anytype) void {38        const r = std.fmt.bufPrint(&self.message, fmt, args) catch return;39        self.message_len = r.len;40    }4142    pub fn setDetail(self: *ErrorInfo, comptime fmt: []const u8, args: anytype) void {43        const r = std.fmt.bufPrint(&self.detail, fmt, args) catch return;44        self.detail_len = r.len;45    }4647    pub fn setSuggestion(self: *ErrorInfo, comptime fmt: []const u8, args: anytype) void {48        const r = std.fmt.bufPrint(&self.suggestion, fmt, args) catch return;49        self.suggestion_len = r.len;50    }5152    pub fn getMessage(self: *const ErrorInfo) ?[]const u8 {53        if (self.message_len == 0) return null;54        return self.message[0..self.message_len];55    }5657    pub fn getDetail(self: *const ErrorInfo) ?[]const u8 {58        if (self.detail_len == 0) return null;59        return self.detail[0..self.detail_len];60    }6162    pub fn getSuggestion(self: *const ErrorInfo) ?[]const u8 {63        if (self.suggestion_len == 0) return null;64        return self.suggestion[0..self.suggestion_len];65    }6667    pub fn reset(self: *ErrorInfo) void {68        self.message_len = 0;69        self.detail_len = 0;70        self.suggestion_len = 0;71    }72};7374pub fn staticMessage(err: anyerror) []const u8 {75    return switch (err) {76        error.ConfigNotFound => "~/.nina.conf not found",77        error.ConfigParseError => "could not parse ~/.nina.conf",78        error.SearchFailed => "nix search failed",79        error.NetworkError => "network unavailable",80        error.OutOfMemory => "out of memory",81        error.ProcessFailed => "process failed",82        error.SshFailed => "ssh connection failed",83        error.MachineNotFound => "machine not found in config",84        error.PackageNotFound => "package not found",85        error.GenerationNotFound => "generation not found",86        error.BuildFailed => "build failed",87        error.NotImplemented => "not implemented yet",88        else => "internal error",89    };90}9192// ─── Build stderr capture ─────────────────────────────────────────────────────93//94// nixosRebuildRun (and home manager commands) capture stderr into this buffer95// before returning so translateError can read it in reportError.9697var build_stderr_buf: [65536]u8 = undefined;98var build_stderr_len: usize = 0;99100pub fn setBuildStderr(stderr: []const u8) void {101    const n = @min(stderr.len, build_stderr_buf.len);102    @memcpy(build_stderr_buf[0..n], stderr[0..n]);103    build_stderr_len = n;104}105106pub fn getBuildStderr() []const u8 {107    return build_stderr_buf[0..build_stderr_len];108}109110// ─── TranslatedError ──────────────────────────────────────────────────────────111112pub const TranslatedError = struct {113    title: []const u8,114    body: []const u8,115    // May contain multiple lines separated by '\n'. buildError prints the first116    // with "-> " and subsequent lines with "   " indentation.117    suggestion: []const u8,118    is_system: bool = false,119    // Dim location shown below body: "configuration.nix  line 47"120    location: ?[]const u8 = null,121};122123// ─── Helpers ──────────────────────────────────────────────────────────────────124125// Return the single-quoted content at the first occurrence in s.126fn extractQuoted(s: []const u8) ?[]const u8 {127    const a = std.mem.indexOf(u8, s, "'") orelse return null;128    const b = std.mem.indexOfPos(u8, s, a + 1, "'") orelse return null;129    const v = s[a + 1 .. b];130    return if (v.len > 0) v else null;131}132133// Return the first line in text that contains pattern.134fn lineWith(text: []const u8, pattern: []const u8) ?[]const u8 {135    var it = std.mem.splitScalar(u8, text, '\n');136    while (it.next()) |line| {137        if (std.mem.indexOf(u8, line, pattern) != null) return line;138    }139    return null;140}141142// Extract the innermost error: last "error: ..." line before "For full logs".143fn innermostError(text: []const u8) ?[]const u8 {144    var last: ?[]const u8 = null;145    var it = std.mem.splitScalar(u8, text, '\n');146    while (it.next()) |line| {147        const t = std.mem.trim(u8, line, " \t");148        if (std.mem.startsWith(u8, t, "error:")) last = t;149        if (std.mem.startsWith(u8, t, "For full logs")) break;150    }151    return last;152}153154fn isUserPath(path: []const u8) bool {155    return std.mem.startsWith(u8, path, "/etc/nixos/") or156        std.mem.startsWith(u8, path, "/home/") or157        std.mem.startsWith(u8, path, "~");158}159160fn justFilename(path: []const u8) []const u8 {161    return if (std.mem.lastIndexOf(u8, path, "/")) |i| path[i + 1 ..] else path;162}163164fn findLocationInLine(allocator: std.mem.Allocator, line: []const u8) ?[]const u8 {165    const nix_pos = std.mem.indexOf(u8, line, ".nix:") orelse return null;166    const before = line[0 .. nix_pos + 4]; // includes ".nix"167    const rest = line[nix_pos + 5 ..]; // "47:3" or "47"168    const colon_or_end = std.mem.indexOf(u8, rest, ":") orelse rest.len;169    const line_num_str = rest[0..colon_or_end];170    if (line_num_str.len == 0 or line_num_str.len > 6) return null;171    for (line_num_str) |ch| if (ch < '0' or ch > '9') return null;172173    // Extract the actual path from context (after " /" or after "(")174    const full_path = blk: {175        if (std.mem.indexOf(u8, before, " /")) |sp| break :blk before[sp + 1 ..];176        if (std.mem.indexOf(u8, before, "(")) |paren| break :blk before[paren + 1 ..];177        break :blk before;178    };179180    const display = if (isUserPath(full_path)) full_path else justFilename(full_path);181    return std.fmt.allocPrint(allocator, "{s}  line {s}", .{ display, line_num_str }) catch null;182}183184pub fn extractLocation(allocator: std.mem.Allocator, text: []const u8) ?[]const u8 {185    var it = std.mem.splitScalar(u8, text, '\n');186    while (it.next()) |line| {187        if (findLocationInLine(allocator, line)) |loc| return loc;188    }189    return null;190}191192// Find a /nix/store/xxx.drv path in stderr for the "nix log" suggestion.193fn extractDrvPath(text: []const u8) ?[]const u8 {194    var it = std.mem.splitScalar(u8, text, '\n');195    while (it.next()) |line| {196        if (std.mem.indexOf(u8, line, "/nix/store/")) |start| {197            const after = line[start..];198            if (std.mem.indexOf(u8, after, ".drv")) |end| {199                return after[0 .. end + 4];200            }201        }202    }203    return null;204}205206// ─── Layer 1: Known pattern matching ─────────────────────────────────────────207//208// 25 nixos-rebuild patterns + home manager patterns. First match wins.209210pub fn matchKnownPattern(allocator: std.mem.Allocator, stderr: []const u8) ?TranslatedError {211    // ── nixos-rebuild patterns ────────────────────────────────────────────────212213    if (std.mem.indexOf(u8, stderr, "undefined variable '") != null) {214        const var_name = blk: {215            if (lineWith(stderr, "undefined variable '")) |l| {216                const pos = std.mem.indexOf(u8, l, "undefined variable '") orelse break :blk "this package";217                break :blk extractQuoted(l[pos..]) orelse "this package";218            }219            break :blk "this package";220        };221        return .{222            .title = std.fmt.allocPrint(allocator, "'{s}' is not defined", .{var_name}) catch "package is not defined",223            .body = std.fmt.allocPrint(allocator, "nixpkgs doesn't know about '{s}'.", .{var_name}) catch "nixpkgs doesn't know about this package.",224            .suggestion = std.fmt.allocPrint(allocator, "nina search {s}", .{var_name}) catch "nina search",225            .is_system = false,226        };227    }228229    if (std.mem.indexOf(u8, stderr, "has been renamed to") != null) {230        const body = blk: {231            if (lineWith(stderr, "has been renamed to")) |l| {232                const old = extractQuoted(l) orelse break :blk "the package has a new name.";233                if (std.mem.indexOf(u8, l, "renamed to")) |rp| {234                    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.";235                    break :blk std.fmt.allocPrint(allocator, "'{s}' has been renamed to '{s}'.", .{ old, new_name }) catch "the package has a new name.";236                }237                break :blk std.fmt.allocPrint(allocator, "'{s}' has a new name.", .{old}) catch "the package has a new name.";238            }239            break :blk "the package has been renamed.";240        };241        return .{242            .title = "package was renamed",243            .body = body,244            .suggestion = "update your config to use the new name",245            .is_system = false,246        };247    }248249    if (std.mem.indexOf(u8, stderr, "attribute '") != null and250        std.mem.indexOf(u8, stderr, "' missing") != null)251    {252        const attr = if (lineWith(stderr, "' missing")) |l| extractQuoted(l) orelse "this attribute" else "this attribute";253        return .{254            .title = std.fmt.allocPrint(allocator, "'{s}' not found", .{attr}) catch "attribute not found",255            .body = "this attribute doesn't exist in your nixpkgs version.",256            .suggestion = std.fmt.allocPrint(allocator, "check spelling or try: nina search {s}", .{attr}) catch "nina search",257            .is_system = false,258        };259    }260261    if (std.mem.indexOf(u8, stderr, "collision between") != null) {262        const is_hm = std.mem.indexOf(u8, stderr, "home-manager") != null;263        return .{264            .title = if (is_hm) "two home packages share a file" else "two packages share a file",265            .body = if (is_hm)266                "two home-manager packages are trying to provide the same file."267            else268                "two installed packages are trying to provide the same file.",269            .suggestion = "remove one of the conflicting packages",270            .is_system = false,271        };272    }273274    if (std.mem.indexOf(u8, stderr, "infinite recursion encountered") != null) {275        return .{276            .title = "circular reference in config",277            .body = "two options reference each other indefinitely.",278            .suggestion = "check your recent config edits",279            .is_system = false,280        };281    }282283    if (std.mem.indexOf(u8, stderr, "No space left on device") != null) {284        return .{285            .title = "disk is full",286            .body = "the nix store has no room to build.",287            .suggestion = "nina clean",288            .is_system = true,289        };290    }291292    if (std.mem.indexOf(u8, stderr, "hash mismatch") != null) {293        return .{294            .title = "download checksum mismatch",295            .body = "the downloaded file doesn't match its expected hash.",296            .suggestion = "try again — this is usually temporary",297            .is_system = true,298        };299    }300301    if (std.mem.indexOf(u8, stderr, "cannot write lock file") != null) {302        return .{303            .title = "can't write flake.lock",304            .body = "the lock file can't be updated in this directory.",305            .suggestion = "run with --no-write-lock-file",306            .is_system = false,307        };308    }309310    if (std.mem.indexOf(u8, stderr, "SSL certificate") != null) {311        return .{312            .title = "ssl error",313            .body = "the connection couldn't be verified.",314            .suggestion = "check your network connection",315            .is_system = true,316        };317    }318319    if (std.mem.indexOf(u8, stderr, "permission denied") != null) {320        return .{321            .title = "permission denied",322            .body = "nina needs elevated access for this operation.",323            .suggestion = "check that sudo is available",324            .is_system = true,325        };326    }327328    if (std.mem.indexOf(u8, stderr, "is not allowed by the 'allowed-uris'") != null) {329        return .{330            .title = "url not in allowed list",331            .body = "this fetch is blocked by nix's allowed-uris setting.",332            .suggestion = "add the url to nix.settings.allowed-uris in your config",333            .is_system = false,334        };335    }336337    if (std.mem.indexOf(u8, stderr, "top-level") != null and338        std.mem.indexOf(u8, stderr, "is not available") != null)339    {340        const pkg = if (lineWith(stderr, "is not available")) |l| extractQuoted(l) orelse "this package" else "this package";341        return .{342            .title = std.fmt.allocPrint(allocator, "'{s}' is not available", .{pkg}) catch "package is not available",343            .body = "this package doesn't exist in your current nixpkgs.",344            .suggestion = std.fmt.allocPrint(allocator, "nina search {s}  to find the right name", .{pkg}) catch "nina search",345            .is_system = false,346        };347    }348349    if (std.mem.indexOf(u8, stderr, "is marked as broken") != null) {350        const pkg = if (lineWith(stderr, "is marked as broken")) |l| extractQuoted(l) orelse "this package" else "this package";351        return .{352            .title = "package is marked broken",353            .body = std.fmt.allocPrint(allocator, "'{s}' is marked broken in nixpkgs.", .{pkg}) catch "this package is marked broken in nixpkgs.",354            .suggestion = std.fmt.allocPrint(allocator, "check if there's a working alternative: nina search {s}", .{pkg}) catch "nina search",355            .is_system = false,356        };357    }358359    if (std.mem.indexOf(u8, stderr, "requires unfree license") != null or360        std.mem.indexOf(u8, stderr, "has an unfree license") != null)361    {362        const pkg = blk: {363            if (lineWith(stderr, "unfree")) |l| break :blk extractQuoted(l) orelse "this package";364            break :blk "this package";365        };366        return .{367            .title = "package has an unfree license",368            .body = std.fmt.allocPrint(allocator, "'{s}' uses a license that nix won't install by default.", .{pkg}) catch "this package uses an unfree license.",369            .suggestion = "add nixpkgs.config.allowUnfree = true; to your config",370            .is_system = false,371        };372    }373374    if (std.mem.indexOf(u8, stderr, "sandbox violation") != null) {375        return .{376            .title = "sandbox access denied",377            .body = "a build tried to access something outside its sandbox.",378            .suggestion = "check the package derivation for network/filesystem access",379            .is_system = true,380        };381    }382383    if (std.mem.indexOf(u8, stderr, "output path") != null and384        std.mem.indexOf(u8, stderr, "already exists") != null)385    {386        return .{387            .title = "output path conflict",388            .body = "a store path from a previous failed build is in the way.",389            .suggestion = "nina store gc  then try again",390            .is_system = true,391        };392    }393394    if (std.mem.indexOf(u8, stderr, "cannot connect to daemon") != null) {395        return .{396            .title = "nix daemon is not running",397            .body = "the nix daemon isn't available.",398            .suggestion = "sudo systemctl start nix-daemon",399            .is_system = true,400        };401    }402403    if (std.mem.indexOf(u8, stderr, "does not provide attribute") != null) {404        const attr = blk: {405            if (lineWith(stderr, "does not provide attribute")) |l| {406                if (std.mem.indexOf(u8, l, "provide attribute")) |pos| {407                    break :blk extractQuoted(l[pos..]) orelse "the requested output";408                }409            }410            break :blk "the requested output";411        };412        return .{413            .title = "flake attribute not found",414            .body = std.fmt.allocPrint(allocator, "'{s}' doesn't exist in this flake.", .{attr}) catch "the flake attribute doesn't exist.",415            .suggestion = "run nina flake show  to see available outputs",416            .is_system = false,417        };418    }419420    if (std.mem.indexOf(u8, stderr, "is not valid") != null and421        std.mem.indexOf(u8, stderr, "/nix/store/") != null)422    {423        return .{424            .title = "store path is invalid",425            .body = "a referenced path doesn't exist in the store.",426            .suggestion = "nina store repair",427            .is_system = true,428        };429    }430431    if (std.mem.indexOf(u8, stderr, "missing input 'nixpkgs'") != null) {432        return .{433            .title = "nixpkgs input missing",434            .body = "the flake can't find its nixpkgs input.",435            .suggestion = "nina flake update",436            .is_system = false,437        };438    }439440    if (std.mem.indexOf(u8, stderr, "getting status of") != null and441        std.mem.indexOf(u8, stderr, "flake.nix") != null)442    {443        return .{444            .title = "flake.nix not found",445            .body = "no flake.nix in this directory.",446            .suggestion = "nina flake init  to create one",447            .is_system = false,448        };449    }450451    if (std.mem.indexOf(u8, stderr, "syntax error") != null and452        std.mem.indexOf(u8, stderr, "unexpected") != null)453    {454        return .{455            .title = "syntax error in config",456            .body = "there's a syntax error in your NixOS config.",457            .suggestion = "run nina edit  to fix it",458            .is_system = false,459            .location = extractLocation(allocator, stderr),460        };461    }462463    if (std.mem.indexOf(u8, stderr, "while evaluating") != null and464        std.mem.indexOf(u8, stderr, "configuration.nix") != null)465    {466        const inner = innermostError(stderr) orelse "evaluation failed";467        const body = if (std.mem.startsWith(u8, inner, "error: ")) inner[7..] else inner;468        return .{469            .title = "config evaluation failed",470            .body = body,471            .suggestion = "run nina check  for details",472            .is_system = false,473        };474    }475476    if (std.mem.indexOf(u8, stderr, "type error") != null) {477        return .{478            .title = "type mismatch in config",479            .body = "a config option received a value of the wrong type.",480            .suggestion = "check the option type in the NixOS manual",481            .is_system = false,482            .location = extractLocation(allocator, stderr),483        };484    }485486    if (std.mem.indexOf(u8, stderr, "does not match") != null and487        std.mem.indexOf(u8, stderr, "option") != null)488    {489        const opt = if (lineWith(stderr, "does not match")) |l| extractQuoted(l) orelse "this option" else "this option";490        return .{491            .title = "wrong value for option",492            .body = std.fmt.allocPrint(allocator, "'{s}' received an unexpected value.", .{opt}) catch "an option received an unexpected value.",493            .suggestion = "check the option documentation: man configuration.nix",494            .is_system = false,495        };496    }497498    // "warning: the following units failed: svc.service"499    // switch-to-configuration exits 4 when one or more units fail to start.500    if (std.mem.indexOf(u8, stderr, "the following units failed:") != null) {501        const svc = blk: {502            if (lineWith(stderr, "the following units failed:")) |l| {503                if (std.mem.indexOf(u8, l, "failed:")) |p| {504                    const rest = std.mem.trim(u8, l[p + 7 ..], " \t");505                    if (rest.len > 0 and rest.len <= 80) break :blk rest;506                }507            }508            break :blk "a service";509        };510        return .{511            .title = "service failed after upgrade",512            .body = std.fmt.allocPrint(allocator, "{s} failed to start after activation.", .{svc}) catch "a service failed to start after activation.",513            .suggestion = std.fmt.allocPrint(allocator, "systemctl status {s}", .{svc}) catch "systemctl status <service>",514            .is_system = true,515        };516    }517518    // ── home manager patterns ─────────────────────────────────────────────────519520    if (std.mem.indexOf(u8, stderr, "The option") != null and521        std.mem.indexOf(u8, stderr, "does not exist") != null)522    {523        const opt = if (lineWith(stderr, "does not exist")) |l| extractQuoted(l) orelse "this option" else "this option";524        return .{525            .title = "unknown home-manager option",526            .body = std.fmt.allocPrint(allocator, "'{s}' is not a valid home-manager option.", .{opt}) catch "this option doesn't exist in home-manager.",527            .suggestion = "check the home-manager option name",528            .is_system = false,529        };530    }531532    if (std.mem.indexOf(u8, stderr, "is used but not defined") != null) {533        const mod = if (lineWith(stderr, "is used but not defined")) |l| extractQuoted(l) orelse "a module" else "a module";534        return .{535            .title = "module not imported",536            .body = std.fmt.allocPrint(allocator, "'{s}' is used but not imported.", .{mod}) catch "a module is used but not imported.",537            .suggestion = "add the module import to your home.nix",538            .is_system = false,539        };540    }541542    return null;543}544545// ─── Layer 2: Structured extraction ──────────────────────────────────────────546//547// When no pattern matches, extract the innermost error from nix's nested output548// and present it with any available location information.549550pub fn extractStructured(allocator: std.mem.Allocator, stderr: []const u8) ?TranslatedError {551    if (stderr.len == 0) return null;552553    const inner = innermostError(stderr) orelse return null;554    if (inner.len == 0) return null;555556    // Strip "error: " prefix, cap at 3 lines, skip store paths and note: lines.557    const raw_msg = if (std.mem.startsWith(u8, inner, "error: ")) inner[7..] else inner;558    const body = cleanExtractedBody(allocator, raw_msg);559    if (body.len == 0) return null;560561    const loc = extractLocation(allocator, stderr);562    const drv = extractDrvPath(stderr);563    const suggestion = if (drv) |path|564        std.fmt.allocPrint(allocator, "nina doctor  for a full diagnostic\nnix log {s}", .{path}) catch "nina doctor  for a full diagnostic"565    else566        "nina doctor  for a full diagnostic";567568    return .{569        .title = "build failed",570        .body = body,571        .suggestion = suggestion,572        .is_system = true,573        .location = loc,574    };575}576577fn cleanExtractedBody(allocator: std.mem.Allocator, raw: []const u8) []const u8 {578    var parts: [3][]const u8 = undefined;579    var count: usize = 0;580    var it = std.mem.splitScalar(u8, raw, '\n');581    while (it.next()) |line| {582        if (count >= 3) break;583        const t = std.mem.trim(u8, line, " \t");584        if (t.len == 0) continue;585        // Skip pure store paths and builder output lines586        if (std.mem.startsWith(u8, t, "/nix/store/")) continue;587        if (std.mem.startsWith(u8, t, "note:") and std.mem.indexOf(u8, t, "/nix/store/") != null) continue;588        if (std.mem.startsWith(u8, t, "> ")) continue;589        parts[count] = t;590        count += 1;591    }592    if (count == 0) return "";593    if (count == 1) return allocator.dupe(u8, parts[0]) catch "";594    return std.mem.join(allocator, "\n", parts[0..count]) catch "";595}596597// ─── Layer 3: Graceful fallback ───────────────────────────────────────────────598599pub fn fallback(allocator: std.mem.Allocator, stderr: []const u8) TranslatedError {600    const drv = extractDrvPath(stderr);601    const suggestion = if (drv) |path|602        std.fmt.allocPrint(allocator, "nina doctor  for diagnostics\nnix log {s}", .{path}) catch "nina doctor  for diagnostics"603    else604        "nina doctor  for diagnostics";605    return .{606        .title = "build failed",607        .body = "something went wrong that nina couldn't translate.",608        .suggestion = suggestion,609        .is_system = true,610    };611}612613// ─── Entry point ─────────────────────────────────────────────────────────────614615pub fn translateError(allocator: std.mem.Allocator, stderr: []const u8) TranslatedError {616    if (matchKnownPattern(allocator, stderr)) |t| return t;617    if (extractStructured(allocator, stderr)) |t| return t;618    return fallback(allocator, stderr);619}620621// ─── Warning extraction ───────────────────────────────────────────────────────622623const warning_translations = [_][2][]const u8{624    .{ "is too new", "nixpkgs version is ahead of your channel" },625    .{ "unknown setting", "unknown nix setting — check nix.conf" },626    .{ "Git tree", "uncommitted changes in your flake directory" },627    .{ "not writing modified lock file", "flake.lock is out of date — run nina flake update" },628};629630fn translateWarning(msg: []const u8) []const u8 {631    for (warning_translations) |pair| {632        if (std.mem.indexOf(u8, msg, pair[0]) != null) return pair[1];633    }634    return msg;635}636637// Extract and translate warning lines from captured stderr.638// Caller owns the returned slice and each string within it.639pub fn extractWarnings(allocator: std.mem.Allocator, stderr: []const u8) ![][]const u8 {640    var warnings: std.ArrayList([]const u8) = .empty;641    var it = std.mem.splitScalar(u8, stderr, '\n');642    while (it.next()) |line| {643        const t = std.mem.trim(u8, line, " \t");644        if (!std.mem.startsWith(u8, t, "warning:")) continue;645        const msg = std.mem.trim(u8, t[8..], " \t");646        if (msg.len == 0) continue;647        const translated = translateWarning(msg);648        try warnings.append(allocator, try allocator.dupe(u8, translated));649    }650    return warnings.toOwnedSlice(allocator);651}652653// ─── Tests ───────────────────────────────────────────────────────────────────654655test "layer 1: undefined variable" {656    const t = matchKnownPattern(std.testing.allocator, "error: undefined variable 'pkgs.notexist'");657    try std.testing.expect(t != null);658    defer std.testing.allocator.free(t.?.title);659    defer std.testing.allocator.free(t.?.body);660    defer std.testing.allocator.free(t.?.suggestion);661    try std.testing.expect(std.mem.indexOf(u8, t.?.title, "notexist") != null);662}663664test "layer 1: disk full" {665    const t = matchKnownPattern(std.testing.allocator, "error: No space left on device");666    try std.testing.expect(t != null);667    try std.testing.expectEqualStrings("disk is full", t.?.title);668    try std.testing.expect(t.?.is_system);669}670671test "layer 1: unfree" {672    const t = matchKnownPattern(std.testing.allocator, "error: Package 'discord' has an unfree license");673    try std.testing.expect(t != null);674    defer std.testing.allocator.free(t.?.body);675    try std.testing.expectEqualStrings("package has an unfree license", t.?.title);676}677678test "layer 1: no match returns null" {679    const t = matchKnownPattern(std.testing.allocator, "some unrecognized error text");680    try std.testing.expect(t == null);681}682683test "layer 1: service activation failure" {684    const t = matchKnownPattern(std.testing.allocator, "warning: the following units failed: rtorrent.service\n" ++685        "Command 'systemd-run ...' returned non-zero exit status 4.");686    try std.testing.expect(t != null);687    defer std.testing.allocator.free(t.?.body);688    defer std.testing.allocator.free(t.?.suggestion);689    try std.testing.expectEqualStrings("service failed after upgrade", t.?.title);690    try std.testing.expect(std.mem.indexOf(u8, t.?.body, "rtorrent.service") != null);691    try std.testing.expect(std.mem.indexOf(u8, t.?.suggestion, "rtorrent.service") != null);692}693694test "layer 3: fallback always returns" {695    const t = fallback(std.testing.allocator, "");696    try std.testing.expectEqualStrings("build failed", t.title);697    try std.testing.expect(t.is_system);698}699700test "translateError: falls through to fallback" {701    const t = translateError(std.testing.allocator, "unrecognized garbage stderr");702    try std.testing.expectEqualStrings("build failed", t.title);703}704705test "staticMessage" {706    try std.testing.expectEqualStrings("~/.nina.conf not found", staticMessage(error.ConfigNotFound));707}708709test "extractWarnings: translates known warnings" {710    const stderr = "warning: Git tree '/home/user/config' is dirty\nwarning: unknown setting 'foo'\n";711    const ws = try extractWarnings(std.testing.allocator, stderr);712    defer {713        for (ws) |w| std.testing.allocator.free(w);714        std.testing.allocator.free(ws);715    }716    try std.testing.expectEqual(@as(usize, 2), ws.len);717    try std.testing.expectEqualStrings("uncommitted changes in your flake directory", ws[0]);718    try std.testing.expectEqualStrings("unknown nix setting — check nix.conf", ws[1]);719}