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 NotImplemented,23};24252627pub 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 => "~/.config/nina/config not found",77 error.ConfigParseError => "could not parse ~/.config/nina/config",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}91929394959697var 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}109110111112pub const TranslatedError = struct {113 title: []const u8,114 body: []const u8,115 116 117 suggestion: []const u8,118 is_system: bool = false,119 120 location: ?[]const u8 = null,121};122123124125126fn 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}132133134fn 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}141142143fn 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]; 167 const rest = line[nix_pos + 5 ..]; 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 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}191192193fn 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}205206207208209210pub fn matchKnownPattern(allocator: std.mem.Allocator, stderr: []const u8) ?TranslatedError {211 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 499 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 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}544545546547548549550pub 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 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 586 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}596597598599pub 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}612613614615pub 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}620621622623const 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}636637638639pub 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}652653654655test "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("~/.config/nina/config 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}