Error handling in zcli
The zcli framework provides comprehensive error handling through structured error types and detailed diagnostic information. All parsing functions use standard Zig error unions to provide both type safety and rich error context.
Overview
zcli uses structured error types where parsing functions return standard Zig error unions:
- Success: The successfully parsed data (e.g.,
ArgsTypeorOptionsResult(OptionsType)) - Error: A
ZcliErrorwith detailed diagnostic information about what went wrong
Core error types
ZcliError
The main error union used throughout zcli:
pub const ZcliError = error{
// Argument parsing errors
ArgumentMissingRequired,
ArgumentInvalidValue,
ArgumentTooMany,
ArgumentValidationFailed,
// Option parsing errors
OptionUnknown,
OptionMissingValue,
OptionInvalidValue,
OptionBooleanWithValue,
OptionDuplicate,
OptionMissingRequired,
OptionMutuallyExclusive,
OptionMissingDependency,
OptionValidationFailed,
// Command routing errors
CommandNotFound,
SubcommandNotFound,
// Build-time errors
BuildCommandDiscoveryFailed,
BuildRegistryGenerationFailed,
BuildOutOfMemory,
// System errors
SystemOutOfMemory,
SystemFileNotFound,
SystemAccessDenied,
// Special cases
HelpRequested,
VersionRequested,
// Resource limits
ResourceLimitExceeded,
};
Diagnostic information
When errors occur, zcli provides detailed diagnostic information through the ZcliDiagnostic type, which includes:
- Field context: Which argument or option caused the error
- Position information: Where in the command line the error occurred
- Type expectations: What was expected vs. what was provided
- Suggestions: Smart suggestions for typos and similar names
API: parseCommandLine
For standalone parsing outside the framework, the public entry point is zcli.parseCommandLine. It handles positionals and options together in a single pass — the same parser the framework runs for your commands:
pub fn parseCommandLine(
comptime ArgsType: type,
comptime OptionsType: type,
comptime meta: anytype,
allocator: std.mem.Allocator,
environ: ?*const std.process.Environ.Map,
args: []const []const u8,
diag: ?*?ZcliDiagnostic,
) ZcliError!CommandParseResult(ArgsType, OptionsType)
The final diag parameter is an optional out-parameter: pass null if you only need the error, or a *?ZcliDiagnostic to capture rich context about the failure (see Diagnostic-driven error messages below). The result owns any parsed arrays — release everything with a single result.deinit().
const Args = struct {
name: []const u8,
};
const Options = struct {
verbose: bool = false,
count: u32 = 1,
};
const result = zcli.parseCommandLine(Args, Options, null, allocator, null, args, null) catch |err| switch (err) {
error.ArgumentMissingRequired => {
std.debug.print("Missing required argument\n", .{});
return;
},
error.OptionInvalidValue => {
std.debug.print("Invalid value for option\n", .{});
return;
},
else => return err,
};
defer result.deinit();
std.debug.print("Name: {s}, Count: {}\n", .{ result.args.name, result.options.count });
Error handling patterns
Basic pattern
const result = zcli.parseCommandLine(Args, Options, null, allocator, null, args, null) catch |err| {
std.debug.print("Parse error: {}\n", .{err});
std.process.exit(1);
};
defer result.deinit();
Detailed error handling
const result = zcli.parseCommandLine(Args, Options, null, allocator, null, args, null) catch |err| switch (err) {
error.OptionUnknown => {
std.debug.print("Unknown option. Run with --help for usage.\n", .{});
std.process.exit(2);
},
error.OptionInvalidValue => {
std.debug.print("Invalid value provided for option.\n", .{});
std.process.exit(2);
},
error.ArgumentMissingRequired => {
std.debug.print("Missing required argument. Run with --help for usage.\n", .{});
std.process.exit(2);
},
error.SystemOutOfMemory => return error.SystemOutOfMemory,
else => {
std.debug.print("Unexpected error: {}\n", .{err});
std.process.exit(1);
},
};
defer result.deinit();
Diagnostic-driven error messages
The catch-and-switch patterns above only know which error occurred. To tell the user what went wrong — which option, what value, what was expected — pass a diagnostic out-parameter and format it with formatDiagnostic:
var diag: ?zcli.ZcliDiagnostic = null;
const result = zcli.parseCommandLine(Args, Options, null, allocator, null, args, &diag) catch {
if (diag) |d| {
const msg = try zcli.formatDiagnostic(d, allocator);
defer allocator.free(msg);
std.debug.print("{s}\n", .{msg});
}
std.process.exit(2);
};
defer result.deinit();
The formatted messages carry the full context, for example:
Missing required argument 'name' at position 1. Expected text.
Invalid value 'abc' for option '--count'. Expected an integer.
Boolean option '--verbose' does not accept a value (got 'yes')
Unknown option '--verbos'
Did you mean:
--verbose
OptionUnknown and CommandNotFound diagnostics include “did you mean?” suggestions computed by edit distance. This is exactly how the framework produces its own error output — commands get it for free.
Framework integration
Automatic error handling
When using the zcli framework, error handling is automatic:
// In your command's execute function
const Context = @import("command_registry").Context;
pub fn execute(args: Args, options: Options, context: *Context) !void {
// args and options are already parsed and validated
// Any parsing errors were handled by the framework
try context.stdout().print("Hello, {s}!\n", .{args.name});
}
The framework automatically:
- Parses all arguments and options
- Displays user-friendly error messages for parsing errors
- Provides appropriate exit codes
- Shows help information when requested
Command errors
Commands return ordinary Zig errors. Use context.fail to attach a useful explanation; the framework records the failure, selects its status, and renders it after command cleanup. Printing an explanation and returning normally would incorrectly report success to the shell.
pub fn execute(args: Args, options: Options, context: *Context) !void {
const io = context.io;
const file = std.Io.Dir.cwd().openFile(io, args.filename, .{}) catch |err| switch (err) {
error.FileNotFound => return context.fail("File '{s}' not found", .{args.filename}),
error.AccessDenied => return context.fail("Permission denied accessing '{s}'", .{args.filename}),
else => return err, // Let framework handle unexpected errors
};
defer file.close(io);
// Process file...
}
Invocation outcomes
The registry classifies failures where they originate. A command returning an error whose name happens to match a parser error does not become a usage failure. Failures retain their category, cause, lifecycle stage, origin, and diagnostic. An application renderer may print JSON or human text without changing whether the invocation failed.
Framework defaults are 2 for invalid input, 3 for unknown commands, and 1 for reported application failures. Applications own their numeric exit policy. A classified failure keeps its status even if writing the diagnostic fails; otherwise a broken downstream pipe exits 141, and other failed writes remain failures even when command execution itself succeeded.
Configure framework statuses in zcli.generate without maintaining a list of parser error names:
const policy = b.createModule(.{
.root_source_file = b.path("src/failure_policy.zig"),
});
const registry = try zcli.generate(b, exe, zcli_dep, .{
.commands_dir = "src/commands",
.app_name = "myapp",
.app_description = "My CLI application",
.exit_codes = .{ .usage = 64, .command_not_found = 64 },
.failure_policy_module = policy,
});
The optional policy module receives a zcli import and can map command or plugin errors using typed rules:
const zcli = @import("zcli");
pub const error_codes = [_]zcli.failure.ErrorRule{
.{ .cause = error.NotFound, .command = "card show", .code = 2,
.id = "card_not_found", .message = "Card not found" },
.{ .cause = error.Unauthorized, .plugin = "auth", .code = 10,
.message = "Sign in first" },
};
Command scopes use canonical paths, so aliases share the same rule. A plugin scope uses its plugin_id. A rule with neither scope applies to both command and plugin failures; it never remaps framework usage errors. Zero statuses and overlapping rules fail at compile time. A command can attach details with return context.failWith(error.NotFound, "Card '{s}' was not found", .{id});; context.fail(...) records a message and returns error.CommandFailed.
Use the non-exiting invocation interface when embedding a CLI or testing the whole pipeline. Its result owns retained diagnostics until deinit(). The normal run() entry point applies process-exit behavior. Direct command-body tests use runCommand; argv, plugins, stdin resolution, rendering, and status are covered by runInvocation through the same engine used in production.
Setup and cleanup failures
Help and version run before application input loading and operational preparation. Configuration supplies values before validation; authentication and service startup run afterward. A typo in a flag therefore cannot start a daemon. All fallible stages use the same failure path, and rendering a setup failure never permits the command to proceed.
Application rendering selected by global options is available after global handling completes successfully. Earlier failures use the default renderer. Completion and renderer failures cannot replace the original cause or turn a failed invocation into success.
Plugins use describeFailure(context, failure) !?zcli.failure.Description to supply an identity or explanation and renderFailure(context, failure) !bool to print or decline. An application policy can instead export renderFailure(context, failure, status) !void; it has precedence over plugins and owns every failure it is called for. Returning normally claims the output even if it wrote nothing. If output selection depends on --json, the application renderer must render both JSON and human formats itself; a plugin renderer can return false to request the default human output. Writes through context streams are buffered; if a renderer returns an error or declines, its buffered bytes are discarded before fallback. Final writes to the destination remain ordinary I/O. An unexpected unreported error without a message or identity still propagates its Zig trace; a custom renderer that reports it suppresses that default trace.
Invalid numeric array elements and empty segments in explicitly delimited arrays produce structured option diagnostics and use the configured usage status, just like invalid scalar values. This applies to both long and short options; custom failure renderers receive the diagnostic through the normal invocation path.
Memory management
Result cleanup
When using parseCommandLine directly, the returned result owns any allocated values (array options, accumulated positionals) — release them with deinit:
const result = try zcli.parseCommandLine(Args, Options, null, allocator, null, args, null);
defer result.deinit();
The framework handles this cleanup automatically for command implementations.
Error contexts
Standalone parser diagnostics borrow their input and do not require separate cleanup. An owned invocation result retains its diagnostics and must be released with deinit(); do not keep pointers into it after that call.
Best practices
- Use specific error handling: Match on specific error types rather than catching all errors
- Provide helpful messages: Give users actionable information about what went wrong
- Use appropriate exit codes (or configure them for your application):
0for success1for general errors2for usage errors (wrong arguments/options)
- Let framework handle parsing: Use command implementations instead of manual parsing when possible
- Clean up resources: Always
defer result.deinit()on aparseCommandLineresult
Examples
Complete command-line parser
A full standalone program using zcli.parseCommandLine, wired through std.process.Init:
const std = @import("std");
const zcli = @import("zcli");
const Args = struct {
input: []const u8,
};
const Options = struct {
verbose: bool = false,
output: ?[]const u8 = null,
threads: u32 = 1,
};
pub fn main(init: std.process.Init) !void {
const allocator = init.gpa;
const args = try init.minimal.args.toSlice(init.arena.allocator());
var diag: ?zcli.ZcliDiagnostic = null;
const result = zcli.parseCommandLine(Args, Options, null, allocator, init.environ_map, args[1..], &diag) catch |err| switch (err) {
error.SystemOutOfMemory => return error.SystemOutOfMemory,
else => {
if (diag) |d| {
const msg = try zcli.formatDiagnostic(d, allocator);
defer allocator.free(msg);
std.debug.print("{s}\n", .{msg});
} else {
std.debug.print("Parse error: {}\n", .{err});
}
std.process.exit(2);
},
};
defer result.deinit();
if (result.options.verbose) {
std.debug.print("Verbose mode enabled\n", .{});
}
if (result.options.output) |output| {
std.debug.print("Output: {s}\n", .{output});
}
std.debug.print("Input: {s}, threads: {}\n", .{ result.args.input, result.options.threads });
}
Summary
The zcli error handling system provides:
- Standard Zig patterns using error unions
- Rich diagnostic information for debugging
- Framework integration for automatic error handling
- Memory safety with clear cleanup patterns
- User-friendly messages with actionable information
- Type safety through compile-time validation
This approach ensures users get helpful error messages while developers work with familiar Zig error handling patterns.