1const std = @import("std");2345pub 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};23242526pub 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}89909192939495var 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}107108109110pub const TranslatedError = struct {111 title: []const u8,112 body: []const u8,113 114 115 suggestion: []const u8,116 is_system: bool = false,117 118 location: ?[]const u8 = null,119};120121122123124fn 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}130131132fn 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}139140141fn 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]; 165 const rest = line[nix_pos + 5 ..]; 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 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}189190191fn 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}203204205206207208pub fn matchKnownPattern(allocator: std.mem.Allocator, stderr: []const u8) ?TranslatedError {209 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 497 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 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}542543544545546547548pub 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 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 584 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}594595596597pub 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}610611612613pub 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}618619620621const 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}634635636637pub 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}650651652653test "layer 1: undefined variable" {654 const t = matchKnownPattern(std.testing.allocator, "error: undefined variable 'pkgs.notexist'");655 try std.testing.expect(t != null);656 defer std.testing.allocator.free(t.?.title);657 defer std.testing.allocator.free(t.?.body);658 defer std.testing.allocator.free(t.?.suggestion);659 try std.testing.expect(std.mem.indexOf(u8, t.?.title, "notexist") != null);660}661662test "layer 1: disk full" {663 const t = matchKnownPattern(std.testing.allocator, "error: No space left on device");664 try std.testing.expect(t != null);665 try std.testing.expectEqualStrings("disk is full", t.?.title);666 try std.testing.expect(t.?.is_system);667}668669test "layer 1: unfree" {670 const t = matchKnownPattern(std.testing.allocator, "error: Package 'discord' has an unfree license");671 try std.testing.expect(t != null);672 defer std.testing.allocator.free(t.?.body);673 try std.testing.expectEqualStrings("package has an unfree license", t.?.title);674}675676test "layer 1: no match returns null" {677 const t = matchKnownPattern(std.testing.allocator, "some unrecognized error text");678 try std.testing.expect(t == null);679}680681test "layer 1: service activation failure" {682 const t = matchKnownPattern(std.testing.allocator, "warning: the following units failed: rtorrent.service\n" ++683 "Command 'systemd-run ...' returned non-zero exit status 4.");684 try std.testing.expect(t != null);685 defer std.testing.allocator.free(t.?.body);686 defer std.testing.allocator.free(t.?.suggestion);687 try std.testing.expectEqualStrings("service failed after upgrade", t.?.title);688 try std.testing.expect(std.mem.indexOf(u8, t.?.body, "rtorrent.service") != null);689 try std.testing.expect(std.mem.indexOf(u8, t.?.suggestion, "rtorrent.service") != null);690}691692test "layer 3: fallback always returns" {693 const t = fallback(std.testing.allocator, "");694 try std.testing.expectEqualStrings("build failed", t.title);695 try std.testing.expect(t.is_system);696}697698test "translateError: falls through to fallback" {699 const t = translateError(std.testing.allocator, "unrecognized garbage stderr");700 try std.testing.expectEqualStrings("build failed", t.title);701}702703test "staticMessage" {704 try std.testing.expectEqualStrings("~/.nina.conf not found", staticMessage(error.ConfigNotFound));705}706707test "extractWarnings: translates known warnings" {708 const stderr = "warning: Git tree '/home/user/config' is dirty\nwarning: unknown setting 'foo'\n";709 const ws = try extractWarnings(std.testing.allocator, stderr);710 defer {711 for (ws) |w| std.testing.allocator.free(w);712 std.testing.allocator.free(ws);713 }714 try std.testing.expectEqual(@as(usize, 2), ws.len);715 try std.testing.expectEqualStrings("uncommitted changes in your flake directory", ws[0]);716 try std.testing.expectEqualStrings("unknown nix setting — check nix.conf", ws[1]);717}