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., ArgsType or OptionsResult(OptionsType))
  • Error: A ZcliError with 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 can return errors which will be handled by the framework:

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 => {
            try context.stderr().print("Error: File '{s}' not found\n", .{args.filename});
            return;
        },
        error.AccessDenied => {
            try context.stderr().print("Error: Permission denied accessing '{s}'\n", .{args.filename});
            return;
        },
        else => return err, // Let framework handle unexpected errors
    };
    defer file.close(io);

    // Process file...
}

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

Error contexts are lightweight and don’t require explicit cleanup.

Best practices

  1. Use specific error handling: Match on specific error types rather than catching all errors
  2. Provide helpful messages: Give users actionable information about what went wrong
  3. Use appropriate exit codes:
    • 0 for success
    • 1 for general errors
    • 2 for usage errors (wrong arguments/options)
  4. Let framework handle parsing: Use command implementations instead of manual parsing when possible
  5. Clean up resources: Always defer result.deinit() on a parseCommandLine result

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.