From 72bc86aa2d0baf90ae1011697186f34b0e271a42 Mon Sep 17 00:00:00 2001 From: Jeremy Huntwork Date: Sun, 23 Aug 2026 12:43:33 -0400 Subject: [PATCH] Preserve error context across command boundaries Command-layer translations were collapsing operational failures into generic messages and exit statuses, losing the resource and diagnostic details that identify what failed. Route failures through the shared typed error boundary so categories, subjects, details, and category-derived exits survive to the CLI. Intentional command outcomes remain unchanged, including verification findings, empty search results, strict sync aggregation, and uninstall refusal. --- src/activation.zig | 22 +++--- src/cli/cli.zig | 33 +++++--- src/cli/command.zig | 29 +++---- src/cli/commands/build.zig | 93 ++++------------------- src/cli/commands/dev.zig | 60 ++------------- src/cli/commands/etc.zig | 75 ++++++------------- src/cli/commands/gc.zig | 7 +- src/cli/commands/generation.zig | 24 +++--- src/cli/commands/import.zig | 29 +------ src/cli/commands/init.zig | 6 +- src/cli/commands/key.zig | 119 +++++++---------------------- src/cli/commands/profile.zig | 129 ++++++++++++++------------------ src/cli/commands/release.zig | 13 +--- src/cli/commands/search.zig | 8 +- src/cli/commands/service.zig | 21 ++---- src/cli/commands/verify.zig | 9 +-- src/cli/commands_test.zig | 104 +++++++++++++++++++++++++ src/errors.zig | 87 +++++++++++++++++++-- src/install.zig | 59 ++++++++++++++- src/services.zig | 11 +-- 20 files changed, 450 insertions(+), 488 deletions(-) diff --git a/src/activation.zig b/src/activation.zig index 3d90e56..fc6dc98 100644 --- a/src/activation.zig +++ b/src/activation.zig @@ -28,7 +28,7 @@ const store = @import("store.zig"); /// Activation error set const Std = errors.StandardErrors; -pub const ActivationError = Std.OutOfMemory || Std.FileSystem || Std.PermissionDenied || Std.InvalidInput || error{ +pub const ActivationError = Std.OutOfMemory || Std.FileSystem || Std.PermissionDenied || Std.InvalidInput || Std.CorruptData || error{ GenerationNotFound, // Target generation doesn't exist ManifestNotFound, // Generation exists but manifest is missing/unreadable DuplicateEtcTemplate, // Two packages provide same /etc path @@ -71,7 +71,7 @@ pub fn switchProfileGeneration( }; defer ctx.allocator.free(store_root_a); gcroots.updateRoots(ctx.allocator, store_root_a, gc_roots_dir, profile_dir, gcroots.DEFAULT_RETENTION_COUNT) catch |err| { - return mapGCRootError(err); + return ctx.fail(mapGCRootError(err), gc_roots_dir, "failed to update GC roots after activation"); }; } @@ -110,7 +110,7 @@ pub fn activateSystemGeneration( }; defer ctx.allocator.free(store_root_b); gcroots.updateRoots(ctx.allocator, store_root_b, gc_roots_dir, profile_dir, gcroots.DEFAULT_RETENTION_COUNT) catch |err| { - return mapGCRootError(err); + return ctx.fail(mapGCRootError(err), gc_roots_dir, "failed to update GC roots after activation"); }; const result = SystemActivationResult{ @@ -249,10 +249,10 @@ fn loadValidatedTargetManifest( }; return ctx.fail(switch (err) { generation.GenerationError.OutOfMemory => ActivationError.OutOfMemory, - generation.GenerationError.GenerationNotFound, generation.GenerationError.InvalidManifest, generation.GenerationError.ParseError, - => ActivationError.ManifestNotFound, + => ActivationError.CorruptData, + generation.GenerationError.GenerationNotFound => ActivationError.ManifestNotFound, else => ActivationError.FileSystem, }, manifest_path, detail); }; @@ -522,7 +522,7 @@ fn validateGenerationStorePaths( defer ctx.allocator.free(normalized_store_path); if (!path_safety.isWithinBoundary(normalized_store_path, normalized_store_root)) { - return ctx.fail(ActivationError.InvalidInput, pkg.store_path, "store path outside store root"); + return ctx.fail(ActivationError.CorruptData, pkg.store_path, "store path outside store root"); } var store_dir = path_mod.openExistingDir(pkg.store_path) catch |err| { @@ -550,14 +550,14 @@ fn validateGenerationStorePaths( if (do_hash_verify) { if (pkg.content_hash.len != 64) { - return ctx.fail(ActivationError.InvalidInput, pkg.store_path, "invalid content hash length in manifest"); + return ctx.fail(ActivationError.CorruptData, pkg.store_path, "invalid content hash length in manifest"); } const format = package_manifest.detectFormat(ctx.allocator, pkg.store_path) catch |err| { return ctx.fail(switch (err) { package_manifest.ManifestError.OutOfMemory => ActivationError.OutOfMemory, package_manifest.ManifestError.PermissionDenied => ActivationError.PermissionDenied, - package_manifest.ManifestError.InvalidInput => ActivationError.InvalidInput, + package_manifest.ManifestError.InvalidInput => ActivationError.CorruptData, else => ActivationError.FileSystem, }, pkg.store_path, "failed to detect package manifest format"); }; @@ -571,7 +571,7 @@ fn validateGenerationStorePaths( return ctx.fail(switch (err) { hash.HashError.OutOfMemory => ActivationError.OutOfMemory, hash.HashError.PermissionDenied => ActivationError.PermissionDenied, - hash.HashError.InvalidInput => ActivationError.InvalidInput, + hash.HashError.InvalidInput => ActivationError.CorruptData, else => ActivationError.FileSystem, }, pkg.store_path, "failed to compute store content hash"); }; @@ -587,7 +587,7 @@ fn validateGenerationStorePaths( } } if (!accepted_transitional) { - return ctx.fail(ActivationError.InvalidInput, pkg.store_path, "store content hash mismatch"); + return ctx.fail(ActivationError.CorruptData, pkg.store_path, "store content hash mismatch"); } } } @@ -1154,7 +1154,7 @@ test "system profile activation rejects mismatched content hash without verify-s // Activate with .fast (no explicit verify-store) — should STILL fail // because system profile hash verification is now mandatory try std.testing.expectError( - ActivationError.InvalidInput, + ActivationError.CorruptData, activateSystemGeneration(&test_env.ctx, 2, .fast), ); diff --git a/src/cli/cli.zig b/src/cli/cli.zig index e55691b..dcb68c6 100644 --- a/src/cli/cli.zig +++ b/src/cli/cli.zig @@ -79,16 +79,19 @@ pub const CLI = struct { var parse_args_with_program = std.ArrayList([]const u8).empty; defer parse_args_with_program.deinit(self.allocator); parse_args_with_program.append(self.allocator, args[0]) catch { - return 1; + emitFormattedCliError(ctx, null, MereError.OutOfMemory, null); + return command.exitCodeForError(MereError.OutOfMemory); }; for (prescan_result.filtered_args) |arg| { parse_args_with_program.append(self.allocator, arg) catch { - return 1; + emitFormattedCliError(ctx, null, MereError.OutOfMemory, null); + return command.exitCodeForError(MereError.OutOfMemory); }; } - var inferred_command_path = self.inferCommandPath(parse_args_with_program.items) catch { - return 1; + var inferred_command_path = self.inferCommandPath(parse_args_with_program.items) catch |err| { + emitFormattedCliError(ctx, null, err, null); + return command.exitCodeForError(err); }; defer inferred_command_path.deinit(self.allocator); @@ -100,10 +103,16 @@ pub const CLI = struct { // Store verbose flag in parsed_args for consistency if (prescan_result.verbose) { - parsed_args.global_flags.put("verbose", types.FlagValue{ .bool = true }) catch {}; + parsed_args.global_flags.put("verbose", types.FlagValue{ .bool = true }) catch { + emitFormattedCliError(ctx, commandPhase(parsed_args.command_path), MereError.OutOfMemory, null); + return command.exitCodeForError(MereError.OutOfMemory); + }; } if (prescan_result.no_color) { - parsed_args.global_flags.put("no-color", types.FlagValue{ .bool = true }) catch {}; + parsed_args.global_flags.put("no-color", types.FlagValue{ .bool = true }) catch { + emitFormattedCliError(ctx, commandPhase(parsed_args.command_path), MereError.OutOfMemory, null); + return command.exitCodeForError(MereError.OutOfMemory); + }; } // Handle case where no command was specified - this is a usage error @@ -323,10 +332,14 @@ pub const CLI = struct { return 2; // Usage error for invalid flags }; - // Execute the command - const result = cmd.handler(ctx, args) catch |err| { - emitFormattedCliError(ctx, commandPhase(args.command_path), err, null); - return 1; // Execution error + // Execute the command. This is the final error boundary: handlers may + // return a CommandResult themselves, but any propagated MereError still + // receives the same diagnostic formatting and exit-code mapping. + const result = cmd.handler(ctx, args) catch |err| blk: { + break :blk command.errorResult(ctx, err, null) catch { + emitFormattedCliError(ctx, commandPhase(args.command_path), err, null); + return command.exitCodeForError(err); + }; }; if (result.segments) |segments| { diff --git a/src/cli/command.zig b/src/cli/command.zig index c19d882..07c0c7f 100644 --- a/src/cli/command.zig +++ b/src/cli/command.zig @@ -258,11 +258,8 @@ pub fn exitCodeForError(err: MereError) u8 { /// a CommandResult the handler should return immediately. pub fn acquireStoreLockOrResult(ctx: *mere.Context) !?types.CommandResult { ctx.acquireStoreLock() catch |err| { - return types.CommandResult{ - .success = false, - .exit_code = exitCodeForError(mere.errors.ErrorMapping.mapZigError(err)), - .message = try ctx.allocator.dupe(u8, mere.errors.getUserFriendlyMessage(err)), - }; + ctx.setDiagnosticContext(ctx.root_path, "failed to acquire store lock"); + return try errorResult(ctx, err, null); }; return null; } @@ -291,18 +288,24 @@ pub fn errorResult(ctx: *mere.Context, err: anyerror, message_override: ?[]const }; } -test "errorResult maps the exit code correctly instead of hardcoding it" { +test "errorResult preserves the standard vocabulary's exit-code classes" { const testing = std.testing; var ctx = mere.Context.init(testing.allocator, "/test"); defer ctx.deinit(); - // Regression: every CLI handler's error boundary used to hardcode - // exit_code = 1 regardless of the underlying error, so a - // PermissionDenied surfaced identically to an out-of-memory failure. - const result = try errorResult(&ctx, error.PermissionDenied, null); - defer ctx.allocator.free(result.message.?); - try testing.expectEqual(@as(u8, 13), result.exit_code); - try testing.expect(!result.success); + const cases = [_]struct { err: anyerror, exit_code: u8 }{ + .{ .err = error.InvalidInput, .exit_code = 2 }, + .{ .err = error.PermissionDenied, .exit_code = 13 }, + .{ .err = error.OutOfMemory, .exit_code = 12 }, + .{ .err = error.OutOfDisk, .exit_code = 12 }, + .{ .err = error.TooManyFiles, .exit_code = 12 }, + }; + for (cases) |case| { + const result = try errorResult(&ctx, case.err, null); + defer ctx.allocator.free(result.message.?); + try testing.expectEqual(case.exit_code, result.exit_code); + try testing.expect(!result.success); + } } test "errorResult folds existing diagnostic context into the message" { diff --git a/src/cli/commands/build.zig b/src/cli/commands/build.zig index 5b742b5..8b0f7d4 100644 --- a/src/cli/commands/build.zig +++ b/src/cli/commands/build.zig @@ -8,8 +8,6 @@ const download = mere.download; const build = mere.build; const DiagnosticContext = mere.errors.DiagnosticContext; const getUserFriendlyMessage = mere.errors.getUserFriendlyMessage; -const ui = mere.ui; -const emit = ui.emit; /// Build (dev) subcommand metadata const build_meta = command.CommandMeta{ @@ -51,33 +49,14 @@ fn handleBuild(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!type // Ensure configuration is loaded for dependency resolution _ = ctx.getConfig() catch |err| { - // Enrich diagnostic context for configuration load failures ctx.setDiagnosticContext("configuration", "failed to load configuration"); - const user_message = getUserFriendlyMessage(err); - const error_ctx = ctx.getDiagnosticContext().toErrorContext(); - const formatted_message = error_ctx.formatWithMessage(ctx.allocator, user_message) catch user_message; - - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Configuration error: {s}", .{formatted_message}), - }; + return try command.errorResult(ctx, err, "configuration error"); }; // Initialize real curl-backed transfer client for the build request. var curl_client = download.CurlTransferClient.init(ctx, command.user_agent) catch |err| { - // Add diagnostic context for download initialization failures ctx.setDiagnosticContext(recipe_path, "failed to initialize download client"); - // Get user-friendly error message and format with context - const user_message = getUserFriendlyMessage(err); - const error_ctx = ctx.getDiagnosticContext().toErrorContext(); - const formatted_message = error_ctx.formatWithMessage(ctx.allocator, user_message) catch user_message; - - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, formatted_message), - }; + return try command.errorResult(ctx, err, null); }; defer download.CurlTransferClient.cleanupFn(ctx, curl_client); const client = curl_client.client(); @@ -85,82 +64,38 @@ fn handleBuild(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!type // Load recipe file contents into memory (allocator-owned buffer) var buf_path: [std.fs.max_path_bytes]u8 = undefined; const abs_recipe_path = path.resolveToAbsolutePath(recipe_path, &buf_path) catch |err| { - // Enrich diagnostic context for path resolution failures ctx.setDiagnosticContext(recipe_path, "failed to resolve recipe path"); - // Get user-friendly error message - const user_message = getUserFriendlyMessage(err); - - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Unable to resolve recipe path '{s}': {s}", .{ recipe_path, user_message }), - }; + return try command.errorResult(ctx, err, null); }; var recipe_file = path.openExistingFile(abs_recipe_path) catch |err| { - // Enrich diagnostic context for file open failures ctx.setDiagnosticContext(abs_recipe_path, "failed to open recipe file"); - // Get user-friendly error message - const user_message = getUserFriendlyMessage(err); - - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Unable to open recipe '{s}': {s}", .{ abs_recipe_path, user_message }), - }; + return try command.errorResult(ctx, err, null); }; defer recipe_file.close(path.currentIo()); // Prefer explicit size read to avoid readToEndAlloc FileTooBig errors and to validate size. const file_size = (recipe_file.stat(path.currentIo()) catch |err| { - // Enrich diagnostic context for stat failures ctx.setDiagnosticContext(abs_recipe_path, "failed to stat recipe file"); - // Get user-friendly error message - const user_message = getUserFriendlyMessage(err); - - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Unable to stat recipe '{s}': {s}", .{ abs_recipe_path, user_message }), - }; + return try command.errorResult(ctx, err, null); }).size; if (file_size > 1024 * 1024 * 10) { - // Enrich diagnostic context for oversized recipe files ctx.setDiagnosticContext(abs_recipe_path, "recipe file too large"); - - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Recipe file too large: {s}", .{abs_recipe_path}), - }; + return try command.errorResult(ctx, MereError.InvalidInput, "recipe file too large"); } const recipe_buf = try ctx.allocator.alloc(u8, file_size); defer ctx.allocator.free(recipe_buf); const bytes_read = recipe_file.readPositionalAll(path.currentIo(), recipe_buf, 0) catch |err| { - // Enrich diagnostic context for read failures ctx.setDiagnosticContext(abs_recipe_path, "failed to read recipe file"); - // Get user-friendly error message - const user_message = getUserFriendlyMessage(err); - - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Unable to read recipe '{s}': {s}", .{ abs_recipe_path, user_message }), - }; + return try command.errorResult(ctx, err, null); }; if (bytes_read != file_size) { - // Enrich diagnostic context for short reads ctx.setDiagnosticContext(abs_recipe_path, "short read while reading recipe file"); - - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Short read for recipe '{s}'", .{abs_recipe_path}), - }; + return try command.errorResult(ctx, MereError.FileSystem, "short read while reading recipe file"); } var request = build.BuildRequest.init(); @@ -212,11 +147,13 @@ fn handleBuild(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!type } } - emit.diagnostic(ctx, .build, "build failed", diagnostic_subject, diagnostic_details, base_message); - return types.CommandResult{ - .success = false, - .exit_code = 1, - }; + if (diagnostic_subject != null or diagnostic_details != null) { + ctx.withDiagnosticContext(DiagnosticContext{ + .subject = diagnostic_subject, + .details = diagnostic_details, + }); + } + return try command.errorResult(ctx, err, base_message); }; defer result.deinit(); diff --git a/src/cli/commands/dev.zig b/src/cli/commands/dev.zig index f4734ce..6d6b919 100644 --- a/src/cli/commands/dev.zig +++ b/src/cli/commands/dev.zig @@ -13,7 +13,6 @@ const Repository = mere.repository.Repository; const RepoError = mere.repository.Error; const MereError = mere.errors.MereError; const DiagnosticContext = mere.errors.DiagnosticContext; -const ErrorMapping = mere.errors.ErrorMapping; const getUserFriendlyMessage = mere.errors.getUserFriendlyMessage; /// Dev command metadata @@ -166,17 +165,7 @@ fn handleHash(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types // Perform hash calculation with error handling at CLI boundary const hash_str = hash.calculateFileHash(ctx, file_path) catch |err| { - // Get user-friendly error message and format with context - const user_message = getUserFriendlyMessage(err); - const error_ctx = ctx.getDiagnosticContext().toErrorContext(); - const formatted_message = error_ctx.formatWithMessage(ctx.allocator, user_message) catch user_message; - defer if (formatted_message.ptr != user_message.ptr) ctx.allocator.free(formatted_message); - - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, formatted_message), - }; + return try command.errorResult(ctx, err, null); }; // Return the computed hash in "hash filename" format (compatible with sha256sum) @@ -211,15 +200,7 @@ fn handleValidate(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t error.InvalidInput => "recipe validation failed", else => getUserFriendlyMessage(err), }; - const error_ctx = ctx.getDiagnosticContext().toErrorContext(); - const formatted_message = error_ctx.formatWithMessage(ctx.allocator, base_message) catch base_message; - defer if (formatted_message.ptr != base_message.ptr) ctx.allocator.free(formatted_message); - - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, formatted_message), - }; + return try command.errorResult(ctx, err, base_message); }; return types.CommandResult{ @@ -295,12 +276,7 @@ fn handleClean(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!type defer summary_parts.deinit(ctx.allocator); const clean_result = dev_cleanup.clean(ctx, selection) catch |err| { - const user_message = getUserFriendlyMessage(err); - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, user_message), - }; + return try command.errorResult(ctx, err, null); }; if (selection.workspaces) { @@ -365,20 +341,7 @@ fn handleRepoSign(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t ctx.withDiagnosticContext(diagnostic_ctx); performRepoSign(ctx, repo_name) catch |err| { - const mapped_error = ErrorMapping.mapModuleError(@TypeOf(err), err); - - const user_message = getUserFriendlyMessage(err); - const error_ctx = ctx.getDiagnosticContext().toErrorContext(); - const formatted_message = error_ctx.formatWithMessage(ctx.allocator, user_message) catch user_message; - defer if (formatted_message.ptr != user_message.ptr) ctx.allocator.free(formatted_message); - - const exit_code = command.exitCodeForError(mapped_error); - - return types.CommandResult{ - .success = false, - .exit_code = exit_code, - .message = try ctx.allocator.dupe(u8, formatted_message), - }; + return try command.errorResult(ctx, err, null); }; const sign_segments = [_]mere.ui.Segment{ @@ -446,20 +409,7 @@ fn handleRepoRemove(ctx: *mere.Context, args: *const types.ParsedArgs) MereError ctx.signing_key_path = key_path; performRepoRemove(ctx, repo_name, pkg_name, version, release, arch) catch |err| { - const mapped_error = ErrorMapping.mapModuleError(@TypeOf(err), err); - - const user_message = getUserFriendlyMessage(err); - const error_ctx = ctx.getDiagnosticContext().toErrorContext(); - const formatted_message = error_ctx.formatWithMessage(ctx.allocator, user_message) catch user_message; - defer if (formatted_message.ptr != user_message.ptr) ctx.allocator.free(formatted_message); - - const exit_code = command.exitCodeForError(mapped_error); - - return types.CommandResult{ - .success = false, - .exit_code = exit_code, - .message = try ctx.allocator.dupe(u8, formatted_message), - }; + return try command.errorResult(ctx, err, null); }; var release_buf: [32]u8 = undefined; diff --git a/src/cli/commands/etc.zig b/src/cli/commands/etc.zig index 6c73dbf..f31cee6 100644 --- a/src/cli/commands/etc.zig +++ b/src/cli/commands/etc.zig @@ -246,14 +246,7 @@ pub fn handleDiff(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t const left_path = if (entry.state == .missing) "/dev/null" else entry.etc_path; const diff_result = collectUnifiedDiff(ctx, left_path, entry.source_path) catch |err| { - return switch (err) { - MereError.OutOfMemory => MereError.OutOfMemory, - else => types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "failed to run diff -u"), - }, - }; + return try command.errorResult(ctx, err, "failed to run diff -u"); }; defer ctx.allocator.free(diff_result.output); defer ctx.allocator.free(diff_result.stderr); @@ -307,58 +300,32 @@ fn mapEtcCommandError(ctx: *mere.Context, err: etc.EtcError, default_msg: []cons etc.EtcError.PermissionDenied => "permission denied", else => default_msg, }; - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, msg), - }; + return command.errorResult(ctx, err, msg); } -fn mapActiveStatusError(ctx: *mere.Context, err: etc.ActiveStatusError) types.CommandResult { - return switch (err) { - error.NoActiveGeneration => .{ - .success = false, - .exit_code = 1, - .message = ctx.allocator.dupe(u8, "system profile has no active generation") catch "system profile has no active generation", - }, - error.OutOfMemory => .{ - .success = false, - .exit_code = 1, - .message = ctx.allocator.dupe(u8, "out of memory while loading active system generation") catch "out of memory while loading active system generation", - }, - error.DuplicateTemplate => .{ - .success = false, - .exit_code = 1, - .message = ctx.allocator.dupe(u8, "duplicate /etc template in active system generation") catch "duplicate /etc template in active system generation", - }, - error.PermissionDenied => .{ - .success = false, - .exit_code = 1, - .message = ctx.allocator.dupe(u8, "permission denied") catch "permission denied", - }, - else => .{ - .success = false, - .exit_code = 1, - .message = ctx.allocator.dupe(u8, "failed to inspect /etc state") catch "failed to inspect /etc state", - }, +fn mapActiveStatusError(ctx: *mere.Context, err: etc.ActiveStatusError) !types.CommandResult { + const message: ?[]const u8 = switch (err) { + error.NoActiveGeneration => "system profile has no active generation", + error.OutOfMemory => "out of memory while loading active system generation", + error.DuplicateTemplate => "duplicate /etc template in active system generation", + error.PermissionDenied => "permission denied", + else => "failed to inspect /etc state", }; + return command.errorResult(ctx, err, message); } fn mapActiveLookupError(ctx: *mere.Context, raw_path: []const u8, err: etc.ActiveLookupError) !types.CommandResult { - return switch (err) { - error.TemplateNotFound => .{ - .success = false, - .exit_code = 2, - .message = try std.fmt.allocPrint(ctx.allocator, "no active system default found for {s}", .{raw_path}), - }, - else => mapActiveStatusError(ctx, switch (err) { - error.NoActiveGeneration => error.NoActiveGeneration, - error.OutOfMemory => error.OutOfMemory, - error.PermissionDenied => error.PermissionDenied, - error.DuplicateTemplate => error.DuplicateTemplate, - else => error.FileSystem, - }), - }; + if (err == error.TemplateNotFound) { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(raw_path)); + return command.errorResult(ctx, MereError.InvalidInput, "no active system default found"); + } + return mapActiveStatusError(ctx, switch (err) { + error.NoActiveGeneration => error.NoActiveGeneration, + error.OutOfMemory => error.OutOfMemory, + error.PermissionDenied => error.PermissionDenied, + error.DuplicateTemplate => error.DuplicateTemplate, + else => error.FileSystem, + }); } /// Create the etc command with its subcommands diff --git a/src/cli/commands/gc.zig b/src/cli/commands/gc.zig index 34014c5..415c79a 100644 --- a/src/cli/commands/gc.zig +++ b/src/cli/commands/gc.zig @@ -57,7 +57,6 @@ pub fn handleGC(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!typ var result = gc.collectGarbage(ctx, .{ .dry_run = dry_run, }) catch |err| { - const diag = ctx.getDiagnosticContext(); const msg = switch (err) { gc.GCError.NoRoots => "no GC roots found - nothing is protected, refusing to run", gc.GCError.PermissionDenied => "permission denied", @@ -65,12 +64,8 @@ pub fn handleGC(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!typ else => "garbage collection failed", }; emit.stepEnd(ctx, .gc, "collect", false); - emit.diagnostic(ctx, .gc, msg, diag.subject, diag.details, null); emit.phaseEnd(ctx, .gc, false); - return types.CommandResult{ - .success = false, - .exit_code = 1, - }; + return try command.errorResult(ctx, err, msg); }; defer result.deinit(); diff --git a/src/cli/commands/generation.zig b/src/cli/commands/generation.zig index 1b4da78..7298252 100644 --- a/src/cli/commands/generation.zig +++ b/src/cli/commands/generation.zig @@ -216,8 +216,11 @@ pub fn handleList(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t }; defer ctx.allocator.free(profile_gc_dir); - const rooted_gens = gcroots.listGenerationRoots(ctx.allocator, profile_gc_dir) catch &[_]u32{}; - defer if (rooted_gens.len > 0) ctx.allocator.free(rooted_gens); + const rooted_gens = gcroots.listGenerationRoots(ctx.allocator, profile_gc_dir) catch |err| { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(profile_gc_dir)); + return try command.errorResult(ctx, err, "failed to list generation roots"); + }; + defer ctx.allocator.free(rooted_gens); // Build output var output: std.ArrayList(u8) = .empty; @@ -234,7 +237,10 @@ pub fn handleList(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t } break :blk false; }; - const is_kept = gcroots.isExplicitlyKept(ctx.allocator, profile_dir, gen) catch false; + const is_kept = gcroots.isExplicitlyKept(ctx.allocator, profile_dir, gen) catch |err| { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(profile_dir)); + return try command.errorResult(ctx, err, "failed to inspect generation keep state"); + }; var flags_buf: [32]u8 = undefined; var flags_len: usize = 0; @@ -446,12 +452,12 @@ pub fn handleActivate(ctx: *mere.Context, args: *const types.ParsedArgs) MereErr }; defer ctx.allocator.free(gen_path); - std.Io.Dir.accessAbsolute(path.currentIo(), gen_path, .{}) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "generation {d} not found", .{gen_num}), - }; + std.Io.Dir.accessAbsolute(path.currentIo(), gen_path, .{}) catch |err| { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(gen_path)); + if (err == error.FileNotFound) { + return try command.errorResult(ctx, activation.ActivationError.GenerationNotFound, "generation not found"); + } + return try command.errorResult(ctx, err, "failed to inspect generation"); }; const result = activation.activateSystemGeneration( diff --git a/src/cli/commands/import.zig b/src/cli/commands/import.zig index 13e49d7..e5ab46c 100644 --- a/src/cli/commands/import.zig +++ b/src/cli/commands/import.zig @@ -87,38 +87,13 @@ fn handleImport(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!typ var package_paths: []const []const u8 = positional_package_paths; if (package_paths.len == 0) { mere.build.collectBuildOutputPackageArchives(ctx, &owned_package_paths) catch |err| { - const mapped_error = mere.errors.ErrorMapping.mapModuleError(@TypeOf(err), err); - const exit_code = command.exitCodeForError(mapped_error); - const user_message = mere.errors.getUserFriendlyMessage(err); - return types.CommandResult{ - .success = false, - .exit_code = exit_code, - .message = try std.fmt.allocPrint(ctx.allocator, "Failed to collect package archives from /mere/dev/outputs: {s}", .{user_message}), - }; + return try command.errorResult(ctx, err, "failed to collect package archives from /mere/dev/outputs"); }; package_paths = owned_package_paths.items; } - // Error boundary: catch all errors and map them to user-friendly messages at CLI boundary performImport(ctx, repo_dir, package_paths, force) catch |err| { - // Map error to MereError vocabulary - const mapped_error = mere.errors.ErrorMapping.mapModuleError(@TypeOf(err), err); - - // Get user-friendly error message and format with context - const user_message = mere.errors.getUserFriendlyMessage(err); - const error_ctx = ctx.getDiagnosticContext().toErrorContext(); - const formatted_message = error_ctx.formatWithMessage(ctx.allocator, user_message) catch user_message; - defer if (formatted_message.ptr != user_message.ptr) ctx.allocator.free(formatted_message); - - // Return error result with appropriate exit code - // Note: Don't log here - the CLI layer handles error output via CommandResult.message - const exit_code = command.exitCodeForError(mapped_error); - - return types.CommandResult{ - .success = false, - .exit_code = exit_code, - .message = try ctx.allocator.dupe(u8, formatted_message), - }; + return try command.errorResult(ctx, err, null); }; // Success case - no error logging needed diff --git a/src/cli/commands/init.zig b/src/cli/commands/init.zig index 4ad8c87..3e1a54a 100644 --- a/src/cli/commands/init.zig +++ b/src/cli/commands/init.zig @@ -39,11 +39,7 @@ pub fn handleInit(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t init_mod.InitError.InvalidInput => "invalid filesystem state detected", init_mod.InitError.OutOfMemory => "out of memory", }; - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, msg), - }; + return try command.errorResult(ctx, err, msg); }; defer result.deinit(); diff --git a/src/cli/commands/key.zig b/src/cli/commands/key.zig index 231ae48..5af16fd 100644 --- a/src/cli/commands/key.zig +++ b/src/cli/commands/key.zig @@ -73,70 +73,22 @@ fn handleGenerate(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t try ctx.allocator.dupe(u8, dir) else sign.getDefaultKeyDirectory(ctx) catch |err| { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Could not determine home directory: {s}", .{@errorName(err)}), - }; + return try command.errorResult(ctx, err, "could not determine home directory"); }; defer ctx.allocator.free(key_dir); - // Generate and save key pair with specific error handling const result = sign.generateAndSaveKeyPair(ctx, key_dir) catch |err| { - // Provide specific error messages based on error type - switch (err) { - sign.SignError.FileSystem => { - // Check if keys already exist - const pub_path = std.fs.path.join(ctx.allocator, &.{ key_dir, "mere.pub" }) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Failed to generate key pair in {s}: file system error", .{key_dir}), - }; - }; - defer ctx.allocator.free(pub_path); - - const key_path = std.fs.path.join(ctx.allocator, &.{ key_dir, "mere.key" }) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Failed to generate key pair in {s}: file system error", .{key_dir}), - }; - }; - defer ctx.allocator.free(key_path); - - if (path.fileExists(pub_path) or path.fileExists(key_path)) { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Key pair already exists in {s}", .{key_dir}), - }; - } - - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Failed to generate key pair in {s}: file system error", .{key_dir}), - }; - }, - sign.SignError.PermissionDenied => { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Failed to generate key pair in {s}: permission denied", .{key_dir}), - }; - }, - sign.SignError.OutOfMemory => { - return MereError.OutOfMemory; - }, - else => { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Failed to generate key pair in {s}: {s}", .{ key_dir, @errorName(err) }), - }; - }, + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(key_dir)); + if (err == sign.SignError.FileSystem) { + const pub_path = std.fs.path.join(ctx.allocator, &.{ key_dir, "mere.pub" }) catch return MereError.OutOfMemory; + defer ctx.allocator.free(pub_path); + const key_path = std.fs.path.join(ctx.allocator, &.{ key_dir, "mere.key" }) catch return MereError.OutOfMemory; + defer ctx.allocator.free(key_path); + if (path.fileExists(pub_path) or path.fileExists(key_path)) { + return try command.errorResult(ctx, err, "key pair already exists"); + } } + return try command.errorResult(ctx, err, "failed to generate key pair"); }; defer ctx.allocator.free(result.public_key_path); defer ctx.allocator.free(result.secret_key_path); @@ -161,13 +113,12 @@ fn handleGenerate(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t }; emit.logSegmentsSeverity(ctx, .key, .info, &public_segments); - // Load the generated public key to compute its fingerprint - const pub_key = sign.PublicKey.loadFromFile(result.public_key_path) catch { - return types.CommandResult{ .success = true }; + const pub_key = sign.PublicKey.loadFromFile(result.public_key_path) catch |err| { + return try command.errorResult(ctx, err, "key pair generated, but failed to load public key"); }; - const fingerprint = pub_key.fingerprint(ctx.allocator) catch { - return types.CommandResult{ .success = true }; + const fingerprint = pub_key.fingerprint(ctx.allocator) catch |err| { + return try command.errorResult(ctx, err, "key pair generated, but failed to compute fingerprint"); }; defer ctx.allocator.free(fingerprint); @@ -211,31 +162,21 @@ fn handleFingerprint(ctx: *mere.Context, args: *const types.ParsedArgs) MereErro } const pub_key: sign.PublicKey = if (is_secret_key) blk: { - var secret_key = sign.SecretKey.loadFromFile(key_path) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Failed to load secret key: '{s}'", .{key_path}), - }; + var secret_key = sign.SecretKey.loadFromFile(key_path) catch |err| { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(key_path)); + return try command.errorResult(ctx, err, "failed to load secret key"); }; defer secret_key.deinit(); break :blk secret_key.derivePublicKey(); } else blk: { - break :blk sign.PublicKey.loadFromFile(key_path) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Failed to load public key: '{s}'", .{key_path}), - }; + break :blk sign.PublicKey.loadFromFile(key_path) catch |err| { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(key_path)); + return try command.errorResult(ctx, err, "failed to load public key"); }; }; - const fingerprint = pub_key.fingerprint(ctx.allocator) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "Failed to compute fingerprint"), - }; + const fingerprint = pub_key.fingerprint(ctx.allocator) catch |err| { + return try command.errorResult(ctx, err, "failed to compute fingerprint"); }; const segments = [_]mere.ui.Segment{ @@ -250,12 +191,8 @@ fn handleFingerprint(ctx: *mere.Context, args: *const types.ParsedArgs) MereErro fn handleList(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types.CommandResult { _ = args; - var all_keys = sign.loadAllKeys(ctx) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "Failed to scan key directories"), - }; + var all_keys = sign.loadAllKeys(ctx) catch |err| { + return try command.errorResult(ctx, err, "failed to scan key directories"); }; defer { for (all_keys.items) |*k| k.deinit(ctx.allocator); @@ -277,11 +214,7 @@ fn handleList(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types for (all_keys.items) |key| { out.print("{s}: {s}\n", .{ std.fs.path.basename(key.path), key.fingerprint }) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "Failed to format output"), - }; + return MereError.OutOfMemory; }; } output = out_buf.toArrayList(); diff --git a/src/cli/commands/profile.zig b/src/cli/commands/profile.zig index a5dde92..c1da168 100644 --- a/src/cli/commands/profile.zig +++ b/src/cli/commands/profile.zig @@ -129,10 +129,9 @@ pub fn handleList(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t .success = true, .message = try ctx.allocator.dupe(u8, "No profiles found (profiles directory does not exist)"), }, - else => types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "Failed to open profiles directory"), + else => { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(profiles_dir)); + return try command.errorResult(ctx, err, "failed to open profiles directory"); }, }; }; @@ -149,7 +148,10 @@ pub fn handleList(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t // Iterate over profiles directory entries var iter = dir.iterate(); - while (iter.next(path.currentIo()) catch null) |entry| { + while (iter.next(path.currentIo()) catch |err| { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(profiles_dir)); + return try command.errorResult(ctx, err, "failed to enumerate profiles"); + }) |entry| { if (entry.kind != .directory) continue; const profile_name = entry.name; @@ -174,11 +176,12 @@ pub fn handleList(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t return MereError.OutOfMemory; }; defer ctx.allocator.free(store_root); - const generations = generation_mod.listGenerations(ctx.allocator, store_root, profile_path) catch null; - const gen_count = if (generations) |gens| blk: { - defer ctx.allocator.free(gens); - break :blk gens.len; - } else 0; + const generations = generation_mod.listGenerations(ctx.allocator, store_root, profile_path) catch |err| { + ctx.setDiagnosticContextFmt(profile_path, "failed to list generations: {s}", .{@errorName(err)}); + return try command.errorResult(ctx, err, "failed to list generations"); + }; + defer ctx.allocator.free(generations); + const gen_count = generations.len; if (current_gen) |gen| { out.print(" {s}{s}: gen-{d} ({d} generations)\n", .{ profile_name, kind_str, gen, gen_count }) catch return MereError.OutOfMemory; @@ -192,10 +195,9 @@ pub fn handleList(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t const has_root = blk: { std.Io.Dir.accessAbsolute(path.currentIo(), root_path, .{}) catch |err| switch (err) { error.FileNotFound => break :blk false, - else => return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "Failed to inspect profile root"), + else => { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(root_path)); + return try command.errorResult(ctx, err, "failed to inspect profile root"); }, }; break :blk true; @@ -273,7 +275,7 @@ pub fn handleCreate(ctx: *mere.Context, args: *const types.ParsedArgs) MereError @constCast(&d).close(path.currentIo()); return types.CommandResult{ .success = false, - .exit_code = 1, + .exit_code = 2, .message = try std.fmt.allocPrint(ctx.allocator, "Profile '{s}' already exists", .{profile_name}), }; } else |_| { @@ -294,29 +296,25 @@ pub fn handleCreate(ctx: *mere.Context, args: *const types.ParsedArgs) MereError return switch (err) { error.FileNotFound => types.CommandResult{ .success = false, - .exit_code = 1, + .exit_code = 2, .message = try std.fmt.allocPrint(ctx.allocator, "Base profile '{s}' does not exist", .{base_name}), }, - else => types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "Failed to open base profile"), + else => { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(base_path)); + return try command.errorResult(ctx, err, "failed to open base profile"); }, }; }; base_dir.close(path.currentIo()); const base_realization_path = if (std.mem.eql(u8, base_name, "system")) blk: { - const current_gen = generation_mod.getCurrentGeneration(base_path) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Base profile '{s}' has no current generation", .{base_name}), - }; + const current_gen = generation_mod.getCurrentGeneration(base_path) catch |err| { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(base_path)); + return try command.errorResult(ctx, err, "failed to read base profile generation"); } orelse { return types.CommandResult{ .success = false, - .exit_code = 1, + .exit_code = 2, .message = try std.fmt.allocPrint(ctx.allocator, "Base profile '{s}' has no current generation", .{base_name}), }; }; @@ -324,43 +322,39 @@ pub fn handleCreate(ctx: *mere.Context, args: *const types.ParsedArgs) MereError break :blk generation_mod.getGenerationPath(ctx.allocator, base_path, current_gen) catch return MereError.OutOfMemory; } else blk: { const root_path = profile_mod.getRootPath(ctx.allocator, base_path) catch return MereError.OutOfMemory; - std.Io.Dir.accessAbsolute(path.currentIo(), root_path, .{}) catch { + std.Io.Dir.accessAbsolute(path.currentIo(), root_path, .{}) catch |err| { + if (err == error.FileNotFound) { + ctx.allocator.free(root_path); + return types.CommandResult{ + .success = false, + .exit_code = 2, + .message = try std.fmt.allocPrint(ctx.allocator, "Base profile '{s}' has no realized state", .{base_name}), + }; + } + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(root_path)); + const failure = try command.errorResult(ctx, err, "failed to inspect base profile state"); ctx.allocator.free(root_path); - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "Base profile '{s}' has no realized state", .{base_name}), - }; + return failure; }; break :blk root_path; }; defer ctx.allocator.free(base_realization_path); const store_root = std.fs.path.join(ctx.allocator, &.{ ctx.root_path, "mere", "store" }) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "Out of memory"), - }; + return MereError.OutOfMemory; }; defer ctx.allocator.free(store_root); - var manifest = generation_mod.readManifest(ctx.allocator, store_root, base_realization_path) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "Failed to read base profile manifest"), - }; + var manifest = generation_mod.readManifest(ctx.allocator, store_root, base_realization_path) catch |err| { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(base_realization_path)); + return try command.errorResult(ctx, err, "failed to read base profile manifest"); }; defer manifest.deinit(); // Create new profile directory - path.ensureDirExists(profile_path) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "Failed to create profile directory"), - }; + path.ensureDirExists(profile_path) catch |err| { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(profile_path)); + return try command.errorResult(ctx, err, "failed to create profile directory"); }; // Build package entries for the new generation @@ -385,23 +379,16 @@ pub fn handleCreate(ctx: *mere.Context, args: *const types.ParsedArgs) MereError profile_path, store_root, packages.items, - ) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "Failed to publish profile root"), - }; + ) catch |err| { + return try command.errorResult(ctx, err, "failed to publish profile root"); }; return try profileCreationSegments(ctx, profile_name, base_name); } else { // Create empty profile - path.ensureDirExists(profile_path) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "Failed to create profile directory"), - }; + path.ensureDirExists(profile_path) catch |err| { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(profile_path)); + return try command.errorResult(ctx, err, "failed to create profile directory"); }; return try profileCreationSegments(ctx, profile_name, null); @@ -443,25 +430,21 @@ pub fn handleDelete(ctx: *mere.Context, args: *const types.ParsedArgs) MereError return switch (err) { error.FileNotFound => types.CommandResult{ .success = false, - .exit_code = 1, + .exit_code = 2, .message = try std.fmt.allocPrint(ctx.allocator, "Profile '{s}' does not exist", .{profile_name}), }, - else => types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "Failed to open profile"), + else => { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(profile_path)); + return try command.errorResult(ctx, err, "failed to open profile"); }, }; }; dir.close(path.currentIo()); // Delete profile directory recursively - path.deleteTreeAbsolute(profile_path) catch { - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try ctx.allocator.dupe(u8, "Failed to delete profile directory"), - }; + path.deleteTreeAbsolute(profile_path) catch |err| { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(profile_path)); + return try command.errorResult(ctx, err, "failed to delete profile directory"); }; const delete_segments = [_]mere.ui.Segment{ diff --git a/src/cli/commands/release.zig b/src/cli/commands/release.zig index e561953..e1a6926 100644 --- a/src/cli/commands/release.zig +++ b/src/cli/commands/release.zig @@ -113,18 +113,7 @@ fn handlePublish(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!ty defer ctx.allocator.free(abs_output); mere.release.publish(ctx, abs_dev_repo, abs_output) catch |err| { - const mapped_error = mere.errors.ErrorMapping.mapModuleError(@TypeOf(err), err); - const user_message = mere.errors.getUserFriendlyMessage(err); - const error_ctx = ctx.getDiagnosticContext().toErrorContext(); - const formatted_message = error_ctx.formatWithMessage(ctx.allocator, user_message) catch user_message; - defer if (formatted_message.ptr != user_message.ptr) ctx.allocator.free(formatted_message); - const exit_code = command.exitCodeForError(mapped_error); - - return types.CommandResult{ - .success = false, - .exit_code = exit_code, - .message = try ctx.allocator.dupe(u8, formatted_message), - }; + return try command.errorResult(ctx, err, null); }; const segments = [_]mere.ui.Segment{ diff --git a/src/cli/commands/search.zig b/src/cli/commands/search.zig index a09290b..9207a21 100644 --- a/src/cli/commands/search.zig +++ b/src/cli/commands/search.zig @@ -39,6 +39,7 @@ fn handleSearch(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!typ } const term = args.positional[0]; + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(term)); const sync_policy = sync_options.repositorySyncPolicy(args) catch return MereError.InvalidInput; var curl_client = try mere.download.CurlTransferClient.init(ctx, command.user_agent); @@ -46,12 +47,7 @@ fn handleSearch(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!typ const client = curl_client.client(); var results = mere.search.searchPackagesWithPolicy(ctx, term, client, sync_policy) catch |err| { - const user_message = mere.errors.getUserFriendlyMessage(err); - return types.CommandResult{ - .success = false, - .exit_code = 1, - .message = try std.fmt.allocPrint(ctx.allocator, "search failed: {s}", .{user_message}), - }; + return try command.errorResult(ctx, err, null); }; defer { for (results.items) |*r| r.deinit(ctx.allocator); diff --git a/src/cli/commands/service.zig b/src/cli/commands/service.zig index 7fda4c1..3e0c4de 100644 --- a/src/cli/commands/service.zig +++ b/src/cli/commands/service.zig @@ -4,7 +4,6 @@ const mere = @import("mere"); const types = @import("../types.zig"); const command = @import("../command.zig"); const MereError = mere.errors.MereError; -const getUserFriendlyMessage = mere.errors.getUserFriendlyMessage; const ui = mere.ui; const emit = ui.emit; const services = mere.services; @@ -167,9 +166,8 @@ fn handleLogs(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types const sio = mere.path.currentIo(); const output = services.readLogs(ctx, name) catch |err| switch (err) { - error.NoLogDirectory => return .{ .success = false, .exit_code = 1, .message = "no log directory for service" }, - error.NoLogFile => return .{ .success = false, .exit_code = 1, .message = "no log file for service" }, - error.OutOfMemory => return .{ .success = false, .exit_code = 1, .message = "out of memory" }, + error.NoLogDirectory => return try command.errorResult(ctx, err, "no log directory for service"), + error.NoLogFile => return try command.errorResult(ctx, err, "no log file for service"), else => return serviceFailure(ctx, err, "failed to read logs"), }; defer ctx.allocator.free(output); @@ -209,18 +207,11 @@ fn handleList(ctx: *mere.Context, _: *const types.ParsedArgs) MereError!types.Co fn serviceFailure(ctx: *mere.Context, err: services.ServiceError, fallback: []const u8) !types.CommandResult { const message = switch (err) { - error.UnsupportedProvider => try ctx.allocator.dupe(u8, "configured init provider is not implemented for service management"), - error.InvalidConfig, error.PermissionDenied => blk: { - const user_message = getUserFriendlyMessage(err); - const error_ctx = ctx.getDiagnosticContext().toErrorContext(); - const formatted = error_ctx.formatWithMessage(ctx.allocator, user_message) catch - try ctx.allocator.dupe(u8, user_message); - break :blk formatted; - }, - error.OutOfMemory => try ctx.allocator.dupe(u8, "out of memory"), - else => try std.fmt.allocPrint(ctx.allocator, "{s}: {s}", .{ fallback, getUserFriendlyMessage(err) }), + error.UnsupportedProvider => "configured init provider is not implemented for service management", + error.InvalidConfig, error.PermissionDenied, error.OutOfMemory => null, + else => fallback, }; - return .{ .success = false, .exit_code = 1, .message = message }; + return command.errorResult(ctx, err, message); } // ── Registration ────────────────────────────────────────────────── diff --git a/src/cli/commands/verify.zig b/src/cli/commands/verify.zig index 22c9e50..d4de4e0 100644 --- a/src/cli/commands/verify.zig +++ b/src/cli/commands/verify.zig @@ -105,17 +105,14 @@ pub fn handleVerify(ctx: *mere.Context, args: *const types.ParsedArgs) MereError } var result = verify_mod.verifyAll(ctx, opts) catch |err| { - emit.diagnostic(ctx, .verify, switch (err) { + emit.phaseEnd(ctx, .verify, false); + const message = switch (err) { verify_mod.VerifyError.PermissionDenied => "permission denied", verify_mod.VerifyError.FileSystem => "filesystem error", verify_mod.VerifyError.InvalidInput => "invalid input", verify_mod.VerifyError.OutOfMemory => "out of memory", - }, null, null, null); - emit.phaseEnd(ctx, .verify, false); - return types.CommandResult{ - .success = false, - .exit_code = 1, }; + return try command.errorResult(ctx, err, message); }; defer result.deinit(ctx.allocator); diff --git a/src/cli/commands_test.zig b/src/cli/commands_test.zig index 91462f6..9ee4249 100644 --- a/src/cli/commands_test.zig +++ b/src/cli/commands_test.zig @@ -10,6 +10,100 @@ const mere = @import("mere"); const types = @import("types.zig"); const pin_cmd = @import("commands/pin.zig"); const profile_cmd = @import("commands/profile.zig"); +const cli_mod = @import("cli.zig"); +const command = @import("command.zig"); + +const CaptureEmitter = struct { + emitter: mere.ui.Emitter, + allocator: std.mem.Allocator, + lines: std.ArrayList([]const u8) = .empty, + + fn init(allocator: std.mem.Allocator) CaptureEmitter { + return .{ .emitter = .{ .emitFn = onEmit }, .allocator = allocator }; + } + + fn deinit(self: *CaptureEmitter) void { + for (self.lines.items) |line| self.allocator.free(line); + self.lines.deinit(self.allocator); + } + + fn onEmit(emitter: *mere.ui.Emitter, event: mere.ui.Event) void { + const self: *CaptureEmitter = @fieldParentPtr("emitter", emitter); + var line: std.ArrayList(u8) = .empty; + defer line.deinit(self.allocator); + switch (event.kind) { + .log_line => line.appendSlice(self.allocator, event.message orelse return) catch return, + .log_segments => for (event.data.log_segments) |segment| { + line.appendSlice(self.allocator, segment.text) catch return; + }, + else => return, + } + const owned = line.toOwnedSlice(self.allocator) catch return; + self.lines.append(self.allocator, owned) catch self.allocator.free(owned); + } + + fn contains(self: *const CaptureEmitter, needle: []const u8) bool { + for (self.lines.items) |line| { + if (std.mem.indexOf(u8, line, needle) != null) return true; + } + return false; + } +}; + +fn inputFailure(ctx: *mere.Context, _: *const types.ParsedArgs) mere.errors.MereError!types.CommandResult { + return ctx.fail(mere.errors.MereError.InvalidInput, "recipe.kdl", "invalid package field"); +} + +fn permissionFailure(ctx: *mere.Context, _: *const types.ParsedArgs) mere.errors.MereError!types.CommandResult { + return ctx.fail(mere.errors.MereError.PermissionDenied, "/etc/mere", "permission denied writing configuration"); +} + +fn filesystemFailure(ctx: *mere.Context, _: *const types.ParsedArgs) mere.errors.MereError!types.CommandResult { + return ctx.fail(mere.errors.MereError.FileSystem, "/mere/store/object", "failed to open store object"); +} + +fn networkFailure(ctx: *mere.Context, _: *const types.ParsedArgs) mere.errors.MereError!types.CommandResult { + return ctx.fail(mere.errors.MereError.Network, "https://repo.example/index", "connection timed out"); +} + +fn integrityFailure(ctx: *mere.Context, _: *const types.ParsedArgs) mere.errors.MereError!types.CommandResult { + return ctx.fail(mere.errors.MereError.CorruptData, "/tmp/package.mere", "archive hash mismatch"); +} + +fn resourceFailure(ctx: *mere.Context, _: *const types.ParsedArgs) mere.errors.MereError!types.CommandResult { + return ctx.fail(mere.errors.MereError.OutOfDisk, "/mere/store", "insufficient space for admission"); +} + +fn activationFailure(ctx: *mere.Context, _: *const types.ParsedArgs) mere.errors.MereError!types.CommandResult { + return ctx.fail(mere.errors.MereError.CorruptData, "generation 7", "store content hash mismatch during activation"); +} + +fn expectCommandFailure( + handler: command.CommandHandler, + expected_exit: u8, + expected_subject: []const u8, + expected_details: []const u8, +) !void { + var ctx = mere.Context.init(std.testing.allocator, "/test"); + defer ctx.deinit(); + var capture = CaptureEmitter.init(std.testing.allocator); + defer capture.deinit(); + ctx.setEmitter(&capture.emitter); + + var cli = cli_mod.CLI.init(std.testing.allocator, "mere", &.{}); + defer cli.deinit(); + var probe = command.Command.init(std.testing.allocator, .{ + .name = "probe", + .description = "exercise the final command boundary", + }, handler); + defer probe.deinit(); + try cli.registerCommand(&probe); + + const exit_code = cli.execute(&.{ "mere", "probe" }, &ctx); + try std.testing.expectEqual(expected_exit, exit_code); + try std.testing.expect(capture.contains(expected_subject)); + try std.testing.expect(capture.contains(expected_details)); +} fn makeArgs(positional: []const []const u8) !types.ParsedArgs { var parsed = types.ParsedArgs.init(std.testing.allocator); @@ -17,6 +111,16 @@ fn makeArgs(positional: []const []const u8) !types.ParsedArgs { return parsed; } +test "final command boundary preserves category and diagnostics for representative failures" { + try expectCommandFailure(inputFailure, 2, "recipe.kdl", "invalid package field"); + try expectCommandFailure(permissionFailure, 13, "/etc/mere", "permission denied writing configuration"); + try expectCommandFailure(filesystemFailure, 1, "/mere/store/object", "failed to open store object"); + try expectCommandFailure(networkFailure, 1, "https://repo.example/index", "connection timed out"); + try expectCommandFailure(integrityFailure, 1, "/tmp/package.mere", "archive hash mismatch"); + try expectCommandFailure(resourceFailure, 12, "/mere/store", "insufficient space for admission"); + try expectCommandFailure(activationFailure, 1, "generation 7", "store content hash mismatch during activation"); +} + test "pin add fails without ever touching gc-roots when the store lock can't be acquired" { const testing = std.testing; var tmp = testing.tmpDir(.{}); diff --git a/src/errors.zig b/src/errors.zig index 14825c7..3ec7819 100644 --- a/src/errors.zig +++ b/src/errors.zig @@ -50,13 +50,70 @@ pub const ErrorMapping = struct { /// Map common Zig errors to standard vocabulary pub fn mapZigError(err: anyerror) MereError { return switch (err) { - error.OutOfMemory => MereError.OutOfMemory, - error.Network, error.ConnectionTimeout => MereError.Network, - error.AccessDenied, error.PermissionDenied => MereError.PermissionDenied, + // Preserve Mere's standard vocabulary when it crosses a boundary. + error.InvalidInput => MereError.InvalidInput, + error.MissingArgument => MereError.MissingArgument, + error.Network => MereError.Network, + error.FileSystem => MereError.FileSystem, + error.PermissionDenied => MereError.PermissionDenied, + error.CorruptData => MereError.CorruptData, error.SignatureInvalid => MereError.SignatureInvalid, - error.FileNotFound, error.IsDir, error.NotDir => MereError.FileSystem, - error.ConnectionRefused, error.NetworkUnreachable => MereError.Network, - error.InvalidConfig, error.ParseError => MereError.InvalidInput, + error.OutOfMemory => MereError.OutOfMemory, + error.OutOfDisk => MereError.OutOfDisk, + error.TooManyFiles => MereError.TooManyFiles, + error.Internal => MereError.Internal, + + // Translate common Zig and module errors into that vocabulary. + error.ConnectionTimeout, + error.ConnectionTimedOut, + error.ConnectionRefused, + error.ConnectionResetByPeer, + error.NetworkUnreachable, + error.NetworkSubsystemFailed, + error.HostLookupFailed, + error.RepositoryUnavailable, + => MereError.Network, + error.AccessDenied, + error.OperationNotPermitted, + error.ReadOnlyFileSystem, + => MereError.PermissionDenied, + error.FileNotFound, + error.IsDir, + error.NotDir, + error.PathAlreadyExists, + error.ProcessFailed, + error.NoLogDirectory, + error.NoLogFile, + error.NoActiveGeneration, + error.LockFailed, + error.Locked, + => MereError.FileSystem, + error.InvalidConfig, + error.ParseError, + error.ParseFailed, + error.InvalidFormat, + error.InvalidCharacter, + error.BadPathName, + error.UnsupportedProvider, + error.NoRoots, + error.GenerationNotFound, + error.RepositoryNotFound, + error.PackageNotFound, + error.ConflictingProvision, + error.ConflictingProvisions, + error.UnsatisfiableDependencies, + error.DuplicateTemplate, + error.TemplateNotFound, + => MereError.InvalidInput, + error.ChecksumMismatch, + error.InvalidData, + error.BadData, + error.CorruptInput, + error.ManifestNotFound, + error.InvalidManifest, + error.ArchiveHashMismatch, + error.SignatureVerificationFailed, + => MereError.CorruptData, error.NoSpaceLeft => MereError.OutOfDisk, error.ProcessFdQuotaExceeded, error.SystemFdQuotaExceeded => MereError.TooManyFiles, else => MereError.Internal, @@ -80,6 +137,7 @@ pub const ErrorMapping = struct { .{ MereError.SignatureInvalid, @as(anyerror, error.SignatureInvalid) }, .{ MereError.OutOfDisk, @as(anyerror, error.OutOfDisk) }, .{ MereError.TooManyFiles, @as(anyerror, error.TooManyFiles) }, + .{ MereError.Internal, @as(anyerror, error.Internal) }, }; inline for (mere_errors) |pair| { if (any == pair[1]) return pair[0]; @@ -158,8 +216,11 @@ pub fn getUserFriendlyMessage(err: anyerror) []const u8 { // Standard module vocabulary error.FileSystem => "file system error", error.InvalidInput => "invalid input", + error.MissingArgument => "missing required argument", error.CorruptData => "corrupt or incompatible data", error.SignatureInvalid => "signature verification failed", + error.OutOfDisk => "insufficient disk space", + error.TooManyFiles => "too many open files", error.Internal => "internal error", error.SessionSetupError => "failed to set up namespace session", error.SyntheticRootSetupError => "failed to build synthetic root", @@ -279,8 +340,22 @@ test "mapZigError maps common errors correctly" { const testing = std.testing; // Test mapping of common Zig errors + try testing.expectEqual(MereError.InvalidInput, ErrorMapping.mapZigError(error.InvalidInput)); + try testing.expectEqual(MereError.MissingArgument, ErrorMapping.mapZigError(error.MissingArgument)); + try testing.expectEqual(MereError.FileSystem, ErrorMapping.mapZigError(error.FileSystem)); + try testing.expectEqual(MereError.Network, ErrorMapping.mapZigError(error.Network)); + try testing.expectEqual(MereError.PermissionDenied, ErrorMapping.mapZigError(error.PermissionDenied)); + try testing.expectEqual(MereError.CorruptData, ErrorMapping.mapZigError(error.CorruptData)); + try testing.expectEqual(MereError.SignatureInvalid, ErrorMapping.mapZigError(error.SignatureInvalid)); try testing.expectEqual(MereError.OutOfMemory, ErrorMapping.mapZigError(error.OutOfMemory)); + try testing.expectEqual(MereError.OutOfDisk, ErrorMapping.mapZigError(error.OutOfDisk)); + try testing.expectEqual(MereError.TooManyFiles, ErrorMapping.mapZigError(error.TooManyFiles)); + try testing.expectEqual(MereError.Internal, ErrorMapping.mapZigError(error.Internal)); + try testing.expectEqual(MereError.PermissionDenied, ErrorMapping.mapZigError(error.AccessDenied)); + try testing.expectEqual(MereError.FileSystem, ErrorMapping.mapZigError(error.Locked)); + try testing.expectEqual(MereError.InvalidInput, ErrorMapping.mapZigError(error.UnsatisfiableDependencies)); + try testing.expectEqual(MereError.CorruptData, ErrorMapping.mapZigError(error.SignatureVerificationFailed)); try testing.expectEqual(MereError.FileSystem, ErrorMapping.mapZigError(error.FileNotFound)); try testing.expectEqual(MereError.FileSystem, ErrorMapping.mapZigError(error.IsDir)); try testing.expectEqual(MereError.FileSystem, ErrorMapping.mapZigError(error.NotDir)); diff --git a/src/install.zig b/src/install.zig index 37516e2..546657f 100644 --- a/src/install.zig +++ b/src/install.zig @@ -1464,7 +1464,13 @@ pub fn resolveProfile( .policy = sync_policy, }, loaded_keys.items); defer sync_result.deinit(ctx.allocator); - if (sync_result.firstFailure()) |err| return err; + for (sync_result.outcomes.items) |outcome| { + switch (outcome.status) { + .ready => {}, + .failed => return ctx.fail(outcome.failure orelse error.RepositoryUnavailable, outcome.name, "repository synchronization failed"), + .not_found => return ctx.fail(error.RepositoryNotFound, outcome.name, "repository not found"), + } + } var arena = std.heap.ArenaAllocator.init(ctx.allocator); errdefer arena.deinit(); @@ -1503,7 +1509,7 @@ pub fn realizeProfile( ctx, if (verify_store) .full_store else .fast, ) catch |err| { - return ctx.fail(mapActivationError(err), "boot", "failed to stage boot artifacts"); + return mapActivationFailure(ctx, err, "boot", "failed to stage boot artifacts"); }; if (staged > 0) { emit.logLineSeverity(ctx, phase, .info, "staged boot artifacts for active system generation"); @@ -1916,7 +1922,7 @@ fn applyProfileRealization(ctx: *Context, prof_name: []const u8, installed_packa if (verify_store) .full_store else .fast, ) catch |err| { ctx.debug("failed to activate generation: {}", .{err}); - return ctx.fail(mapActivationError(err), profile_dir, "failed to activate generation"); + return mapActivationFailure(ctx, err, profile_dir, "failed to activate generation"); }; if (staged_dinit) |*staged| { @@ -2241,16 +2247,60 @@ fn mapInstallFsError(err: anyerror) anyerror { }; } +fn mapActivationFailure( + ctx: *Context, + err: activation.ActivationError, + fallback_subject: []const u8, + fallback_details: []const u8, +) anyerror { + const mapped = mapActivationError(err); + const diagnostic = ctx.getDiagnosticContext(); + if (diagnostic.subject != null or diagnostic.details != null) return mapped; + return ctx.fail(mapped, fallback_subject, fallback_details); +} + fn mapActivationError(err: activation.ActivationError) anyerror { return switch (err) { activation.ActivationError.OutOfMemory => error.OutOfMemory, activation.ActivationError.PermissionDenied => error.PermissionDenied, activation.ActivationError.InvalidInput => error.InvalidInput, + activation.ActivationError.CorruptData, + activation.ActivationError.ManifestNotFound, + => error.CorruptData, activation.ActivationError.DuplicateEtcTemplate => error.ConflictingProvision, + activation.ActivationError.GenerationNotFound => error.InvalidInput, else => error.FileSystem, }; } +test "mapActivationFailure preserves specific lower-level diagnostics and supplies a fallback" { + var ctx = Context.init(std.testing.allocator, "/test"); + defer ctx.deinit(); + + ctx.setDiagnosticContext("/test/etc/service.conf", "permission denied writing /etc template"); + const mapped = mapActivationFailure(&ctx, activation.ActivationError.PermissionDenied, "/test/profile", "failed to activate generation"); + + try std.testing.expectEqual(error.PermissionDenied, mapped); + var diagnostic = ctx.getDiagnosticContext(); + try std.testing.expectEqualStrings("/test/etc/service.conf", diagnostic.subject.?); + try std.testing.expectEqualStrings("permission denied writing /etc template", diagnostic.details.?); + + ctx.resetDiagnostics(); + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject("/test/store/package")); + const subject_only_mapped = mapActivationFailure(&ctx, activation.ActivationError.CorruptData, "/test/profile", "failed to activate generation"); + try std.testing.expectEqual(error.CorruptData, subject_only_mapped); + diagnostic = ctx.getDiagnosticContext(); + try std.testing.expectEqualStrings("/test/store/package", diagnostic.subject.?); + try std.testing.expect(diagnostic.details == null); + + ctx.resetDiagnostics(); + const fallback_mapped = mapActivationFailure(&ctx, activation.ActivationError.OutOfMemory, "/test/profile", "failed to activate generation"); + try std.testing.expectEqual(error.OutOfMemory, fallback_mapped); + diagnostic = ctx.getDiagnosticContext(); + try std.testing.expectEqualStrings("/test/profile", diagnostic.subject.?); + try std.testing.expectEqualStrings("failed to activate generation", diagnostic.details.?); +} + fn mapGenerationError(err: generation.GenerationError) anyerror { return switch (err) { generation.GenerationError.OutOfMemory => error.OutOfMemory, @@ -4900,7 +4950,8 @@ test "install mapActivationError preserves actionable classes" { try std.testing.expectEqual(error.PermissionDenied, mapActivationError(activation.ActivationError.PermissionDenied)); try std.testing.expectEqual(error.InvalidInput, mapActivationError(activation.ActivationError.InvalidInput)); try std.testing.expectEqual(error.ConflictingProvision, mapActivationError(activation.ActivationError.DuplicateEtcTemplate)); - try std.testing.expectEqual(error.FileSystem, mapActivationError(activation.ActivationError.ManifestNotFound)); + try std.testing.expectEqual(error.CorruptData, mapActivationError(activation.ActivationError.CorruptData)); + try std.testing.expectEqual(error.CorruptData, mapActivationError(activation.ActivationError.ManifestNotFound)); } test "install mapGenerationError preserves actionable classes" { diff --git a/src/services.zig b/src/services.zig index b5a2584..43a83e2 100644 --- a/src/services.zig +++ b/src/services.zig @@ -177,9 +177,9 @@ fn statusDinit(ctx: *mere.Context, name: []const u8) ServiceError!StatusDetail { const raw = dinit.status(allocator, name) catch return error.ProcessFailed; defer allocator.free(raw); - const kind = dinit.serviceKind(allocator, name) catch .daemon; + const kind = dinit.serviceKind(allocator, name) catch return error.ProcessFailed; const state = try dinitState(allocator, raw); - const boot_state: BootState = if (dinit.isBootEnabled(allocator, name) catch false) .enabled else .disabled; + const boot_state: BootState = if (dinit.isBootEnabled(allocator, name) catch return error.ProcessFailed) .enabled else .disabled; const is_daemon = kind == .daemon; return .{ @@ -261,11 +261,12 @@ fn listDinit(ctx: *mere.Context) ServiceError![]ListEntry { for (all_names) |name| { const owned_name = name; - const failed = dinit.isFailed(allocator, name) catch false; - const started = dinit.isStarted(allocator, name) catch false; + const failed = dinit.isFailed(allocator, name) catch return error.ProcessFailed; + const started = dinit.isStarted(allocator, name) catch return error.ProcessFailed; + const boot_enabled = dinit.isBootEnabled(allocator, name) catch return error.ProcessFailed; try entries.append(allocator, .{ .name = owned_name, - .boot_state = if (dinit.isBootEnabled(allocator, name) catch false) .enabled else .disabled, + .boot_state = if (boot_enabled) .enabled else .disabled, .state = if (failed) "failed" else if (started) "started" else "stopped", }); }