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", }); }