Skip to content
zit Artifact [0d3440abae]
Login

Artifact [0d3440abae]

Artifact 0d3440abae879f24b95b5fd279fd66a3de06427381d90efde8bc2a755eaaee75:


//! zit — CLI-layer verb runner (NOT part of the embeddable `zit` library).
//!
//! Compiled only into the `zit` executable (imported by main.zig); never
//! re-exported from root.zig, so it stays outside the library. It holds the
//! reusable, verb-agnostic mechanism every future verb shares: the uniform
//! exit codes, the stdout/stderr discipline, and a labeled-switch flag
//! scanner. No verb-specific effect logic lives here — that is main.zig's
//! `runInit` (contracts/init.md B-init-1, B-init-10, B-init-12).

const std = @import("std");
const Io = std.Io;
// The working-state record's fixed name (`.zit-checkout`). Imported for the
// authoritative constant used by the shared repo-root probe below; checkout.zig
// itself depends only on std, so this adds no cycle.
const checkout = @import("checkout.zig");
const settings = @import("settings.zig");
// The artifact-format vocabulary `commit` composes over: the `FCard` value the
// F-set is built from, the §4.5.3 comparator that orders it, the §4.5.2-r1
// admission oracle that guards the order, and the §4.6.4 writer-timestamp
// oracle. Reached through the `zit` module (the same instance main.zig uses),
// so the `FCard` this file produces is byte-for-byte the type `artifact.create`
// consumes — never a second, incompatible copy from a relative import.
const artifact = @import("artifact.zig");
const cone_mod = @import("cone.zig");
const repocache = @import("repocache.zig");

/// Uniform process exit codes across the whole CLI (B-init-12): 0 success,
/// 1 an expected failure, 2 a usage error. Backed by the actual byte handed
/// to `std.process.exit`, so the mapping is defined once and here only.
pub const Exit = enum(u8) {
    ok = 0,
    failure = 1,
    usage = 2,
    /// `zit frames`: the cursor is current and no new artifacts exist.
    no_new = 3,
    /// `zit frames`: prefix validation failed — the store was rewritten
    /// (pack / shun reclaim) and the consumer's cursor is dead.
    cursor_invalid = 4,
    /// `zit landing accept`: the branch does not merge cleanly, so NOTHING was
    /// written. Distinct from `failure` because a forge must tell "this
    /// needs a human" apart from "the command was wrong" — the first is a state
    /// it renders, the second is a bug it reports.
    conflicts = 5,
};

/// The two output sinks carried together, so the discipline "data → out,
/// diagnostics → err" is structural rather than a per-call decision
/// (B-init-12). Both writers are owned by the caller and outlive every use.
pub const Streams = struct {
    out: *Io.Writer,
    err: *Io.Writer,
};

/// The ambient environment/clock values the CLI's runners need, threaded as
/// plain values so each runner stays pure w.r.t. time and environment
/// (design-seams rule: no ambient clock or env read inside a runner). `main`
/// builds it once — the single place these effects are read — and forwards it;
/// a verb that needs none of the fields ignores it. `now_epoch` is whole Unix
/// seconds (UTC, for the D card timestamp, B-commit-7); `user_env` is the
/// `$USER`/`$LOGNAME` fallback author (B-commit-6), or null when neither is
/// set; `push_password` is `$ZIT_PASSWORD` (sync §6.3, a no-persistence
/// password source for `push`), or null when unset.
pub const Ambient = struct {
    now_epoch: i64,
    /// Milliseconds within `now_epoch`'s second, [0, 999]. Artifact
    /// D cards carry them so same-second writes keep a real-time order;
    /// SOURCE_DATE_EPOCH runs (and every test default) pin 0.
    now_ms: u16 = 0,
    user_env: ?[]const u8,
    push_password: ?[]const u8 = null,
    /// The process environment, from main's `Init` — the seam that keeps
    /// runners from reaching into `std.c.environ` (which would demand libc
    /// linkage on Linux). Spawning verbs thread it to their child factories;
    /// everything else ignores it.
    environ: std.process.Environ = .empty,
    /// The `-R`/`--repo <path>` override, resolved before verb dispatch: the
    /// directory holding the `.zit` store to read, instead of the one found by
    /// walking up from the cwd. Null means "discover from the cwd" — the only
    /// behavior before this option existed. Read verbs honor it; every verb that
    /// needs a working tree rejects it (a store is not a checkout).
    repo_override: ?[]const u8 = null,
};

/// The parsed shape of a `zit init` argument list (B-init-1). `directory`
/// null means "the caller's default" (the cwd); the verb, not the parser,
/// owns that default so parsing stays a pure function of its input.
pub const Parsed = struct {
    directory: ?[]const u8 = null,
    quiet: bool = false,
    help: bool = false,
};

/// Result of parsing: a clean `Parsed`, or the exact offending token for one
/// of the two B-init-10 usage errors. A tagged union so the renderer prints
/// the precise token with no out-of-band side channel.
pub const ParseResult = union(enum) {
    ok: Parsed,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// The usage errors the shared renderer knows how to phrase. The path-less,
/// check-in-less, and name-less variants carry no token; the others name the
/// offending token.
pub const UsageKind = enum { unknown_option, extra_argument, no_paths, no_checkin, no_name, no_file, no_hash, no_url, no_dir, bad_subcommand, no_override, repeated_option, empty_value, bad_date, bad_manifest, bad_limit, repo_unsupported, too_many_cones, cone_and_dense, option_not_for_subcommand };

// ---------------------------------------------------------------------------
// The one flag scanner every verb shares (Ousterhout: a single deep mechanism,
// not four near-identical per-verb copies). Each verb hands in a `spec` — its
// options and where each records its result — and the scanner drives the common
// two-state grammar (options, then `--`, then positionals). The precedence
// between `--help` and a usage error is the caller's to decide, because it
// differs by verb: init lets an offense stand; add/rm/commit let `--help` win
// (B-add-13/B-rm-13/B-commit-18a). Keeping that policy out of the scanner is
// what lets one scanner serve all four grammars.
// ---------------------------------------------------------------------------

/// One option in a verb's flag grammar. `long` is the mandatory two-dash
/// spelling ("--quiet"); `short` an optional one-dash alias ("-q") or null.
/// `target` is where a match records its result.
const FlagSpec = struct {
    long: []const u8,
    short: ?[]const u8 = null,
    target: Target,

    /// A `flag` records a bool set true on sight; a `value` option records the
    /// token that immediately follows it.
    const Target = union(enum) {
        flag: *bool,
        value: *?[]const u8,
    };
};

/// The one usage error a pure scan can detect, carrying the exact offending
/// token. `unknown_option` covers an unrecognized `-x` and a dangling value
/// option (no following value); `extra_argument` is a positional beyond a
/// grammar's capacity — only a bounded-positional grammar (init) can raise it.
/// The first offense in token order wins.
const Offense = union(enum) {
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Outcome of a scan: how many positionals landed in the caller's buffer and
/// the first offense (or null). Flag and value results are written through the
/// spec's targets, so they are not repeated here.
const ScanResult = struct {
    positional_count: usize,
    offense: ?Offense = null,
};

/// Returns the target of the spec option `tok` names (by long or short
/// spelling), or null when `tok` matches no option. Bounded by `spec.len`.
fn matchFlag(spec: []const FlagSpec, tok: []const u8) ?FlagSpec.Target {
    for (spec) |f| {
        if (std.mem.eql(u8, tok, f.long)) return f.target;
        if (f.short) |s| if (std.mem.eql(u8, tok, s)) return f.target;
    }
    return null;
}

/// Appends `tok` to `out[0..count.*]` when room remains, else records the first
/// `extra_argument` offense. The shared positional sink for both scanner states.
fn appendPositional(tok: []const u8, out: [][]const u8, count: *usize, offense: *?Offense) void {
    if (count.* < out.len) {
        out[count.*] = tok;
        count.* += 1;
    } else if (offense.* == null) {
        offense.* = .{ .extra_argument = tok };
    }
}

/// Scans `verb_args` against `spec`, writing recognized flags/values through
/// their targets and positionals into `positionals_out` (up to its length).
/// Pure: no library calls, no I/O, no allocation. A labeled switch drives the
/// two parser states — `.scan` recognizes the `spec` options; `--` switches to
/// `.positional_only`, where every remaining token is a positional (so a file
/// literally named `-q` is reachable). A value option consumes the following
/// token; a dangling one (no following token) is the first offense. Any other
/// `-x` (not a lone `-`) is an unknown option; a lone `-` and every bare word is
/// a positional. The whole list is always scanned (offenses are recorded, never
/// returned early) so a caller can let `--help` win regardless of order.
fn scanFlags(verb_args: []const []const u8, spec: []const FlagSpec, positionals_out: [][]const u8) ScanResult {
    const Mode = enum { scan, positional_only };
    var count: usize = 0;
    var offense: ?Offense = null;
    var i: usize = 0;
    state: switch (Mode.scan) {
        .scan => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
            if (matchFlag(spec, tok)) |t| {
                switch (t) {
                    .flag => |b| b.* = true,
                    .value => |v| {
                        if (i >= verb_args.len) {
                            if (offense == null) offense = .{ .unknown_option = tok };
                        } else {
                            v.* = verb_args[i];
                            i += 1;
                        }
                    },
                }
                continue :state .scan;
            }
            // Any other token that starts with '-' (and is not a lone "-") is an
            // unrecognized option; a lone "-" and every bare word is a positional.
            if (tok.len > 1 and tok[0] == '-') {
                if (offense == null) offense = .{ .unknown_option = tok };
                continue :state .scan;
            }
            appendPositional(tok, positionals_out, &count, &offense);
            continue :state .scan;
        },
        .positional_only => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            appendPositional(tok, positionals_out, &count, &offense);
            continue :state .positional_only;
        },
    }
    std.debug.assert(count <= positionals_out.len);
    return .{ .positional_count = count, .offense = offense };
}

/// The positional slice a multi-positional verb (add/rm/commit) scanned, plus
/// the first unknown option (or dangling value option) it saw. `paths` is
/// exactly sized and caller-owned (an empty slice needs no free). These verbs
/// size their buffer to the token count, so a positional never overflows and an
/// `extra_argument` offense cannot arise — only an unknown option can surface.
const Collected = struct {
    paths: []const []const u8,
    unknown_option: ?[]const u8,
};

/// Scans `verb_args` against `spec` into an exactly-sized, caller-owned
/// positional slice — the one allocating step the three multi-positional verbs
/// share. A scratch buffer sized to the token count (so no positional is ever
/// dropped) is scanned, then shrunk to the positionals actually seen. The caller
/// applies `--help` precedence over the returned `unknown_option`.
fn collectPositionals(gpa: std.mem.Allocator, verb_args: []const []const u8, spec: []const FlagSpec) error{OutOfMemory}!Collected {
    const buf = try gpa.alloc([]const u8, verb_args.len);
    const scan = scanFlags(verb_args, spec, buf);
    const unknown: ?[]const u8 = if (scan.offense) |o| switch (o) {
        .unknown_option => |t| t,
        .extra_argument => unreachable, // buffer is sized to fit every token
    } else null;
    if (scan.positional_count == 0) {
        gpa.free(buf);
        return .{ .paths = &.{}, .unknown_option = unknown };
    }
    if (scan.positional_count == verb_args.len) {
        return .{ .paths = buf, .unknown_option = unknown };
    }
    const paths = gpa.realloc(buf, scan.positional_count) catch |e| {
        gpa.free(buf);
        return e;
    };
    return .{ .paths = paths, .unknown_option = unknown };
}

/// Scans `verb init` arguments into a `ParseResult` (B-init-1) by feeding the
/// init flag spec to the shared `scanFlags`. Pure: no library calls, no I/O, no
/// allocation — the lone positional lands in a stack slot. init accepts a single
/// positional directory; a second is an `extra_argument` error. Unlike the
/// multi-positional verbs, init has no help-wins clause, so an offense stands
/// even when `-h` also appears — the offense is therefore checked first.
pub fn parseInitArgs(verb_args: []const []const u8) ParseResult {
    var parsed: Parsed = .{};
    var slot: [1][]const u8 = undefined;
    const spec = [_]FlagSpec{
        .{ .long = "--help", .short = "-h", .target = .{ .flag = &parsed.help } },
        .{ .long = "--quiet", .short = "-q", .target = .{ .flag = &parsed.quiet } },
    };
    const scan = scanFlags(verb_args, &spec, &slot);
    if (scan.offense) |o| return switch (o) {
        .unknown_option => |t| .{ .unknown_option = t },
        .extra_argument => |t| .{ .extra_argument = t },
    };
    if (scan.positional_count == 1) parsed.directory = slot[0];
    return .{ .ok = parsed };
}

/// The parsed shape of a no-positional verb's argument list. `status` and
/// `diff` each accept only the standard help flag, so sharing this scanner keeps
/// their option/argument precedence byte-identical without adding unreachable
/// fields from `Parsed`.
pub const NoPositionalResult = union(enum) {
    ok: struct { help: bool },
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Scans a help-only, no-positional verb's arguments. Pure: no library calls,
/// I/O, or allocation. `-h`/`--help` win over every otherwise-invalid token;
/// `--` switches to positional-only mode, where even option-looking tokens are
/// extra arguments. The first invalid token is retained for the diagnostic.
/// `undo` / `redo`: no positionals; `-n` previews.
pub const UndoArgs = struct { help: bool = false, dry_run: bool = false };
pub const UndoResult = union(enum) { ok: UndoArgs, unknown_option: []const u8, extra_argument: []const u8 };

pub fn parseUndoArgs(verb_args: []const []const u8) UndoResult {
    var args: UndoArgs = .{};
    var first_err: ?UndoResult = null;
    var positional_only = false;
    for (verb_args) |tok| {
        if (positional_only) {
            if (first_err == null) first_err = .{ .extra_argument = tok };
        } else if (std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            args.help = true;
        } else if (std.mem.eql(u8, tok, "-n") or std.mem.eql(u8, tok, "--dry-run")) {
            args.dry_run = true;
        } else if (first_err == null) {
            first_err = if (tok.len > 1 and tok[0] == '-') .{ .unknown_option = tok } else .{ .extra_argument = tok };
        }
    }
    if (args.help) return .{ .ok = args };
    return first_err orelse .{ .ok = args };
}

test "cli undo: -n previews, --help wins, a positional is extra" {
    try std.testing.expect(parseUndoArgs(&.{}).ok.dry_run == false);
    try std.testing.expect(parseUndoArgs(&.{"-n"}).ok.dry_run);
    try std.testing.expect(parseUndoArgs(&.{"--dry-run"}).ok.dry_run);
    try std.testing.expect(parseUndoArgs(&.{ "x", "--help" }).ok.help);
    try std.testing.expect(parseUndoArgs(&.{"x"}) == .extra_argument);
    try std.testing.expectEqualStrings("3", parseUndoArgs(&.{ "--", "3" }).extra_argument);
    try std.testing.expect(parseUndoArgs(&.{"--bogus"}) == .unknown_option);
}

/// The one spelling of "show the dot-prefixed entries too",
/// shared by every verb that scans the tree so none can drift from the others.
/// Fossil spells it `--dotfiles` on add, addremove, clean, extras and status.
pub const dotfiles_flag: []const u8 = "--dotfiles";

/// Parsed `zit extras [--dotfiles]` arguments. extras took no flag but `--help`
/// until item 7 gave it something to show.
pub const ExtrasArgs = struct {
    help: bool = false,
    dotfiles: bool = false,
};

pub const ExtrasResult = union(enum) {
    ok: ExtrasArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// `parseNoPositionalArgs` plus `--dotfiles`. Pure. `-h`/`--help` win over any
/// otherwise-invalid token (B-extras-11); `--` switches to positional-only
/// mode, where even an option-looking token is an extra argument (B-extras-13).
pub fn parseExtrasArgs(verb_args: []const []const u8) ExtrasResult {
    var parsed: ExtrasArgs = .{};
    var first_err: ?ExtrasResult = null;
    var positional_only = false;
    for (verb_args) |tok| {
        if (positional_only) {
            if (first_err == null) first_err = .{ .extra_argument = tok };
        } else if (std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            parsed.help = true;
        } else if (std.mem.eql(u8, tok, dotfiles_flag)) {
            parsed.dotfiles = true;
        } else if (first_err == null) {
            first_err = if (tok.len > 1 and tok[0] == '-')
                .{ .unknown_option = tok }
            else
                .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

pub fn parseNoPositionalArgs(verb_args: []const []const u8) NoPositionalResult {
    const Mode = enum { scan, positional_only };
    var help = false;
    var first_err: ?NoPositionalResult = null;
    var i: usize = 0;
    state: switch (Mode.scan) {
        .scan => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
            if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
                help = true;
                continue :state .scan;
            }
            if (first_err == null) {
                first_err = if (tok.len > 1 and tok[0] == '-')
                    .{ .unknown_option = tok }
                else
                    .{ .extra_argument = tok };
            }
            continue :state .scan;
        },
        .positional_only => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (first_err == null) first_err = .{ .extra_argument = tok };
            continue :state .positional_only;
        },
    }
    if (help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = .{ .help = false } };
}

// ---------------------------------------------------------------------------
// The `-n N` entry limit shared by `timeline` and `log`.
//
// `-n` is fossil's spelling and is deliberately overloaded: `fossil timeline -n
// 20` limits the listing while `fossil clean -n` is a dry run, and zit follows
// that split exactly (`-n` remains `--dry-run` on add/rm/clean/addremove). The
// meaning is per-verb, decided by the verb's own grammar, which is why the
// limit lives in a helper the two history verbs opt into rather than in the
// shared scanner every verb sees.
// ---------------------------------------------------------------------------

/// The two spellings of the entry limit. `--limit` is a readable long alias;
/// `-n` is the required surface.
const limit_flag_short: []const u8 = "-n";
const limit_flag_long: []const u8 = "--limit";

/// Whether `tok` names the entry-limit option.
fn isLimitFlag(tok: []const u8) bool {
    return std.mem.eql(u8, tok, limit_flag_short) or std.mem.eql(u8, tok, limit_flag_long);
}

/// Parses an `-n` value into an entry cap. Returns null for `0` — fossil's
/// spelling of "unlimited", which is also what an absent `-n` means, so both
/// collapse to the same no-cap representation and no downstream code has to
/// distinguish them. A non-numeric, negative, or overflowing value is refused
/// (null is never used to mean "bad input"; the caller sees `error.BadLimit`).
fn parseLimitValue(tok: []const u8) error{BadLimit}!?usize {
    const n = std.fmt.parseInt(usize, tok, 10) catch return error.BadLimit;
    return if (n == 0) null else n;
}

/// Parsed `zit timeline [-n N]` arguments. `limit` null means "every check-in" —
/// both the default and the explicit `-n 0`. NOTE the deliberate divergence from
/// fossil: `fossil timeline` defaults to 20 entries, `zit timeline` defaults to
/// the whole listing, because that has been its behavior since it shipped and
/// silently truncating existing output would be the larger surprise.
/// Parsed `zit status` arguments: Fossil's classification flags.
pub const StatusArgs = struct {
    help: bool = false,
    /// `--classify`: upper-case Fossil words in an aligned column.
    classify: bool = false,
    /// `-v`: the header block (repository, checkout, parent, tags, comment).
    verbose: bool = false,
    /// Kind filters; none set = every kind. `--differ` is edited + extra.
    added: bool = false,
    edited: bool = false,
    missing: bool = false,
    conflict: bool = false,
    extra: bool = false,
    /// List dot-prefixed extras, which are hidden by default.
    dotfiles: bool = false,

    pub fn filtered(self: StatusArgs) bool {
        return self.added or self.edited or self.missing or self.conflict or self.extra;
    }
};

pub const StatusResult = union(enum) {
    ok: StatusArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

pub fn parseStatusArgs(verb_args: []const []const u8) StatusResult {
    var parsed: StatusArgs = .{};
    var first_err: ?StatusResult = null;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    for (verb_args) |tok| {
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--classify"))) {
            parsed.classify = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--no-classify"))) {
            parsed.classify = false;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-v") or std.mem.eql(u8, tok, "--verbose"))) {
            parsed.verbose = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--added"))) {
            parsed.added = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--edited") or std.mem.eql(u8, tok, "--changed"))) {
            parsed.edited = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--missing"))) {
            parsed.missing = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--conflict"))) {
            parsed.conflict = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--extra"))) {
            parsed.extra = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, dotfiles_flag))) {
            parsed.dotfiles = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--differ"))) {
            parsed.edited = true;
            parsed.extra = true;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

pub const TimelineFormat = enum { oneline, medium, full, html };

pub const TimelineArgs = struct {
    limit: ?usize = null,
    /// `--weekday`: print each row's UTC day of week beside the date.
    weekday: bool = false,
    /// Fossil's filters and formats. `branch` is `-b NAME`,
    /// `current_branch` is `-c`, `path` is `-p PATH` (check-ins whose
    /// effective content for PATH differs from their primary parent's),
    /// `user` is `-u LOGIN`, `reverse` is `-r`, `offset` skips the first N
    /// matches, `verbose` adds the changed files under each entry.
    branch: ?[]const u8 = null,
    current_branch: bool = false,
    path: ?[]const u8 = null,
    user: ?[]const u8 = null,
    reverse: bool = false,
    offset: ?usize = null,
    verbose: bool = false,
    format: TimelineFormat = .medium, // Fossil's day-grouped shape
    help: bool = false,
};

pub const TimelineResult = union(enum) {
    ok: TimelineArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    bad_limit: []const u8,
};

/// Parses the timeline surface: no positionals, the standard help flag, and the
/// `-n N` entry limit. Pure: no library calls, I/O, or allocation. Help wins
/// over every other token including a bad limit; `--` switches to positional-only
/// mode, where even option-looking tokens are extra arguments. The first offense
/// in token order is retained for the diagnostic.
pub fn parseTimelineArgs(verb_args: []const []const u8) TimelineResult {
    var parsed: TimelineArgs = .{};
    var first_err: ?TimelineResult = null;
    var positional_only = false;
    var i: usize = 0;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (positional_only) {
            if (first_err == null) first_err = .{ .extra_argument = tok };
            continue;
        }
        if (std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            parsed.help = true;
        } else if (std.mem.eql(u8, tok, "--weekday")) {
            parsed.weekday = true;
        } else if (std.mem.eql(u8, tok, "-c") or std.mem.eql(u8, tok, "--current-branch")) {
            parsed.current_branch = true;
        } else if (std.mem.eql(u8, tok, "-r") or std.mem.eql(u8, tok, "--reverse")) {
            parsed.reverse = true;
        } else if (std.mem.eql(u8, tok, "-v") or std.mem.eql(u8, tok, "--verbose")) {
            parsed.verbose = true;
        } else if (std.mem.eql(u8, tok, "--oneline")) {
            parsed.format = .oneline;
        } else if (std.mem.eql(u8, tok, "--medium")) {
            parsed.format = .medium;
        } else if (std.mem.eql(u8, tok, "--full")) {
            parsed.format = .full;
        } else if (std.mem.eql(u8, tok, "-b") or std.mem.eql(u8, tok, "--branch") or
            std.mem.eql(u8, tok, "-p") or std.mem.eql(u8, tok, "--path") or
            std.mem.eql(u8, tok, "-u") or std.mem.eql(u8, tok, "--for-user") or
            std.mem.eql(u8, tok, "-F") or std.mem.eql(u8, tok, "--format") or
            std.mem.eql(u8, tok, "--offset"))
        {
            if (i + 1 >= verb_args.len) {
                if (first_err == null) first_err = .{ .unknown_option = tok };
                continue;
            }
            i += 1;
            const val = verb_args[i];
            if (std.mem.eql(u8, tok, "-b") or std.mem.eql(u8, tok, "--branch")) {
                parsed.branch = val;
            } else if (std.mem.eql(u8, tok, "-p") or std.mem.eql(u8, tok, "--path")) {
                parsed.path = val;
            } else if (std.mem.eql(u8, tok, "-u") or std.mem.eql(u8, tok, "--for-user")) {
                parsed.user = val;
            } else if (std.mem.eql(u8, tok, "--offset")) {
                parsed.offset = parseLimitValue(val) catch {
                    if (first_err == null) first_err = .{ .bad_limit = val };
                    continue;
                };
            } else {
                parsed.format = std.meta.stringToEnum(TimelineFormat, val) orelse {
                    if (first_err == null) first_err = .{ .unknown_option = val };
                    continue;
                };
            }
        } else if (isLimitFlag(tok)) {
            if (i + 1 >= verb_args.len) {
                if (first_err == null) first_err = .{ .unknown_option = tok };
                continue;
            }
            i += 1;
            parsed.limit = parseLimitValue(verb_args[i]) catch {
                if (first_err == null) first_err = .{ .bad_limit = verb_args[i] };
                continue;
            };
        } else if (first_err == null) {
            first_err = if (tok.len > 1 and tok[0] == '-')
                .{ .unknown_option = tok }
            else
                .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

/// Parsed `zit log CHECKIN [PATH]` arguments. The first positional is a full
/// check-in artifact ID borrowed from argv; `null` lets the runner render the
/// missing-check-in usage error after help has had its chance to win. The
/// optional second positional `path` is a repo-relative path — present, it
/// restricts the ancestry walk to the check-ins that change PATH (B-log-2);
/// absent, the walk is unchanged (B-log-1).
pub const LogArgs = struct {
    checkin: ?[]const u8 = null,
    path: ?[]const u8 = null,
    /// Entry cap; null is "every reachable check-in" — both the default and the
    /// explicit `-n 0`.
    limit: ?usize = null,
    help: bool = false,
    /// `--long`: emit each check-in's timestamp, user and comment beside its
    /// id, so a consumer reads a history page in one invocation.
    long: bool = false,
};

pub const LogResult = union(enum) {
    ok: LogArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    bad_limit: []const u8,
};

/// Parses the log surface: one full check-in ID, an OPTIONAL repo-relative PATH
/// second positional, the `-n N` entry limit, and the standard help request.
/// Pure: no library calls, I/O, or allocation. Help wins over every other token;
/// any other `-x` (a bare `--` included) is an unknown option — `log` has no
/// positional-only separator — the first positional binds CHECKIN, the second
/// binds PATH, and a THIRD positional is an extra argument. The first offense in
/// token order is retained for the diagnostic.
pub fn parseLogArgs(verb_args: []const []const u8) LogResult {
    var parsed: LogArgs = .{};
    var first_err: ?LogResult = null;
    var i: usize = 0;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--long"))) {
            parsed.long = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (isLimitFlag(tok))) {
            if (i + 1 >= verb_args.len) {
                if (first_err == null) first_err = .{ .unknown_option = tok };
                continue;
            }
            i += 1;
            parsed.limit = parseLimitValue(verb_args[i]) catch {
                if (first_err == null) first_err = .{ .bad_limit = verb_args[i] };
                continue;
            };
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (parsed.checkin == null) {
            parsed.checkin = tok;
        } else if (parsed.path == null) {
            parsed.path = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

/// Parsed `zit update CHECKIN` arguments: one full check-in ID and the help
/// flag. A distinct type from `LogArgs`, though `update` and `log` now share
/// the same one-positional, help-only grammar.
pub const UpdateArgs = struct {
    checkin: ?[]const u8 = null,
    help: bool = false,
    /// `--cone=PATH` members in argv order, borrowing argv. Bounded by the
    /// cone member cap; the parser refuses the 65th rather than truncating.
    cone_members: [255][]const u8 = undefined,
    cone_len: usize = 0,
    /// `--dense`: drop the stored cone, return to dense operation.
    dense: bool = false,
    /// `--no-sync` suppresses the autosync pull for this invocation.
    no_sync: bool = false,
    /// `-n`/`--dry-run`: print the receipt, change nothing.
    dry_run: bool = false,
    /// `--latest`: the current branch's most recent check-in, in
    /// place of CHECKIN.
    latest: bool = false,
    /// `-v`/`--verbose`: list unchanged files in the receipt too.
    verbose: bool = false,
    /// `--cone-file=PATH`: members read one per line in the cmd layer (the
    /// parser stays pure); combines with any `--cone=` members.
    cone_file: ?[]const u8 = null,
    /// `--cone-not=PATH` exclusion members in argv order, borrowing argv.
    cone_not_members: [255][]const u8 = undefined,
    cone_not_len: usize = 0,
};

pub const UpdateResult = union(enum) {
    ok: UpdateArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    empty_value: []const u8,
    too_many_cones: []const u8,
    cone_and_dense,
};

/// Parses the one-verb update surface: one full check-in ID and no flags except
/// the standard help request. Pure: no library calls, I/O, or allocation. Help
/// wins over every other token; any `-x` is an unknown option here.
pub fn parseUpdateArgs(verb_args: []const []const u8) UpdateResult {
    var parsed: UpdateArgs = .{};
    var first_err: ?UpdateResult = null;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    for (verb_args) |tok| {
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.startsWith(u8, tok, "--cone-file="))) {
            const value = tok["--cone-file=".len..];
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = "--cone-file" };
                continue;
            }
            if (parsed.cone_file != null) {
                if (first_err == null) first_err = .{ .empty_value = "--cone-file" };
                continue;
            }
            parsed.cone_file = value;
        } else if (!positional_only and (std.mem.startsWith(u8, tok, "--cone-not="))) {
            const value = tok["--cone-not=".len..];
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = "--cone-not" };
                continue;
            }
            if (parsed.cone_not_len >= parsed.cone_not_members.len) {
                if (first_err == null) first_err = .{ .too_many_cones = value };
                continue;
            }
            parsed.cone_not_members[parsed.cone_not_len] = value;
            parsed.cone_not_len += 1;
        } else if (!positional_only and (std.mem.startsWith(u8, tok, "--cone="))) {
            // Sparse-cone RFC section 3: repeatable, borrows argv; validation
            // (worktree-path rules, member bound) happens at Cone.init so the
            // parser stays allocation-free and single-pass.
            const value = tok["--cone=".len..];
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = "--cone" };
                continue;
            }
            if (parsed.cone_len >= parsed.cone_members.len) {
                if (first_err == null) first_err = .{ .too_many_cones = value };
                continue;
            }
            parsed.cone_members[parsed.cone_len] = value;
            parsed.cone_len += 1;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--dense"))) {
            parsed.dense = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--no-sync"))) {
            parsed.no_sync = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-n") or std.mem.eql(u8, tok, "--dry-run"))) {
            parsed.dry_run = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--latest"))) {
            parsed.latest = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-v") or std.mem.eql(u8, tok, "--verbose"))) {
            parsed.verbose = true;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (parsed.checkin == null) {
            parsed.checkin = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    // Contradictory instruction: --dense (drop the cone) with --cone (set
    // one). Refused rather than last-wins -- the operator meant something.
    if (parsed.dense and parsed.cone_len + parsed.cone_not_len != 0) return .cone_and_dense;
    return .{ .ok = parsed };
}

/// Parsed `zit open` arguments: the help flag and nothing else. Unlike
/// `update`, `open` takes NO operand — it derives its target from the
/// repository's own state (the tip of the main branch), which is the whole
/// point of the verb: `update` accepts only literal 40/64-hex check-in IDs, so
/// a freshly cloned tree has no way to name its own tip. A positional is
/// therefore an extra-argument usage error, not a check-in.
pub const OpenArgs = struct {
    help: bool = false,
    /// The `--manifest[=r,u,t]` opt-in, canonicalized to one of the eight
    /// static subsets of "rut"; null when the flag was not given (the default,
    /// which writes no manifest files). Never borrows argv, so it outlives the
    /// argument slice with no copy.
    manifest: ?[]const u8 = null,
    /// The optional CHECKIN to open at, resolved like `zit info`; null means
    /// the tip of the main branch (the shipped default). Borrows argv.
    checkin: ?[]const u8 = null,
    /// `--store=PATH`: create a DETACHED checkout of the store at PATH
    /// in the current directory before opening. PATH names the directory
    /// holding `.zit` (or the `.zit` file itself). Borrows argv.
    store: ?[]const u8 = null,
    /// `--cone=PATH` members in argv order, borrowing argv. Bounded by the
    /// cone member cap; the parser refuses the 65th rather than truncating.
    /// `open` has no `--dense`: a fresh checkout with no flag IS dense.
    cone_members: [255][]const u8 = undefined,
    cone_len: usize = 0,
    cone_file: ?[]const u8 = null,
    /// `--cone-not=PATH` exclusion members in argv order, borrowing argv.
    cone_not_members: [255][]const u8 = undefined,
    cone_not_len: usize = 0,
};

pub const OpenResult = union(enum) {
    ok: OpenArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    repeated_option: []const u8,
    empty_value: []const u8,
    bad_manifest: []const u8,
    too_many_cones: []const u8,
};

/// The eight canonical `--manifest` values, indexed by the selector bitmask
/// (bit 0 `r`, bit 1 `u`, bit 2 `t`). A table, not a formatter: it makes the
/// canonical order and the dedup fall out of the indexing, and it means a
/// parsed value is a static string rather than a slice of argv.
const manifest_canon = [8][]const u8{ "", "r", "u", "ru", "t", "rt", "ut", "rut" };

/// Canonicalize a `--manifest=` value: any combination of `r`, `u` and `t`, in
/// any order, repeats allowed. Returns null on any other byte. Pure.
fn canonicalManifest(value: []const u8) ?[]const u8 {
    var mask: u3 = 0;
    for (value) |c| switch (c) {
        'r' => mask |= 0b001,
        'u' => mask |= 0b010,
        't' => mask |= 0b100,
        else => return null,
    };
    return manifest_canon[mask];
}

/// Parses the operand-free `zit open` surface: `-h`/`--help` and the
/// `--manifest[=r,u,t]` opt-in. Help wins over every other token, valid or not;
/// a bare `-x` is an unknown option and anything else is an unexpected
/// argument. The bare `--manifest` means `ru` — the check-in artifact and its
/// hash, the pair a build reads. Pure: no library calls, I/O, or allocation.
pub fn parseOpenArgs(verb_args: []const []const u8) OpenResult {
    var parsed: OpenArgs = .{};
    var first_err: ?OpenResult = null;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    for (verb_args) |tok| {
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--manifest") or std.mem.startsWith(u8, tok, "--manifest="))) {
            if (parsed.manifest != null) {
                if (first_err == null) first_err = .{ .repeated_option = "--manifest" };
                continue;
            }
            if (tok.len == "--manifest".len) {
                parsed.manifest = "ru";
            } else {
                const value = tok["--manifest=".len..];
                if (value.len == 0) {
                    if (first_err == null) first_err = .{ .empty_value = "--manifest" };
                    continue;
                }
                // Fossil's boolean spellings, so `off` is the
                // explicit refusal the checkout remembers and `on` is `ru`.
                parsed.manifest = if (settings.isFalse(value))
                    checkout.manifest_off
                else if (settings.isTruth(value))
                    "ru"
                else
                    canonicalManifest(value) orelse {
                        if (first_err == null) first_err = .{ .bad_manifest = value };
                        continue;
                    };
            }
        } else if (!positional_only and (std.mem.startsWith(u8, tok, "--cone-file="))) {
            const value = tok["--cone-file=".len..];
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = "--cone-file" };
                continue;
            }
            if (parsed.cone_file != null) {
                if (first_err == null) first_err = .{ .empty_value = "--cone-file" };
                continue;
            }
            parsed.cone_file = value;
        } else if (!positional_only and (std.mem.startsWith(u8, tok, "--cone-not="))) {
            const value = tok["--cone-not=".len..];
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = "--cone-not" };
                continue;
            }
            if (parsed.cone_not_len >= parsed.cone_not_members.len) {
                if (first_err == null) first_err = .{ .too_many_cones = value };
                continue;
            }
            parsed.cone_not_members[parsed.cone_not_len] = value;
            parsed.cone_not_len += 1;
        } else if (!positional_only and (std.mem.startsWith(u8, tok, "--store="))) {
            const value = tok["--store=".len..];
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = "--store" };
                continue;
            }
            if (parsed.store != null) {
                if (first_err == null) first_err = .{ .repeated_option = "--store" };
                continue;
            }
            parsed.store = value;
        } else if (!positional_only and (std.mem.startsWith(u8, tok, "--cone="))) {
            // Sparse-cone RFC section 4.1: `open --cone` materializes the
            // first checkout already scoped. Same collection discipline as
            // update's parser; Cone.init validates later.
            const value = tok["--cone=".len..];
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = "--cone" };
                continue;
            }
            if (parsed.cone_len >= parsed.cone_members.len) {
                if (first_err == null) first_err = .{ .too_many_cones = value };
                continue;
            }
            parsed.cone_members[parsed.cone_len] = value;
            parsed.cone_len += 1;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (parsed.checkin == null) {
            parsed.checkin = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

/// The alternate baseline a special merge selects (merge-flags.md). Absent =
/// the plain three-way merge (the shipped `zit merge NAME` surface, untouched).
pub const SpecialMerge = enum { cherrypick, backout };

/// The parsed shape of a `zit merge` argument list: the one positional NAME, an
/// optional special-merge flag, and the help request. Extends the one-positional
/// `parseUpdateArgs` template with the two mutually-exclusive baseline flags; the
/// flagless grammar is byte-identical to that template (B-merge-31/40).
pub const MergeArgs = struct {
    checkin: ?[]const u8 = null,
    /// `--probe`: report whether NAME would merge cleanly and write
    /// NOTHING. A read, so it takes `-R` and needs no checkout -- the caller
    /// names both sides and the store answers.
    probe: bool = false,
    /// `--into TARGET`, probe only. Absent under `--probe` in a checkout, the
    /// current check-in is the other side, exactly as a real merge would take
    /// it. Required with `-R`, where there is no checkout to supply one.
    into: ?[]const u8 = null,
    special: ?SpecialMerge = null,
    help: bool = false,
    /// `--no-sync` suppresses the autosync pull for this invocation.
    no_sync: bool = false,
    /// `--no-precedent` declines remembered conflict resolutions for this
    /// invocation, so the merge behaves exactly as it did before the
    /// feature existed. Declining does not forget: the precedents survive.
    no_precedent: bool = false,
    /// `-X ours|theirs`: resolve every content conflict in favour of one
    /// side instead of writing markers. Null is the default -- conflicts are
    /// left for a human, which is the only behaviour this verb had before.
    ///
    /// Orthogonal to `no_precedent` above, and they compose without either
    /// knowing about the other: a strategy leaves no conflict behind, so a
    /// precedent has nothing to replay and nothing to record.
    strategy: ?[]const u8 = null,
    /// Close the merged-in leaf when the merge is committed.
    integrate: bool = false,
    /// Use this check-in as the common ancestor instead of the merge base.
    baseline: ?[]const u8 = null,
};

pub const MergeResult = union(enum) {
    ok: MergeArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    /// Both `--cherrypick` and `--backout` were given: they are mutually
    /// exclusive (merge-flags.md grammar). Carries the second flag's token.
    conflicting_flags: []const u8,
    /// `-X` given without a value, or with one that is not `ours`/`theirs`.
    /// Carries the offending token, or an empty slice when `-X` ended the
    /// argument list. Refused rather than defaulted: a strategy silently
    /// falling back to "leave markers" would look like it had been applied.
    bad_strategy: []const u8,
};

/// Parses `zit merge [--cherrypick | --backout] NAME`. `-h`/`--help` is the
/// only other flag and wins over every otherwise-valid or -invalid token; a
/// bare `-x` is an unknown option; a second positional is an extra argument;
/// giving both baseline flags is a conflict (B-merge-40). Repeating the SAME
/// baseline flag is idempotent (harmless). Pure: no library calls, I/O, or
/// allocation. NAME being null is left for the runner to render as the
/// no-name usage error after help has had its chance to win.
pub fn parseMergeArgs(verb_args: []const []const u8) MergeResult {
    var parsed: MergeArgs = .{};
    var first_err: ?MergeResult = null;
    var conflict: ?[]const u8 = null;
    // `-X` takes its value as the NEXT token, so the scan carries the
    // expectation across one iteration.
    var want_strategy = false;
    var want_into = false;
    var want_baseline = false;
    var bad_strategy: ?[]const u8 = null;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    for (verb_args) |tok| {
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (want_baseline)) {
            want_baseline = false;
            parsed.baseline = tok;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--baseline"))) {
            want_baseline = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--integrate"))) {
            parsed.integrate = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-n") or std.mem.eql(u8, tok, "--dry-run"))) {
            parsed.probe = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--cherrypick"))) {
            if (parsed.special == .backout and conflict == null) conflict = tok;
            parsed.special = .cherrypick;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--backout"))) {
            if (parsed.special == .cherrypick and conflict == null) conflict = tok;
            parsed.special = .backout;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--no-sync"))) {
            parsed.no_sync = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--probe"))) {
            parsed.probe = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--into"))) {
            want_into = true;
        } else if (!positional_only and (want_into)) {
            want_into = false;
            parsed.into = tok;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-X") or std.mem.eql(u8, tok, "--strategy"))) {
            want_strategy = true;
        } else if (!positional_only and (want_strategy)) {
            // The value slot, checked BEFORE the remaining flags so that
            // `zit merge -X --no-precedent NAME` reports a bad strategy rather
            // than consuming the flag and then taking NAME as the value.
            want_strategy = false;
            if (std.mem.eql(u8, tok, "ours") or std.mem.eql(u8, tok, "theirs")) {
                parsed.strategy = tok;
            } else if (bad_strategy == null) {
                bad_strategy = tok;
            }
        } else if (!positional_only and (std.mem.eql(u8, tok, "--no-precedent"))) {
            parsed.no_precedent = true;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (parsed.checkin == null) {
            parsed.checkin = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (conflict) |tok| return .{ .conflicting_flags = tok };
    // A dangling `--into` never got its value; report it the way a missing
    // operand is reported rather than silently probing against the checkout.
    if (want_into) return .{ .unknown_option = "--into" };
    // A dangling `-X` is the empty token: the value never arrived.
    if (want_strategy) return .{ .bad_strategy = "" };
    if (bad_strategy) |tok| return .{ .bad_strategy = tok };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

/// Parsed `zit mv OLD NEW` arguments: exactly two positional operands and the
/// help flag. `old`/`new` null means the operand was not supplied — the runner
/// renders the missing-operand usage error after help has had its chance to win
/// (B-mv-14), so parsing stays a pure function of its input.
pub const MvArgs = struct {
    /// OLD... then NEW (a file name) or DIR (moved into).
    operands: []const []const u8,
    /// Record the rename without touching the working tree.
    soft: bool = false,
    dry_run: bool = false,
    help: bool = false,
};

pub const MvResult = union(enum) {
    ok: MvArgs,
    unknown_option: []const u8,
};

/// Parses the two-positional `zit mv OLD NEW` surface via the shared
/// `scanFlags` scanner (the two-positional variant of the `parseUpdateArgs`
/// template): `-h`/`--help` is the only flag, `--` flips to positional-only (so
/// a file literally named `-x` is reachable), and a THIRD positional overflows
/// the two-slot buffer into an `extra_argument` offense (B-mv-14). Help wins
/// over every otherwise-valid or -invalid token (B-mv-12). Pure: no library
/// calls, I/O, or allocation — the operands land in stack slots. A missing
/// operand is NOT a parse error; the runner maps it to the usage class.
pub fn parseMvArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!MvResult {
    var args: MvArgs = .{ .operands = &.{} };
    var hard = false;
    const spec = [_]FlagSpec{
        .{ .long = "--help", .short = "-h", .target = .{ .flag = &args.help } },
        .{ .long = "--soft", .target = .{ .flag = &args.soft } },
        .{ .long = "--hard", .target = .{ .flag = &hard } },
        .{ .long = "--dry-run", .short = "-n", .target = .{ .flag = &args.dry_run } },
    };
    const c = try collectPositionals(gpa, verb_args, &spec);
    if (!args.help) {
        if (c.unknown_option) |tok| {
            gpa.free(c.paths);
            return .{ .unknown_option = tok };
        }
    }
    if (hard) args.soft = false; // --hard names the default; it wins over --soft
    args.operands = c.paths;
    return .{ .ok = args };
}

/// Parsed `zit info [NAME...]` arguments. The positionals are
/// symbolic NAMEs (repo-state §8) borrowed from argv — deliberately distinct
/// from `LogArgs`, whose positional is a full artifact ID. `names` is exactly
/// sized and caller-owned (an empty slice needs no free); empty lets the runner
/// print the checkout block. A lone `-` among the names means "read names
/// from stdin", one per line — the runner expands it.
pub const InfoArgs = struct {
    names: []const []const u8 = &.{},
    /// `-F`: `text` is the per-check-in block; `json` is one object per line.
    format: InfoFormat = .text,
    /// Bypass the `.zit-cache` memo and force a full-log re-derive.
    no_cache: bool = false,
    help: bool = false,
};

pub const InfoFormat = enum { text, json };

pub const InfoResult = union(enum) {
    ok: InfoArgs,
    unknown_option: []const u8,
};

/// Parses the `zit info [NAME...] [-F text|json] [--no-cache] [-h|--help]`
/// surface through the shared `scanFlags` scanner (the `parseMvArgs` template
/// with an unbounded positional list): `--` flips to positional-only, so a
/// name literally spelled `-x` is reachable. Help wins over every other token;
/// an unrecognized option or an unknown `-F` value is retained for the
/// diagnostic. Allocates only the exactly-sized name slice.
pub fn parseInfoArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!InfoResult {
    var args: InfoArgs = .{};
    var format: ?[]const u8 = null;
    const spec = [_]FlagSpec{
        .{ .long = "--help", .short = "-h", .target = .{ .flag = &args.help } },
        .{ .long = "--no-cache", .target = .{ .flag = &args.no_cache } },
        .{ .long = "--format", .short = "-F", .target = .{ .value = &format } },
    };
    const c = try collectPositionals(gpa, verb_args, &spec);
    if (args.help) {
        gpa.free(c.paths);
        return .{ .ok = .{ .help = true } };
    }
    if (c.unknown_option) |tok| {
        gpa.free(c.paths);
        return .{ .unknown_option = tok };
    }
    if (format) |f| {
        args.format = std.meta.stringToEnum(InfoFormat, f) orelse {
            gpa.free(c.paths);
            return .{ .unknown_option = f };
        };
    }
    args.names = c.paths;
    return .{ .ok = args };
}

/// Parsed `zit ls [VERSION]` arguments. The lone positional is an optional
/// symbolic VERSION (repo-state §8); when absent the runner lists the current
/// checkout. Mirrors `InfoArgs` but keeps a distinct type so the runner reads
/// `version` (a check-in selector), not `name`.
pub const LsArgs = struct {
    version: ?[]const u8 = null,
    /// `--long`: also report each file's §4.7.6 permission token, its size,
    /// and whether its content is binary. Opt-in, so the default
    /// two-field output stays byte-identical for whatever already parses it.
    long: bool = false,
    /// `--size`: the CHEAP half of `--long`. Reports the permission and
    /// the blob's logical size, and deliberately NOT the binary class.
    ///
    /// The split is not cosmetic, it is where the cost is. A size is readable
    /// from a frame header — the zlib container states it outright and a delta
    /// carries it in its own §3.2 header — so it costs a header read per file.
    /// The binary verdict is "a NUL anywhere in the plaintext", and the NUL can
    /// be the last byte, so it needs the whole blob and therefore the whole
    /// delta chain. One flag could not be both cheap and complete, so there are
    /// two; `--long` keeps its exact contract for whatever already parses it.
    size: bool = false,
    /// `--under PATH`: list only the entries at or beneath PATH. A browser
    /// draws ONE directory, and without this the consumer pays for the whole
    /// tree to render a corner of it — which is also what makes `--long`'s
    /// per-file content read affordable, since the cost then tracks what is
    /// displayed rather than what exists.
    under: ?[]const u8 = null,
    /// Bypass the `.zit-cache` memo and force a full-log re-derive.
    no_cache: bool = false,
    help: bool = false,
};

pub const LsResult = union(enum) {
    ok: LsArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Parses `zit ls [VERSION] [--long|--size] [--under=PATH] [--no-cache]
/// [-h|--help]`. Pure: no library calls, I/O, or allocation. Help wins over
/// every other token; a second positional is an extra argument, and any
/// unrecognized option is retained for the diagnostic.
///
/// `--long` and `--size` are refused TOGETHER rather than one silently winning:
/// they are two different output shapes with two different costs, and a
/// consumer that asked for both wrote something it did not mean.
pub fn parseLsArgs(verb_args: []const []const u8) LsResult {
    var parsed: LsArgs = .{};
    var first_err: ?LsResult = null;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    for (verb_args) |tok| {
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--no-cache"))) {
            parsed.no_cache = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--long"))) {
            parsed.long = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--size"))) {
            parsed.size = true;
        } else if (!positional_only and (std.mem.startsWith(u8, tok, "--under="))) {
            const value = tok["--under=".len..];
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .unknown_option = "--under" };
            } else parsed.under = value;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (parsed.version == null) {
            parsed.version = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    if (parsed.long and parsed.size) return .{ .unknown_option = "--long with --size" };
    return .{ .ok = parsed };
}

/// Parsed `zit cat FILE [VERSION]` arguments. `file` is the required first
/// positional (the pathname to emit); `version` is the optional second
/// positional (a check-in selector — the current checkout when omitted).
/// Mirrors `LsArgs` with the extra required positional; the runner enforces
/// `file != null` as a `.no_file` usage error so parsing stays pure.
pub const CatArgs = struct {
    file: ?[]const u8 = null,
    version: ?[]const u8 = null,
    /// Bypass the `.zit-cache` memo and force a full-log re-derive.
    no_cache: bool = false,
    help: bool = false,
};

pub const CatResult = union(enum) {
    ok: CatArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Parses `zit cat FILE [VERSION] [--no-cache] [-h|--help]`: the first
/// positional is FILE, the second is an optional VERSION, plus the cache-bypass
/// and help flags. Pure: no library calls, I/O, or allocation. Help wins over
/// every other token; a third positional is an extra argument, and any
/// unrecognized option is retained for the diagnostic. A missing FILE is not an
/// error here — the runner raises `.no_file` — so the grammar stays uniform
/// with `ls`.
pub fn parseCatArgs(verb_args: []const []const u8) CatResult {
    var parsed: CatArgs = .{};
    var first_err: ?CatResult = null;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    for (verb_args) |tok| {
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--no-cache"))) {
            parsed.no_cache = true;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (parsed.file == null) {
            parsed.file = tok;
        } else if (parsed.version == null) {
            parsed.version = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

/// Parsed `zit tarball|zip VERSION [OUTPUTFILE] [options]` arguments.
/// VERSION is the required first positional, OUTPUTFILE the second — optional
/// only because `--list` has nothing to write. The three value-taking options
/// match fossil's spelling; each also accepts `--opt=VALUE`, since every other
/// value-taking option in this CLI is spelled that way and an operator should
/// not have to remember which family a verb belongs to.
pub const ArchiveArgs = struct {
    version: ?[]const u8 = null,
    output: ?[]const u8 = null,
    /// Top-level directory inside the archive; derived when absent.
    name: ?[]const u8 = null,
    /// Glob lists in the shared fossil dialect. `exclude` wins over `include`
    /// on a file matching both — fossil's documented precedence, copied rather
    /// than decided afresh.
    include: ?[]const u8 = null,
    exclude: ?[]const u8 = null,
    /// Name the members instead of writing an archive.
    list: bool = false,
    no_cache: bool = false,
    help: bool = false,
};

pub const ArchiveResult = union(enum) {
    ok: ArchiveArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    /// A value-taking option given no value, or an empty one.
    empty_value: []const u8,
    repeated_option: []const u8,
};

/// Parses the archive surface. Pure: no library calls, I/O, or allocation.
/// Help wins over every other token. A repeated value option is refused rather
/// than last-wins, because silently dropping one of two `--exclude` lists
/// produces an archive containing a file the operator asked to leave out.
pub fn parseArchiveArgs(verb_args: []const []const u8) ArchiveResult {
    var parsed: ArchiveArgs = .{};
    var first_err: ?ArchiveResult = null;
    var pending: ?*?[]const u8 = null;
    var pending_flag: []const u8 = "";

    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    for (verb_args) |tok| {
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (pending) |slot| {
            pending = null;
            if (tok.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = pending_flag };
                continue;
            }
            slot.* = tok;
            continue;
        }
        if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
            continue;
        }
        if (!positional_only and (std.mem.eql(u8, tok, "--list"))) {
            parsed.list = true;
            continue;
        }
        if (!positional_only and (std.mem.eql(u8, tok, "--no-cache"))) {
            parsed.no_cache = true;
            continue;
        }
        const valued = [_]struct { flag: []const u8, slot: *?[]const u8 }{
            .{ .flag = "--name", .slot = &parsed.name },
            .{ .flag = "--include", .slot = &parsed.include },
            .{ .flag = "--exclude", .slot = &parsed.exclude },
        };
        var matched = false;
        for (valued) |v| {
            if (std.mem.eql(u8, tok, v.flag)) {
                if (v.slot.* != null and first_err == null) first_err = .{ .repeated_option = v.flag };
                pending = v.slot;
                pending_flag = v.flag;
                matched = true;
                break;
            }
            if (tok.len > v.flag.len and std.mem.startsWith(u8, tok, v.flag) and tok[v.flag.len] == '=') {
                const value = tok[v.flag.len + 1 ..];
                if (v.slot.* != null and first_err == null) first_err = .{ .repeated_option = v.flag };
                if (value.len == 0) {
                    if (first_err == null) first_err = .{ .empty_value = v.flag };
                } else {
                    v.slot.* = value;
                }
                matched = true;
                break;
            }
        }
        if (matched) continue;
        if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (parsed.version == null) {
            parsed.version = tok;
        } else if (parsed.output == null) {
            parsed.output = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    // A trailing value option with nothing after it.
    if (pending != null and first_err == null) first_err = .{ .empty_value = pending_flag };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

test "cli archive: positionals, both option spellings, and the refusals" {
    const ok1 = parseArchiveArgs(&.{ "tip", "out.tar.gz" }).ok;
    try std.testing.expectEqualStrings("tip", ok1.version.?);
    try std.testing.expectEqualStrings("out.tar.gz", ok1.output.?);
    try std.testing.expect(ok1.name == null and !ok1.list);

    // Space-separated (fossil's spelling) and `=` (this CLI's) agree.
    const spaced = parseArchiveArgs(&.{ "tip", "o.tar.gz", "--name", "rel-1", "--exclude", "*.o,*.a" }).ok;
    const equals = parseArchiveArgs(&.{ "tip", "o.tar.gz", "--name=rel-1", "--exclude=*.o,*.a" }).ok;
    try std.testing.expectEqualStrings("rel-1", spaced.name.?);
    try std.testing.expectEqualStrings("rel-1", equals.name.?);
    try std.testing.expectEqualStrings("*.o,*.a", spaced.exclude.?);
    try std.testing.expectEqualStrings("*.o,*.a", equals.exclude.?);

    // `--list` needs no OUTPUTFILE.
    const listed = parseArchiveArgs(&.{ "tip", "--list" }).ok;
    try std.testing.expect(listed.list and listed.output == null);
    try std.testing.expectEqualStrings("tip", listed.version.?);

    // Help wins over everything, including an otherwise-fatal token.
    try std.testing.expect(parseArchiveArgs(&.{ "--bogus", "-h" }).ok.help);

    // A repeated value option is refused, never last-wins: dropping one of two
    // --exclude lists would archive a file the operator asked to leave out.
    const rep = parseArchiveArgs(&.{ "tip", "o.tar", "--exclude=a", "--exclude=b" });
    try std.testing.expectEqualStrings("--exclude", rep.repeated_option);

    // A value option with an empty or missing value.
    try std.testing.expectEqualStrings("--name", parseArchiveArgs(&.{ "tip", "o.tar", "--name=" }).empty_value);
    try std.testing.expectEqualStrings("--name", parseArchiveArgs(&.{ "tip", "o.tar", "--name" }).empty_value);
    try std.testing.expectEqualStrings("--name", parseArchiveArgs(&.{ "tip", "--name", "" }).empty_value);

    try std.testing.expectEqualStrings("--frob", parseArchiveArgs(&.{ "tip", "--frob" }).unknown_option);
    try std.testing.expectEqualStrings("x", parseArchiveArgs(&.{ "tip", "o.tar", "x" }).extra_argument);

    // An option VALUE that looks like a flag is still a value: `--exclude
    // --list` must not silently become the list flag.
    const valueish = parseArchiveArgs(&.{ "tip", "o.tar", "--exclude", "--list" }).ok;
    try std.testing.expectEqualStrings("--list", valueish.exclude.?);
    try std.testing.expect(!valueish.list);
}

/// Parsed `zit grep PATTERN PATH...` arguments. PATTERN is a LITERAL
/// string, never a pattern language: zit carries no regex engine, and inventing
/// one is a larger decision than this verb. `git log -S` is literal for the
/// same reason and is the closer analogue; `fossil grep`'s POSIX ERE is the
/// later slice.
pub const GrepArgs = struct {
    pattern: ?[]const u8 = null,
    paths: [max_grep_paths][]const u8 = undefined,
    path_count: usize = 0,
    /// `-n N`: stop after N matching versions across all paths; 0 means all.
    limit: ?usize = null,
    /// `-S`: the pickaxe. Report the check-ins where the NUMBER OF OCCURRENCES
    /// of PATTERN changed, rather than every version containing it — which is
    /// the question "when did this appear, and when did it go" rather than
    /// "where is it". PATH operands become optional and, when given, restrict
    /// the walk instead of driving it.
    pickaxe: bool = false,
    /// `-G`: report check-ins where a CHANGED LINE mentions PATTERN, whether
    /// or not the occurrence count moved. Renaming `x = SECRET` to
    /// `y = SECRET` leaves the count at one, so `-S` is silent and `-G` is not
    /// — git draws the same line between its two pickaxes, and it is the
    /// reason both exist.
    diff_match: bool = false,
    no_cache: bool = false,
    help: bool = false,
    /// Match case-insensitively.
    ignore_case: bool = false,
    /// Print only a count of matching lines per file.
    count_only: bool = false,
    /// Print only the names of files with a match.
    files_only: bool = false,
    /// Select the lines that do NOT match.
    invert: bool = false,
    /// Stop at the first matching line of each file.
    once: bool = false,
};

/// TigerStyle: a fixed bound, so the path array is a stack value and the walk
/// is finite by inspection. A search naming more paths than this is a scripted
/// sweep that belongs in a loop over invocations.
pub const max_grep_paths: usize = 64;

pub const GrepResult = union(enum) {
    ok: GrepArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    bad_limit: []const u8,
    /// No PATTERN operand at all.
    no_name,
    /// A PATTERN but no PATH to search.
    no_paths,
    /// `-S` and `-G` together. They ask different questions of the same walk
    /// and would need two answers per check-in; refused rather than silently
    /// letting one win, which is how an operator gets the other one's answer.
    both_pickaxes,
};

/// Parses `zit grep [-n N] [--no-cache] PATTERN PATH...`. Pure. The first
/// positional is the pattern; every later one is a path.
pub fn parseGrepArgs(verb_args: []const []const u8) GrepResult {
    var parsed: GrepArgs = .{};
    var first_err: ?GrepResult = null;
    var i: usize = 0;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--no-cache"))) {
            parsed.no_cache = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-S") or std.mem.eql(u8, tok, "--pickaxe"))) {
            parsed.pickaxe = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-G") or std.mem.eql(u8, tok, "--diff-match"))) {
            parsed.diff_match = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-i") or std.mem.eql(u8, tok, "--ignore-case"))) {
            parsed.ignore_case = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-c") or std.mem.eql(u8, tok, "--count"))) {
            parsed.count_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-l") or std.mem.eql(u8, tok, "--files-with-matches"))) {
            parsed.files_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-v") or std.mem.eql(u8, tok, "--invert-match"))) {
            parsed.invert = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--once"))) {
            parsed.once = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-n") or std.mem.eql(u8, tok, "--limit"))) {
            i += 1;
            if (i >= verb_args.len) {
                if (first_err == null) first_err = .{ .bad_limit = tok };
                continue;
            }
            parsed.limit = std.fmt.parseInt(usize, verb_args[i], 10) catch {
                if (first_err == null) first_err = .{ .bad_limit = verb_args[i] };
                continue;
            };
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (parsed.pattern == null) {
            parsed.pattern = tok;
        } else if (parsed.path_count < max_grep_paths) {
            parsed.paths[parsed.path_count] = tok;
            parsed.path_count += 1;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    if (parsed.pattern == null) return .no_name;
    // The pickaxe walks the whole tree, so naming a path is a narrowing, not a
    // requirement — "when did this string appear anywhere" is the question it
    // exists for, and demanding a path would be demanding the answer.
    if (parsed.pickaxe and parsed.diff_match) return .both_pickaxes;
    if (parsed.path_count == 0 and !(parsed.pickaxe or parsed.diff_match)) return .no_paths;
    return .{ .ok = parsed };
}

pub const PurgeSub = enum { checkins, list, undo, obliterate };

/// TigerStyle: a fixed bound, so the name array is a stack value. A purge
/// naming more check-ins than this is not the accident this verb exists for.
pub const max_purge_names: usize = 64;

/// Parsed `zit purge` arguments. Every string borrows its argv token.
/// `names` are check-in names under `checkins` and graveyard event ids under
/// `undo` and `obliterate` — the same positional run, read by the subcommand.
pub const PurgeArgs = struct {
    sub: PurgeSub = .list,
    names: [max_purge_names][]const u8 = undefined,
    name_count: usize = 0,
    /// Compute and print the plan, change nothing. Distinct from the absence
    /// of `--force`: a refusal is an error, a dry run is a successful answer.
    dry_run: bool = false,
    /// List every artifact the plan would remove, not only the counts.
    explain: bool = false,
    /// Confirm a plan that has been shown. Purge removes content, so it is
    /// refuse-by-default like `shun` — but unlike `shun` it is recoverable, so
    /// the refusal names `purge undo` rather than warning about permanence.
    force: bool = false,
    /// Recorded on the graveyard event, so `purge list` can say what a buried
    /// set was buried for.
    reason: ?[]const u8 = null,
    no_cache: bool = false,
    help: bool = false,
};

pub const PurgeResult = union(enum) {
    ok: PurgeArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    bad_subcommand: []const u8,
    /// `--reason` given with no text after it.
    bad_reason: []const u8,
    /// A subcommand that requires operands was given none.
    no_name,
    /// An option the given subcommand has no meaning for.
    option_not_for_subcommand: []const u8,
};

/// Parses `zit purge checkins [--dry-run] [--explain] [--force] [--reason T]
/// CHECKIN...` / `zit purge list` / `zit purge undo ID` / `zit purge
/// obliterate [--force] ID...`, plus `[--no-cache] [-h|--help]`. Pure.
pub fn parsePurgeArgs(verb_args: []const []const u8) PurgeResult {
    var parsed: PurgeArgs = .{};
    var first_err: ?PurgeResult = null;
    var seen_sub = false;
    var i: usize = 0;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--no-cache"))) {
            parsed.no_cache = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--dry-run"))) {
            parsed.dry_run = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--explain"))) {
            parsed.explain = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--force"))) {
            parsed.force = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--reason"))) {
            i += 1;
            if (i >= verb_args.len) {
                if (first_err == null) first_err = .{ .bad_reason = tok };
                continue;
            }
            parsed.reason = verb_args[i];
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (!seen_sub) {
            seen_sub = true;
            if (std.mem.eql(u8, tok, "checkins")) {
                parsed.sub = .checkins;
            } else if (std.mem.eql(u8, tok, "list") or std.mem.eql(u8, tok, "ls")) {
                parsed.sub = .list;
            } else if (std.mem.eql(u8, tok, "undo")) {
                parsed.sub = .undo;
            } else if (std.mem.eql(u8, tok, "obliterate")) {
                parsed.sub = .obliterate;
            } else if (first_err == null) {
                first_err = .{ .bad_subcommand = tok };
            }
        } else if (parsed.name_count < max_purge_names) {
            parsed.names[parsed.name_count] = tok;
            parsed.name_count += 1;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    if (!seen_sub) return .{ .bad_subcommand = "" };
    // `--reason` labels a graveyard event, which only `checkins` creates; on
    // the read-only and recovery subcommands it is inert, so it is refused
    // rather than accepted-and-ignored, exactly as `tag`'s `--propagate` is.
    if (parsed.reason != null and parsed.sub != .checkins) {
        return .{ .option_not_for_subcommand = "--reason" };
    }
    if (parsed.sub != .list and parsed.name_count == 0) return .no_name;
    if (parsed.sub == .list and parsed.name_count != 0) {
        return .{ .extra_argument = parsed.names[0] };
    }
    return .{ .ok = parsed };
}

/// Parsed `zit annotate PATH [VERSION] [--full]` arguments. PATH is the
/// required first positional; VERSION is the optional second, resolved by the
/// runner exactly as `cat`'s (defaulting to the current checkout). `--full`
/// swaps the 12-character artifact-ID prefix for the full ID on every line.
pub const AnnotateArgs = struct {
    path: ?[]const u8 = null,
    version: ?[]const u8 = null,
    full: bool = false,
    help: bool = false,
    /// -n N: look back at most N check-ins.
    limit: ?usize = null,
    /// -l: list the check-ins consulted before the lines.
    log: bool = false,
};

pub const AnnotateResult = union(enum) {
    ok: AnnotateArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Parses the `zit annotate PATH [VERSION] [--full] [-h|--help]` surface.
/// Pure: no library calls, I/O, or allocation. Help wins over every other
/// token; a third positional is an extra argument. Mirrors `parseCatArgs`'
/// two-positional grammar; a missing PATH is the runner's `.no_file`.
pub fn parseAnnotateArgs(verb_args: []const []const u8) AnnotateResult {
    var parsed: AnnotateArgs = .{};
    var first_err: ?AnnotateResult = null;
    var want_limit = false;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    for (verb_args) |tok| {
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (want_limit)) {
            want_limit = false;
            parsed.limit = std.fmt.parseInt(usize, tok, 10) catch {
                if (first_err == null) first_err = .{ .unknown_option = tok };
                continue;
            };
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--full"))) {
            parsed.full = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-n") or std.mem.eql(u8, tok, "--limit"))) {
            want_limit = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-l") or std.mem.eql(u8, tok, "--log"))) {
            parsed.log = true;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (parsed.path == null) {
            parsed.path = tok;
        } else if (parsed.version == null) {
            parsed.version = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (want_limit and first_err == null) first_err = .{ .unknown_option = "-n" };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

/// Parsed `zit frames [--since CURSOR] [--verify TOKEN]` arguments.
/// Both values are opaque strings minted by a previous invocation's trailer,
/// borrowed from argv; the runner decodes and validates them.
pub const FramesArgs = struct {
    since: ?[]const u8 = null,
    verify: ?[]const u8 = null,
    /// `--structural`: emit only artifacts that parse as structural
    /// artifacts, skipping content blobs. The cursor still advances over
    /// everything, so the flag never changes what a later poll sees.
    structural: bool = false,
    help: bool = false,
};

pub const FramesResult = union(enum) {
    ok: FramesArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    empty_value: []const u8,
};

/// Parses the `zit frames [--since=CURSOR] [--verify=TOKEN] [-h|--help]`
/// surface. Pure: no library calls, I/O, or allocation. Help wins over every
/// other token; any positional is an extra argument.
pub fn parseFramesArgs(verb_args: []const []const u8) FramesResult {
    var parsed: FramesArgs = .{};
    var first_err: ?FramesResult = null;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    for (verb_args) |tok| {
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.startsWith(u8, tok, "--since="))) {
            const value = tok["--since=".len..];
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = "--since" };
                continue;
            }
            parsed.since = value;
        } else if (!positional_only and (std.mem.startsWith(u8, tok, "--verify="))) {
            const value = tok["--verify=".len..];
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = "--verify" };
                continue;
            }
            parsed.verify = value;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--structural"))) {
            parsed.structural = true;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

/// Parsed `zit artifact HASH [--raw]` arguments. `hash` is the required first
/// positional (a full or abbreviated artifact hash borrowed from argv); `raw`
/// selects the byte-exact dump over the summary. A distinct type from
/// `LogArgs`: HASH addresses ANY stored artifact (blobs included), not a
/// symbolic check-in name. `null` hash lets the runner render the missing-hash
/// usage error after help has had its chance to win.
pub const ArtifactArgs = struct {
    hash: ?[]const u8 = null,
    raw: bool = false,
    help: bool = false,
};

pub const ArtifactResult = union(enum) {
    ok: ArtifactArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Parses the `zit artifact HASH [--raw] [-h|--help]` surface: one positional
/// hash plus the raw-dump and help flags. Pure: no library calls, I/O, or
/// allocation. Help wins over every other token; a second positional is an
/// extra argument, and any unrecognized option (there is no `--no-cache` here,
/// unlike `cat`) is retained for the diagnostic. Mirrors `parseLogArgs`' one-
/// positional grammar. A missing HASH is not a parse error — the runner raises
/// `.no_hash` — so parsing stays a pure function of its input.
pub fn parseArtifactArgs(verb_args: []const []const u8) ArtifactResult {
    var parsed: ArtifactArgs = .{};
    var first_err: ?ArtifactResult = null;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    for (verb_args) |tok| {
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--raw"))) {
            parsed.raw = true;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (parsed.hash == null) {
            parsed.hash = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

/// Parsed `zit leaves` arguments. `all` selects the closed-inclusive listing
/// (repo-state §7.4: closed leaves stay leaf-table members, so the open/closed
/// split is observable); `no_cache` bypasses the `.zit-cache` memo and forces a
/// full-log re-derive (whole-log integrity re-verified); `help` requests the
/// verbatim help block.
pub const LeavesArgs = struct {
    all: bool = false,
    no_cache: bool = false,
    help: bool = false,
};

/// The port `zit serve` binds when `--port` is absent.
pub const default_serve_port: u16 = 8080;

pub const ServeArgs = struct {
    port: u16 = default_serve_port,
    /// A trusted front on loopback is this server's only client, so
    /// its per-request `X-Zit-Protect` header is honoured. Off by default.
    fronted: bool = false,
    help: bool = false,
};

pub const ServeResult = union(enum) {
    ok: ServeArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    /// `--port` carried a value that is not a u16 (or nothing followed it).
    bad_port: []const u8,
};

/// Parses `zit serve [--port N] [--fronted] [-h|--help]`. Pure: no library
/// calls, I/O, or allocation. `--port` consumes the following token, parsed as
/// a base-10 u16; `--fronted` is a bare flag;
/// `-h`/`--help` win over every otherwise-invalid token; `--` switches to
/// positional-only mode, where any token is an extra argument. The verb takes
/// no positional, so any bare word is an extra argument. The first offense in
/// token order is retained for the diagnostic.
pub fn parseServeArgs(verb_args: []const []const u8) ServeResult {
    const Mode = enum { scan, positional_only };
    var parsed: ServeArgs = .{};
    var help = false;
    var first_err: ?ServeResult = null;
    var i: usize = 0;
    state: switch (Mode.scan) {
        .scan => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
            if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
                help = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "--fronted")) {
                parsed.fronted = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "--port")) {
                if (i >= verb_args.len) {
                    if (first_err == null) first_err = .{ .bad_port = tok };
                    continue :state .scan;
                }
                const val = verb_args[i];
                i += 1;
                if (std.fmt.parseInt(u16, val, 10)) |p| {
                    parsed.port = p;
                } else |_| {
                    if (first_err == null) first_err = .{ .bad_port = val };
                }
                continue :state .scan;
            }
            if (first_err == null) {
                first_err = if (tok.len > 1 and tok[0] == '-')
                    .{ .unknown_option = tok }
                else
                    .{ .extra_argument = tok };
            }
            continue :state .scan;
        },
        .positional_only => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (first_err == null) first_err = .{ .extra_argument = tok };
            continue :state .positional_only;
        },
    }
    if (help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}
/// Attribution identity recorded for Git-originated check-ins.
pub const ImportGitAttribution = enum { committer, author };

/// The single authority for `zit import-git`'s option surface. Both the help
/// text in `main.zig` and the scripted dispatcher in `git_import_cli.zig`
/// render this one string, because the two hand transcriptions that preceded it
/// had already drifted apart by two options.
pub const export_git_usage = "zit export-git SOURCE DEST [--email-domain DOMAIN] [--rebuild] [--quiet]";

/// `zit export-git` arguments. SOURCE resolves the way `serve`/`open`
/// document it (a directory holding `.zit`, or the `.zit` file itself);
/// DEST is the Git repository directory (created when absent).
pub const ExportGitArgs = struct {
    source: []const u8 = "",
    destination: []const u8 = "",
    /// The synthesized-identity email domain (`login@DOMAIN`). The default
    /// names an invalid TLD on purpose: these addresses are provenance
    /// markers, never deliverable mail.
    email_domain: []const u8 = "zit.invalid",
    /// Ignore any existing mirror state and export from scratch. Refused
    /// when DEST already carries refs — a rebuild over foreign work would
    /// overwrite it.
    rebuild: bool = false,
    quiet: bool = false,
    help: bool = false,
};

pub const ExportGitParseError = error{
    MissingSource,
    MissingDestination,
    ExtraArgument,
    UnknownOption,
    MissingValue,
    DuplicateOption,
};

/// `parseImportGitArgs`' discipline: options may surround positionals, a
/// value option appears at most once, and `--help` wins over malformed input.
pub fn parseExportGitArgs(verb_args: []const []const u8) ExportGitParseError!ExportGitArgs {
    var parsed: ExportGitArgs = .{};
    var positionals: usize = 0;
    var domain_seen = false;
    var first_err: ?ExportGitParseError = null;
    var i: usize = 0;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--email-domain"))) {
            i += 1;
            if (i >= verb_args.len) {
                if (first_err == null) first_err = error.MissingValue;
                break;
            }
            if (domain_seen) {
                if (first_err == null) first_err = error.DuplicateOption;
                continue;
            }
            domain_seen = true;
            parsed.email_domain = verb_args[i];
        } else if (!positional_only and (std.mem.eql(u8, tok, "--rebuild"))) {
            parsed.rebuild = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--quiet"))) {
            parsed.quiet = true;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = error.UnknownOption;
        } else switch (positionals) {
            0 => {
                parsed.source = tok;
                positionals += 1;
            },
            1 => {
                parsed.destination = tok;
                positionals += 1;
            },
            else => if (first_err == null) {
                first_err = error.ExtraArgument;
            },
        }
    }
    if (parsed.help) return parsed;
    if (first_err) |err| return err;
    if (positionals < 1) return error.MissingSource;
    if (positionals < 2) return error.MissingDestination;
    return parsed;
}

test "cli export-git: options surround positionals, --help wins, value options at most once" {
    {
        const parsed = try parseExportGitArgs(&.{ "--rebuild", "src", "--quiet", "dst" });
        try std.testing.expectEqualStrings("src", parsed.source);
        try std.testing.expectEqualStrings("dst", parsed.destination);
        try std.testing.expect(parsed.rebuild);
        try std.testing.expect(parsed.quiet);
        try std.testing.expectEqualStrings("zit.invalid", parsed.email_domain);
    }
    {
        const parsed = try parseExportGitArgs(&.{ "src", "dst", "--email-domain", "example.com" });
        try std.testing.expectEqualStrings("example.com", parsed.email_domain);
    }
    {
        // --help wins over malformed input.
        const parsed = try parseExportGitArgs(&.{ "--bogus", "--help" });
        try std.testing.expect(parsed.help);
    }
    try std.testing.expectError(error.MissingSource, parseExportGitArgs(&.{}));
    try std.testing.expectError(error.MissingDestination, parseExportGitArgs(&.{"src"}));
    try std.testing.expectError(error.ExtraArgument, parseExportGitArgs(&.{ "a", "b", "c" }));
    try std.testing.expectError(error.UnknownOption, parseExportGitArgs(&.{ "a", "b", "--nope" }));
    try std.testing.expectError(error.MissingValue, parseExportGitArgs(&.{ "a", "b", "--email-domain" }));
    try std.testing.expectError(error.DuplicateOption, parseExportGitArgs(&.{ "a", "b", "--email-domain", "x", "--email-domain", "y" }));
}

pub const import_git_usage = "zit import-git SOURCE DEST [--submanifest-k K | --no-submanifests] [--attribution committer|author] [--default-branch NAME] [--refuse-signed] [--provenance] [--max-export-payload-bytes BYTES] [--quiet]";

/// Parsed `zit import-git SOURCE DEST` arguments. The two positionals borrow
/// argv; the runner receives them only after this parser has required both.
pub const ImportGitArgs = struct {
    source: []const u8 = "",
    destination: []const u8 = "",
    /// Absent means "analyze": the runner pins draft-fossil-submanifests-00
    /// §6.3's optimum for the source's own name set. A present value is an
    /// explicit operator override — the reason it exists is interoperability
    /// (§6.3 makes k project-scope, so matching an existing repository's pin
    /// is what lets the two share sub-manifests by content address), not
    /// tuning by guesswork.
    submanifest_k: ?u4 = null,
    /// Import with sub-manifest emission OFF: every check-in gets a flat
    /// manifest, no SUBMANIFEST artifact is stored, and the destination's
    /// config is left at the §8.1 default (`enable-submanifests` false, no
    /// pinned k). Mutually exclusive with `submanifest_k`, which only ever
    /// tunes WHICH k a tree import uses and never whether trees are used.
    ///
    /// Flat is the spec-normal shape — §8.1 makes emission default-false — so
    /// this flag restores the default rather than departing from it; it is
    /// import-git's analyze-and-pin that is the deviation.
    no_submanifests: bool = false,
    attribution: ImportGitAttribution = .committer,
    default_branch: ?[]const u8 = null,
    /// Git signs commits and tags; Fossil has no signature card, so no import
    /// can carry a signature through. Stripping is therefore the default —
    /// refusing withheld the import without saving the signature. Setting this
    /// restores the refusal for an operator who would rather not import at all
    /// than import without the signature.
    refuse_signed: bool = false,
    provenance: bool = false,
    /// Absent leaves `ImportLimits`' default in place. Present raises the
    /// parser's byte authorization for this import — both cumulative payload
    /// and, through it, the derived per-commit file-command ceiling. It exists
    /// because a repository whose root commit lands an entire monorepo is a
    /// legitimate source, not an adversarial one, and no ceiling that an honest
    /// source can reach may require a recompile to clear.
    max_export_payload_bytes: ?u64 = null,
    quiet: bool = false,
    help: bool = false,
};

pub const ImportGitParseError = error{
    UnknownOption,
    MissingOptionValue,
    RepeatedOption,
    EmptyOptionValue,
    InvalidSubmanifestK,
    /// `--no-submanifests` and `--submanifest-k` together. Not a repeat and not
    /// an unknown option: each is individually well-formed and the pair is
    /// contradictory, so it earns its own cause rather than being reported as
    /// one of them.
    ConflictingSubmanifestOptions,
    InvalidExportPayloadBytes,
    InvalidAttribution,
    MissingSource,
    MissingDestination,
    ExtraArgument,
};

/// Parses `zit import-git SOURCE DEST [--submanifest-k K] [--attribution
/// committer|author] [--default-branch NAME] [--refuse-signed]
/// [--max-export-payload-bytes BYTES] [--quiet]`.
/// Options may surround
/// the two positionals, but every value option appears at most once. Help wins
/// over every malformed token so `zit import-git --help` is always informative.
pub fn parseImportGitArgs(verb_args: []const []const u8) ImportGitParseError!ImportGitArgs {
    var parsed: ImportGitArgs = .{};
    var source: ?[]const u8 = null;
    var destination: ?[]const u8 = null;
    var seen_k = false;
    var seen_payload_bytes = false;
    var seen_attribution = false;
    var seen_default_branch = false;
    var seen_quiet = false;
    var seen_refuse_signed = false;
    var seen_provenance = false;
    var seen_no_submanifests = false;
    var first_err: ?ImportGitParseError = null;
    var i: usize = 0;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--quiet"))) {
            if (seen_quiet) {
                if (first_err == null) first_err = error.RepeatedOption;
            } else {
                seen_quiet = true;
                parsed.quiet = true;
            }
        } else if (!positional_only and (std.mem.eql(u8, tok, "--no-submanifests"))) {
            if (seen_no_submanifests) {
                if (first_err == null) first_err = error.RepeatedOption;
            } else {
                seen_no_submanifests = true;
                parsed.no_submanifests = true;
            }
        } else if (!positional_only and (std.mem.eql(u8, tok, "--refuse-signed"))) {
            if (seen_refuse_signed) {
                if (first_err == null) first_err = error.RepeatedOption;
            } else {
                seen_refuse_signed = true;
                parsed.refuse_signed = true;
            }
        } else if (!positional_only and (std.mem.eql(u8, tok, "--provenance"))) {
            if (seen_provenance) {
                if (first_err == null) first_err = error.RepeatedOption;
            } else {
                seen_provenance = true;
                parsed.provenance = true;
            }
        } else if (std.mem.eql(u8, tok, "--submanifest-k") or
            std.mem.eql(u8, tok, "--attribution") or
            std.mem.eql(u8, tok, "--default-branch") or
            std.mem.eql(u8, tok, "--max-export-payload-bytes"))
        {
            if (i + 1 >= verb_args.len) {
                if (first_err == null) first_err = error.MissingOptionValue;
                continue;
            }
            i += 1;
            const value = verb_args[i];
            if (std.mem.eql(u8, tok, "--submanifest-k")) {
                if (seen_k) {
                    if (first_err == null) first_err = error.RepeatedOption;
                    continue;
                }
                seen_k = true;
                const k = std.fmt.parseInt(u4, value, 10) catch {
                    if (first_err == null) first_err = error.InvalidSubmanifestK;
                    continue;
                };
                if (k < artifact.min_k or k > artifact.max_k) {
                    if (first_err == null) first_err = error.InvalidSubmanifestK;
                    continue;
                }
                parsed.submanifest_k = k;
            } else if (std.mem.eql(u8, tok, "--max-export-payload-bytes")) {
                if (seen_payload_bytes) {
                    if (first_err == null) first_err = error.RepeatedOption;
                    continue;
                }
                seen_payload_bytes = true;
                const bytes = std.fmt.parseInt(u64, value, 10) catch {
                    if (first_err == null) first_err = error.InvalidExportPayloadBytes;
                    continue;
                };
                // Zero authorizes nothing: it would derive a per-commit file
                // command ceiling of zero and reject every commit that touches
                // a file. A budget an import can never satisfy is a typo.
                if (bytes == 0) {
                    if (first_err == null) first_err = error.InvalidExportPayloadBytes;
                    continue;
                }
                parsed.max_export_payload_bytes = bytes;
            } else if (std.mem.eql(u8, tok, "--attribution")) {
                if (seen_attribution) {
                    if (first_err == null) first_err = error.RepeatedOption;
                    continue;
                }
                seen_attribution = true;
                parsed.attribution = if (std.mem.eql(u8, value, "committer"))
                    .committer
                else if (std.mem.eql(u8, value, "author"))
                    .author
                else {
                    if (first_err == null) first_err = error.InvalidAttribution;
                    continue;
                };
            } else {
                if (seen_default_branch) {
                    if (first_err == null) first_err = error.RepeatedOption;
                    continue;
                }
                seen_default_branch = true;
                if (value.len == 0) {
                    if (first_err == null) first_err = error.EmptyOptionValue;
                    continue;
                }
                parsed.default_branch = value;
            }
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = error.UnknownOption;
        } else if (source == null) {
            source = tok;
        } else if (destination == null) {
            destination = tok;
        } else if (first_err == null) {
            first_err = error.ExtraArgument;
        }
    }
    if (parsed.help) return parsed;
    // Checked after the loop, not inside either arm, so the pair is rejected in
    // BOTH orders — an in-arm check would only fire when the second-seen token
    // happened to be the one carrying the test. Ranked below `first_err` so a
    // malformed token still reports its own cause rather than being masked by
    // the conflict.
    if (first_err == null and parsed.no_submanifests and parsed.submanifest_k != null) {
        return error.ConflictingSubmanifestOptions;
    }
    if (first_err) |err| return err;
    parsed.source = source orelse return error.MissingSource;
    parsed.destination = destination orelse return error.MissingDestination;
    return parsed;
}

/// Parsed `zit clone URL DIR` arguments. `url` and `dir` borrow argv tokens;
/// both are required positionals (the runner never sees them empty on `.ok`).
pub const CloneArgs = struct {
    url: []const u8 = "",
    dir: []const u8 = "",
    /// sync §6 — a login for the rounds after the first; null falls back to
    /// the URL's user, or clones anonymously.
    user: ?[]const u8 = null,
    password: ?[]const u8 = null,
    help: bool = false,
    /// Open a working tree in DIR after the clone (Fossil 2.x's
    /// behaviour); `--no-open` keeps today's bare clone.
    open: bool = true,
    /// `--once`: do not save the clone URL as the default remote (/// fossil's flag of the same name).
    once: bool = false,
    /// `--cone=PATH` members (sparse-cone RFC section 6.3): the clone
    /// filters locally and persists a sparse store. Borrows argv.
    cone_members: [255][]const u8 = undefined,
    cone_len: usize = 0,
    cone_file: ?[]const u8 = null,
    /// `--cone-not=PATH` exclusion members in argv order, borrowing argv.
    cone_not_members: [255][]const u8 = undefined,
    cone_not_len: usize = 0,
};

pub const CloneResult = union(enum) {
    ok: CloneArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    /// No repository URL positional was given.
    no_url,
    /// A URL was given but the target directory positional was not.
    no_dir,
    empty_value: []const u8,
    too_many_cones: []const u8,
};

/// Parses `zit clone URL DIR [-h|--help]`: exactly two positionals — the peer
/// repository URL then the fresh target directory — plus the standard help
/// request. Pure: no library calls, I/O, or allocation. Help wins over every
/// other token (including missing positionals); a third positional is an extra
/// argument; the first offense in token order is retained for the diagnostic.
/// Mirrors the positional grammar of `parseLogArgs`/`parseInfoArgs`.
/// The directory a clone lands in when none is given: the URL's last
/// non-empty path segment, minus a `.fossil` suffix; null when the URL has
/// no such segment (`http://host/`, `http://host:8080`).
pub fn deriveCloneDir(url: []const u8) ?[]const u8 {
    var rest = url;
    if (std.mem.indexOf(u8, rest, "://")) |k| rest = rest[k + 3 ..];
    rest = std.mem.trimEnd(u8, rest, "/");
    const slash = std.mem.lastIndexOfScalar(u8, rest, '/') orelse return null;
    var seg = rest[slash + 1 ..];
    if (std.mem.endsWith(u8, seg, ".fossil")) seg = seg[0 .. seg.len - ".fossil".len];
    if (seg.len == 0 or std.mem.eql(u8, seg, ".") or std.mem.eql(u8, seg, "..")) return null;
    if (std.mem.indexOfScalar(u8, seg, '?') != null or std.mem.indexOfScalar(u8, seg, '#') != null) return null;
    return seg;
}

pub fn parseCloneArgs(verb_args: []const []const u8) CloneResult {
    var url: ?[]const u8 = null;
    var dir: ?[]const u8 = null;
    var help = false;
    var once = false;
    var open = true;
    var cone_members: [255][]const u8 = undefined;
    var cone_len: usize = 0;
    var cone_file: ?[]const u8 = null;
    var cone_not_members: [255][]const u8 = undefined;
    var cone_not_len: usize = 0;
    var user: ?[]const u8 = null;
    var password: ?[]const u8 = null;
    var first_err: ?CloneResult = null;
    var i: usize = 0;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--user"))) {
            if (i + 1 >= verb_args.len) {
                if (first_err == null) first_err = .{ .unknown_option = tok };
            } else {
                i += 1;
                user = verb_args[i];
            }
        } else if (!positional_only and (std.mem.eql(u8, tok, "--password"))) {
            if (i + 1 >= verb_args.len) {
                if (first_err == null) first_err = .{ .unknown_option = tok };
            } else {
                i += 1;
                password = verb_args[i];
            }
        } else if (!positional_only and (std.mem.eql(u8, tok, "--no-open"))) {
            open = false;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--once"))) {
            once = true;
        } else if (!positional_only and (std.mem.startsWith(u8, tok, "--cone-file="))) {
            const value = tok["--cone-file=".len..];
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = "--cone-file" };
                continue;
            }
            if (cone_file != null) {
                if (first_err == null) first_err = .{ .empty_value = "--cone-file" };
                continue;
            }
            cone_file = value;
        } else if (!positional_only and (std.mem.startsWith(u8, tok, "--cone="))) {
            const value = tok["--cone=".len..];
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = "--cone" };
                continue;
            }
            if (cone_len >= cone_members.len) {
                if (first_err == null) first_err = .{ .too_many_cones = value };
                continue;
            }
            cone_members[cone_len] = value;
            cone_len += 1;
        } else if (!positional_only and (std.mem.startsWith(u8, tok, "--cone-not="))) {
            const value = tok["--cone-not=".len..];
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = "--cone-not" };
                continue;
            }
            if (cone_not_len >= cone_not_members.len) {
                if (first_err == null) first_err = .{ .too_many_cones = value };
                continue;
            }
            cone_not_members[cone_not_len] = value;
            cone_not_len += 1;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (url == null) {
            url = tok;
        } else if (dir == null) {
            dir = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    const u = url orelse return .no_url;
    // DIR defaults to the URL's last path segment (Fossil's rule),
    // `.fossil` stripped; a URL with no usable segment still needs DIR.
    const d = dir orelse deriveCloneDir(u) orelse return .no_dir;
    return .{ .ok = .{ .url = u, .dir = d, .once = once, .user = user, .password = password, .open = open, .cone_members = cone_members, .cone_len = cone_len, .cone_file = cone_file, .cone_not_members = cone_not_members, .cone_not_len = cone_not_len } };
}

/// Parsed `zit pull URL` arguments. `url` borrows an argv token; it is the sole
/// required positional (the runner never sees it empty on `.ok`). A saved remote
/// is not persisted yet, so the URL is always given explicitly.
pub const PullArgs = struct {
    url: []const u8 = "",
    /// sync §6 — as for push: null reuses a stored login or the URL's user,
    /// or reads anonymously when there is neither.
    user: ?[]const u8 = null,
    password: ?[]const u8 = null,
    help: bool = false,
};

pub const PullResult = union(enum) {
    ok: PullArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    /// No repository URL positional was given.
    no_url,
};

/// Parses `zit pull URL [-h|--help]`: exactly one positional — the peer sync
/// endpoint — plus the standard help request. Pure: no library calls, I/O, or
/// allocation. Help wins over every other token (including a missing URL); a
/// second positional is an extra argument; the first offense in token order is
/// retained for the diagnostic. Mirrors `parseCloneArgs` with one positional.
pub fn parsePullArgs(verb_args: []const []const u8) PullResult {
    var url: ?[]const u8 = null;
    var user: ?[]const u8 = null;
    var password: ?[]const u8 = null;
    var help = false;
    var first_err: ?PullResult = null;
    var i: usize = 0;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--user"))) {
            if (i + 1 >= verb_args.len) {
                if (first_err == null) first_err = .{ .unknown_option = tok };
            } else {
                i += 1;
                user = verb_args[i];
            }
        } else if (!positional_only and (std.mem.eql(u8, tok, "--password"))) {
            if (i + 1 >= verb_args.len) {
                if (first_err == null) first_err = .{ .unknown_option = tok };
            } else {
                i += 1;
                password = verb_args[i];
            }
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (url == null) {
            url = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    const u = url orelse return .no_url;
    return .{ .ok = .{ .url = u, .user = user, .password = password } };
}

/// Parsed `zit push [URL] [--user U] [--password P]` arguments. `url` borrows an
/// argv token, or is empty when omitted — an omitted URL means "use the saved
/// remote", resolved at run time, exactly as `sync` does. `user` and `password`
/// (sync §6) are null when absent: a null `user` reuses a stored login (or is
/// anonymous when none is stored); a present `user` with a null `password`
/// resolves the secret from the stored credential, the `ZIT_PASSWORD`
/// environment, or a no-echo prompt.
pub const PushArgs = struct {
    url: []const u8 = "",
    user: ?[]const u8 = null,
    password: ?[]const u8 = null,
    help: bool = false,
};

pub const PushResult = union(enum) {
    ok: PushArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Parses `zit push URL [--user U] [--password P] [-h|--help]`: exactly one
/// positional — the peer sync endpoint — plus the two value options and the
/// standard help request. Pure: no library calls, I/O, or allocation. Help
/// wins over every other token (including a missing URL); `--user`/`--password`
/// each consume the following token (a dangling one is the offending flag as an
/// unknown option); a second positional is an extra argument; the first offense
/// in token order is retained for the diagnostic. The advance is data-dependent
/// (a value option skips two tokens), so the index loop is `while`, not `for`.
pub fn parsePushArgs(verb_args: []const []const u8) PushResult {
    var url: ?[]const u8 = null;
    var user: ?[]const u8 = null;
    var password: ?[]const u8 = null;
    var help = false;
    var first_err: ?PushResult = null;
    var i: usize = 0;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--user"))) {
            if (i + 1 >= verb_args.len) {
                if (first_err == null) first_err = .{ .unknown_option = tok };
            } else {
                i += 1;
                user = verb_args[i];
            }
        } else if (!positional_only and (std.mem.eql(u8, tok, "--password"))) {
            if (i + 1 >= verb_args.len) {
                if (first_err == null) first_err = .{ .unknown_option = tok };
            } else {
                i += 1;
                password = verb_args[i];
            }
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (url == null) {
            url = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    // An omitted URL is not an error and must not discard the credential: it
    // means "use the saved remote", resolved at run time. Returning a payload-
    // less no_url here dropped --user/--password, so `push --user X` with a
    // saved remote pushed anonymously and the peer refused the write.
    return .{ .ok = .{ .url = url orelse "", .user = user, .password = password } };
}

/// Parsed `zit sync [URL] [--user U] [--password P]` arguments. Unlike push, the
/// URL positional is OPTIONAL: an empty `url` means "use the saved remote"
/// (sync §4/§6). `user`/`password` are the same §6 credential options push
/// carries — a sync uploads, so it may authenticate. A null `user` is anonymous.
pub const SyncArgs = struct {
    url: []const u8 = "",
    user: ?[]const u8 = null,
    password: ?[]const u8 = null,
    help: bool = false,
};

pub const SyncResult = union(enum) {
    ok: SyncArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Parses `zit sync [URL] [--user U] [--password P] [-h|--help]`: an OPTIONAL
/// positional (the peer endpoint — omitted falls back to the saved remote at
/// run time), the two value options, and the standard help request. Pure: no
/// library calls, I/O, or allocation. Help wins over every other token;
/// `--user`/`--password` each consume the following token (a dangling one is the
/// offending flag as an unknown option); a second positional is an extra
/// argument; the first offense in token order is retained for the diagnostic.
/// Mirrors `parsePushArgs` save for the URL being optional (no `no_url`).
pub fn parseSyncArgs(verb_args: []const []const u8) SyncResult {
    var url: ?[]const u8 = null;
    var user: ?[]const u8 = null;
    var password: ?[]const u8 = null;
    var help = false;
    var first_err: ?SyncResult = null;
    var i: usize = 0;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--user"))) {
            if (i + 1 >= verb_args.len) {
                if (first_err == null) first_err = .{ .unknown_option = tok };
            } else {
                i += 1;
                user = verb_args[i];
            }
        } else if (!positional_only and (std.mem.eql(u8, tok, "--password"))) {
            if (i + 1 >= verb_args.len) {
                if (first_err == null) first_err = .{ .unknown_option = tok };
            } else {
                i += 1;
                password = verb_args[i];
            }
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (url == null) {
            url = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = .{ .url = url orelse "", .user = user, .password = password } };
}

/// Parsed `zit remote [URL | --unset]` arguments — the single default remote
/// (sync §4/§6), not a git-style named-remote registry. `url` empty and `unset`
/// false is the bare "print the saved remote" query; a `url` sets it; `unset`
/// clears it. Setting and clearing are mutually exclusive (a URL with `--unset`
/// is an extra argument).
pub const RemoteArgs = struct {
    url: []const u8 = "",
    unset: bool = false,
    help: bool = false,
};

pub const RemoteResult = union(enum) {
    ok: RemoteArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    /// A positional that is not an http:// or https:// address. `remote list`
    /// used to save the word `list` as the remote and report success.
    not_a_url: []const u8,
};

/// Parses `zit remote [URL] [--unset] [-h|--help]`: at most one positional (the
/// URL to save) and the `--unset` flag, mutually exclusive with a URL. Pure: no
/// library calls, I/O, or allocation. Help wins over every other token; a
/// second positional — or a URL given together with `--unset` — is an extra
/// argument; the first offense in token order is retained for the diagnostic.
pub fn parseRemoteArgs(verb_args: []const []const u8) RemoteResult {
    var url: ?[]const u8 = null;
    var unset = false;
    var help = false;
    var first_err: ?RemoteResult = null;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    for (verb_args) |tok| {
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--unset"))) {
            unset = true;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (url == null) {
            url = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    // Setting a URL and clearing it are contradictory: report the URL token as
    // the unexpected argument (the flag it conflicts with was the intent).
    if (unset and url != null) return .{ .extra_argument = url.? };
    if (url) |u| {
        if (!std.mem.startsWith(u8, u, "http://") and !std.mem.startsWith(u8, u, "https://")) return .{ .not_a_url = u };
    }
    return .{ .ok = .{ .url = url orelse "", .unset = unset } };
}

/// Parsed `zit settings [NAME [VALUE]]` arguments. Bare lists every
/// setting; NAME alone prints one; NAME VALUE sets it. The runner owns name
/// validation — the parser is shape only, like every other verb parser.
pub const SettingsArgs = struct {
    name: ?[]const u8 = null,
    value: ?[]const u8 = null,
    help: bool = false,
    /// `--global` — read or write the OPERATOR-scope settings file instead
    /// of this repository's config. Fossil spells the same thing
    /// `fossil settings --global`.
    ///
    /// It selects a TIER, it does not change what is shown: a bare listing
    /// always reports the value in EFFECT together with the tier that supplied
    /// it, because a display that showed only the global file would be a
    /// display of a value that may not be in force anywhere.
    global: bool = false,
    /// List only settings whose effective value is not the default.
    changed: bool = false,
    /// Print the bare value, no name.
    value_only: bool = false,
};

pub const SettingsResult = union(enum) {
    ok: SettingsArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

pub fn parseSettingsArgs(verb_args: []const []const u8) SettingsResult {
    var parsed: SettingsArgs = .{};
    var first_err: ?SettingsResult = null;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    for (verb_args) |tok| {
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--global"))) {
            parsed.global = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--changed"))) {
            parsed.changed = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--value"))) {
            parsed.value_only = true;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (parsed.name == null) {
            parsed.name = tok;
        } else if (parsed.value == null) {
            parsed.value = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

/// draft-fossil-submanifests-00 §6.3/§6.4/§8.1 — the sub-manifest operator
/// surface: show the settings, turn emission on or off, re-tune the pinned
/// partition parameter, and run the §6.3 analysis pass.
///
/// One verb with four actions rather than a general `zit settings KEY VALUE`:
/// zit's config is a fixed record, not a key-value store, so a general settings
/// verb would be a facade over exactly two keys — and neither of these actions
/// is a plain assignment. Enabling pins k and prints a consequence warning;
/// re-tuning is §6.4's forward-only, every-leaf-rewriting operation; analyzing
/// writes nothing at all.
pub const SubmanifestAction = enum { show, enable, disable, retune, analyze, convert };

pub const SubmanifestArgs = struct {
    action: SubmanifestAction = .show,
    /// The §6.3 partition parameter given by `--k N`, or null when absent. Not
    /// range-checked here: §6.3's 1..12 rule is the library's (`submanifest
    /// .inRange`), and the parser's job is only to say what the operator typed.
    k: ?u8 = null,
    /// `--names PATH` — run the analysis pass over a newline-separated name list
    /// from a file instead of the current checkout's file set. §6.1's partition
    /// predicate "reads the filename only", so a name list is a complete input
    /// to the measurement; this is how a name set from a repository zit cannot
    /// open (a fossil repository, a projected tree) gets measured.
    names_path: ?[]const u8 = null,
    /// `--replay-all`: replay the WHOLE history through the tree
    /// builder instead of the recent window. Analyze only; this is the
    /// conversion gate's full-history evidence pass.
    replay_all: bool = false,
    /// `convert SRC DEST`: the two positionals of the conversion
    /// action — a flat zit store and the directory to create.
    src: ?[]const u8 = null,
    dest: ?[]const u8 = null,
    help: bool = false,
};

pub const SubmanifestResult = union(enum) {
    ok: SubmanifestArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    /// `--k` with a missing, non-numeric, or over-255 value. The offending token
    /// (or the flag itself when the value is missing) is retained.
    bad_k: []const u8,
    /// `--names` with no path after it.
    missing_value: []const u8,
};

/// Parses `zit submanifest [show|enable|disable|retune|analyze|convert SRC DEST]
/// [--k N] [--names PATH] [-h|--help]`. Pure: no library calls, I/O, or allocation.
/// Help wins over every other token; the first offense in token order is
/// retained for the diagnostic. A bare invocation is `show`.
pub fn parseSubmanifestArgs(verb_args: []const []const u8) SubmanifestResult {
    var parsed: SubmanifestArgs = .{};
    var action_seen = false;
    var first_err: ?SubmanifestResult = null;
    var i: usize = 0;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--k"))) {
            i += 1;
            if (i >= verb_args.len) {
                if (first_err == null) first_err = .{ .bad_k = tok };
                break;
            }
            parsed.k = std.fmt.parseInt(u8, verb_args[i], 10) catch {
                if (first_err == null) first_err = .{ .bad_k = verb_args[i] };
                continue;
            };
        } else if (!positional_only and (std.mem.eql(u8, tok, "--replay-all"))) {
            parsed.replay_all = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--names"))) {
            i += 1;
            if (i >= verb_args.len) {
                if (first_err == null) first_err = .{ .missing_value = tok };
                break;
            }
            parsed.names_path = verb_args[i];
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (!action_seen) {
            const a = std.meta.stringToEnum(SubmanifestAction, tok) orelse {
                if (first_err == null) first_err = .{ .extra_argument = tok };
                continue;
            };
            parsed.action = a;
            action_seen = true;
        } else if (parsed.action == .convert and parsed.src == null) {
            parsed.src = tok;
        } else if (parsed.action == .convert and parsed.dest == null) {
            parsed.dest = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

pub const ImportFossilMode = enum { compatible, submanifest };

/// Parsed `zit import-fossil SOURCE DIR (--compatible | --submanifest)
/// [--k N]` arguments. The mode is REQUIRED and exclusive: the
/// compatibility consequence is permanent, so the parser refuses to default
/// it. `--k` belongs to the submanifest arm; the runner enforces that pairing
/// so the parser stays a pure token collector.
pub const ImportFossilArgs = struct {
    source: ?[]const u8 = null,
    dir: ?[]const u8 = null,
    mode: ?ImportFossilMode = null,
    k: ?u8 = null,
    help: bool = false,
};

pub const ImportFossilResult = union(enum) {
    ok: ImportFossilArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    bad_k: []const u8,
    both_modes,
};

pub fn parseImportFossilArgs(verb_args: []const []const u8) ImportFossilResult {
    var parsed: ImportFossilArgs = .{};
    var first_err: ?ImportFossilResult = null;
    var i: usize = 0;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--compatible"))) {
            if (parsed.mode == .submanifest) {
                if (first_err == null) first_err = .both_modes;
                continue;
            }
            parsed.mode = .compatible;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--submanifest"))) {
            if (parsed.mode == .compatible) {
                if (first_err == null) first_err = .both_modes;
                continue;
            }
            parsed.mode = .submanifest;
        } else if (!positional_only and (std.mem.eql(u8, tok, "--k"))) {
            i += 1;
            if (i >= verb_args.len) {
                if (first_err == null) first_err = .{ .bad_k = tok };
                break;
            }
            parsed.k = std.fmt.parseInt(u8, verb_args[i], 10) catch {
                if (first_err == null) first_err = .{ .bad_k = verb_args[i] };
                continue;
            };
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (parsed.source == null) {
            parsed.source = tok;
        } else if (parsed.dir == null) {
            parsed.dir = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

pub const CheckpointAction = enum { show, build, verify, drop };

pub const CheckpointArgs = struct {
    action: CheckpointAction = .show,
    /// `build CHECKIN` — the anchor check-in's full hex name, or null for the
    /// merge-base default. Only `build` takes one; anywhere else it is an
    /// extra argument.
    checkin: ?[]const u8 = null,
    help: bool = false,
};

pub const CheckpointResult = union(enum) {
    ok: CheckpointArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Parses `zit checkpoint [show | build [CHECKIN] | verify | drop]
/// [-h|--help]`. Pure: no library calls, I/O, or allocation. Help wins over
/// every other token; the first offense in token order is retained for the
/// diagnostic. A bare invocation is `show`, matching `zit submanifest`.
pub fn parseCheckpointArgs(verb_args: []const []const u8) CheckpointResult {
    var parsed: CheckpointArgs = .{};
    var action_seen = false;
    var first_err: ?CheckpointResult = null;
    // A bare `--` ends the options: every token after it is an operand,
    // which is the only way to name a path that begins with a dash.
    var positional_only = false;
    for (verb_args) |tok| {
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
        } else if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
        } else if (!positional_only and (tok.len > 1 and tok[0] == '-')) {
            if (first_err == null) first_err = .{ .unknown_option = tok };
        } else if (!action_seen) {
            const a = std.meta.stringToEnum(CheckpointAction, tok) orelse {
                if (first_err == null) first_err = .{ .extra_argument = tok };
                continue;
            };
            parsed.action = a;
            action_seen = true;
        } else if (parsed.action == .build and parsed.checkin == null) {
            parsed.checkin = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

pub const LeavesResult = union(enum) {
    ok: LeavesArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Parses the `zit leaves [--all] [--no-cache] [-h|--help]` surface. Pure: no library calls,
/// I/O, or allocation. `-h`/`--help` win over every otherwise-invalid token;
/// `--` switches to positional-only mode, where even an option-looking token is
/// an extra argument. The first invalid token is retained for the diagnostic.
/// The verb takes no positional, so any bare word is an extra argument.
pub fn parseLeavesArgs(verb_args: []const []const u8) LeavesResult {
    const Mode = enum { scan, positional_only };
    var parsed: LeavesArgs = .{};
    var first_err: ?LeavesResult = null;
    var i: usize = 0;
    state: switch (Mode.scan) {
        .scan => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
            if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
                parsed.help = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "--all")) {
                parsed.all = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "--no-cache")) {
                parsed.no_cache = true;
                continue :state .scan;
            }
            if (first_err == null) {
                first_err = if (tok.len > 1 and tok[0] == '-')
                    .{ .unknown_option = tok }
                else
                    .{ .extra_argument = tok };
            }
            continue :state .scan;
        },
        .positional_only => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (first_err == null) first_err = .{ .extra_argument = tok };
            continue :state .positional_only;
        },
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

/// Parsed `zit branch` arguments. `no_cache` bypasses the `.zit-cache` memo and
/// forces a full-log re-derive (whole-log integrity re-verified); `help`
/// requests the verbatim help block. `branch` takes no positional, so it does
/// not share `parseNoPositionalArgs` with `status`/`diff` — those verbs never
/// consult the cache, so they must not accept `--no-cache`.
pub const BranchAction = enum { list, new, close, reopen, hide, unhide, current, info };

pub const BranchArgs = struct {
    no_cache: bool = false,
    help: bool = false,
    /// Fossil's branch verbs. `list` is the bare form.
    action: BranchAction = .list,
    name: ?[]const u8 = null,
    basis: ?[]const u8 = null,
    /// list: `-c` closed only, `-t` most recently changed first, `-r` reverse.
    closed_only: bool = false,
    by_time: bool = false,
    reverse: bool = false,
    /// List with each branch's tip and time.
    verbose: bool = false,
};

pub const BranchResult = union(enum) {
    ok: BranchArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Parses the `zit branch [--no-cache] [-h|--help]` surface. Pure: no library
/// calls, I/O, or allocation. `-h`/`--help` win over every otherwise-invalid
/// token; `--` switches to positional-only mode, where even an option-looking
/// token is an extra argument. The verb takes no positional, so any bare word
/// is an extra argument. The first invalid token is retained for the diagnostic.
pub fn parseBranchArgs(verb_args: []const []const u8) BranchResult {
    const Mode = enum { scan, positional_only };
    var parsed: BranchArgs = .{};
    var first_err: ?BranchResult = null;
    var i: usize = 0;
    state: switch (Mode.scan) {
        .scan => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
            if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
                parsed.help = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "--no-cache")) {
                parsed.no_cache = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "-c") or std.mem.eql(u8, tok, "--closed")) {
                parsed.closed_only = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "-a") or std.mem.eql(u8, tok, "--all")) {
                continue :state .scan; // every branch is listed already
            }
            if (std.mem.eql(u8, tok, "-v") or std.mem.eql(u8, tok, "--verbose")) {
                parsed.verbose = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "-t")) {
                parsed.by_time = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "-r")) {
                parsed.reverse = true;
                continue :state .scan;
            }
            if (tok.len > 1 and tok[0] == '-') {
                if (first_err == null) first_err = .{ .unknown_option = tok };
                continue :state .scan;
            }
            if (parsed.action == .list and parsed.name == null) {
                if (std.meta.stringToEnum(BranchAction, tok)) |act| {
                    parsed.action = act;
                    continue :state .scan;
                }
            }
            if (parsed.name == null and parsed.action != .list) {
                parsed.name = tok;
            } else if (parsed.basis == null and parsed.action == .new and parsed.name != null) {
                parsed.basis = tok;
            } else if (first_err == null) {
                first_err = .{ .extra_argument = tok };
            }
            continue :state .scan;
        },
        .positional_only => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (first_err == null) first_err = .{ .extra_argument = tok };
            continue :state .positional_only;
        },
    }
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

/// The `zit tag` subcommands. `list` (alias `ls`) enumerates, read-only;
/// `add` and `cancel` mint the `+` (or `*`) and `-` cards of §4.7.16's tag
/// vocabulary. The set and the spellings are fossil's `fossil tag` surface —
/// matching it, rather than inventing a second dialect, is the whole reason
/// `--raw` is a flag here instead of a `set`/`unset` subcommand pair.
pub const TagSub = enum { list, add, cancel };

/// Parsed `zit tag` arguments. Every string borrows its argv token. `name` is
/// required by `add` and `cancel`; `checkin` is `list`'s optional positional
/// and their required second one; `value` is `add`'s optional third.
///
/// `raw` selects the namespace, and it is the distinction the whole surface
/// turns on. Without it a NAME is a symbolic *name* for one check-in, stored
/// `sym-`-prefixed by the composer — right for `release-1.2`. With it the
/// NAME is written verbatim as a *property* many check-ins may carry — right
/// for `ci-pass`, which must never become a check-in name. On `list` it
/// selects the raw listing (every tag, prefixes intact) over the default
/// symbolic-names-only one.
pub const TagArgs = struct {
    sub: TagSub = .list,
    name: ?[]const u8 = null,
    checkin: ?[]const u8 = null,
    value: ?[]const u8 = null,
    raw: bool = false,
    /// `add --propagate`: mint `*` rather than `+`, so descendants inherit.
    propagate: bool = false,
    /// Bypass the `.zit-cache` memo and force a full-log re-derive.
    no_cache: bool = false,
    help: bool = false,
};

pub const TagResult = union(enum) {
    ok: TagArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    /// A missing or unrecognized subcommand; the token is the offending word,
    /// or an empty slice when none was given.
    bad_subcommand: []const u8,
    /// `add`/`cancel` given no NAME positional.
    no_name,
    /// `add`/`cancel` given a NAME but no CHECKIN positional.
    no_checkin,
    /// An option the given subcommand has no meaning for (`--propagate`
    /// outside `add`). Named rather than silently ignored: a flag that looks
    /// accepted and does nothing is the worse failure.
    option_not_for_subcommand: []const u8,
};

/// Positional classifier shared by both parser modes so the subcommand /
/// NAME / CHECKIN / VALUE / extra-argument grammar lives in one place.
/// Positional 0 is the subcommand (`list`, its alias `ls`, `add`, or
/// `cancel`); the meaning of the rest is the subcommand's: `list` takes an
/// optional CHECKIN at 1, `add` and `cancel` take NAME at 1 and CHECKIN at 2,
/// and `add` alone takes an optional VALUE at 3. Any further positional is an
/// extra argument. The first error seen is retained. An unrecognized
/// subcommand leaves `sub` at its `list` default, which only shapes where the
/// retained `bad_subcommand` verdict is reported from — it is never returned
/// as `ok`.
const TagScan = struct {
    npos: usize = 0,
    sub: TagSub = .list,
    name: ?[]const u8 = null,
    checkin: ?[]const u8 = null,
    value: ?[]const u8 = null,
    first_err: ?TagResult = null,

    fn positional(self: *TagScan, tok: []const u8) void {
        switch (self.npos) {
            0 => if (std.mem.eql(u8, tok, "list") or std.mem.eql(u8, tok, "ls")) {
                self.sub = .list;
            } else if (std.mem.eql(u8, tok, "add")) {
                self.sub = .add;
            } else if (std.mem.eql(u8, tok, "cancel")) {
                self.sub = .cancel;
            } else if (self.first_err == null) {
                self.first_err = .{ .bad_subcommand = tok };
            },
            1 => switch (self.sub) {
                .list => self.checkin = tok,
                .add, .cancel => self.name = tok,
            },
            2 => switch (self.sub) {
                .list => self.extra(tok),
                .add, .cancel => self.checkin = tok,
            },
            3 => switch (self.sub) {
                .add => self.value = tok,
                .list, .cancel => self.extra(tok),
            },
            else => self.extra(tok),
        }
        self.npos += 1;
    }

    fn extra(self: *TagScan, tok: []const u8) void {
        if (self.first_err == null) self.first_err = .{ .extra_argument = tok };
    }
};

/// Parses the `zit tag list|ls [--raw] [CHECKIN]` / `zit tag add [--raw]
/// [--propagate] NAME CHECKIN [VALUE]` / `zit tag cancel [--raw] NAME
/// CHECKIN` surface, plus `[--no-cache] [-h|--help]`. Pure: no
/// library calls, I/O, or allocation. `-h`/`--help` win over every otherwise-
/// invalid token; `--` switches to positional-only mode, where an option-
/// looking token is a positional. The first invalid token is retained for the
/// diagnostic. A missing subcommand is a `bad_subcommand` with an empty token.
pub fn parseTagArgs(verb_args: []const []const u8) TagResult {
    const Mode = enum { scan, positional_only };
    var help = false;
    var no_cache = false;
    var raw = false;
    var propagate = false;
    var scan: TagScan = .{};
    var i: usize = 0;
    state: switch (Mode.scan) {
        .scan => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
            if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
                help = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "--no-cache")) {
                no_cache = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "--raw")) {
                raw = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "--propagate")) {
                propagate = true;
                continue :state .scan;
            }
            if (tok.len > 1 and tok[0] == '-') {
                if (scan.first_err == null) scan.first_err = .{ .unknown_option = tok };
                continue :state .scan;
            }
            scan.positional(tok);
            continue :state .scan;
        },
        .positional_only => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            scan.positional(tok);
            continue :state .positional_only;
        },
    }
    if (help) return .{ .ok = .{ .help = true } };
    if (scan.first_err) |e| return e;
    if (scan.npos == 0) return .{ .bad_subcommand = "" };
    // `--propagate` only shapes the sign of a card `add` mints; on any other
    // subcommand it is inert, so it is refused rather than accepted-and-ignored.
    if (propagate and scan.sub != .add) return .{ .option_not_for_subcommand = "--propagate" };
    // The mutating subcommands' arity, checked after the token-level errors so
    // a bad option still wins: NAME and CHECKIN are both required, and a tag
    // with no check-in to point at is meaningless rather than defaultable.
    if (scan.sub != .list) {
        if (scan.name == null) return .no_name;
        if (scan.checkin == null) return .no_checkin;
    }
    return .{ .ok = .{
        .sub = scan.sub,
        .name = scan.name,
        .checkin = scan.checkin,
        .value = scan.value,
        .raw = raw,
        .propagate = propagate,
        .no_cache = no_cache,
    } };
}

/// The five `zit bisect` subcommands (contracts/bisect.md). `bad`/`good` take a
/// mandatory NAME; `next`/`status`/`reset` take none. The tag names are the
/// spelling — never a second transcription.
pub const BisectSub = enum { bad, good, next, status, reset };

/// Parsed `zit bisect SUBCOMMAND [NAME]` arguments (contracts/bisect.md
/// B-bisect-16/17). `name` is present exactly for `bad`/`good`. `help` requests
/// the verbatim help block, winning over every other classification.
pub const BisectArgs = struct {
    sub: BisectSub,
    name: ?[]const u8 = null,
    help: bool = false,
};

pub const BisectResult = union(enum) {
    ok: BisectArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    bad_subcommand: []const u8, // offending word, or "" when none given
    missing_name: void, // `bad`/`good` with no NAME
};

fn bisectSubOf(tok: []const u8) ?BisectSub {
    inline for (comptime std.enums.values(BisectSub)) |s| {
        if (std.mem.eql(u8, tok, @tagName(s))) return s;
    }
    return null;
}

const BisectScan = struct {
    npos: usize = 0,
    sub: ?BisectSub = null,
    name: ?[]const u8 = null,
    first_err: ?BisectResult = null,

    fn positional(self: *BisectScan, tok: []const u8) void {
        switch (self.npos) {
            0 => if (bisectSubOf(tok)) |s| {
                self.sub = s;
            } else if (self.first_err == null) {
                self.first_err = .{ .bad_subcommand = tok };
            },
            1 => if (self.sub) |s| switch (s) {
                // NAME is legal only for the marking subcommands.
                .bad, .good => self.name = tok,
                .next, .status, .reset => if (self.first_err == null) {
                    self.first_err = .{ .extra_argument = tok };
                },
            },
            else => if (self.first_err == null) {
                self.first_err = .{ .extra_argument = tok };
            },
        }
        self.npos += 1;
    }
};

/// Parses `zit bisect SUBCOMMAND [NAME] [-h|--help]`. Pure: no library calls,
/// I/O, or allocation. `-h`/`--help` win over every other classification; the
/// first offense (unknown option, then bad/missing subcommand, then extra
/// argument or missing NAME) is what the caller renders. `--` forces the rest
/// positional, mirroring `parseTagArgs`.
pub fn parseBisectArgs(verb_args: []const []const u8) BisectResult {
    const Mode = enum { scan, positional_only };
    var help = false;
    var scan: BisectScan = .{};
    var i: usize = 0;
    state: switch (Mode.scan) {
        .scan => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
            if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
                help = true;
                continue :state .scan;
            }
            if (tok.len > 1 and tok[0] == '-') {
                if (scan.first_err == null) scan.first_err = .{ .unknown_option = tok };
                continue :state .scan;
            }
            scan.positional(tok);
            continue :state .scan;
        },
        .positional_only => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            scan.positional(tok);
            continue :state .positional_only;
        },
    }
    if (help) return .{ .ok = .{ .sub = .status, .help = true } };
    if (scan.first_err) |e| return e;
    const sub = scan.sub orelse return .{ .bad_subcommand = "" };
    switch (sub) {
        .bad, .good => if (scan.name == null) return .missing_name,
        .next, .status, .reset => {},
    }
    return .{ .ok = .{ .sub = sub, .name = scan.name } };
}

test "parseBisectArgs: subcommands, NAME rules, and the offense taxonomy" {
    // bad/good bind a NAME.
    switch (parseBisectArgs(&.{ "bad", "abc" })) {
        .ok => |p| {
            try std.testing.expectEqual(BisectSub.bad, p.sub);
            try std.testing.expectEqualStrings("abc", p.name.?);
        },
        else => return error.TestUnexpectedResult,
    }
    switch (parseBisectArgs(&.{ "good", "feature" })) {
        .ok => |p| try std.testing.expectEqual(BisectSub.good, p.sub),
        else => return error.TestUnexpectedResult,
    }
    // next/status/reset take no NAME.
    switch (parseBisectArgs(&.{"next"})) {
        .ok => |p| {
            try std.testing.expectEqual(BisectSub.next, p.sub);
            try std.testing.expectEqual(@as(?[]const u8, null), p.name);
        },
        else => return error.TestUnexpectedResult,
    }
    // help wins.
    try std.testing.expect(parseBisectArgs(&.{ "status", "--help" }).ok.help);
    try std.testing.expect(parseBisectArgs(&.{"-h"}).ok.help);
    // Missing subcommand, unknown subcommand, missing NAME, extra arg, unknown opt.
    try std.testing.expectEqualStrings("", parseBisectArgs(&.{}).bad_subcommand);
    try std.testing.expectEqualStrings("frob", parseBisectArgs(&.{"frob"}).bad_subcommand);
    try std.testing.expect(parseBisectArgs(&.{"bad"}) == .missing_name);
    try std.testing.expectEqualStrings("x", parseBisectArgs(&.{ "reset", "x" }).extra_argument);
    try std.testing.expectEqualStrings("y", parseBisectArgs(&.{ "good", "n", "y" }).extra_argument);
    try std.testing.expectEqualStrings("--nope", parseBisectArgs(&.{ "next", "--nope" }).unknown_option);
}

/// Parsed `zit clean [--dry-run] [--force]` arguments (contracts/clean.md
/// B-clean-1..14). `dry_run` and `force` are independent bools: the B-clean-11
/// precedence (dry-run wins over force) is a runner decision, not a parse one,
/// so both may be set at once. `help` requests the verbatim help block.
pub const CleanArgs = struct {
    dry_run: bool = false,
    force: bool = false,
    /// Remove directories the deletions leave empty.
    emptydirs: bool = false,
    /// Consider dot-prefixed entries, hidden by default.
    dotfiles: bool = false,
    help: bool = false,
};

pub const CleanResult = union(enum) {
    ok: CleanArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Parses the `zit clean [--dry-run] [--force] [-h|--help]` surface. Pure: no
/// library calls, I/O, or allocation. `-h`/`--help` win over every otherwise-
/// invalid token and over `--force` (B-clean-12: help never deletes); `--`
/// switches to positional-only mode, where even an option-looking token is an
/// extra argument (B-clean-14). clean takes no positional, so any bare word is
/// an extra argument. `--force`/`--dry-run` are the only non-help flags; `-f`
/// and `-n` are NOT accepted short aliases (B-clean-13). The first invalid token
/// is retained for the diagnostic.
pub fn parseCleanArgs(verb_args: []const []const u8) CleanResult {
    const Mode = enum { scan, positional_only };
    var parsed: CleanArgs = .{};
    var first_err: ?CleanResult = null;
    var i: usize = 0;
    state: switch (Mode.scan) {
        .scan => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
            if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
                parsed.help = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "--dry-run") or std.mem.eql(u8, tok, "-n")) {
                parsed.dry_run = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "--force") or std.mem.eql(u8, tok, "-f")) {
                parsed.force = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "--emptydirs")) {
                parsed.emptydirs = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, dotfiles_flag)) {
                parsed.dotfiles = true;
                continue :state .scan;
            }
            if (first_err == null) {
                first_err = if (tok.len > 1 and tok[0] == '-')
                    .{ .unknown_option = tok }
                else
                    .{ .extra_argument = tok };
            }
            continue :state .scan;
        },
        .positional_only => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (first_err == null) first_err = .{ .extra_argument = tok };
            continue :state .positional_only;
        },
    }
    // Help wins over any recorded error and over --force/--dry-run (B-clean-12).
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

/// Parsed `zit addremove [--dry-run]` arguments (contracts/addremove.md
/// B-addremove-9/-10). `dry_run` previews the reconciliation without touching
/// `.zit-managed`; `help` requests the verbatim help block. The verb takes no
/// positional and has no `--force` — it is non-destructive by construction
/// (working files are never touched), so there is nothing to gate.
pub const AddremoveArgs = struct {
    dry_run: bool = false,
    /// Reconcile dot-prefixed entries, hidden by default.
    dotfiles: bool = false,
    help: bool = false,
};

pub const AddremoveResult = union(enum) {
    ok: AddremoveArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Parses the `zit addremove [--dry-run] [-h|--help]` surface. Pure: no library
/// calls, I/O, or allocation. `-h`/`--help` win over every otherwise-invalid
/// token (help never scans); `--` switches to positional-only mode, where even
/// an option-looking token is an extra argument (B-addremove-10). addremove
/// takes no positional, so any bare word is an extra argument; `--dry-run` is
/// the only non-help flag (`-n` is NOT an accepted short alias, matching
/// clean). The first invalid token is retained for the diagnostic.
pub fn parseAddremoveArgs(verb_args: []const []const u8) AddremoveResult {
    const Mode = enum { scan, positional_only };
    var parsed: AddremoveArgs = .{};
    var first_err: ?AddremoveResult = null;
    var i: usize = 0;
    state: switch (Mode.scan) {
        .scan => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (std.mem.eql(u8, tok, "--")) continue :state .positional_only;
            if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
                parsed.help = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, "--dry-run")) {
                parsed.dry_run = true;
                continue :state .scan;
            }
            if (std.mem.eql(u8, tok, dotfiles_flag)) {
                parsed.dotfiles = true;
                continue :state .scan;
            }
            if (first_err == null) {
                first_err = if (tok.len > 1 and tok[0] == '-')
                    .{ .unknown_option = tok }
                else
                    .{ .extra_argument = tok };
            }
            continue :state .scan;
        },
        .positional_only => {
            if (i >= verb_args.len) break :state;
            const tok = verb_args[i];
            i += 1;
            if (first_err == null) first_err = .{ .extra_argument = tok };
            continue :state .positional_only;
        },
    }
    // Help wins over any recorded error and over --dry-run (help never scans).
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    return .{ .ok = parsed };
}

// ---------------------------------------------------------------------------
// `zit shun` grammar.
//
// NO DRAFT SPECIFIES THIS VERB. sync §3.3 (docs/rfcs/
// draft-fossil-sync-protocol-00.txt L518-519) defines the repository shun table
// as "a persistent, repository-local set keyed by complete artifact name" and
// L520-521 grants that "Local administration MAY add or remove names" — it names
// no command, no options, and no reporting. The whole surface below is this
// implementation's, chosen to match the `clean` register: refuse by default, name
// the consequence, require `--force`, never prompt.
// ---------------------------------------------------------------------------

/// The parsed shape of a `zit shun` argument list. `hashes` borrows the argv
/// tokens (order preserved; the caller owns the returned slice) — each is an
/// artifact-name prefix the runner resolves, never resolved here (a pure parse
/// holds no store). `reason` is the optional note recorded with every hash in
/// the invocation.
///
/// `defer_reclaim` spells `--defer` (a Zig keyword cannot be a field name). It
/// records the tombstones WITHOUT rewriting the store, so `zit pack` reclaims
/// later; `force` is required either way, because both paths append an
/// irreversible tombstone and the read mask hides the content immediately.
pub const ShunArgs = struct {
    hashes: []const []const u8 = &.{},
    reason: ?[]const u8 = null,
    defer_reclaim: bool = false,
    force: bool = false,
    list: bool = false,
    help: bool = false,
};

/// Result of parsing shun's arguments: a clean `ShunArgs`, or the exact
/// offending token for the two usage errors a pure parse can detect. An empty
/// `hashes` is NOT an error here — it is the signal the runner turns into
/// `no_hash`, after `--help` and `--list` have had their chance.
pub const ShunResult = union(enum) {
    ok: ShunArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
};

/// Scans `verb_args` (everything after `shun`) into a `ShunResult` by feeding
/// the shun flag spec to the shared `collectPositionals` scanner. Pure apart
/// from the one positional allocation every multi-positional verb makes: no
/// library calls, no I/O, no clock — so the whole grammar is unit-testable
/// without a repository.
///
/// `--reason` is a value option (a dangling one — final token, no value — is
/// recorded as an unknown option carrying that token, exactly as commit's `-m`
/// is); `--defer`, `--force`, `--list` and `-h`/`--help` are flags with no short
/// aliases (matching `clean`, where `-f` is deliberately not accepted for a
/// destructive flag). Every bare word is a hash, order preserved.
///
/// Precedence, in this order and for these reasons:
///
///   1. `-h`/`--help` wins over every otherwise-invalid token and over `--force`
///      — help never destroys, the same rule `clean` follows (B-clean-12).
///   2. A scan offense (unknown or dangling option) stands next, naming the
///      first offending token in argument order.
///   3. `--list` with a positional is `extra_argument`: listing the table and
///      shunning a hash are two different operations and a run that asked for
///      both stated no single intent. `.extra_argument` rather than a new
///      `UsageKind` because "unexpected argument" is exactly what it is.
///   4. `--list` with `--force`, `--defer` or `--reason` is the SAME offense and
///      gets the same answer, because the alternative is worse than untidy: those
///      three belong only to the recording path, and silently ignoring them let
///      `zit shun --list --reason <201 bytes>` fail with a length diagnostic about
///      a note the listing would never have recorded. Rejecting the combination is
///      what makes `--list` a mode rather than a mode plus three no-ops.
///
/// A repeated `--reason` takes the last value (the shared scanner's `value`
/// semantics, as commit's `-m` does) and an empty `--reason ""` is accepted: an
/// empty reason is indistinguishable from none in the durable row, so rejecting
/// it would add an error surface with nothing behind it.
pub fn parseShunArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!ShunResult {
    var args: ShunArgs = .{};
    const spec = [_]FlagSpec{
        .{ .long = "--help", .short = "-h", .target = .{ .flag = &args.help } },
        .{ .long = "--defer", .target = .{ .flag = &args.defer_reclaim } },
        .{ .long = "--force", .target = .{ .flag = &args.force } },
        .{ .long = "--list", .target = .{ .flag = &args.list } },
        .{ .long = "--reason", .target = .{ .value = &args.reason } },
    };
    const c = try collectPositionals(gpa, verb_args, &spec);
    if (args.help) {
        gpa.free(c.paths);
        return .{ .ok = .{ .help = true } };
    }
    if (c.unknown_option) |tok| {
        gpa.free(c.paths);
        return .{ .unknown_option = tok };
    }
    if (args.list and c.paths.len != 0) {
        const first = c.paths[0];
        gpa.free(c.paths);
        return .{ .extra_argument = first };
    }
    if (args.list and (args.force or args.defer_reclaim or args.reason != null)) {
        gpa.free(c.paths);
        return .{ .extra_argument = firstRecordingFlag(verb_args) };
    }
    args.hashes = c.paths;
    return .{ .ok = args };
}

/// The first recording-path option in `verb_args`, in argument order, for the
/// `--list` combination diagnostic. Stops at `--`, past which those tokens are
/// positionals the check above already caught. Exact token matches only — the
/// shared scanner has no `--opt=value` form — so one of the three is always found
/// when one of the three fields is set; the fallback names the flag that is set
/// rather than trusting that.
fn firstRecordingFlag(verb_args: []const []const u8) []const u8 {
    for (verb_args) |tok| {
        if (std.mem.eql(u8, tok, "--")) break;
        if (std.mem.eql(u8, tok, "--force")) return tok;
        if (std.mem.eql(u8, tok, "--defer")) return tok;
        if (std.mem.eql(u8, tok, "--reason")) return tok;
    }
    return "--force";
}

test "cli shun: the flag grammar, and the two usage errors a pure parse can detect" {
    const gpa = std.testing.allocator;

    // The bare destructive form: hashes in argument order, nothing else set.
    {
        const r = try parseShunArgs(gpa, &.{ "--force", "aabb", "ccdd" });
        const a = r.ok;
        defer gpa.free(a.hashes);
        try std.testing.expect(a.force);
        try std.testing.expect(!a.defer_reclaim);
        try std.testing.expect(!a.list);
        try std.testing.expect(a.reason == null);
        try std.testing.expectEqual(@as(usize, 2), a.hashes.len);
        try std.testing.expectEqualStrings("aabb", a.hashes[0]);
        try std.testing.expectEqualStrings("ccdd", a.hashes[1]);
    }
    // --defer and --reason together; the reason value is borrowed verbatim.
    {
        const r = try parseShunArgs(gpa, &.{ "--defer", "--reason", "takedown 4417", "--force", "aabb" });
        const a = r.ok;
        defer gpa.free(a.hashes);
        try std.testing.expect(a.defer_reclaim);
        try std.testing.expect(a.force);
        try std.testing.expectEqualStrings("takedown 4417", a.reason.?);
        try std.testing.expectEqual(@as(usize, 1), a.hashes.len);
    }
    // --list alone.
    {
        const r = try parseShunArgs(gpa, &.{"--list"});
        const a = r.ok;
        defer gpa.free(a.hashes);
        try std.testing.expect(a.list);
        try std.testing.expectEqual(@as(usize, 0), a.hashes.len);
    }
    // No hash and no --list is not a parse error: the runner raises `no_hash`.
    // AND THE SAFE DEFAULT: `force` is FALSE when the flag is absent. That is the
    // load-bearing half of `--force` — a parse that defaulted it on would leave the
    // refusal defended only by tests that spawn the real binary.
    {
        const r = try parseShunArgs(gpa, &.{});
        try std.testing.expectEqual(@as(usize, 0), r.ok.hashes.len);
        try std.testing.expect(!r.ok.list);
        try std.testing.expect(!r.ok.force);
        try std.testing.expect(!r.ok.defer_reclaim);
    }
    // ...including with a real hash present, which is the shape an operator types.
    {
        const r = try parseShunArgs(gpa, &.{"aabb"});
        const a = r.ok;
        defer gpa.free(a.hashes);
        try std.testing.expect(!a.force);
        try std.testing.expectEqual(@as(usize, 1), a.hashes.len);
    }
    // -h/--help wins over --force, over a positional, and over an unknown option.
    {
        const r = try parseShunArgs(gpa, &.{ "--force", "aabb", "-h" });
        try std.testing.expect(r.ok.help);
        try std.testing.expectEqual(@as(usize, 0), r.ok.hashes.len);
    }
    {
        const r = try parseShunArgs(gpa, &.{ "--nope", "--help" });
        try std.testing.expect(r.ok.help);
    }
    // An unknown option is a usage error naming the first offending token.
    {
        const r = try parseShunArgs(gpa, &.{ "--force", "-f", "aabb" });
        try std.testing.expectEqualStrings("-f", r.unknown_option);
    }
    // A dangling value option is an unknown option carrying the flag itself.
    {
        const r = try parseShunArgs(gpa, &.{ "aabb", "--reason" });
        try std.testing.expectEqualStrings("--reason", r.unknown_option);
    }
    // --list with a positional stated no single intent.
    {
        const r = try parseShunArgs(gpa, &.{ "--list", "aabb" });
        try std.testing.expectEqualStrings("aabb", r.extra_argument);
    }
    // ...and the offense stands whichever order the two arrive in.
    {
        const r = try parseShunArgs(gpa, &.{ "aabb", "--list" });
        try std.testing.expectEqualStrings("aabb", r.extra_argument);
    }
    // `--list` with a recording-path option is the same offense, naming the first
    // such flag in argument order. Silently ignoring them is what let
    // `--list --reason <too long>` fail about a note nothing would record.
    {
        const r = try parseShunArgs(gpa, &.{ "--list", "--force" });
        try std.testing.expectEqualStrings("--force", r.extra_argument);
    }
    {
        const r = try parseShunArgs(gpa, &.{ "--defer", "--list" });
        try std.testing.expectEqualStrings("--defer", r.extra_argument);
    }
    {
        const r = try parseShunArgs(gpa, &.{ "--reason", "note", "--list" });
        try std.testing.expectEqualStrings("--reason", r.extra_argument);
    }
    {
        // Argument order, not field order: `--defer` arrives first here.
        const r = try parseShunArgs(gpa, &.{ "--list", "--defer", "--force" });
        try std.testing.expectEqualStrings("--defer", r.extra_argument);
    }
    // ...and `-h` still wins over the combination, because help never destroys and
    // never lists either.
    {
        const r = try parseShunArgs(gpa, &.{ "--list", "--force", "-h" });
        try std.testing.expect(r.ok.help);
    }
    // `--` reaches a hash that begins with a dash (the shared scanner's
    // positional-only mode), and a lone `-` is a positional, not an option.
    {
        const r = try parseShunArgs(gpa, &.{ "--force", "--", "--reason" });
        const a = r.ok;
        defer gpa.free(a.hashes);
        try std.testing.expectEqual(@as(usize, 1), a.hashes.len);
        try std.testing.expectEqualStrings("--reason", a.hashes[0]);
        try std.testing.expect(a.reason == null);
    }
}

/// Renders a B-init-10 usage error for `verb` to `err` and returns `.usage`.
/// General over the verb name (never special-cased to init) and best-effort
/// on the write — a diagnostic that cannot be printed must not change the
/// exit code. The trailing hint teaches where the full syntax lives.
pub fn usageError(s: Streams, verb: []const u8, kind: UsageKind, token: []const u8) Exit {
    switch (kind) {
        .no_paths => s.err.print("zit {s}: no paths given\n", .{verb}) catch {},
        .no_checkin => s.err.print("zit {s}: no check-in given\n", .{verb}) catch {},
        .no_name => s.err.print("zit {s}: no name given\n", .{verb}) catch {},
        .no_file => s.err.print("zit {s}: no file given\n", .{verb}) catch {},
        .no_hash => s.err.print("zit {s}: no hash given\n", .{verb}) catch {},
        .no_url => s.err.print("zit {s}: no repository URL given\n", .{verb}) catch {},
        .no_dir => s.err.print("zit {s}: no target directory given\n", .{verb}) catch {},
        .bad_subcommand => if (token.len == 0)
            s.err.print("zit {s}: no subcommand given\n", .{verb}) catch {}
        else
            s.err.print("zit {s}: unknown subcommand '{s}'\n", .{ verb, token }) catch {},
        // amend's additive arms (B-amend-8/11/2): a run with no override to
        // apply, a value flag repeated, an empty value, or a `--date` that fails
        // the §4.6.4 writer-timestamp predicate. General over the verb name.
        .no_override => s.err.print("zit {s}: no override given; give at least one of --comment/--user/--date/--branch\n", .{verb}) catch {},
        .repeated_option => s.err.print("zit {s}: option '{s}' given more than once\n", .{ verb, token }) catch {},
        .empty_value => s.err.print("zit {s}: option '{s}' needs a non-empty value\n", .{ verb, token }) catch {},
        .too_many_cones => s.err.print("zit {s}: too many --cone members (the cone holds at most 255); '{s}' is one too many\n", .{ verb, token }) catch {},
        .cone_and_dense => s.err.print("zit {s}: --cone and --dense contradict; pass one\n", .{verb}) catch {},
        .option_not_for_subcommand => s.err.print("zit {s}: option '{s}' does not apply to this subcommand\n", .{ verb, token }) catch {},
        .bad_date => s.err.print("zit {s}: invalid date '{s}' (expected YYYY-MM-DDTHH:MM:SS)\n", .{ verb, token }) catch {},
        .bad_manifest => s.err.print("zit {s}: invalid --manifest value '{s}' (expected any combination of r, u, t, or on/off)\n", .{ verb, token }) catch {},
        .bad_limit => s.err.print("zit {s}: invalid -n value '{s}' (expected a whole number of entries, 0 for all)\n", .{ verb, token }) catch {},
        // `-R` names a store, not a checkout. A verb that reads or writes the
        // working tree has nowhere to apply it, so it is refused by name rather
        // than accepted and ignored — silently operating on the cwd's checkout
        // while the user believed they had redirected the repository is the one
        // outcome worth spending a diagnostic to prevent.
        .repo_unsupported => s.err.print(
            "zit {s}: {s} is not supported here; '{s}' works in a checkout, and {s} names a repository with no working tree\n",
            .{ verb, token, verb, token },
        ) catch {},
        .unknown_option, .extra_argument => {
            const label = switch (kind) {
                .unknown_option => "unknown option",
                .extra_argument => "unexpected argument",
                else => unreachable,
            };
            s.err.print("zit {s}: {s} '{s}'\n", .{ verb, label, token }) catch {};
        },
    }
    s.err.print("try 'zit {s} --help'\n", .{verb}) catch {};
    return .usage;
}

// ---------------------------------------------------------------------------
// `zit add` grammar (contracts/add.md B-add-1) + the shared working-tree-root
// probe (B-add-8) and repo-relative resolver (B-add-15). These are the CLI
// mechanism `add` (and every future working-tree verb) consumes; no add effect
// logic lives here.
// ---------------------------------------------------------------------------

/// The parsed shape of a `zit add` argument list (B-add-1). `paths` borrows the
/// argv tokens (argument order preserved); the caller owns the returned slice.
pub const AddArgs = struct {
    paths: []const []const u8,
    dry_run: bool = false,
    quiet: bool = false,
    /// A directory expansion also takes ignored files.
    force: bool = false,
    /// A directory expansion also takes dot-prefixed entries,
    /// which are hidden by default. An explicitly named dotfile always adds.
    dotfiles: bool = false,
    help: bool = false,
};

/// Result of parsing add's arguments: a clean `AddArgs`, or the exact offending
/// token for the one B-add-10 usage error a pure parse can detect (an unknown
/// option). "No paths" is not an error here — an empty `paths` is the signal the
/// runner turns into B-add-9, after `--help` has had its chance to win.
pub const AddResult = union(enum) {
    ok: AddArgs,
    unknown_option: []const u8,
};

/// Scans `verb_args` (everything after `add`) into an `AddResult` (B-add-1) by
/// feeding the add flag spec to the shared `collectPositionals` scanner.
/// `-h/--help`, `-n/--dry-run`, `-q/--quiet` are the recognized flags; every
/// bare word (and a lone `-`) is a path, order preserved. `--help` wins over an
/// unknown option regardless of order (B-add-13), so it is checked first; only
/// when no `--help` appeared does an unknown option become the verdict.
pub fn parseAddArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!AddResult {
    var args: AddArgs = .{ .paths = &.{} };
    const spec = [_]FlagSpec{
        .{ .long = "--help", .short = "-h", .target = .{ .flag = &args.help } },
        .{ .long = "--dry-run", .short = "-n", .target = .{ .flag = &args.dry_run } },
        .{ .long = "--quiet", .short = "-q", .target = .{ .flag = &args.quiet } },
        .{ .long = "--force", .short = "-f", .target = .{ .flag = &args.force } },
        .{ .long = dotfiles_flag, .target = .{ .flag = &args.dotfiles } },
    };
    const c = try collectPositionals(gpa, verb_args, &spec);
    if (!args.help) {
        if (c.unknown_option) |tok| {
            gpa.free(c.paths);
            return .{ .unknown_option = tok };
        }
    }
    args.paths = c.paths;
    return .{ .ok = args };
}

/// The parsed shape of a `zit rm` argument list (B-rm-1). The mirror of
/// `AddArgs` — `paths` borrows the argv tokens (argument order preserved; caller
/// owns the returned slice) — plus `hard`, rm's one extra flag (`--hard` also
/// deletes the working-tree file after forgetting it).
pub const RmArgs = struct {
    paths: []const []const u8,
    dry_run: bool = false,
    hard: bool = false,
    quiet: bool = false,
    help: bool = false,
};

/// Result of parsing rm's arguments: a clean `RmArgs`, or the exact offending
/// token for the one B-rm-10 usage error a pure parse can detect (an unknown
/// option). An empty `paths` is not an error here — it is the signal the runner
/// turns into B-rm-9, after `--help` has had its chance to win (B-rm-13).
pub const RmResult = union(enum) {
    ok: RmArgs,
    unknown_option: []const u8,
};

/// Scans `verb_args` (everything after `rm`) into an `RmResult` (B-rm-1) by
/// feeding the rm flag spec to the shared `collectPositionals` scanner — the
/// mirror of `parseAddArgs` plus `--hard` (which has no short form; the contract
/// names only `--hard`). Every bare word (and a lone `-`) is a path, order
/// preserved. `--help` wins over an unknown option regardless of order (B-rm-13),
/// so it is checked first; only when no `--help` appeared does an unknown option
/// become the verdict.
pub fn parseRmArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!RmResult {
    var args: RmArgs = .{ .paths = &.{} };
    const spec = [_]FlagSpec{
        .{ .long = "--help", .short = "-h", .target = .{ .flag = &args.help } },
        .{ .long = "--dry-run", .short = "-n", .target = .{ .flag = &args.dry_run } },
        .{ .long = "--hard", .target = .{ .flag = &args.hard } },
        .{ .long = "--quiet", .short = "-q", .target = .{ .flag = &args.quiet } },
    };
    const c = try collectPositionals(gpa, verb_args, &spec);
    if (!args.help) {
        if (c.unknown_option) |tok| {
            gpa.free(c.paths);
            return .{ .unknown_option = tok };
        }
    }
    args.paths = c.paths;
    return .{ .ok = args };
}

// ---------------------------------------------------------------------------
// `zit commit` grammar (contracts/commit.md B-commit-1) + the pure timestamp
// formatter (B-commit-7) and the F-set composer (B-commit-8/9/11/12). These are
// the CLI-layer mechanism `commit` consumes; no commit effect logic (no I/O, no
// clock, no env) lives here — `runCommit` in main.zig wires them together.
// ---------------------------------------------------------------------------

/// The parsed shape of a `zit commit` argument list (B-commit-1). `message`/
/// `user` are the value-carrying options (null when absent); `paths` borrows the
/// argv tokens (argument order preserved; caller owns the returned slice) — an
/// empty `paths` means "commit every managed file" (B-commit-8), a non-empty
/// list narrows the check-in (B-commit-11).
pub const CommitArgs = struct {
    message: ?[]const u8 = null,
    /// `-M FILE`: read the comment from FILE.
    message_file: ?[]const u8 = null,
    /// `--editor PROG`: compose the comment in PROG, whatever the
    /// terminal situation; also the test seam for the editor path.
    editor: ?[]const u8 = null,
    /// `-n`/`--dry-run`: say what would be committed, write nothing.
    dry_run: bool = false,
    /// `--allow-empty`: record a check-in whose file set equals the
    /// parent's.
    allow_empty: bool = false,
    user: ?[]const u8 = null,
    /// `--branch NAME` starts (or switches to) branch NAME at this check-in
    /// (repostate §6.2), null when the check-in stays on its parent's branch.
    branch: ?[]const u8 = null,
    /// `--close` marks the new check-in a closed leaf (artifact §4.7 `+closed`).
    close: bool = false,
    /// `--allow-fork` proceeds when the parent is no longer its branch's leaf
    /// — a deliberate fork instead of the refusal (fossil's flag).
    allow_fork: bool = false,
    /// `--no-sync` suppresses autosync for this one invocation.
    no_sync: bool = false,
    paths: []const []const u8,
    quiet: bool = false,
    help: bool = false,
    /// `--no-submanifest-advice` skips draft-fossil-submanifests-00 §6.4's
    /// post-commit drift advisory entirely (no `driftCheck` scan, no output) —
    /// the escape hatch for a caller that wants commit's classic behavior on a
    /// repository that has opted into sub-manifests.
    no_submanifest_advice: bool = false,
    /// Clearsign the manifest through the operator's PGP command
    /// before storing (fossil's `clearsign` behavior).
    sign: bool = false,
    /// Skip the project's before-commit check for THIS commit.
    /// Deliberately loud rather than absent: withholding an escape hatch does
    /// not prevent bypass, it makes people delete the declaration instead,
    /// which is permanent and invisible. The skip prints the command it did
    /// not run.
    no_checks: bool = false,
};

/// Result of parsing commit's arguments: a clean `CommitArgs`, or the exact
/// offending token for the one B-commit-18b usage error a pure parse can detect
/// (an unknown option, or a value option `-m`/`--user` left dangling with no
/// following value — B-commit-1). A missing message value is `unknown_option`-
/// shaped, per B-commit-1/18b; total absence of `-m` is the distinct B-commit-5
/// message the runner raises after `--help` has had its chance to win.
pub const CommitResult = union(enum) {
    ok: CommitArgs,
    unknown_option: []const u8,
};

/// Scans `verb_args` (everything after `commit`) into a `CommitResult`
/// (B-commit-1) by feeding the commit flag spec to the shared
/// `collectPositionals` scanner. `-m`/`--message` and `--user` are value options
/// (each consumes the following token; a dangling one — final token, no value —
/// is recorded as an unknown option carrying that token, B-commit-1);
/// `-q`/`--quiet` and `-h`/`--help` are flags; every bare word (and a lone `-`)
/// is a path. `--help` wins over an unknown/dangling option regardless of order
/// (B-commit-18a), so it is checked first; only when no `--help` appeared does
/// the offense stand.
pub fn parseCommitArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!CommitResult {
    var args: CommitArgs = .{ .paths = &.{} };
    const spec = [_]FlagSpec{
        .{ .long = "--help", .short = "-h", .target = .{ .flag = &args.help } },
        .{ .long = "--quiet", .short = "-q", .target = .{ .flag = &args.quiet } },
        .{ .long = "--message", .short = "-m", .target = .{ .value = &args.message } },
        .{ .long = "--message-file", .short = "-M", .target = .{ .value = &args.message_file } },
        .{ .long = "--editor", .target = .{ .value = &args.editor } },
        .{ .long = "--dry-run", .short = "-n", .target = .{ .flag = &args.dry_run } },
        .{ .long = "--allow-empty", .target = .{ .flag = &args.allow_empty } },
        .{ .long = "--user", .target = .{ .value = &args.user } },
        .{ .long = "--branch", .target = .{ .value = &args.branch } },
        .{ .long = "--close", .target = .{ .flag = &args.close } },
        .{ .long = "--allow-fork", .target = .{ .flag = &args.allow_fork } },
        .{ .long = "--no-sync", .target = .{ .flag = &args.no_sync } },
        .{ .long = "--no-submanifest-advice", .target = .{ .flag = &args.no_submanifest_advice } },
        .{ .long = "--sign", .target = .{ .flag = &args.sign } },
        .{ .long = "--no-checks", .target = .{ .flag = &args.no_checks } },
        // Fossil's spelling of the same waiver.
        .{ .long = "--no-warnings", .target = .{ .flag = &args.no_checks } },
    };
    const c = try collectPositionals(gpa, verb_args, &spec);
    if (!args.help) {
        if (c.unknown_option) |tok| {
            gpa.free(c.paths);
            return .{ .unknown_option = tok };
        }
    }
    args.paths = c.paths;
    return .{ .ok = args };
}

/// Parsed `revert` arguments: the optional path filter (empty ⇒ every managed
/// file) and the `--help` flag.
pub const RevertArgs = struct {
    paths: []const []const u8,
    help: bool = false,
    /// Restore from this check-in instead of the current one.
    version: ?[]const u8 = null,
    /// Name what would be restored; write nothing.
    dry_run: bool = false,
};

/// Result of parsing revert's arguments: a clean `RevertArgs`, or the offending
/// unknown-option token. `--help` wins over an unknown option (like commit).
pub const RevertResult = union(enum) {
    ok: RevertArgs,
    unknown_option: []const u8,
};

/// Scans `verb_args` (everything after `revert`) into a `RevertResult`. Every
/// bare word (and a lone `-`) is a path; `-h`/`--help` is the only flag and
/// wins over an unknown option regardless of order. The caller frees
/// `result.ok.paths`.
pub fn parseRevertArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!RevertResult {
    var args: RevertArgs = .{ .paths = &.{} };
    const spec = [_]FlagSpec{
        .{ .long = "--help", .short = "-h", .target = .{ .flag = &args.help } },
        .{ .long = "--revision", .short = "-r", .target = .{ .value = &args.version } },
        .{ .long = "--dry-run", .short = "-n", .target = .{ .flag = &args.dry_run } },
    };
    const c = try collectPositionals(gpa, verb_args, &spec);
    if (!args.help) {
        if (c.unknown_option) |tok| {
            gpa.free(c.paths);
            return .{ .unknown_option = tok };
        }
    }
    args.paths = c.paths;
    return .{ .ok = args };
}

/// The largest Unix second whose UTC date has a 4-digit year: 9999-12-31
/// 23:59:59Z. A reading past this (or before the epoch) is clamped so the
/// writer form is always exactly 19 canonical bytes, never malformed
/// (TigerStyle: an explicit bound, never a silent overflow).
const max_writer_epoch: i64 = 253402300799;

/// Writes two decimal digits of `v` (0..99) into `buf[0..2]`, zero-padded.
fn put2(buf: []u8, v: u32) void {
    std.debug.assert(buf.len >= 2 and v < 100);
    buf[0] = '0' + @as(u8, @intCast((v / 10) % 10));
    buf[1] = '0' + @as(u8, @intCast(v % 10));
}

/// Formats `epoch` (whole Unix seconds, UTC) into the §4.6.4 writer form
/// `YYYY-MM-DDTHH:MM:SS` (B-commit-7): a date, exactly the byte 'T', then
/// HH:MM:SS with mandatory seconds — no timezone, no leading sign, no fraction.
/// Pure arithmetic: the civil date is derived from the day count by the standard
/// days-from-civil inverse (no `std.time`, no clock — this module reads neither).
/// `epoch` is clamped to `[0, max_writer_epoch]` so a pre-epoch or far-future
/// reading still yields a 4-digit-year token. The result is a slice of `out`
/// (19 bytes); the caller owns the buffer. An in-function postcondition asserts
/// the token satisfies the library's own §4.6.4 writer oracle, so a formatting
/// bug trips here, never on the wire.
/// The D/E-card writer form WITH milliseconds: `...SS.SSS`, fossil's
/// own canonical emission (§4.6.4 w1). Every artifact-minting path uses this
/// one; whole-second `formatWriterTimestamp` remains for display surfaces.
/// Same-second writes differing in `ms` now differ in the D card, which is
/// what gives a latest-wins consumer a real-time tiebreak (the lost-update
/// class). Millisecond ties remain possible and remain the consumer's
/// deterministic-tiebreak case — rarer by three orders of magnitude.
pub fn formatWriterTimestampMs(epoch: i64, ms: u16, out: *[23]u8) []const u8 {
    std.debug.assert(ms <= 999);
    _ = formatWriterTimestamp(epoch, out[0..19]);
    out[19] = '.';
    out[20] = @intCast('0' + ms / 100);
    out[21] = @intCast('0' + (ms / 10) % 10);
    out[22] = @intCast('0' + ms % 10);
    std.debug.assert(artifact.isWriterTimestamp(out[0..23])); // §4.6.4 writer form, frac included
    return out[0..23];
}

/// How many hex characters a human line shows of an artifact name.
/// Twelve is unique in any store zit has met and still a glance; the full
/// name stays where a script reads it.
pub const short_hash_len: usize = 12;

pub fn shortHash(hex: []const u8) []const u8 {
    return hex[0..@min(hex.len, short_hash_len)];
}

/// `Sat 2026-08-29 15:08:31 UTC` — Fossil's `YYYY-MM-DD HH:MM:SS UTC` with
/// the weekday in front. `mtime_ms` is the store's millisecond
/// mtime; the zone is named because the number is UTC and a reader would
/// otherwise take it for local time.
pub fn humanTime(mtime_ms: u64, out: *[32]u8) []const u8 {
    const epoch: i64 = @intCast(mtime_ms / 1000);
    var iso: [19]u8 = undefined;
    const ts = formatWriterTimestamp(epoch, &iso);
    const days: i64 = @divFloor(epoch, 86_400);
    const names = [_][]const u8{ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" };
    const wd: usize = @intCast(@mod(days + 4, 7)); // 1970-01-01 was a Thursday
    const n = (std.fmt.bufPrint(out, "{s} {s} {s} UTC", .{ names[wd], ts[0..10], ts[11..19] }) catch return ts).len;
    return out[0..n];
}

test "cli humanTime: weekday, date, time, zone" {
    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("Thu 1970-01-01 00:00:00 UTC", humanTime(0, &buf));
    try std.testing.expectEqualStrings("Sat 2026-08-29 15:08:31 UTC", humanTime(1_788_016_111_000, &buf));
    try std.testing.expectEqualStrings("2da7d98f3c92", shortHash("2da7d98f3c9208f170b041f55f9bcb72269669dcfe43facace6cb10e5988dc9e"));
}

pub fn formatWriterTimestamp(epoch: i64, out: *[19]u8) []const u8 {
    const secs: u64 = @intCast(std.math.clamp(epoch, 0, max_writer_epoch));
    const days = secs / 86400;
    const rem = secs % 86400;
    // Civil date from the day count (Howard Hinnant's days-from-civil inverse):
    // pure integer arithmetic, exact for the whole clamped range, no leap tables.
    const z = days + 719468; // shift epoch to 0000-03-01
    const era = z / 146097; // 400-year era
    const doe = z - era * 146097; // day-of-era [0, 146096]
    const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // year-of-era
    const doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day-of-year (Mar-based)
    const mp = (5 * doy + 2) / 153; // month, Mar=0 .. Feb=11
    const day = doy - (153 * mp + 2) / 5 + 1; // day of month [1,31]
    const month = if (mp < 10) mp + 3 else mp - 9; // civil month [1,12]
    const year = yoe + era * 400 + @as(u64, if (month <= 2) 1 else 0);

    put2(out[0..2], @intCast(year / 100));
    put2(out[2..4], @intCast(year % 100));
    out[4] = '-';
    put2(out[5..7], @intCast(month));
    out[7] = '-';
    put2(out[8..10], @intCast(day));
    out[10] = 'T';
    put2(out[11..13], @intCast(rem / 3600));
    out[13] = ':';
    put2(out[14..16], @intCast((rem % 3600) / 60));
    out[16] = ':';
    put2(out[17..19], @intCast(rem % 60));
    std.debug.assert(artifact.isWriterTimestamp(out[0..19])); // §4.6.4 writer form
    return out[0..19];
}

/// Merge policy for the new check-in's F-set.
///  - `.snapshot`     : the F-set IS `captured` (the managed set is
///                      authoritative; a parent file absent from `captured` is a
///                      DELETION and is dropped — B-commit-8/12, all-files
///                      commit). `parent` is consulted only for the no-op verdict.
///  - `.carry_forward`: `captured` files, plus every `parent` file whose
///                      filename is NOT in `captured`, carried forward at the
///                      parent's card unchanged (B-commit-11 partial commit; a
///                      root partial has an empty `parent`, so this degrades to
///                      `captured`).
pub const ComposePolicy = enum { snapshot, carry_forward };

/// Value inputs to `composeFCards`. `parent` is the parent check-in's F-set
/// (empty for a root); `captured` are the files read + hashed from the working
/// tree this commit; `policy` selects the merge rule above.
pub const ComposeInput = struct {
    parent: []const artifact.FCard,
    captured: []const artifact.FCard,
    policy: ComposePolicy,
    /// Sparse-cone RFC section 4.2: when set, a `.snapshot` compose ALSO
    /// carries forward every parent card the cone does not cover, byte-
    /// verbatim -- the snapshot semantics (deletion by absence) apply only
    /// inside the cone. Null = dense, bit-identical to the shipped compose.
    /// `.carry_forward` needs no cone: it already carries everything
    /// uncaptured, which subsumes the out-of-cone set.
    sparse: ?*const cone_mod.Cone = null,
};

/// The composed F-set and the no-op verdict.
pub const ComposeResult = struct {
    /// Merged F-set, ascending by filename per §4.5.3 (`artifact.compareBytes`),
    /// allocated on the caller's arena. Filenames are unique.
    f: []const artifact.FCard,
    /// True iff `f` is identical to `parent` on the file-set identity (filename +
    /// hash + mode, entrywise, in sorted order) — the child no-op verdict
    /// (B-commit-9). For a root (`parent` empty) this is exactly `f.len == 0`
    /// ("nothing tracked").
    unchanged: bool,
};

/// §4.5.3 ordering predicate: raw-byte ascending by filename (shorter-first on a
/// prefix tie, via `artifact.compareBytes`). Filenames are unique, so a strict
/// `.lt` total order needs no tiebreak.
fn lessByFilename(_: void, a: artifact.FCard, b: artifact.FCard) bool {
    return artifact.compareBytes(a.filename, b.filename) == .lt;
}

/// The one genuinely deep, RFC-normative unit of `commit`, extracted pure so it
/// is reusable and unit-tested without a subprocess: it merges already-hashed
/// `FCard` values into the new check-in's F-set and reports the no-op verdict.
/// No I/O, no clock, no env, no hashing — it rearranges values and allocates
/// only the result array on `arena` (value-in/value-out, so it lifts verbatim
/// into a future deep `checkin.compose`). (B-commit-8/9/11/12, §4.5.3.)
///
/// Algorithm: (1) gather `captured`, plus — under `.carry_forward` — every
/// `parent` card whose filename is neither a captured filename nor a captured
/// artifact §4.7.6 old-name provenance. A carried rename consumes that one-shot
/// provenance by clearing `old_name` and its regular-file `w` placeholder;
/// untouched parent files carry forward otherwise, then (2) sort ascending by
/// filename (§4.5.3); (3) in a safety build, drive `artifact.FCardAdmission`
/// over the sorted filenames to assert strict §4.5.2-r1 order with no reserved
/// names — a dup or mis-sort is a bug, never shipped; (4) the no-op verdict
/// compares the result to `parent` entrywise on filename+hash+mode (§4.5.3-
/// sorted parent from a parsed manifest, so the comparison is aligned).
/// Captured rename tokens remain intact; the verdict keys on filename+hash+mode,
/// so a rename changes the filename and is never a no-op.
pub fn composeFCards(arena: std.mem.Allocator, in: ComposeInput) error{OutOfMemory}!ComposeResult {
    var list: std.ArrayList(artifact.FCard) = .empty;
    errdefer list.deinit(arena);
    try list.appendSlice(arena, in.captured);
    if (in.policy == .carry_forward or in.sparse != null) {
        for (in.parent) |pc| {
            if (in.policy == .snapshot) {
                // Sparse snapshot: only OUT-OF-CONE parent cards carry; an
                // in-cone parent file absent from `captured` is a deletion,
                // exactly as in a dense snapshot (RFC section 4.2).
                if (in.sparse.?.covers(pc.filename)) continue;
            }
            var captured_here = false;
            for (in.captured) |cc| {
                if (std.mem.eql(u8, pc.filename, cc.filename) or
                    (cc.old_name != null and std.mem.eql(u8, pc.filename, cc.old_name.?)))
                {
                    captured_here = true;
                    break;
                }
            }
            if (!captured_here) {
                var carried = pc;
                if (carried.old_name != null) {
                    carried.old_name = null;
                    if (carried.mode == .regular and
                        carried.perm != null and
                        std.mem.eql(u8, carried.perm.?, "w"))
                    {
                        carried.perm = null;
                    }
                }
                try list.append(arena, carried);
            }
        }
    }
    std.mem.sort(artifact.FCard, list.items, {}, lessByFilename);

    // Postcondition (§4.5.2-r1 order + §4.6.3 admission): every card is admitted
    // — strictly ascending, no reserved name among managed paths. Safety-gated
    // so it costs nothing in ReleaseFast; a violation is a compose bug, not a
    // wire condition, so a trip here is correct.
    if (std.debug.runtime_safety) {
        var adm: artifact.FCardAdmission = .{};
        for (list.items) |card| {
            const kept = adm.admit(card.filename) catch |e|
                std.debug.panic("composeFCards produced out-of-order F cards: {s}", .{@errorName(e)});
            std.debug.assert(kept);
        }
    }

    const unchanged = blk: {
        if (list.items.len != in.parent.len) break :blk false;
        for (list.items, in.parent) |a, b| {
            if (!std.mem.eql(u8, a.filename, b.filename)) break :blk false;
            if (artifact.compareOptional(a.hash, b.hash) != .eq) break :blk false;
            if (a.mode != b.mode) break :blk false;
        }
        break :blk true;
    };
    return .{ .f = try list.toOwnedSlice(arena), .unchanged = unchanged };
}

// --- composeCommitTags (repostate §6.2 + artifact §4.7; pure; no I/O) --------

/// The reserved `sym-` prefix (repo-state §2): a symbolic tag name is stored
/// with it. Every branch/tag control tag this commit emits carries it.
const sym_tag_prefix = artifact.sym_tag_prefix;

/// The §6.2 branch-root card set lives in `artifact` beside the `TCard` it
/// builds, because `import-git` composes the same set from the `zit` module and
/// cannot import this file. Re-exported here so `commit`, `amend` and `tag`
/// keep reading as `cli.composeCommitTags` / `cli.retargetTCards`; see the
/// composer's own doc comment for the vocabulary.
pub const ComposeCommitTagsInput = artifact.ComposeCommitTagsInput;
pub const composeCommitTags = artifact.composeCommitTags;
pub const retargetTCards = artifact.retargetTCards;
/// The §4.7.16 wire order the two sibling composers below sort their own runs
/// into — the same comparator `composeCommitTags` uses, not a second reading of
/// the ordering rule.
const tagTokenLess = artifact.tagTokenLess;

// ---------------------------------------------------------------------------
// `zit amend` grammar (contracts/amend.md B-amend-1) + the display-override
// vocabulary (comment/user/date/branch → tag names) and the one-control-artifact
// T-run composer. `amend` never rewrites a check-in: it mints a control artifact
// whose T cards change how the target DISPLAYS. Grammar and card composition are
// pure (no I/O, no clock, no env); `runAmend` in main.zig wires them together.
// ---------------------------------------------------------------------------

/// The parsed shape of a `zit amend` argument list (B-amend-1). `name` is the
/// one mandatory positional (the target check-in), each override is null when
/// its flag is absent; at least one override is required (the runner's/parser's
/// `no_override` guard). All slices borrow the argv tokens.
pub const AmendArgs = struct {
    name: ?[]const u8 = null,
    comment: ?[]const u8 = null,
    user: ?[]const u8 = null,
    date: ?[]const u8 = null,
    branch: ?[]const u8 = null,
    help: bool = false,
};

/// Result of parsing amend's arguments: a clean `AmendArgs`, or the exact
/// offending token for each B-amend usage error a pure parse can detect. Closed
/// set — the runner switches it exhaustively into `usageError` arms. `no_override`
/// carries no token (there is nothing to name); every other error names the
/// offending flag or value.
pub const AmendResult = union(enum) {
    ok: AmendArgs,
    unknown_option: []const u8,
    extra_argument: []const u8,
    repeated_option: []const u8,
    empty_value: []const u8,
    bad_date: []const u8,
    no_override,
};

/// Scans `verb_args` (everything after `amend`) into an `AmendResult`
/// (B-amend-1). `--comment/--user/--date/--branch` are value flags (each takes
/// exactly one value and may appear at most once — a second occurrence is
/// `repeated_option`, a dangling one at the end is `unknown_option`, an empty
/// value is `empty_value`); `--date` failing the §4.6.4 writer-timestamp
/// predicate is `bad_date`. `-h`/`--help` is the only bare flag and wins over any
/// otherwise-invalid token (B-amend-13). Exactly one positional NAME; a second is
/// `extra_argument`. With `--help` off, a NAME present, and zero overrides the
/// verdict is `no_override` (B-amend-8). Pure; no allocation.
pub fn parseAmendArgs(verb_args: []const []const u8) AmendResult {
    var parsed: AmendArgs = .{};
    var first_err: ?AmendResult = null;
    var i: usize = 0;
    // A bare `--` ends the options: every token after it is an operand, which
    // is the only way to name a check-in whose name begins with a dash.
    var positional_only = false;
    while (i < verb_args.len) {
        const tok = verb_args[i];
        i += 1;
        if (!positional_only and std.mem.eql(u8, tok, "--")) {
            positional_only = true;
            continue;
        }
        if (!positional_only and (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help"))) {
            parsed.help = true;
            continue;
        }
        // The four value flags share one handler: the offending token for a
        // repeat/empty/dangling error is always the flag itself.
        const slot: ?*?[]const u8 = if (positional_only) null else if (std.mem.eql(u8, tok, "--comment")) &parsed.comment else if (std.mem.eql(u8, tok, "--user")) &parsed.user else if (std.mem.eql(u8, tok, "--date")) &parsed.date else if (std.mem.eql(u8, tok, "--branch")) &parsed.branch else null;
        if (slot) |field| {
            if (i >= verb_args.len) {
                // A value flag with no following value is a dangling option.
                if (first_err == null) first_err = .{ .unknown_option = tok };
                break;
            }
            const value = verb_args[i];
            i += 1;
            if (field.* != null) {
                if (first_err == null) first_err = .{ .repeated_option = tok };
                continue;
            }
            if (value.len == 0) {
                if (first_err == null) first_err = .{ .empty_value = tok };
                continue;
            }
            if (std.mem.eql(u8, tok, "--date") and !artifact.isWriterTimestamp(value)) {
                if (first_err == null) first_err = .{ .bad_date = value };
                continue;
            }
            field.* = value;
            continue;
        }
        if (!positional_only and tok.len > 1 and tok[0] == '-') {
            if (first_err == null) first_err = .{ .unknown_option = tok };
            continue;
        }
        if (parsed.name == null) {
            parsed.name = tok;
        } else if (first_err == null) {
            first_err = .{ .extra_argument = tok };
        }
    }
    // Help wins over every recorded error and over a missing override (B-amend-13).
    if (parsed.help) return .{ .ok = .{ .help = true } };
    if (first_err) |e| return e;
    // A NAME with no override flag is nothing to change (B-amend-8). A missing
    // NAME is left to the runner (it reports `no_name` before this could apply).
    if (parsed.name != null and parsed.comment == null and parsed.user == null and
        parsed.date == null and parsed.branch == null)
    {
        return .no_override;
    }
    return .{ .ok = parsed };
}

/// The display-override values one `amend` invocation carries into the control
/// artifact's T run (B-amend-1/2/3). `parent_syms` are the target's propagating
/// `sym-*` tags a `--branch` move must cancel (repo-state §6.2), each already
/// carrying the `sym-` prefix and excluding the new branch's own sym — the same
/// set `composeCommitTags` consumes.
pub const AmendOverrides = struct {
    comment: ?[]const u8 = null,
    user: ?[]const u8 = null,
    date: ?[]const u8 = null,
    branch: ?[]const u8 = null,
    parent_syms: []const []const u8 = &.{},
};

/// Compose the one CONTROL artifact's T run for `amend` (B-amend-1/2/3/4): the
/// singleton (`+`) comment/user/date overrides plus, for `--branch`, the §6.2
/// branch-move tag set (reusing `composeCommitTags`), merged into ONE run sorted
/// by the full signed token (§4.7.16/§4.5.2) and retargeted at `target_hash`.
/// Never one artifact per flag. Allocates only the result (and each `sym-*` name)
/// on `arena`; value-in/value-out and unit-tested without a subprocess.
/// Precondition: at least one override present (the runner's `no_override` guard).
pub fn composeAmendTags(arena: std.mem.Allocator, target_hash: []const u8, ov: AmendOverrides) error{OutOfMemory}![]artifact.TCard {
    var list: std.ArrayList(artifact.TCard) = .empty;
    errdefer list.deinit(arena);

    // Non-propagating (`+`) display overrides — each applies only to the target
    // (artifact §5.4 singleton), value escaped by `appendTCard`.
    if (ov.comment) |v| try list.append(arena, .{ .sign = .add, .name = "comment", .target = null, .value = v });
    if (ov.user) |v| try list.append(arena, .{ .sign = .add, .name = "user", .target = null, .value = v });
    if (ov.date) |v| try list.append(arena, .{ .sign = .add, .name = "date", .target = null, .value = v });
    // `--branch` is the §6.2 branch-move tag set (the propagating branch/sym add
    // plus one cancel per departed sym); `composeCommitTags` owns that vocabulary.
    if (ov.branch) |name| {
        const branch_tags = try composeCommitTags(arena, .{ .branch = name, .parent_syms = ov.parent_syms });
        try list.appendSlice(arena, branch_tags);
    }
    std.debug.assert(list.items.len >= 1); // precondition: ≥1 override

    std.mem.sort(artifact.TCard, list.items, {}, tagTokenLess);
    const cards = try list.toOwnedSlice(arena);
    retargetTCards(cards, target_hash);

    // Postcondition (§4.7.16/§4.5.2): the merged run is STRICTLY ascending by the
    // full signed token, so `artifact.create` emits it verbatim and a re-parse's
    // intra-run check passes; every card targets the check-in, never `*`.
    if (std.debug.runtime_safety) {
        for (cards) |c| std.debug.assert(c.target != null and std.mem.eql(u8, c.target.?, target_hash));
        if (cards.len >= 2) {
            for (cards[0 .. cards.len - 1], cards[1..]) |prev, next| {
                std.debug.assert(tagTokenLess({}, prev, next));
            }
        }
    }
    return cards;
}

/// What `zit tag add` / `zit tag cancel` mint, as value inputs: the operation's
/// sign, the operator's NAME, which namespace it lands in, and `add`'s optional
/// VALUE. `raw` false is the symbolic namespace (the composer prepends `sym-`);
/// `raw` true writes NAME verbatim.
pub const TagCardSpec = struct {
    sign: artifact.TSign,
    name: []const u8,
    raw: bool,
    value: ?[]const u8 = null,
};

/// Compose the one CONTROL artifact's T run for `tag add` / `tag cancel`: a
/// single `T <sign><name> <target_hash> [value]` card. Pure sibling of
/// `composeAmendTags` — same arena discipline, same retarget (artifact §5.4: a
/// control's T cards target the check-in, never the literal `*`).
///
/// The default sign is SINGLETON (`+`), not propagating (`*`), and for a
/// SYMBOLIC name that is the whole correctness argument:
/// `repostate.resolveName` picks the max-`event.mtime` check-in among ALL
/// active `sym-` rows, so a propagating `*sym-NAME` would be inherited by every
/// descendant and then resolve to the NEWEST of them — the name would silently
/// drift off the check-in the user tagged. `applyTagOps` gates propagation on
/// `op.type != .singleton`, so a `+` card pins the name to exactly this
/// check-in with no repostate change. The contrast with `composeCommitTags` is
/// deliberate: a *branch* sym propagates precisely because descendants must
/// inherit it; a *tag* sym does not, precisely because they must not.
/// `--propagate` is therefore reachable but hazardous on a symbolic name, and
/// exists for the raw namespace, where inheritance is the point (a property
/// every descendant carries) and no name resolution is at stake.
///
/// The namespace choice is the other half. A `sym-` tag is a NAME, and
/// `resolveName` will hand it to `update`/`merge`; two check-ins carrying one
/// makes that name ambiguous. A raw tag is a PROPERTY, outside resolution
/// entirely, which is why a verdict like `ci-pass` belongs there and only
/// there.
///
/// One card, so there is no §4.7.16 ordering obligation to discharge — a
/// single-element run is trivially strictly ascending.
pub fn composeTagCards(
    arena: std.mem.Allocator,
    target_hash: []const u8,
    spec: TagCardSpec,
) error{OutOfMemory}![]artifact.TCard {
    std.debug.assert(spec.name.len != 0); // the runner rejects an empty NAME
    std.debug.assert(spec.value == null or spec.value.?.len != 0); // likewise empty VALUE
    const name = if (spec.raw)
        spec.name
    else
        try std.mem.concat(arena, u8, &.{ sym_tag_prefix, spec.name });
    const cards = try arena.alloc(artifact.TCard, 1);
    cards[0] = .{ .sign = spec.sign, .name = name, .target = null, .value = spec.value };
    retargetTCards(cards, target_hash);
    return cards;
}

/// A discovered working-tree root: the opened directory handle (caller closes it
/// with `dir.close(io)`) and its absolute path (borrowed from the allocator
/// passed to `discoverRoot`).
pub const Root = struct {
    dir: std.Io.Dir,
    abs: []const u8,
};

/// Upper bound on how many ancestors the root probe climbs before giving up —
/// no unbounded ascent (TigerStyle: explicit fixed bound). A real checkout is
/// found in a handful of levels; 256 is far past any legitimate depth.
pub const max_root_search_depth: usize = 256;

/// Upper bound on the cwd path length this CLI will materialize. A working
/// directory deeper than the platform PATH_MAX is legal (`getcwd` grows), so we
/// grow a heap buffer up to this ceiling rather than assume one buffer suffices.
/// Bounded (TigerStyle): a path beyond this is treated as unnameable, never an
/// unbounded allocation.
pub const max_cwd_bytes: usize = 1 << 16;

/// The absolute current-directory path, materialized on `gpa` (caller frees).
/// Returns null when the cwd cannot be named — unlinked, an OS error, or longer
/// than `max_cwd_bytes` — so callers treat an unnameable cwd as "no repository
/// here" rather than aborting.
///
/// This replaces `std.process.currentPathAlloc`, which in Zig 0.16 uses a single
/// `std.fs.max_path_bytes` buffer and does `error.NameTooLong => unreachable`
/// (std/process.zig:85): a cwd deeper than that platform limit panics the whole
/// process (verified: SIGABRT on a >PATH_MAX checkout). We grow the buffer on
/// the buffer-too-small signal instead, so a deep checkout produces an honest
/// diagnostic (exit 1) rather than a crash.
pub fn currentPathAlloc(gpa: std.mem.Allocator, io: std.Io) error{OutOfMemory}!?[]u8 {
    var cap: usize = std.fs.max_path_bytes;
    while (cap <= max_cwd_bytes) : (cap *= 2) {
        const buf = try gpa.alloc(u8, cap);
        const n = std.process.currentPath(io, buf) catch |e| switch (e) {
            // Buffer too small for this (deep) cwd — grow and retry.
            error.NameTooLong => {
                gpa.free(buf);
                continue;
            },
            // Unlinked cwd, cancelation, or any OS error: we cannot name it.
            else => {
                gpa.free(buf);
                return null;
            },
        };
        return gpa.realloc(buf, n) catch {
            gpa.free(buf);
            return error.OutOfMemory;
        };
    }
    return null;
}

/// Walks up from the current directory looking for the checkout root — the
/// nearest ancestor holding BOTH `.zit` (the repository) and `.zit-checkout`
/// (the working-state record) — and returns its opened handle plus absolute
/// path (B-add-8). Returns null when the filesystem root is reached with no
/// match (the caller renders "not a zit repository"). The reusable shape every
/// working-tree verb shares; not special-cased to add.
/// Expand a `--cone-file` into members appended after any `--cone=` flags:
/// one member per line, blank lines and `#` comments skipped, bytes owned by
/// `arena`. Returns the new length, or null on any read failure (the caller
/// renders the one diagnostic). Bounded by the member array.
pub const ConeFileLens = struct { members: usize, not_members: usize };

pub fn appendConeFile(
    arena: std.mem.Allocator,
    io: std.Io,
    path: []const u8,
    members: *[255][]const u8,
    len: usize,
    not_members: *[255][]const u8,
    not_len: usize,
) ?ConeFileLens {
    var file = std.Io.Dir.cwd().openFile(io, path, .{}) catch return null;
    defer file.close(io);
    const st = file.stat(io) catch return null;
    const size = std.math.cast(usize, st.size) orelse return null;
    if (size > 1 << 20) return null; // a megabyte of cone members is operator error
    const buf = arena.alloc(u8, size) catch return null;
    const n = file.readPositionalAll(io, buf, 0) catch return null;
    if (n != size) return null;
    var out: ConeFileLens = .{ .members = len, .not_members = not_len };
    var it = std.mem.splitScalar(u8, buf[0..n], '\n');
    while (it.next()) |raw_line| {
        const line = std.mem.trim(u8, raw_line, " \t\r");
        if (line.len == 0 or line[0] == '#') continue;
        if (line[0] == '!') {
            const member = std.mem.trimStart(u8, line[1..], " \t");
            if (member.len == 0) return null; // a bare '!' is operator error
            if (out.not_members >= not_members.len) return null; // refuse, never truncate
            not_members[out.not_members] = member;
            out.not_members += 1;
            continue;
        }
        if (out.members >= members.len) return null; // over the cap: refuse, never truncate
        members[out.members] = line;
        out.members += 1;
    }
    return out;
}

pub fn discoverRoot(gpa: std.mem.Allocator, io: std.Io) error{OutOfMemory}!?Root {
    // A cwd we cannot even name is not a repository we can operate in. Uses the
    // grow-buffer helper above so a deep checkout diagnoses instead of crashing.
    var cur: []const u8 = (try currentPathAlloc(gpa, io)) orelse return null;
    var depth: usize = 0;
    while (depth < max_root_search_depth) : (depth += 1) {
        if (probeRoot(io, cur)) |dir| return .{ .dir = dir, .abs = cur };
        const parent = std.fs.path.dirname(cur) orelse break;
        if (std.mem.eql(u8, parent, cur)) break; // filesystem root reached
        cur = parent;
    }
    return null;
}

/// The store `-R`/`--repo` names, opened, plus the remaining verb arguments with
/// the option removed. `args` borrows the caller's argv slice when no option was
/// present, else points at an allocator-owned copy.
pub const Global = struct {
    args: []const []const u8,
    repo: ?[]const u8 = null,
};

/// Outcome of lifting the global options out of a verb's argument list. The
/// error variants carry the offending token so the caller renders the exact
/// text, matching every other parser in this file.
pub const GlobalResult = union(enum) {
    ok: Global,
    missing_value: []const u8,
    repeated_option: []const u8,
    empty_value: []const u8,
};

/// The two spellings of the repository override. `-R` is deliberate: it is what
/// `fossil -R` and `go-libfossil repo <verb> -R` accept, so bench drivers and
/// muscle memory carry over unchanged.
const repo_flag_short: []const u8 = "-R";
const repo_flag_long: []const u8 = "--repo";

/// Lifts `-R <path>` / `--repo <path>` out of `verb_args` before the verb's own
/// parser ever sees it, returning the value and the surviving arguments. This is
/// what makes `-R` a GLOBAL option rather than a flag each of ten verbs must
/// re-declare: one scan here, and every per-verb grammar stays exactly as it was.
///
/// Scanning stops at the first `--`: after the positional-only separator a token
/// spelled `-R` is a positional (a file may legitimately be named that), so the
/// separator and everything past it are copied through untouched. A repeated
/// option is a usage error rather than silently last-wins, and an empty value is
/// refused — `-R ""` names no store, and resolving it would silently fall back
/// to the cwd, which is precisely the surprise the option exists to remove.
///
/// Allocates only when the option is present; otherwise `args` aliases the input.
pub fn parseGlobalArgs(
    gpa: std.mem.Allocator,
    verb_args: []const []const u8,
) error{OutOfMemory}!GlobalResult {
    // Cheap pre-scan: the overwhelmingly common case is no `-R` at all, and it
    // must not allocate or copy.
    var present = false;
    for (verb_args) |tok| {
        if (std.mem.eql(u8, tok, "--")) break;
        if (std.mem.eql(u8, tok, repo_flag_short) or std.mem.eql(u8, tok, repo_flag_long)) {
            present = true;
            break;
        }
    }
    if (!present) return .{ .ok = .{ .args = verb_args } };

    const kept = try gpa.alloc([]const u8, verb_args.len);
    // Every usage-error arm below returns a RESULT VARIANT, not a Zig error, so
    // `errdefer` would never fire for them — the scratch buffer is released
    // explicitly on each.
    errdefer gpa.free(kept);
    var n: usize = 0;
    var repo: ?[]const u8 = null;
    var i: usize = 0;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (std.mem.eql(u8, tok, "--")) {
            // Positional-only from here: copy the separator and the rest verbatim.
            while (i < verb_args.len) : (i += 1) {
                kept[n] = verb_args[i];
                n += 1;
            }
            break;
        }
        if (std.mem.eql(u8, tok, repo_flag_short) or std.mem.eql(u8, tok, repo_flag_long)) {
            if (repo != null) {
                gpa.free(kept);
                return .{ .repeated_option = tok };
            }
            if (i + 1 >= verb_args.len) {
                gpa.free(kept);
                return .{ .missing_value = tok };
            }
            i += 1;
            if (verb_args[i].len == 0) {
                gpa.free(kept);
                return .{ .empty_value = tok };
            }
            repo = verb_args[i];
            continue;
        }
        kept[n] = tok;
        n += 1;
    }
    std.debug.assert(repo != null); // the pre-scan saw the flag before any `--`
    std.debug.assert(n < verb_args.len); // the option and its value were removed
    // Shrink to the tokens actually kept, so `args` is a whole allocation the
    // caller can free — the same discipline `collectPositionals` follows.
    if (n == 0) {
        gpa.free(kept);
        return .{ .ok = .{ .args = &.{}, .repo = repo } };
    }
    const args = gpa.realloc(kept, n) catch |e| {
        gpa.free(kept);
        return e;
    };
    return .{ .ok = .{ .args = args, .repo = repo } };
}

/// Resolves the store a read verb should open: the `-R` override when one was
/// given, else the checkout discovered by walking up from the cwd. The single
/// point where those two routes converge, so every read verb reaches its store
/// through one function and neither route is special-cased at a call site.
///
/// The override deliberately requires ONLY `.zit`, not the `.zit-checkout` that
/// `discoverRoot` also demands: a store that no working tree is attached to is
/// exactly what `-R` exists to read, and requiring a checkout would defeat the
/// option. `path` may name the directory holding the store or the `.zit` file
/// itself (`fossil -R repo.fossil` muscle memory); the latter resolves to its
/// parent. Returns null when the path names no readable store, which the caller
/// renders as the standard not-a-repository diagnostic.
pub fn resolveRoot(
    gpa: std.mem.Allocator,
    io: std.Io,
    override: ?[]const u8,
) error{OutOfMemory}!?Root {
    const given = override orelse return discoverRoot(gpa, io);
    std.debug.assert(given.len != 0); // the parser refuses an empty value

    // Interpret relative to the cwd so `-R ../other` behaves like every other
    // path argument. An unnameable cwd cannot anchor a relative path.
    const cwd = (try currentPathAlloc(gpa, io)) orelse return null;
    defer gpa.free(cwd);
    const abs = try std.fs.path.resolve(gpa, &.{ cwd, given });

    // `-R <dir>/.zit` names the store file; the store lives in its parent. The
    // parent is COPIED rather than aliased as a sub-slice of `abs`, so the
    // returned `abs` is always a whole allocation the caller can free.
    const dir_abs = if (std.mem.eql(u8, std.fs.path.basename(abs), repo_marker)) blk: {
        defer gpa.free(abs);
        const parent = std.fs.path.dirname(abs) orelse ".";
        break :blk try gpa.dupe(u8, parent);
    } else abs;
    errdefer gpa.free(dir_abs);

    var dir = std.Io.Dir.cwd().openDir(io, dir_abs, .{}) catch {
        gpa.free(dir_abs);
        return null;
    };
    if (!hasEntry(io, dir, repo_marker)) {
        dir.close(io);
        gpa.free(dir_abs);
        return null;
    }
    return .{ .dir = dir, .abs = dir_abs };
}

/// The store file's name inside a repository root. Named once here so the root
/// probe and the `-R` resolver cannot drift apart.
const repo_marker: []const u8 = ".zit";

/// Open the store for appending, under the store's one write lock.
///
/// Every writer to `.zit` passes through here so that two appenders
/// serialize instead of tearing the log. The lock is the OS advisory lock on
/// the store file itself, exclusive and BLOCKING: a second committer waits
/// its turn rather than failing, and a crashed writer's lock dies with its
/// file description, so there is nothing stale to clean up. Readers take no
/// lock at all (`verify` documents that promise), so a writer never blocks
/// them and they never block a writer.
///
/// Two rules this imposes on callers. The handle must close as soon as the
/// append has committed — holding it across later, store-free work (a push,
/// an advisory) extends the exclusion for no reason. And no flow may open a
/// second locked handle while it still holds one: the OS lock is per open
/// file description, so the same process wedges itself exactly like a
/// competing writer would.
///
/// This serializes append against append. Append against a generation
/// REWRITE (`pack`, `shun --force`) is the pack-tmp lock's job — its
/// reopen-by-name guard already detects an append that lands mid-rewrite,
/// from any worktree, because both files live beside the store.
pub fn openStoreAppend(io: std.Io, root_dir: std.Io.Dir) (std.Io.File.OpenError || StoreLocationError)!std.Io.File {
    if (try storeDirOf(io, root_dir)) |store| {
        var store_dir = store;
        defer store_dir.close(io);
        return store_dir.openFile(io, repo_marker, .{ .mode = .read_write, .lock = .exclusive });
    }
    return root_dir.openFile(io, repo_marker, .{ .mode = .read_write, .lock = .exclusive });
}

/// Open the store read-only, resolved through the checkout the same way the
/// append path resolves it. Readers take no lock (verify's promise).
pub fn openStoreRead(io: std.Io, root_dir: std.Io.Dir) (std.Io.File.OpenError || StoreLocationError)!std.Io.File {
    if (try storeDirOf(io, root_dir)) |store| {
        var store_dir = store;
        defer store_dir.close(io);
        return store_dir.openFile(io, repo_marker, .{});
    }
    return root_dir.openFile(io, repo_marker, .{});
}

/// Stamp this checkout into its store's worktree registry. The
/// store's cache dir is the resolved store root for a detached tree, the
/// checkout root itself otherwise. Best-effort by the registry's own
/// contract: discovery data, never a gate on the operation that stamps.
pub fn stampWorktreeRegistry(gpa: std.mem.Allocator, io: std.Io, root_dir: std.Io.Dir, root_abs: []const u8) void {
    if (checkout.storeDirOf(io, root_dir) catch null) |resolved| {
        var store_dir = resolved;
        defer store_dir.close(io);
        repocache.stampWorktree(gpa, io, store_dir, root_abs);
        return;
    }
    repocache.stampWorktree(gpa, io, root_dir, root_abs);
}

pub const StoreLocationError = error{ Storage, Corrupt };

/// Where this checkout's store lives; see `checkout.storeDirOf`.
pub fn storeDirOf(io: std.Io, root_dir: std.Io.Dir) StoreLocationError!?std.Io.Dir {
    return checkout.storeDirOf(io, root_dir);
}

/// If `dir_abs` is a checkout root, returns its opened handle; otherwise
/// closes any handle it opened and returns null. Pure of allocation — the
/// marker names are compared by `statFile`, not joined.
///
/// A root is a directory whose checkout state names a store it can
/// reach: either `.zit` beside it (every `init`/`clone` layout) or a
/// `store_location` pointer inside the container (a detached worktree). A
/// stray `.zit-checkout` with neither is NOT a root — the climb continues
/// past it exactly as it always did, so a half-deleted checkout cannot
/// shadow the real root above it.
fn probeRoot(io: std.Io, dir_abs: []const u8) ?std.Io.Dir {
    var dir = std.Io.Dir.cwd().openDir(io, dir_abs, .{}) catch return null;
    if (hasEntry(io, dir, checkout.filename)) {
        if (hasEntry(io, dir, repo_marker)) return dir;
        if (checkout.storeDirOf(io, dir) catch null) |resolved| {
            var store_dir = resolved;
            store_dir.close(io);
            return dir;
        }
    }
    dir.close(io);
    return null;
}

/// Whether `name` exists directly under `dir`. A missing entry (or any stat
/// failure) reads as "absent" — the probe never propagates an error, it just
/// keeps climbing.
fn hasEntry(io: std.Io, dir: std.Io.Dir, name: []const u8) bool {
    _ = dir.statFile(io, name, .{ .follow_symlinks = false }) catch return false;
    return true;
}

/// The outcome of resolving one `add` argument against the checkout root.
/// Distinguishes the root directory itself from a genuine escape so the caller
/// can give the right diagnostic (B-add-5's directory guidance for the root vs
/// B-add-6's "outside the repository" for an escape).
pub const Resolved = union(enum) {
    /// A normalized repo-relative key (caller frees `key`).
    key: []const u8,
    /// `arg` denotes the checkout root directory itself (empty relative path) —
    /// a directory inside the repository, e.g. `zit add .` at the root.
    root,
    /// `arg` escapes the checkout root: a `..` climb above it, or an absolute
    /// path on another root.
    outside,
};

/// Resolves `arg` (interpreted relative to `cwd_abs`) to a normalized
/// repo-relative key against `root_abs` (B-add-15): `.`/`..`/redundant
/// separators collapse, so `./a.txt`, `a.txt`, and `sub/../a.txt` all yield the
/// same key. The root itself (empty relative path) is `.root`, distinct from an
/// `.outside` escape (a `..` climb above the root, or an absolute path on
/// another root — B-add-6). The caller frees `.key`. Pure but for allocation: no
/// I/O, so it is unit-testable on strings.
pub fn repoRelative(
    gpa: std.mem.Allocator,
    root_abs: []const u8,
    cwd_abs: []const u8,
    arg: []const u8,
) error{OutOfMemory}!Resolved {
    const abs = try std.fs.path.resolve(gpa, &.{ cwd_abs, arg });
    defer gpa.free(abs);
    const key = try std.fs.path.relative(gpa, cwd_abs, null, root_abs, abs);
    // An empty relative path means `arg` IS the checkout root — a directory
    // inside the repository (B-add-5), not an escape (B-add-6). This is the most
    // likely novice invocation, `zit add .` at the root.
    if (key.len == 0) {
        gpa.free(key);
        return .root;
    }
    if (std.fs.path.isAbsolute(key) or
        std.mem.eql(u8, key, "..") or
        std.mem.startsWith(u8, key, "../"))
    {
        gpa.free(key);
        return .outside;
    }
    return .{ .key = key };
}

/// The top-level subcommand set — the one source of truth for the verb
/// vocabulary. The tag names ARE the on-the-wire spellings (`@tagName`), so
/// dispatch (main's total switch on this enum), help routing (main's
/// `verb_help` table keyed by this enum), and the did-you-mean suggester below
/// all derive from a single list: a new verb is added here in exactly one
/// place. `unknown` is the exhaustive catch-all that keeps the dispatch switch
/// total; it is never a real command. `import_git` is the sole spelling
/// exception: Zig enum tags cannot carry the command's required hyphen, so
/// `verbOf` maps `import-git` onto it by name.
pub const Verb = enum { ui, init, add, rm, mv, status, stat, diff, extras, clean, addremove, commit, log, open, update, merge, revert, undo, redo, leaves, timeline, branch, tag, amend, info, ls, cat, artifact, annotate, grep, frames, bisect, serve, clone, pull, push, sync, remote, settings, pack, submanifest, checkpoint, cone, worktrees, import_git, import_fossil, export_git, shun, purge, stash, precedent, verify, crosslink, wiki, ticket, forum, technote, attach, trust, config, users, tarball, zip, landing, version, help, unknown };

/// Resolve a command-line token to its `Verb`. Spellings come from the enum
/// tag names, never a second transcription, with two deliberate exceptions:
/// `-h`/`--help` (both name `help`), and `rerere` (names `precedent`).
///
/// `rerere` is the codebase's only true synonym for a verb, and it is one on
/// purpose: `precedent` is the fossil-register name for the feature, while
/// `rerere` is what someone arriving from git will type. Carrying the git
/// spelling costs one line here and saves a failed first attempt; it is a
/// migration affordance, not a second name we maintain. Adding a third
/// synonym should have to argue for itself the way this one did.
///
/// An unrecognized token is `.unknown`, the total-switch catch-all. Pure;
/// bounded by the fixed `Verb` value count.
/// Verb aliases: the spellings Fossil users' fingers already know,
/// each an exact synonym of one zit verb. A table, not a fuzzy match, so an
/// alias never shadows a real verb and the set is auditable in one place.
pub const verb_aliases = [_]struct { alias: []const u8, verb: Verb }{
    .{ .alias = "ci", .verb = .commit },
    .{ .alias = "checkin", .verb = .commit },
    .{ .alias = "co", .verb = .update },
    .{ .alias = "checkout", .verb = .update },
    .{ .alias = "changes", .verb = .status },
    .{ .alias = "delete", .verb = .rm },
    .{ .alias = "forget", .verb = .rm },
    .{ .alias = "remove", .verb = .rm },
    .{ .alias = "rename", .verb = .mv },
    .{ .alias = "blame", .verb = .annotate },
    .{ .alias = "praise", .verb = .annotate },
    .{ .alias = "new", .verb = .init },
    .{ .alias = "rerere", .verb = .precedent },
};

/// The exact spelling of a verb as typed (hyphenated where the enum tag is
/// not).
pub fn verbName(v: Verb) []const u8 {
    return switch (v) {
        .import_git => "import-git",
        .import_fossil => "import-fossil",
        .export_git => "export-git",
        else => @tagName(v),
    };
}

/// Exact match only: a verb's own name or one of its aliases. Prefixes are
/// `resolveVerb`'s business, so a help lookup or an `-R` check never treats
/// a prefix as a verb by accident.
pub fn verbOf(name: []const u8) Verb {
    if (std.mem.eql(u8, name, "-h") or std.mem.eql(u8, name, "--help")) return .help;
    for (verb_aliases) |a| {
        if (std.mem.eql(u8, name, a.alias)) return a.verb;
    }
    inline for (comptime std.enums.values(Verb)) |v| {
        if (v == .unknown) continue;
        if (std.mem.eql(u8, name, verbName(v))) return v;
    }
    return .unknown;
}

/// Most candidates an ambiguity diagnostic names; the tail is elided.
pub const max_ambiguous = 8;

pub const Ambiguity = struct {
    names: [max_ambiguous][]const u8,
    n: usize,
    /// True when more candidates matched than `names` holds.
    more: bool,
    fn add(self: *Ambiguity, name: []const u8) void {
        if (self.n < max_ambiguous) {
            self.names[self.n] = name;
            self.n += 1;
        } else self.more = true;
    }
};

pub const VerbMatch = union(enum) {
    verb: Verb,
    ambiguous: Ambiguity,
    unknown,
};

/// What the operator typed, resolved the way Fossil resolves it:
/// an exact verb or alias first; else any unambiguous prefix of at least
/// two characters (`zit up`, `zit tim`); else the ambiguity, naming the
/// candidates, so `zit ti` says "timeline, ticket" rather than guessing.
/// An exact verb always wins over the prefix rule, so `stat` stays the
/// store census and `status` stays `status`. Pure.
pub fn resolveVerb(name: []const u8) VerbMatch {
    const exact = verbOf(name);
    if (exact != .unknown) return .{ .verb = exact };
    if (name.len < 2 or name.len > max_known_verb_len) return .unknown;
    for (name) |c| {
        if (!((c >= 'a' and c <= 'z') or c == '-')) return .unknown;
    }
    // Aliases are candidates too: `zit ch` must not slip past
    // `changes` and `checkout` to land on `checkpoint`. Several spellings of
    // ONE verb are not an ambiguity.
    var amb: Ambiguity = .{ .names = undefined, .n = 0, .more = false };
    var only: ?Verb = null;
    var distinct: usize = 0;
    for (known_verbs) |k| {
        if (!std.mem.startsWith(u8, k, name)) continue;
        const v = verbOf(k);
        if (only == null or only.? != v) distinct += 1;
        only = v;
        amb.add(k);
    }
    for (verb_aliases) |a| {
        if (!std.mem.startsWith(u8, a.alias, name)) continue;
        if (only == null or only.? != a.verb) distinct += 1;
        if (only == null) only = a.verb;
        amb.add(a.alias);
    }
    if (distinct == 1) return .{ .verb = only.? };
    if (distinct > 1) return .{ .ambiguous = amb };
    return .unknown;
}

/// The shortest prefix of a verb or alias that resolves to it alone — what
/// the ambiguity diagnostic tells the user to type. The full name when no
/// shorter prefix is unique.
pub fn shortestPrefix(name: []const u8) []const u8 {
    const target = verbOf(name);
    var len: usize = 2;
    while (len < name.len) : (len += 1) {
        switch (resolveVerb(name[0..len])) {
            .verb => |v| if (v == target) return name[0..len],
            else => {},
        }
    }
    return name;
}

/// Whether `verb` can run against a store named by `-R`/`--repo`. The dividing
/// line is not read-versus-write but CHECKOUT-DEPENDENCE: `-R` names a
/// repository, and a repository has no working tree, so any verb that reads or
/// writes one has nowhere to apply the override. `tag` writes, yet it writes
/// only to the repository, so it qualifies.
///
/// A total switch, deliberately: a verb added later cannot silently inherit
/// either answer — the compiler makes someone choose.
pub fn acceptsRepoOverride(verb: Verb) bool {
    return switch (verb) {
        // `users` reads the store's user table and touches no working
        // tree, so `-R` has something to name: a bare mirrored store answers
        // "who has access here" without being checked out, which is the whole
        // point of carrying the table.
        .users => true,
        // History and store interrogation: a repository is all they need.
        // `verify` sits here on the line this function actually draws, not by
        // analogy with `stat`: it reads the store and touches no working tree,
        // so `-R` has something to name. `stat` is refused because ITS HANDLER
        // takes no redirected repository — a plumbing fact about an existing
        // handler, not a policy binding a handler being written now — and
        // `pack`/`shun` are refused because they rewrite.
        .timeline, .log, .leaves, .branch, .tag, .info, .ls, .cat, .artifact, .annotate, .grep, .verify => true,
        // `frames` is the incremental-consumer read seam: pure store
        // interrogation, the -R grant is its primary use (a bare indexer).
        .frames => true,
        // `crosslink` is the same seam one layer up: pure store
        // interrogation of the derived records, -R its primary use.
        .crosslink => true,
        // `worktrees` lists the store's registered satellites -- a
        // store-side fact a bare -R can ask for; the lazy prune writes only
        // the disposable cache tier, never the store.
        .worktrees => true,
        // Machine verbs: they APPEND one artifact to a bare store under
        // the store's own write lock — quarry's primary use is exactly `-R`
        // against a store no checkout wraps. Appending is not the rewrite
        // class the pack/shun refusal is about.
        .wiki, .ticket, .forum, .technote, .attach => true,
        // Archive export: a check-in's file set and its blobs are entirely
        // store-side facts, so `-R` has something to name — and exporting a
        // release tarball from a bare mirror with no working tree is the
        // primary use, not an edge case. Nothing is written to a checkout;
        // the output file is named on the command line.
        .tarball, .zip => true,
        // `landing` reads and writes only store-side artifacts (a forum
        // thread, a raw tag), and quarry's primary use is exactly `-R` against a
        // bare store. `accept` is the one subcommand that needs a working tree to
        // merge into; it refuses `-R` in-verb, the way `diff` refuses it in
        // worktree mode rather than making the whole verb checkout-dependent.
        .landing => true,
        // `stat` interrogates only the store, and its handler takes a
        // redirected repository like the rest of the family. It was refused for
        // a plumbing reason rather than a policy one, and that accident made a
        // repository's project code unreadable without a checkout.
        .stat => true,
        // Working-tree verbs: they read or write the checkout the cwd names.
        // `amend` rewrites history, not a working tree, but it is out of scope
        // for the read-verb `-R` grant, so it is refused by name like the rest.
        // `trust` records consent for THIS checkout to run a versioned
        // declaration; a bare store has no checkout to consent on behalf of, so
        // it is a working-tree verb like the rest.
        .status, .update, .commit, .revert, .add, .rm, .mv, .undo, .redo, .extras, .clean, .addremove, .open, .bisect, .amend, .stash, .precedent, .cone, .trust, .ui => false,
        // `merge` is granted for `--probe` ONLY, exactly as `diff` is
        // granted for `--from`/`--to`: the probe reads two check-ins out of the
        // store and writes nothing, so a bare store can serve it. A real merge
        // writes a working tree, and refuses `-R` in-verb with the
        // works-in-a-checkout diagnostic.
        .merge => true,
        // `diff` is granted for its `--from`/`--to` mode ONLY: the
        // check-in pair touches no working tree, so a bare store can serve
        // it. The verb itself refuses `-R` in worktree mode with the same
        // works-in-a-checkout diagnostic the lift used to emit.
        .diff => true,
        // `settings` and `config` reach only store-resident state: the
        // repository and global tiers `settings` reads and writes live in the
        // config table and the operator file, and `config`'s user/shun areas
        // ARE store tables (the same `user` table `users => true` grants on a
        // bare store). Neither verb writes a working tree in any mode, so both
        // serve a bare store whole -- the surface a forge needs. The versioned
        // tier `settings` folds into a listing is read, not written, and simply
        // contributes nothing when no checkout exists.
        .settings, .config => true,
        // Verbs that name their own target already (a directory, a URL, a
        // remote), so a second, conflicting way to say where would be a trap.
        // The sync family reads and writes the store only: a bare
        // mirror pushes, pulls, syncs and names its remote without a checkout.
        .pull, .push, .sync, .remote => true,
        .init, .clone, .pack, .serve, .import_git, .import_fossil, .export_git => false,
        // `submanifest` is granted for `analyze` ONLY: the analysis
        // pass reads history and (with --names) needs no checkout at all, so
        // a bare cloned store can be measured before conversion. The verb
        // refuses -R for every settings-writing action in-verb.
        .submanifest => true,
        // `checkpoint` reads and writes files beside the repository the cwd
        // names (the sidecar, and in tree shape the log itself); a second way
        // to say where would be a trap. Same reasoning as `submanifest`.
        .checkpoint => false,
        // A destructive whole-store rewrite operates on the discovered
        // checkout, never on an `-R`-named foreign repository: `shun` sits
        // beside `pack` for the same reason, plus one of its own — its
        // unconditional refusal is "this is the check-in this checkout is on",
        // a question a repository with no working tree cannot answer, so under
        // `-R` the one guard that has no `--force` override would silently
        // become vacuous.
        .shun => false,
        // `purge` sits beside `shun` for both of its reasons: it rewrites the
        // whole store, and its one unconditional refusal is "this is the
        // check-in this checkout is on" — vacuous under `-R`, where the guard
        // that has no `--force` override would silently stop guarding.
        .purge => false,
        // Not repository operations at all.
        .version, .help, .unknown => false,
    };
}

// ---------------------------------------------------------------------------
// Unknown-command discoverability (DX D2 + D4): a git user reaches for a verb
// zit does not have. Rather than a bare "unknown command", teach the zit model
// — immutable history, propagating-tag branches, bidirectional sync — so the
// diagnostic itself corrects the mental model. Both facilities below are pure
// (no I/O, no allocation): the caller renders and owns the exit code, so the
// stderr-only / exit-2 discipline stays in one place (main's dispatch).
// ---------------------------------------------------------------------------

// The shared lessons a git muscle-memory verb should be answered with. Defined
// once and referenced from the table so several verbs teaching the same thing
// do not duplicate prose (Ousterhout: no duplication). Each renders as the tail
// of `zit: '<verb>' is not a zit command. <lesson>`.
const lesson_immutable =
    "zit history is immutable — check-ins are never rewritten. Record a new " ++
    "check-in that supersedes the old one, or merge instead.";
const lesson_sync =
    "zit uses 'zit sync'; sync never discards artifacts and all history " ++
    "transfers both ways, so force-push does not exist.";
/// Maps a common git verb that zit deliberately does not provide to the lesson
/// that corrects the underlying mental model. A gap here just means "not a
/// git-reflex verb" — the caller then falls back to `isPlanned` and, failing
/// that, the generic unknown-command path (with a did-you-mean suggestion).
const reflex_table = [_]struct { verb: []const u8, lesson: []const u8 }{
    .{ .verb = "rebase", .lesson = lesson_immutable },
    // `amend` is NOT a git-reflex verb here (B-amend-12): zit's `amend` mints a
    // control artifact that changes how a check-in DISPLAYS without rewriting it,
    // so answering it with the immutable-history lesson would teach the wrong
    // thing about zit's own format. It dispatches to the real handler instead.
    .{ .verb = "reset", .lesson = lesson_immutable },
    .{ .verb = "filter-branch", .lesson = lesson_immutable },
    .{ .verb = "fetch", .lesson = lesson_sync },
};

/// Returns the educating lesson for a git-reflex `verb`, or null if `verb` is
/// not one we specifically teach against. Pure lookup over `reflex_table`.
pub fn reflexLesson(verb: []const u8) ?[]const u8 {
    for (reflex_table) |entry| {
        if (std.mem.eql(u8, entry.verb, verb)) return entry.lesson;
    }
    return null;
}

/// zit commands that are planned but not yet built. Naming a verb here (rather
/// than in the git-reflex table) says "this IS a zit command, it just isn't
/// implemented yet" — the diagnostic is a promise, not a model correction, and
/// the verb is offered as a did-you-mean target. This table upholds the DX
/// invariant that every verb the shipped help text names resolves to a real
/// handler or a stub — never a bare "unknown command". Currently empty: `clone`,
/// `pull`, and `push` are now real handlers (see `known_verbs`), and the
/// remaining sync verb (`sync`) is not yet named in any shipped help text.
const planned_verbs = [_][]const u8{};

/// Whether `verb` is a planned-but-unimplemented zit command. Pure lookup over
/// `planned_verbs`; the caller renders the promise line and owns the exit code.
pub fn isPlanned(verb: []const u8) bool {
    for (planned_verbs) |v| {
        if (std.mem.eql(u8, v, verb)) return true;
    }
    return false;
}

/// The commands zit actually dispatches, for the "did you mean" suggester —
/// derived from `Verb` (every value but the `unknown` catch-all) so the
/// suggester's candidate set can never drift from the dispatched set.
const known_verbs = blk: {
    const values = std.enums.values(Verb);
    var list: [values.len - 1][]const u8 = undefined;
    var i: usize = 0;
    for (values) |v| {
        if (v == .unknown) continue;
        list[i] = verbName(v);
        i += 1;
    }
    break :blk list;
};

/// The full set a typo can be a near-miss of: the dispatched verbs plus the
/// planned ones, so `comit` suggests the canonical `commit` rather than
/// dead-ending. `known_verbs` stays the real-handler list; suggestion is the
/// only place the two sets merge.
const suggestable_verbs = known_verbs ++ planned_verbs;

/// Longest suggestable verb — bounds the edit-distance DP row so the scan needs
/// no allocation (TigerStyle: explicit fixed bound).
const max_known_verb_len = blk: {
    var m: usize = 0;
    for (suggestable_verbs) |k| m = @max(m, k.len);
    break :blk m;
};

/// A token longer than this is plainly not a typo of a short verb; skipping it
/// keeps the scan bounded and avoids a useless suggestion (TigerStyle: bound).
const max_suggest_input = 128;

/// If `verb` is a known synonym or a near-miss (edit distance 1..2) of a real
/// command, returns the closest one so the diagnostic can say "did you mean
/// 'init'?". Returns null when nothing is close enough — a distant typo gets the
/// plain command list, not a misleading guess. Pure.
pub fn didYouMean(verb: []const u8) ?[]const u8 {
    if (verb.len == 0 or verb.len > max_suggest_input) return null;
    var best: ?[]const u8 = null;
    var best_dist: usize = std.math.maxInt(usize);
    for (suggestable_verbs) |k| {
        const d = editDistance(verb, k);
        if (d < best_dist) {
            best_dist = d;
            best = k;
        }
    }
    // 0 cannot occur (an exact match is not an unknown command); require 1..2 so
    // only a plausible typo is suggested — and for a token of three letters or
    // fewer, exactly 1: at distance 2 nearly any short verb "matches" (`tim`
    // used to suggest `rm`), and a prefix that short already resolved above
    // if it was going to.
    const limit: usize = if (verb.len <= 3) 1 else 2;
    if (best_dist >= 1 and best_dist <= limit) return best;
    return null;
}

/// Levenshtein distance between arbitrary input `a` and a known verb `b`, using
/// a fixed two-row buffer sized to the longest known verb. `b` MUST be one of
/// `known_verbs`; `a` is bounded by `max_suggest_input` by the sole caller.
fn editDistance(a: []const u8, b: []const u8) usize {
    std.debug.assert(b.len <= max_known_verb_len);
    std.debug.assert(a.len <= max_suggest_input);
    var prev: [max_known_verb_len + 1]usize = undefined;
    var curr: [max_known_verb_len + 1]usize = undefined;
    for (0..b.len + 1) |j| prev[j] = j;
    for (a, 1..) |ca, i| {
        curr[0] = i;
        for (b, 1..) |cb, j| {
            const cost: usize = if (ca == cb) 0 else 1;
            curr[j] = @min(@min(prev[j] + 1, curr[j - 1] + 1), prev[j - 1] + cost);
        }
        @memcpy(prev[0 .. b.len + 1], curr[0 .. b.len + 1]);
    }
    return prev[b.len];
}

// ---------------------------------------------------------------------------
// Unit tests — parser & exit-code mechanism (pure; no I/O). Each cites its
// behavior id (contracts/init.md).
// ---------------------------------------------------------------------------

test "cli B-init-1: bare init parses to the default directory" {
    const r = parseInitArgs(&.{});
    try std.testing.expect(r == .ok);
    try std.testing.expectEqual(@as(?[]const u8, null), r.ok.directory);
    try std.testing.expect(!r.ok.quiet);
    try std.testing.expect(!r.ok.help);
}

test "cli B-init-1: -q and --quiet both set quiet" {
    inline for (.{ "-q", "--quiet" }) |flag| {
        const r = parseInitArgs(&.{flag});
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.quiet);
        try std.testing.expectEqual(@as(?[]const u8, null), r.ok.directory);
    }
}

test "cli B-init-1: a single positional is the directory" {
    const r = parseInitArgs(&.{"proj"});
    try std.testing.expect(r == .ok);
    try std.testing.expectEqualStrings("proj", r.ok.directory.?);
    try std.testing.expect(!r.ok.quiet);
}

test "cli B-init-1: -- terminates option parsing so a dir named -q is reachable" {
    const r = parseInitArgs(&.{ "--", "-q" });
    try std.testing.expect(r == .ok);
    try std.testing.expectEqualStrings("-q", r.ok.directory.?);
    try std.testing.expect(!r.ok.quiet);
}

test "cli B-init-1/-11: -h and --help set help" {
    inline for (.{ "-h", "--help" }) |flag| {
        const r = parseInitArgs(&.{flag});
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
}

test "cli B-init-1: flags may precede the positional and combine" {
    const r = parseInitArgs(&.{ "-q", "proj" });
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.quiet);
    try std.testing.expectEqualStrings("proj", r.ok.directory.?);
}

test "cli B-init-10: an unknown option is reported with its exact token" {
    const r = parseInitArgs(&.{"-x"});
    try std.testing.expect(r == .unknown_option);
    try std.testing.expectEqualStrings("-x", r.unknown_option);
}

test "cli B-init-10: a second positional is an extra-argument error" {
    const r = parseInitArgs(&.{ "a", "b" });
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("b", r.extra_argument);
}

test "cli B-init-10: an extra positional after -- is still an error" {
    const r = parseInitArgs(&.{ "--", "a", "b" });
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("b", r.extra_argument);
}

test "cli B-init-1: a lone dash is a positional, not an unknown option" {
    const r = parseInitArgs(&.{"-"});
    try std.testing.expect(r == .ok);
    try std.testing.expectEqualStrings("-", r.ok.directory.?);
}

test "cli B-init-12: exit codes map to their uniform bytes" {
    try std.testing.expectEqual(@as(u8, 0), @intFromEnum(Exit.ok));
    try std.testing.expectEqual(@as(u8, 1), @intFromEnum(Exit.failure));
    try std.testing.expectEqual(@as(u8, 2), @intFromEnum(Exit.usage));
}

test "cli B-init-10: usageError renders both messages with the hint to err, returns usage" {
    var buf: [256]u8 = undefined;
    var w = Io.Writer.fixed(&buf);
    var sink: [1]u8 = undefined;
    var wnull = Io.Writer.fixed(&sink);
    const s: Streams = .{ .out = &wnull, .err = &w };

    const code = usageError(s, "init", .unknown_option, "-x");
    try std.testing.expectEqual(Exit.usage, code);
    try std.testing.expectEqualStrings(
        "zit init: unknown option '-x'\ntry 'zit init --help'\n",
        w.buffered(),
    );
}

// --- ls parser (optional VERSION; pure) -------------------------------------

test "parseLogArgs: CHECKIN + optional PATH grammar — help wins, missing/extra/unknown reject, -n limits, -- ends the options" {
    // A lone positional binds the check-in; the optional PATH stays null.
    {
        const r = parseLogArgs(&.{"abc"});
        try std.testing.expect(r == .ok);
        try std.testing.expectEqualStrings("abc", r.ok.checkin.?);
        try std.testing.expect(r.ok.path == null);
        try std.testing.expect(!r.ok.help);
    }
    // A second positional binds PATH (B-log-2 grammar).
    {
        const r = parseLogArgs(&.{ "abc", "src/a.txt" });
        try std.testing.expect(r == .ok);
        try std.testing.expectEqualStrings("abc", r.ok.checkin.?);
        try std.testing.expectEqualStrings("src/a.txt", r.ok.path.?);
    }
    // No args: the positional stays null so the runner emits `no check-in`.
    {
        const r = parseLogArgs(&.{});
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.checkin == null);
    }
    // -h / --help win over everything else, including an otherwise-bad token.
    inline for (.{ "-h", "--help" }) |flag| {
        const r = parseLogArgs(&.{ "--nope", flag });
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
    // The retired `--all` is an unknown option, not a positional.
    {
        const r = parseLogArgs(&.{"--all"});
        try std.testing.expect(r == .unknown_option);
        try std.testing.expectEqualStrings("--all", r.unknown_option);
    }
    // `--` is no longer a positional-only separator: it is an unknown option.
    {
        // `--` ends the options, so the token after it is the check-in even
        // when it looks like a flag. Without this a path or a name beginning
        // with a dash is unreachable by any spelling.
        const r = parseLogArgs(&.{ "--", "abc" });
        try std.testing.expectEqualStrings("abc", r.ok.checkin.?);
        const dash = parseLogArgs(&.{ "--", "-n", "-x.txt" });
        try std.testing.expectEqualStrings("-n", dash.ok.checkin.?);
        try std.testing.expectEqualStrings("-x.txt", dash.ok.path.?);
        try std.testing.expect(dash.ok.limit == null); // -n after -- is not the limit flag
    }
    // `-n N` is the entry limit (fossil's spelling). This expectation is the
    // deliberate inversion of an earlier one that asserted `-n` was REJECTED:
    // that assertion encoded the absence of the feature, not a rule about the
    // grammar, so implementing the limit is exactly what changes it. Its value
    // is consumed as the limit, so the following token still binds CHECKIN.
    {
        const r = parseLogArgs(&.{ "-n", "5", "abc" });
        try std.testing.expect(r == .ok);
        try std.testing.expectEqual(@as(?usize, 5), r.ok.limit);
        try std.testing.expectEqualStrings("abc", r.ok.checkin.?);
    }
    // A THIRD positional is an extra argument (the second now binds PATH).
    {
        const r = parseLogArgs(&.{ "abc", "path", "third" });
        try std.testing.expect(r == .extra_argument);
        try std.testing.expectEqualStrings("third", r.extra_argument);
    }
    // Help still wins over an extra third positional.
    {
        const r = parseLogArgs(&.{ "abc", "path", "third", "-h" });
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
}

// --- the `-n N` entry limit (timeline + log) --------------------------------

test "parseTimelineArgs: -n and --limit set the entry cap; 0 means unlimited" {
    // No args: no cap, no help.
    {
        const r = parseTimelineArgs(&.{});
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.limit == null);
        try std.testing.expect(!r.ok.help);
    }
    // Both spellings set the same cap.
    inline for (.{ "-n", "--limit" }) |flag| {
        const r = parseTimelineArgs(&.{ flag, "5" });
        try std.testing.expect(r == .ok);
        try std.testing.expectEqual(@as(?usize, 5), r.ok.limit);
    }
    // `-n 0` is fossil's "unlimited", which is the same no-cap state as absent.
    {
        const r = parseTimelineArgs(&.{ "-n", "0" });
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.limit == null);
    }
    // A cap far past the history is accepted here; clamping is the renderer's.
    {
        const r = parseTimelineArgs(&.{ "-n", "100000" });
        try std.testing.expect(r == .ok);
        try std.testing.expectEqual(@as(?usize, 100000), r.ok.limit);
    }
}

test "parseTimelineArgs: a non-numeric, negative, or missing -n value is refused, never defaulted" {
    inline for (.{ "abc", "-1", "3.5", "" }) |bad| {
        const r = parseTimelineArgs(&.{ "-n", bad });
        try std.testing.expect(r == .bad_limit);
        try std.testing.expectEqualStrings(bad, r.bad_limit);
    }
    // A dangling `-n` (no value at all) is a dangling value option, the same
    // shape every other value option in this file reports.
    {
        const r = parseTimelineArgs(&.{"-n"});
        try std.testing.expect(r == .unknown_option);
        try std.testing.expectEqualStrings("-n", r.unknown_option);
    }
}

test "parseTimelineArgs: help wins over a bad limit; unknown options and positionals still reject" {
    {
        const r = parseTimelineArgs(&.{ "-n", "abc", "--help" });
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
    {
        const r = parseTimelineArgs(&.{"-x"});
        try std.testing.expect(r == .unknown_option);
        try std.testing.expectEqualStrings("-x", r.unknown_option);
    }
    {
        const r = parseTimelineArgs(&.{"foo"});
        try std.testing.expect(r == .extra_argument);
        try std.testing.expectEqualStrings("foo", r.extra_argument);
    }
    // After `--`, even an option-looking token is a (rejected) positional.
    {
        const r = parseTimelineArgs(&.{ "--", "-n" });
        try std.testing.expect(r == .extra_argument);
        try std.testing.expectEqualStrings("-n", r.extra_argument);
    }
}

test "parseLogArgs: -n limits, 0 is unlimited, and a bad value is refused" {
    {
        const r = parseLogArgs(&.{ "abc", "-n", "3" });
        try std.testing.expect(r == .ok);
        try std.testing.expectEqual(@as(?usize, 3), r.ok.limit);
        try std.testing.expectEqualStrings("abc", r.ok.checkin.?);
    }
    {
        const r = parseLogArgs(&.{ "abc", "--limit", "0" });
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.limit == null);
    }
    {
        const r = parseLogArgs(&.{ "abc", "-n", "-2" });
        try std.testing.expect(r == .bad_limit);
        try std.testing.expectEqualStrings("-2", r.bad_limit);
    }
    // The limit does not consume the PATH positional.
    {
        const r = parseLogArgs(&.{ "abc", "-n", "2", "src/a.txt" });
        try std.testing.expect(r == .ok);
        try std.testing.expectEqual(@as(?usize, 2), r.ok.limit);
        try std.testing.expectEqualStrings("src/a.txt", r.ok.path.?);
    }
}

test "the -n overload is per-verb: dry-run verbs still read -n as --dry-run, never as a limit" {
    // This is the load-bearing assertion behind following fossil's overload:
    // `-n` means "limit" on the two history verbs and "dry run" on the working-
    // tree verbs, and neither reading may leak into the other.
    // `add` and `rm` read `-n` as the boolean --dry-run: it consumes NO value,
    // so the token after it is still a path.
    {
        const r = try parseAddArgs(std.testing.allocator, &.{ "-n", "5" });
        defer std.testing.allocator.free(r.ok.paths);
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.dry_run);
        try std.testing.expectEqual(@as(usize, 1), r.ok.paths.len);
        try std.testing.expectEqualStrings("5", r.ok.paths[0]);
    }
    {
        const r = try parseRmArgs(std.testing.allocator, &.{ "-n", "a.txt" });
        defer std.testing.allocator.free(r.ok.paths);
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.dry_run);
        try std.testing.expectEqualStrings("a.txt", r.ok.paths[0]);
    }
    // `clean` and `addremove` reject `-n` outright, and still do.
    {
        // clean reads -n as --dry-run too; the extra positional is the error.
        const r = parseCleanArgs(&.{ "-n", "5" });
        try std.testing.expect(r == .extra_argument);
        try std.testing.expectEqualStrings("5", r.extra_argument);
    }
    {
        const r = parseAddremoveArgs(&.{ "-n", "5" });
        try std.testing.expect(r == .unknown_option);
        try std.testing.expectEqualStrings("-n", r.unknown_option);
    }
    // And the history verbs never read `-n` as a boolean: it always takes a value.
    {
        const r = parseTimelineArgs(&.{ "-n", "5" });
        try std.testing.expect(r == .ok);
        try std.testing.expectEqual(@as(?usize, 5), r.ok.limit);
    }
}

// --- the global `-R`/`--repo` override --------------------------------------

test "parseGlobalArgs: absent -R aliases the input and allocates nothing" {
    const argv: []const []const u8 = &.{ "abc", "src/a.txt" };
    const r = try parseGlobalArgs(std.testing.failing_allocator, argv);
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.repo == null);
    try std.testing.expectEqual(argv.ptr, r.ok.args.ptr);
}

test "parseGlobalArgs: both spellings lift the value out and leave the verb args untouched" {
    inline for (.{ "-R", "--repo" }) |flag| {
        const r = try parseGlobalArgs(std.testing.allocator, &.{ flag, "/tmp/store", "abc" });
        try std.testing.expect(r == .ok);
        defer std.testing.allocator.free(r.ok.args);
        try std.testing.expectEqualStrings("/tmp/store", r.ok.repo.?);
        try std.testing.expectEqual(@as(usize, 1), r.ok.args.len);
        try std.testing.expectEqualStrings("abc", r.ok.args[0]);
    }
}

test "parseGlobalArgs: the option is positional-independent and survives other flags" {
    const r = try parseGlobalArgs(std.testing.allocator, &.{ "abc", "-n", "5", "-R", "/tmp/s" });
    try std.testing.expect(r == .ok);
    defer std.testing.allocator.free(r.ok.args);
    try std.testing.expectEqualStrings("/tmp/s", r.ok.repo.?);
    // The verb's own grammar still sees exactly its own tokens, in order.
    try std.testing.expectEqual(@as(usize, 3), r.ok.args.len);
    try std.testing.expectEqualStrings("abc", r.ok.args[0]);
    try std.testing.expectEqualStrings("-n", r.ok.args[1]);
    try std.testing.expectEqualStrings("5", r.ok.args[2]);
}

test "parseGlobalArgs: after -- a token spelled -R is a positional, not the option" {
    const r = try parseGlobalArgs(std.testing.allocator, &.{ "-R", "/tmp/s", "--", "-R" });
    try std.testing.expect(r == .ok);
    defer std.testing.allocator.free(r.ok.args);
    try std.testing.expectEqualStrings("/tmp/s", r.ok.repo.?);
    try std.testing.expectEqual(@as(usize, 2), r.ok.args.len);
    try std.testing.expectEqualStrings("--", r.ok.args[0]);
    try std.testing.expectEqualStrings("-R", r.ok.args[1]);

    // A `-R` that appears ONLY after `--` is never lifted: no allocation, no value.
    const p = try parseGlobalArgs(std.testing.failing_allocator, &.{ "--", "-R", "/tmp/s" });
    try std.testing.expect(p == .ok);
    try std.testing.expect(p.ok.repo == null);
}

test "parseGlobalArgs: a missing, empty, or repeated value is refused rather than guessed" {
    {
        const r = try parseGlobalArgs(std.testing.allocator, &.{"-R"});
        try std.testing.expect(r == .missing_value);
        try std.testing.expectEqualStrings("-R", r.missing_value);
    }
    {
        const r = try parseGlobalArgs(std.testing.allocator, &.{ "-R", "" });
        try std.testing.expect(r == .empty_value);
        try std.testing.expectEqualStrings("-R", r.empty_value);
    }
    {
        const r = try parseGlobalArgs(std.testing.allocator, &.{ "-R", "/a", "--repo", "/b" });
        try std.testing.expect(r == .repeated_option);
        try std.testing.expectEqualStrings("--repo", r.repeated_option);
    }
}

test "resolveRoot: a null override is exactly discoverRoot; a path with no .zit resolves to nothing" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;
    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    // An empty directory holds no store, so `-R` there resolves to null — the
    // caller's not-a-repository diagnostic, never a silent fall back to the cwd.
    const cwd = (try currentPathAlloc(gpa, io)).?;
    defer gpa.free(cwd);
    const abs = try std.fs.path.join(gpa, &.{ cwd, ".zig-cache", "tmp", &tmp.sub_path });
    defer gpa.free(abs);
    try std.testing.expect((try resolveRoot(gpa, io, abs)) == null);

    // With a `.zit` present — and NO `.zit-checkout` — the override resolves.
    // That absence is the point: `discoverRoot` requires both, `-R` requires
    // only the store, which is what lets it read a checkout-less repository.
    var f = try tmp.dir.createFile(io, ".zit", .{});
    f.close(io);
    try std.testing.expect(!hasEntry(io, tmp.dir, checkout.filename));
    var root = (try resolveRoot(gpa, io, abs)).?;
    defer {
        gpa.free(root.abs);
        root.dir.close(io);
    }
    try std.testing.expect(hasEntry(io, root.dir, ".zit"));

    // Naming the store FILE resolves to its parent directory, fossil-style.
    const file_path = try std.fs.path.join(gpa, &.{ abs, ".zit" });
    defer gpa.free(file_path);
    var byfile = (try resolveRoot(gpa, io, file_path)).?;
    defer {
        gpa.free(byfile.abs);
        byfile.dir.close(io);
    }
    try std.testing.expectEqualStrings(root.abs, byfile.abs);
}

test "parseLsArgs: optional VERSION, --no-cache, --help, unknown-option and extra-argument rejections" {
    // No args: no version, defaults off.
    {
        const r = parseLsArgs(&.{});
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.version == null);
        try std.testing.expect(!r.ok.no_cache);
        try std.testing.expect(!r.ok.help);
    }
    // A lone positional is the VERSION selector.
    {
        const r = parseLsArgs(&.{"trunk"});
        try std.testing.expect(r == .ok);
        try std.testing.expectEqualStrings("trunk", r.ok.version.?);
    }
    // --no-cache sets the cache-bypass flag; the positional still binds.
    {
        const r = parseLsArgs(&.{ "abc123", "--no-cache" });
        try std.testing.expect(r == .ok);
        try std.testing.expectEqualStrings("abc123", r.ok.version.?);
        try std.testing.expect(r.ok.no_cache);
    }
    // -h / --help win over everything else, including an otherwise-invalid token.
    inline for (.{ "-h", "--help" }) |flag| {
        const r = parseLsArgs(&.{ flag, "-x" });
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
    // An unrecognized option is retained verbatim.
    {
        const r = parseLsArgs(&.{"-x"});
        try std.testing.expect(r == .unknown_option);
        try std.testing.expectEqualStrings("-x", r.unknown_option);
    }
    // A second positional is an extra-argument error carrying its token.
    {
        const r = parseLsArgs(&.{ "v1", "v2" });
        try std.testing.expect(r == .extra_argument);
        try std.testing.expectEqualStrings("v2", r.extra_argument);
    }
}

// --- cat parser (required FILE, optional VERSION; pure) ---------------------

test "parseCatArgs: a missing FILE parses ok with file==null (the runner raises no_file)" {
    // Parsing is pure: an absent FILE is not a parse error — the runner turns
    // `file == null` into the `.no_file` usage error (exit 2). Bare args yield a
    // clean result with both positionals null and flags off.
    const r = parseCatArgs(&.{});
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.file == null);
    try std.testing.expect(r.ok.version == null);
    try std.testing.expect(!r.ok.no_cache);
    try std.testing.expect(!r.ok.help);
}

test "parseCatArgs: FILE is the first positional, VERSION the optional second; extra positional / unknown option / help precedence" {
    // A lone positional binds FILE; VERSION stays null.
    {
        const r = parseCatArgs(&.{"a.txt"});
        try std.testing.expect(r == .ok);
        try std.testing.expectEqualStrings("a.txt", r.ok.file.?);
        try std.testing.expect(r.ok.version == null);
    }
    // Two positionals: first FILE, second VERSION. --no-cache still binds.
    {
        const r = parseCatArgs(&.{ "a.txt", "trunk", "--no-cache" });
        try std.testing.expect(r == .ok);
        try std.testing.expectEqualStrings("a.txt", r.ok.file.?);
        try std.testing.expectEqualStrings("trunk", r.ok.version.?);
        try std.testing.expect(r.ok.no_cache);
    }
    // A third positional is an extra-argument error carrying its token.
    {
        const r = parseCatArgs(&.{ "a.txt", "trunk", "extra" });
        try std.testing.expect(r == .extra_argument);
        try std.testing.expectEqualStrings("extra", r.extra_argument);
    }
    // An unrecognized option is retained verbatim.
    {
        const r = parseCatArgs(&.{ "a.txt", "-x" });
        try std.testing.expect(r == .unknown_option);
        try std.testing.expectEqualStrings("-x", r.unknown_option);
    }
    // -h / --help win over everything else, including an otherwise-invalid token.
    inline for (.{ "-h", "--help" }) |flag| {
        const r = parseCatArgs(&.{ flag, "-x", "a.txt", "b", "c" });
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
}

test "parseArtifactArgs: --raw sets raw; help wins; unknown option and second positional reject" {
    // Bare args: hash null, flags off — the runner raises .no_hash.
    {
        const r = parseArtifactArgs(&.{});
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.hash == null);
        try std.testing.expect(!r.ok.raw);
        try std.testing.expect(!r.ok.help);
    }
    // A lone positional binds HASH; --raw sets the flag in either order.
    {
        const r = parseArtifactArgs(&.{ "abc123", "--raw" });
        try std.testing.expect(r == .ok);
        try std.testing.expectEqualStrings("abc123", r.ok.hash.?);
        try std.testing.expect(r.ok.raw);
    }
    // A second positional is an extra-argument error carrying its token.
    {
        const r = parseArtifactArgs(&.{ "abc123", "def456" });
        try std.testing.expect(r == .extra_argument);
        try std.testing.expectEqualStrings("def456", r.extra_argument);
    }
    // An unrecognized option is retained verbatim (no --no-cache surface here).
    {
        const r = parseArtifactArgs(&.{ "abc123", "--no-cache" });
        try std.testing.expect(r == .unknown_option);
        try std.testing.expectEqualStrings("--no-cache", r.unknown_option);
    }
    // -h / --help win over everything else, including otherwise-invalid tokens.
    inline for (.{ "-h", "--help" }) |flag| {
        const r = parseArtifactArgs(&.{ flag, "-x", "abc", "def" });
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
}

// --- no-positional help parser (status/diff; pure) --------------------------

test "cli status/diff: no args parse to help=false" {
    const r = parseNoPositionalArgs(&.{});
    try std.testing.expect(r == .ok);
    try std.testing.expect(!r.ok.help);
}

test "cli status/diff: -h and --help set help" {
    inline for (.{ "-h", "--help" }) |flag| {
        const r = parseNoPositionalArgs(&.{flag});
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
}

test "cli status/diff: -h wins over a trailing positional" {
    const r = parseNoPositionalArgs(&.{ "-h", "x" });
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.help);
}

test "cli status/diff: an unknown option carries its exact token" {
    const r = parseNoPositionalArgs(&.{"-x"});
    try std.testing.expect(r == .unknown_option);
    try std.testing.expectEqualStrings("-x", r.unknown_option);
}

test "cli status/diff: a bare positional is an extra-argument error" {
    const r = parseNoPositionalArgs(&.{"foo"});
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("foo", r.extra_argument);
}

test "cli status/diff: a lone dash is an extra-argument error" {
    const r = parseNoPositionalArgs(&.{"-"});
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("-", r.extra_argument);
}

test "cli status/diff: a token after -- is an extra-argument error" {
    const r = parseNoPositionalArgs(&.{ "--", "foo" });
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("foo", r.extra_argument);
}

// --- clean arg parser (contracts/clean.md B-clean-11..14; pure) -------------

test "cli clean B-clean-8: no args parse to no flags set" {
    const r = parseCleanArgs(&.{});
    try std.testing.expect(r == .ok);
    try std.testing.expect(!r.ok.dry_run);
    try std.testing.expect(!r.ok.force);
    try std.testing.expect(!r.ok.help);
}

test "cli clean B-clean-12: -h and --help set help, dropping other flags" {
    inline for (.{ "-h", "--help" }) |flag| {
        const r = parseCleanArgs(&.{flag});
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
}

test "cli clean B-clean-3: --dry-run sets dry_run only" {
    const r = parseCleanArgs(&.{"--dry-run"});
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.dry_run);
    try std.testing.expect(!r.ok.force);
    try std.testing.expect(!r.ok.help);
}

test "cli clean B-clean-5: --force sets force only" {
    const r = parseCleanArgs(&.{"--force"});
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.force);
    try std.testing.expect(!r.ok.dry_run);
    try std.testing.expect(!r.ok.help);
}

test "cli clean B-clean-11: --dry-run and --force together both parse (either order)" {
    const invocations = [_][]const []const u8{
        &.{ "--dry-run", "--force" },
        &.{ "--force", "--dry-run" },
    };
    for (invocations) |argv| {
        const r = parseCleanArgs(argv);
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.dry_run);
        try std.testing.expect(r.ok.force);
    }
}

test "cli clean B-clean-13: an unknown option carries its exact token; -f/-n are the --force/--dry-run aliases" {
    try std.testing.expect(parseCleanArgs(&.{"-f"}).ok.force);
    try std.testing.expect(parseCleanArgs(&.{"-n"}).ok.dry_run);
    try std.testing.expect(parseCleanArgs(&.{"--emptydirs"}).ok.emptydirs);
    inline for (.{ "-x", "--emptydir", "--all", "-0" }) |flag| {
        const r = parseCleanArgs(&.{flag});
        try std.testing.expect(r == .unknown_option);
        try std.testing.expectEqualStrings(flag, r.unknown_option);
    }
}

test "cli clean B-clean-12: help wins over an unknown option and over --force, any order" {
    const invocations = [_][]const []const u8{
        &.{ "--all", "--help" },
        &.{ "-h", "--all" },
        &.{ "--force", "--help" },
    };
    for (invocations) |argv| {
        const r = parseCleanArgs(argv);
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
}

test "cli clean B-clean-14: a bare positional is an extra-argument error" {
    const r = parseCleanArgs(&.{"foo"});
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("foo", r.extra_argument);
}

test "cli clean B-clean-14: a token after -- is an extra-argument error" {
    inline for (.{ "foo", "--force" }) |tok| {
        const r = parseCleanArgs(&.{ "--", tok });
        try std.testing.expect(r == .extra_argument);
        try std.testing.expectEqualStrings(tok, r.extra_argument);
    }
}

test "cli clean B-clean-14: a bare -- with nothing after it is not an error" {
    const r = parseCleanArgs(&.{"--"});
    try std.testing.expect(r == .ok);
    try std.testing.expect(!r.ok.dry_run);
    try std.testing.expect(!r.ok.force);
}

// --- addremove arg parser (contracts/addremove.md B-addremove-9/-10; pure) ---

test "cli addremove B-addremove-5: no args parse to no flags set" {
    const r = parseAddremoveArgs(&.{});
    try std.testing.expect(r == .ok);
    try std.testing.expect(!r.ok.dry_run);
    try std.testing.expect(!r.ok.help);
}

test "cli addremove B-addremove-9: -h and --help set help, dropping other flags" {
    inline for (.{ "-h", "--help" }) |flag| {
        const r = parseAddremoveArgs(&.{flag});
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
}

test "cli addremove B-addremove-4: --dry-run sets dry_run only" {
    const r = parseAddremoveArgs(&.{"--dry-run"});
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.dry_run);
    try std.testing.expect(!r.ok.help);
}

test "cli addremove B-addremove-10: an unknown option carries its exact token; -n/--force are not accepted" {
    inline for (.{ "-n", "--force", "-f", "--all", "-0" }) |flag| {
        const r = parseAddremoveArgs(&.{flag});
        try std.testing.expect(r == .unknown_option);
        try std.testing.expectEqualStrings(flag, r.unknown_option);
    }
}

test "cli addremove B-addremove-9: help wins over an unknown option and over --dry-run, any order" {
    const invocations = [_][]const []const u8{
        &.{ "--all", "--help" },
        &.{ "-h", "--all" },
        &.{ "--dry-run", "--help" },
    };
    for (invocations) |argv| {
        const r = parseAddremoveArgs(argv);
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
}

test "cli addremove B-addremove-10: a bare positional is an extra-argument error" {
    const r = parseAddremoveArgs(&.{"foo"});
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("foo", r.extra_argument);
}

test "cli addremove B-addremove-10: a token after -- is an extra-argument error" {
    inline for (.{ "foo", "--dry-run" }) |tok| {
        const r = parseAddremoveArgs(&.{ "--", tok });
        try std.testing.expect(r == .extra_argument);
        try std.testing.expectEqualStrings(tok, r.extra_argument);
    }
}

test "cli addremove B-addremove-10: a bare -- with nothing after it is not an error" {
    const r = parseAddremoveArgs(&.{"--"});
    try std.testing.expect(r == .ok);
    try std.testing.expect(!r.ok.dry_run);
    try std.testing.expect(!r.ok.help);
}

test "cli addremove B-addremove-13: verbOf resolves addremove and it is not planned" {
    try std.testing.expect(verbOf("addremove") == .addremove);
    try std.testing.expect(!isPlanned("addremove"));
}

test "cli leaves B-leaves-1: no args parse to the default open-only listing" {
    const r = parseLeavesArgs(&.{});
    try std.testing.expect(r == .ok);
    try std.testing.expect(!r.ok.all);
    try std.testing.expect(!r.ok.help);
}

test "cli leaves §7.4: --all selects the closed-inclusive listing" {
    const r = parseLeavesArgs(&.{"--all"});
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.all);
    try std.testing.expect(!r.ok.help);
}

test "cli leaves B-leaves-3: -h and --help set help" {
    for ([_][]const u8{ "-h", "--help" }) |flag| {
        const r = parseLeavesArgs(&.{flag});
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
}

test "cli leaves B-leaves-4: --help wins over an otherwise-invalid token" {
    const r = parseLeavesArgs(&.{ "-h", "-x" });
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.help);
}

test "cli leaves B-leaves-5: an unknown option carries its exact token" {
    const r = parseLeavesArgs(&.{"-x"});
    try std.testing.expect(r == .unknown_option);
    try std.testing.expectEqualStrings("-x", r.unknown_option);
}

test "cli leaves B-leaves-6: a bare positional is an extra-argument error" {
    const r = parseLeavesArgs(&.{"foo"});
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("foo", r.extra_argument);
}

test "cli leaves B-leaves-7: a token after -- is an extra-argument error" {
    const r = parseLeavesArgs(&.{ "--", "--all" });
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("--all", r.extra_argument);
}

test "cli leaves: --no-cache sets no_cache and composes with --all" {
    const r = parseLeavesArgs(&.{ "--all", "--no-cache" });
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.all);
    try std.testing.expect(r.ok.no_cache);
    // The default omits it — no behavior change without the flag.
    const d = parseLeavesArgs(&.{});
    try std.testing.expect(!d.ok.no_cache);
}

test "cli tag B-tag-1: 'list' with no CHECKIN parses to the no-arg listing" {
    const r = parseTagArgs(&.{"list"});
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.checkin == null);
    try std.testing.expect(!r.ok.help);
}

test "cli tag B-tag-2: 'ls' is an alias for 'list'" {
    const r = parseTagArgs(&.{"ls"});
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.checkin == null);
}

test "cli tag B-tag-3: 'list CHECKIN' captures the positional" {
    const r = parseTagArgs(&.{ "list", "abc" });
    try std.testing.expect(r == .ok);
    try std.testing.expectEqualStrings("abc", r.ok.checkin.?);
}

test "cli tag B-tag-4: -h and --help set help" {
    for ([_][]const u8{ "-h", "--help" }) |flag| {
        const r = parseTagArgs(&.{ "list", flag });
        try std.testing.expect(r == .ok);
        try std.testing.expect(r.ok.help);
    }
}

test "cli tag B-tag-5: --help wins over a missing subcommand" {
    const r = parseTagArgs(&.{"--help"});
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.help);
}

test "cli tag B-tag-6: no subcommand is a bad_subcommand with an empty token" {
    const r = parseTagArgs(&.{});
    try std.testing.expect(r == .bad_subcommand);
    try std.testing.expectEqualStrings("", r.bad_subcommand);
}

test "cli tag B-tag-7: an unrecognized subcommand carries its exact token" {
    // Each stand-in for "not a subcommand" has in turn become one: `add` at
    // REGRESSION B6, `cancel` later. `find` is fossil's, and is not ours.
    const r = parseTagArgs(&.{"find"});
    try std.testing.expect(r == .bad_subcommand);
    try std.testing.expectEqualStrings("find", r.bad_subcommand);
}

test "cli REGRESSION B6: 'add NAME CHECKIN' parses into the add subcommand" {
    const r = parseTagArgs(&.{ "add", "v1", "abc" });
    try std.testing.expect(r == .ok);
    try std.testing.expectEqual(TagSub.add, r.ok.sub);
    try std.testing.expectEqualStrings("v1", r.ok.name.?);
    try std.testing.expectEqualStrings("abc", r.ok.checkin.?);
    try std.testing.expect(!r.ok.help);
}

test "cli REGRESSION B6: 'list' leaves the add-only fields unset" {
    const r = parseTagArgs(&.{ "list", "abc" });
    try std.testing.expect(r == .ok);
    try std.testing.expectEqual(TagSub.list, r.ok.sub);
    try std.testing.expect(r.ok.name == null);
}

test "cli REGRESSION B6: add's two positionals are both required" {
    try std.testing.expect(parseTagArgs(&.{"add"}) == .no_name);
    try std.testing.expect(parseTagArgs(&.{ "add", "v1" }) == .no_checkin);
}

test "cli: add's fourth positional is VALUE; the fifth is an extra argument" {
    const v = parseTagArgs(&.{ "add", "v1", "abc", "def" });
    try std.testing.expect(v == .ok);
    try std.testing.expectEqualStrings("def", v.ok.value.?);
    const r = parseTagArgs(&.{ "add", "v1", "abc", "def", "ghi" });
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("ghi", r.extra_argument);
    // `cancel` takes no VALUE, so its fourth positional is already extra.
    const c = parseTagArgs(&.{ "cancel", "v1", "abc", "def" });
    try std.testing.expect(c == .extra_argument);
    try std.testing.expectEqualStrings("def", c.extra_argument);
}

test "cli: the namespace and sign flags parse, and --propagate is refused off add" {
    const raw = parseTagArgs(&.{ "add", "--raw", "ci-pass", "abc", "run-1" });
    try std.testing.expect(raw == .ok);
    try std.testing.expect(raw.ok.raw);
    try std.testing.expectEqualStrings("ci-pass", raw.ok.name.?);
    try std.testing.expectEqualStrings("run-1", raw.ok.value.?);

    const cancel = parseTagArgs(&.{ "cancel", "v1", "abc" });
    try std.testing.expect(cancel == .ok);
    try std.testing.expectEqual(TagSub.cancel, cancel.ok.sub);
    try std.testing.expect(!cancel.ok.raw);

    const listing = parseTagArgs(&.{ "list", "--raw" });
    try std.testing.expect(listing == .ok);
    try std.testing.expect(listing.ok.raw);

    // Inert where it cannot apply, so it is named rather than ignored.
    for ([_][]const []const u8{
        &.{ "cancel", "--propagate", "v1", "abc" },
        &.{ "list", "--propagate" },
    }) |args| {
        const r = parseTagArgs(args);
        try std.testing.expect(r == .option_not_for_subcommand);
        try std.testing.expectEqualStrings("--propagate", r.option_not_for_subcommand);
    }
}

test "cli REGRESSION B6: --help and a bad option win over add's arity" {
    // Arity is checked last, so a user who typo'd an option is told about the
    // option, and `--help` still teaches the grammar instead of scolding.
    const h = parseTagArgs(&.{ "add", "--help" });
    try std.testing.expect(h == .ok);
    try std.testing.expect(h.ok.help);
    const u = parseTagArgs(&.{ "add", "-x" });
    try std.testing.expect(u == .unknown_option);
    try std.testing.expectEqualStrings("-x", u.unknown_option);
}

test "cli REGRESSION B6: add takes --no-cache and honors --" {
    const r = parseTagArgs(&.{ "add", "v1", "abc", "--no-cache" });
    try std.testing.expect(r == .ok);
    try std.testing.expect(r.ok.no_cache);
    // After `--`, an option-looking NAME is a positional like any other.
    const d = parseTagArgs(&.{ "--", "add", "-v1", "abc" });
    try std.testing.expect(d == .ok);
    try std.testing.expectEqualStrings("-v1", d.ok.name.?);
}

test "cli tag B-tag-8: an unknown option carries its exact token" {
    const r = parseTagArgs(&.{ "list", "-x" });
    try std.testing.expect(r == .unknown_option);
    try std.testing.expectEqualStrings("-x", r.unknown_option);
}

test "cli tag B-tag-9: a second positional is an extra-argument error" {
    const r = parseTagArgs(&.{ "list", "abc", "def" });
    try std.testing.expect(r == .extra_argument);
    try std.testing.expectEqualStrings("def", r.extra_argument);
}

test "cli tag B-tag-10: a token after -- is a positional" {
    const r = parseTagArgs(&.{ "--", "list", "abc" });
    try std.testing.expect(r == .ok);
    try std.testing.expectEqualStrings("abc", r.ok.checkin.?);
}

test "cli tag: --no-cache sets no_cache alongside the subcommand and CHECKIN" {
    const r = parseTagArgs(&.{ "list", "abc", "--no-cache" });
    try std.testing.expect(r == .ok);
    try std.testing.expectEqualStrings("abc", r.ok.checkin.?);
    try std.testing.expect(r.ok.no_cache);
    // Absent ⇒ false.
    const d = parseTagArgs(&.{"list"});
    try std.testing.expect(!d.ok.no_cache);
}

test "cli info: names, -F, --no-cache, help, and the error surfaces" {
    const gpa = std.testing.allocator;
    const r = try parseInfoArgs(gpa, &.{ "trunk", "--no-cache" });
    defer gpa.free(r.ok.names);
    try std.testing.expectEqual(@as(usize, 1), r.ok.names.len);
    try std.testing.expectEqualStrings("trunk", r.ok.names[0]);
    try std.testing.expect(r.ok.no_cache);
    try std.testing.expect(r.ok.format == .text);

    // Many names, in argv order, with `-` kept as a positional for the runner.
    const many = try parseInfoArgs(gpa, &.{ "a", "-F", "json", "-", "b" });
    defer gpa.free(many.ok.names);
    try std.testing.expectEqual(@as(usize, 3), many.ok.names.len);
    try std.testing.expectEqualStrings("-", many.ok.names[1]);
    try std.testing.expectEqualStrings("b", many.ok.names[2]);
    try std.testing.expect(many.ok.format == .json);

    // No names at all is fine (the bare checkout form).
    const none = try parseInfoArgs(gpa, &.{});
    try std.testing.expectEqual(@as(usize, 0), none.ok.names.len);
    try std.testing.expect(!none.ok.no_cache);

    // An unknown -F value and an unknown option are both retained; help wins.
    const badf = try parseInfoArgs(gpa, &.{ "-F", "yaml", "trunk" });
    try std.testing.expectEqualStrings("yaml", badf.unknown_option);
    const bad = try parseInfoArgs(gpa, &.{ "--bogus", "trunk" });
    try std.testing.expectEqualStrings("--bogus", bad.unknown_option);
    const help = try parseInfoArgs(gpa, &.{ "--bogus", "-h" });
    try std.testing.expect(help.ok.help);
}

test "cli branch: parses the empty, --no-cache, help, and error surfaces" {
    // Default: no flags.
    const empty = parseBranchArgs(&.{});
    try std.testing.expect(empty == .ok);
    try std.testing.expect(!empty.ok.no_cache and !empty.ok.help);

    // --no-cache sets the bypass.
    const nc = parseBranchArgs(&.{"--no-cache"});
    try std.testing.expect(nc == .ok);
    try std.testing.expect(nc.ok.no_cache);

    // Help wins over an otherwise-invalid token.
    const h = parseBranchArgs(&.{ "-x", "--help" });
    try std.testing.expect(h == .ok);
    try std.testing.expect(h.ok.help);

    // An unknown option and a bare positional carry their exact tokens.
    const opt = parseBranchArgs(&.{"-x"});
    try std.testing.expect(opt == .unknown_option);
    try std.testing.expectEqualStrings("-x", opt.unknown_option);
    const pos = parseBranchArgs(&.{"feature"});
    try std.testing.expect(pos == .extra_argument);
    try std.testing.expectEqualStrings("feature", pos.extra_argument);

    // After --, even --no-cache is an extra argument (no positional grammar).
    const term = parseBranchArgs(&.{ "--", "--no-cache" });
    try std.testing.expect(term == .extra_argument);
    try std.testing.expectEqualStrings("--no-cache", term.extra_argument);
}

// --- DX D2/D4: git-reflex lessons + did-you-mean (pure) --------------------

test "cli D2: every git-reflex verb resolves to a non-empty educating lesson" {
    for (reflex_table) |entry| {
        const lesson = reflexLesson(entry.verb) orelse return error.TestMissingLesson;
        try std.testing.expect(lesson.len > 0);
    }
}

test "cli D2: a real command is not treated as a git-reflex verb" {
    try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("init"));
}

test "cli D2: no verb is left as a planned stub; every planned verb would resolve" {
    // The planned table is currently empty — `clone` graduated to a real
    // handler. Whatever it holds, each entry must resolve so no help-mentioned
    // verb dead-ends.
    for (planned_verbs) |v| {
        try std.testing.expect(isPlanned(v));
    }
    // `clone` is now a real, dispatched verb — not a planned stub.
    try std.testing.expectEqual(Verb.clone, verbOf("clone"));
    try std.testing.expect(!isPlanned("clone"));
    // A git-reflex verb and real commands are not planned either.
    try std.testing.expect(!isPlanned("rebase"));
    try std.testing.expect(!isPlanned("init"));
    try std.testing.expect(!isPlanned("status"));
    try std.testing.expect(!isPlanned("commit"));
}

test "cli artifact B-artifact-14: verbOf resolves artifact (not planned); whatis is not a sibling verb" {
    // `artifact` joins the single-source `Verb` enum; `verbOf` derives its
    // spelling from the tag name, and it is a real dispatched handler, never a
    // planned stub.
    try std.testing.expectEqual(Verb.artifact, verbOf("artifact"));
    try std.testing.expect(!isPlanned("artifact"));
    // No `whatis` sibling — the summary IS the whatis mode (single spelling).
    try std.testing.expectEqual(Verb.unknown, verbOf("whatis"));
}

test "cli undo B-undo-21: verbOf resolves undo and redo, and neither is a planned stub" {
    // Both verbs join the single-source `Verb` enum in this commit; `verbOf`
    // derives their spellings from the tag names, and the did-you-mean set picks
    // them up automatically.
    try std.testing.expectEqual(Verb.undo, verbOf("undo"));
    try std.testing.expectEqual(Verb.redo, verbOf("redo"));
    try std.testing.expect(!isPlanned("undo"));
    try std.testing.expect(!isPlanned("redo"));
    // A near-miss suggests the real verb, proving they are in the candidate set.
    try std.testing.expectEqualStrings("undo", didYouMean("undp").?);
    try std.testing.expectEqualStrings("redo", didYouMean("rdo").?);
}

test "cli merge B-merge-29: merge joins the Verb enum, resolves, and is not a planned stub" {
    // `merge` is a real, dispatched verb wired through the single-source `Verb`
    // enum, so `verbOf` derives its spelling from the tag name and the
    // did-you-mean set picks it up automatically. It is NOT a planned stub.
    try std.testing.expectEqual(Verb.merge, verbOf("merge"));
    try std.testing.expect(!isPlanned("merge"));
    // It reuses the one-positional `parseUpdateArgs` template: one NAME, help
    // the only flag, an unknown option and a second positional are offenses.
    try std.testing.expectEqualStrings("feature", parseUpdateArgs(&.{"feature"}).ok.checkin.?);
    try std.testing.expect(parseUpdateArgs(&.{"--help"}).ok.help);
    try std.testing.expect(parseUpdateArgs(&.{"-x"}) == .unknown_option);
    try std.testing.expect(parseUpdateArgs(&.{ "a", "b" }) == .extra_argument);
    // A near-miss suggests the real verb, proving it is in the candidate set.
    try std.testing.expectEqualStrings("merge", didYouMean("mrge").?);
}

test "cli parseMergeArgs: flagless grammar plus the two baseline flags (merge-flags.md)" {
    // Flagless: byte-identical to the one-positional template — one NAME, help,
    // unknown option, second positional (B-merge-31).
    {
        const p = parseMergeArgs(&.{"feature"}).ok;
        try std.testing.expectEqualStrings("feature", p.checkin.?);
        try std.testing.expect(p.special == null);
    }
    try std.testing.expect(parseMergeArgs(&.{"--help"}).ok.help);
    try std.testing.expect(parseMergeArgs(&.{"-x"}) == .unknown_option);
    try std.testing.expect(parseMergeArgs(&.{ "a", "b" }) == .extra_argument);
    // The two baseline flags each take the one positional NAME.
    {
        const p = parseMergeArgs(&.{ "--cherrypick", "rev" }).ok;
        try std.testing.expect(p.special.? == .cherrypick);
        try std.testing.expectEqualStrings("rev", p.checkin.?);
    }
    {
        const p = parseMergeArgs(&.{ "--backout", "rev" }).ok;
        try std.testing.expect(p.special.? == .backout);
        try std.testing.expectEqualStrings("rev", p.checkin.?);
    }
    // Order-independent: the flag may trail the NAME.
    try std.testing.expect(parseMergeArgs(&.{ "rev", "--backout" }).ok.special.? == .backout);
    // Both flags together is a conflict (B-merge-40); help still wins over it.
    try std.testing.expect(parseMergeArgs(&.{ "--cherrypick", "--backout", "rev" }) == .conflicting_flags);
    try std.testing.expect(parseMergeArgs(&.{ "--cherrypick", "--backout", "--help" }).ok.help);
    // A flag with no NAME leaves checkin null — the runner renders the no-name
    // usage error after help's chance to win.
    try std.testing.expect(parseMergeArgs(&.{"--cherrypick"}).ok.checkin == null);
    // Repeating the SAME flag is idempotent, not a conflict.
    try std.testing.expect(parseMergeArgs(&.{ "--cherrypick", "--cherrypick", "rev" }).ok.special.? == .cherrypick);
}

test "cli clone: URL then DIR parse as the two positionals" {
    const r = parseCloneArgs(&.{ "http://h.example/repo", "dest" });
    try std.testing.expect(r == .ok);
    try std.testing.expectEqualStrings("http://h.example/repo", r.ok.url);
    try std.testing.expectEqualStrings("dest", r.ok.dir);
    try std.testing.expect(!r.ok.help);
}

test "cli clone: a missing URL and a missing DIR are distinct usage errors" {
    try std.testing.expect(parseCloneArgs(&.{}) == .no_url);
    try std.testing.expect(parseCloneArgs(&.{"http://h.example/"}) == .no_dir);
    // DIR defaults to the URL's last path segment, '.fossil' dropped.
    try std.testing.expectEqualStrings("repo", parseCloneArgs(&.{"http://h.example/repo"}).ok.dir);
    try std.testing.expectEqualStrings("proj", parseCloneArgs(&.{"http://h.example/a/proj.fossil"}).ok.dir);
    try std.testing.expect(parseCloneArgs(&.{"http://h.example/repo"}).ok.open);
    try std.testing.expect(!parseCloneArgs(&.{ "http://h.example/repo", "--no-open" }).ok.open);
}

test "cli clone: --help wins over missing positionals; a third positional is extra" {
    try std.testing.expect(parseCloneArgs(&.{"--help"}).ok.help);
    try std.testing.expect(parseCloneArgs(&.{"-h"}).ok.help);
    const extra = parseCloneArgs(&.{ "url", "dir", "surplus" });
    try std.testing.expect(extra == .extra_argument);
    try std.testing.expectEqualStrings("surplus", extra.extra_argument);
    const bad = parseCloneArgs(&.{ "--nope", "url", "dir" });
    try std.testing.expect(bad == .unknown_option);
    try std.testing.expectEqualStrings("--nope", bad.unknown_option);
}

test "cli pull: a single URL parses as the sole positional" {
    const r = parsePullArgs(&.{"http://h.example/xfer"});
    try std.testing.expect(r == .ok);
    try std.testing.expectEqualStrings("http://h.example/xfer", r.ok.url);
    try std.testing.expect(!r.ok.help);
}

test "cli pull: a missing URL is a usage error; --help wins; a second positional is extra" {
    try std.testing.expect(parsePullArgs(&.{}) == .no_url);
    try std.testing.expect(parsePullArgs(&.{"--help"}).ok.help);
    try std.testing.expect(parsePullArgs(&.{"-h"}).ok.help);
    const extra = parsePullArgs(&.{ "url", "surplus" });
    try std.testing.expect(extra == .extra_argument);
    try std.testing.expectEqualStrings("surplus", extra.extra_argument);
    const bad = parsePullArgs(&.{ "--nope", "url" });
    try std.testing.expect(bad == .unknown_option);
    try std.testing.expectEqualStrings("--nope", bad.unknown_option);
}

test "cli pull: 'pull' is a dispatched verb, not a git-reflex redirect" {
    try std.testing.expectEqual(Verb.pull, verbOf("pull"));
    try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("pull"));
    try std.testing.expect(!isPlanned("pull"));
}

test "cli push: a single URL parses as the sole positional; no credential" {
    const r = parsePushArgs(&.{"http://h.example/xfer"});
    try std.testing.expect(r == .ok);
    try std.testing.expectEqualStrings("http://h.example/xfer", r.ok.url);
    try std.testing.expect(!r.ok.help);
    try std.testing.expectEqual(@as(?[]const u8, null), r.ok.user);
    try std.testing.expectEqual(@as(?[]const u8, null), r.ok.password);
}

test "cli push §6: --user and --password each consume the following token, order-independent of the URL" {
    const r = parsePushArgs(&.{ "--user", "alice", "http://h.example/xfer", "--password", "pw" });
    try std.testing.expect(r == .ok);
    try std.testing.expectEqualStrings("http://h.example/xfer", r.ok.url);
    try std.testing.expectEqualStrings("alice", r.ok.user.?);
    try std.testing.expectEqualStrings("pw", r.ok.password.?);

    // --user without --password: the password stays null (resolved later).
    const only_user = parsePushArgs(&.{ "http://h.example/xfer", "--user", "bob" });
    try std.testing.expectEqualStrings("bob", only_user.ok.user.?);
    try std.testing.expectEqual(@as(?[]const u8, null), only_user.ok.password);
}

test "cli push: an omitted URL means the saved remote AND keeps the credential; --help wins; a second positional is extra; a dangling value flag is unknown" {
    // A bare push has no URL and no credential: empty URL (saved remote at run
    // time), anonymous.
    const bare = parsePushArgs(&.{});
    try std.testing.expect(bare == .ok);
    try std.testing.expectEqualStrings("", bare.ok.url);
    try std.testing.expectEqual(@as(?[]const u8, null), bare.ok.user);
    // The regression: `push --user X --password Y` with no URL must keep the
    // credential, not drop it. Dropping it pushed anonymously to the saved
    // remote and the peer refused the write.
    const creds = parsePushArgs(&.{ "--user", "sa", "--password", "sekret" });
    try std.testing.expect(creds == .ok);
    try std.testing.expectEqualStrings("", creds.ok.url);
    try std.testing.expectEqualStrings("sa", creds.ok.user.?);
    try std.testing.expectEqualStrings("sekret", creds.ok.password.?);
    try std.testing.expect(parsePushArgs(&.{"--help"}).ok.help);
    try std.testing.expect(parsePushArgs(&.{"-h"}).ok.help);
    const extra = parsePushArgs(&.{ "url", "surplus" });
    try std.testing.expect(extra == .extra_argument);
    try std.testing.expectEqualStrings("surplus", extra.extra_argument);
    const bad = parsePushArgs(&.{ "--nope", "url" });
    try std.testing.expect(bad == .unknown_option);
    try std.testing.expectEqualStrings("--nope", bad.unknown_option);
    // A `--user` with no following token is the offending flag as unknown.
    const dangling = parsePushArgs(&.{ "http://h.example/xfer", "--user" });
    try std.testing.expect(dangling == .unknown_option);
    try std.testing.expectEqualStrings("--user", dangling.unknown_option);
}

test "cli push: 'push' is a dispatched verb, not a git-reflex redirect" {
    try std.testing.expectEqual(Verb.push, verbOf("push"));
    try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("push"));
    try std.testing.expect(!isPlanned("push"));
}

test "cli sync §4: an omitted URL positional parses (resolves to the saved remote at run time)" {
    // The distinguishing property vs push: no positional is NOT a usage error.
    const bare = parseSyncArgs(&.{});
    try std.testing.expect(bare == .ok);
    try std.testing.expectEqualStrings("", bare.ok.url); // empty → use the persisted remote
    try std.testing.expectEqual(@as(?[]const u8, null), bare.ok.user);

    // A given URL still parses as the sole positional, credential options intact.
    const with_url = parseSyncArgs(&.{ "--user", "alice", "http://h.example/xfer", "--password", "pw" });
    try std.testing.expect(with_url == .ok);
    try std.testing.expectEqualStrings("http://h.example/xfer", with_url.ok.url);
    try std.testing.expectEqualStrings("alice", with_url.ok.user.?);
    try std.testing.expectEqualStrings("pw", with_url.ok.password.?);
}

test "cli sync: --help wins; a second positional is extra; a dangling value flag is unknown" {
    try std.testing.expect(parseSyncArgs(&.{"--help"}).ok.help);
    try std.testing.expect(parseSyncArgs(&.{"-h"}).ok.help);
    const extra = parseSyncArgs(&.{ "url", "surplus" });
    try std.testing.expect(extra == .extra_argument);
    try std.testing.expectEqualStrings("surplus", extra.extra_argument);
    const dangling = parseSyncArgs(&.{"--user"});
    try std.testing.expect(dangling == .unknown_option);
    try std.testing.expectEqualStrings("--user", dangling.unknown_option);
}

test "cli sync: 'sync' is a dispatched verb, not a git-reflex redirect" {
    try std.testing.expectEqual(Verb.sync, verbOf("sync"));
    try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("sync"));
    try std.testing.expect(!isPlanned("sync"));
}

test "cli remote: bare prints, a URL sets, and --unset clears" {
    // Bare `zit remote` — neither a set nor a clear: the print query.
    const bare = parseRemoteArgs(&.{});
    try std.testing.expect(bare == .ok);
    try std.testing.expectEqualStrings("", bare.ok.url);
    try std.testing.expect(!bare.ok.unset);

    // `zit remote URL` — set.
    const set = parseRemoteArgs(&.{"http://h.example/xfer"});
    try std.testing.expect(set == .ok);
    try std.testing.expectEqualStrings("http://h.example/xfer", set.ok.url);
    try std.testing.expect(!set.ok.unset);

    // `zit remote --unset` — clear.
    const clear = parseRemoteArgs(&.{"--unset"});
    try std.testing.expect(clear == .ok);
    try std.testing.expect(clear.ok.unset);
    try std.testing.expectEqualStrings("", clear.ok.url);
}

test "cli remote: setting and clearing together is a usage error; --help wins; a second positional is extra" {
    try std.testing.expect(parseRemoteArgs(&.{"--help"}).ok.help);
    const conflict = parseRemoteArgs(&.{ "http://h.example/xfer", "--unset" });
    try std.testing.expect(conflict == .extra_argument);
    try std.testing.expectEqualStrings("http://h.example/xfer", conflict.extra_argument);
    const extra = parseRemoteArgs(&.{ "http://h.example/xfer", "surplus" });
    try std.testing.expect(extra == .extra_argument);
    try std.testing.expectEqualStrings("surplus", extra.extra_argument);
    const bad = parseRemoteArgs(&.{"--nope"});
    try std.testing.expect(bad == .unknown_option);
    try std.testing.expectEqualStrings("--nope", bad.unknown_option);
}

test "cli pull/clone: --user and --password are parsed as for push" {
    // A private repository is unreadable without a login, and pull and clone
    // used to have no way to give one; only push did.
    const p = parsePullArgs(&.{ "http://h.example/xfer", "--user", "alice", "--password", "pw" }).ok;
    try std.testing.expectEqualStrings("alice", p.user.?);
    try std.testing.expectEqualStrings("pw", p.password.?);
    try std.testing.expect(parsePullArgs(&.{"http://h.example/xfer"}).ok.user == null);
    try std.testing.expectEqualStrings("--user", parsePullArgs(&.{ "http://h.example/xfer", "--user" }).unknown_option);
    const c = parseCloneArgs(&.{ "http://h.example/xfer", "d", "--user", "alice" }).ok;
    try std.testing.expectEqualStrings("alice", c.user.?);
    try std.testing.expect(c.password == null);
    try std.testing.expectEqualStrings("d", c.dir);
}

test "cli remote: a positional that is not an http(s) URL is refused, not saved" {
    // `zit remote list` saved the word `list` as the remote and said so with
    // exit 0; the damage surfaced at the next push. The help defines URL as an
    // http:// or https:// address, and the parser now holds it to that.
    const list = parseRemoteArgs(&.{"list"});
    try std.testing.expectEqualStrings("list", list.not_a_url);
    try std.testing.expectEqualStrings("show", parseRemoteArgs(&.{"show"}).not_a_url);
    try std.testing.expectEqualStrings("h.example/xfer", parseRemoteArgs(&.{"h.example/xfer"}).not_a_url);
    try std.testing.expectEqualStrings("https://h.example/xfer", parseRemoteArgs(&.{"https://h.example/xfer"}).ok.url);
    // Order of offense is kept: a surplus argument is still reported first.
    try std.testing.expectEqualStrings("surplus", parseRemoteArgs(&.{ "list", "surplus" }).extra_argument);
}
test "cli remote: 'remote' is a dispatched verb, not a git-reflex redirect" {
    try std.testing.expectEqual(Verb.remote, verbOf("remote"));
    try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("remote"));
    try std.testing.expect(!isPlanned("remote"));
}

test "cli submanifests §8.1: a bare invocation shows, and each action parses to itself" {
    // `show` is the default because the read is the safe act: an operator who
    // types the verb to find out what it does must not change anything.
    const bare = parseSubmanifestArgs(&.{});
    try std.testing.expectEqual(SubmanifestAction.show, bare.ok.action);
    try std.testing.expect(bare.ok.k == null);
    for ([_]SubmanifestAction{ .show, .enable, .disable, .retune, .analyze }) |a| {
        const r = parseSubmanifestArgs(&.{@tagName(a)});
        try std.testing.expectEqual(a, r.ok.action);
    }
}

test "cli submanifests §6.3: --k parses its value and rejects what is not one" {
    const ok = parseSubmanifestArgs(&.{ "enable", "--k", "3" });
    try std.testing.expectEqual(SubmanifestAction.enable, ok.ok.action);
    try std.testing.expectEqual(@as(?u8, 3), ok.ok.k);
    // The parser reports what was TYPED; §6.3's 1..12 rule is the library's, so
    // an in-syntax but out-of-range value parses here and is refused by the verb
    // (which is what lets the same range live in exactly one place).
    try std.testing.expectEqual(@as(?u8, 13), parseSubmanifestArgs(&.{ "enable", "--k", "13" }).ok.k);
    const nan = parseSubmanifestArgs(&.{ "enable", "--k", "three" });
    try std.testing.expect(nan == .bad_k);
    try std.testing.expectEqualStrings("three", nan.bad_k);
    const over = parseSubmanifestArgs(&.{ "enable", "--k", "999" }); // past u8
    try std.testing.expect(over == .bad_k);
    const missing = parseSubmanifestArgs(&.{ "enable", "--k" });
    try std.testing.expect(missing == .bad_k);
    try std.testing.expectEqualStrings("--k", missing.bad_k);
}

test "cli submanifests: --names takes a path, help wins, and a bad token is retained" {
    const named = parseSubmanifestArgs(&.{ "analyze", "--names", "list.txt" });
    try std.testing.expectEqual(SubmanifestAction.analyze, named.ok.action);
    try std.testing.expectEqualStrings("list.txt", named.ok.names_path.?);
    try std.testing.expect(parseSubmanifestArgs(&.{"--names"}) == .missing_value);
    // Help wins over every otherwise-invalid token (the whole-CLI rule).
    try std.testing.expect(parseSubmanifestArgs(&.{ "--nope", "--help" }).ok.help);
    try std.testing.expect(parseSubmanifestArgs(&.{"-h"}).ok.help);
    const bad = parseSubmanifestArgs(&.{"--nope"});
    try std.testing.expect(bad == .unknown_option);
    try std.testing.expectEqualStrings("--nope", bad.unknown_option);
    // A word that is not an action is an extra argument, not a silent `show`.
    const unknown_action = parseSubmanifestArgs(&.{"enabel"});
    try std.testing.expect(unknown_action == .extra_argument);
    try std.testing.expectEqualStrings("enabel", unknown_action.extra_argument);
    const second = parseSubmanifestArgs(&.{ "enable", "disable" });
    try std.testing.expect(second == .extra_argument);
    try std.testing.expectEqualStrings("disable", second.extra_argument);
}

test "cli submanifests: 'submanifest' is a dispatched verb; -R lifts for its analyze mode" {
    try std.testing.expectEqual(Verb.submanifest, verbOf("submanifest"));
    // The lift admits -R for the verb (`analyze -R` measures a bare
    // store); the RUNNER refuses it for every settings-writing action, the
    // same mode-conditional shape `diff` uses. Pinned end to end by
    // the "submanifest analyze -R" e2e.
    try std.testing.expect(acceptsRepoOverride(.submanifest));
}

test "cli D2: a planned verb is not also a git-reflex verb (no double-classification)" {
    for (planned_verbs) |v| {
        try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson(v));
    }
}

test "cli D4: a near-miss (edit distance 1) suggests the closest command" {
    try std.testing.expectEqualStrings("init", didYouMean("ini").?);
    try std.testing.expectEqualStrings("init", didYouMean("innit").?);
    try std.testing.expectEqualStrings("help", didYouMean("hepl").?);
    try std.testing.expectEqualStrings("version", didYouMean("versoin").?);
    try std.testing.expectEqualStrings("diff", didYouMean("dif").?);
}

test "cli D4: a typo of a planned verb suggests it (planned verbs join the near-miss set)" {
    try std.testing.expectEqualStrings("commit", didYouMean("comit").?);
    try std.testing.expectEqualStrings("status", didYouMean("stauts").?);
    try std.testing.expectEqualStrings("clone", didYouMean("clon").?);
}

test "cli rm: the git synonyms 'remove'/'delete' ARE 'rm' — aliases, not suggestions" {
    try std.testing.expectEqual(Verb.rm, verbOf("remove"));
    try std.testing.expectEqual(Verb.rm, verbOf("delete"));
    try std.testing.expectEqual(Verb.rm, verbOf("forget"));
}

test "cli: aliases resolve exactly; prefixes resolve when unambiguous; exact verbs win" {
    try std.testing.expectEqual(Verb.commit, verbOf("ci"));
    try std.testing.expectEqual(Verb.commit, verbOf("checkin"));
    try std.testing.expectEqual(Verb.update, verbOf("co"));
    try std.testing.expectEqual(Verb.update, verbOf("checkout"));
    try std.testing.expectEqual(Verb.status, verbOf("changes"));
    try std.testing.expectEqual(Verb.mv, verbOf("rename"));
    try std.testing.expectEqual(Verb.annotate, verbOf("blame"));
    try std.testing.expectEqual(Verb.annotate, verbOf("praise"));
    try std.testing.expectEqual(Verb.init, verbOf("new"));
    try std.testing.expectEqual(Verb.precedent, verbOf("rerere"));
    // verbOf never resolves a prefix.
    try std.testing.expectEqual(Verb.unknown, verbOf("tim"));
    // resolveVerb does, when it is unambiguous.
    try std.testing.expectEqual(Verb.timeline, resolveVerb("tim").verb);
    try std.testing.expectEqual(Verb.update, resolveVerb("up").verb);
    try std.testing.expectEqual(Verb.import_fossil, resolveVerb("import-f").verb);
    // An exact verb beats the prefix rule: `stat` is the census, not ambiguous.
    try std.testing.expectEqual(Verb.stat, resolveVerb("stat").verb);
    // Ambiguity names its candidates.
    const amb = resolveVerb("ti").ambiguous;
    try std.testing.expectEqual(@as(usize, 2), amb.n);
    try std.testing.expectEqualStrings("timeline", amb.names[0]);
    try std.testing.expectEqualStrings("ticket", amb.names[1]);
    try std.testing.expect(resolveVerb("sta") == .ambiguous);
    // Aliases are prefix candidates; spellings of one verb collapse.
    try std.testing.expect(resolveVerb("ch") == .ambiguous);
    try std.testing.expectEqual(Verb.rm, resolveVerb("remov").verb);
    try std.testing.expectEqual(Verb.rm, resolveVerb("forg").verb);
    try std.testing.expectEqual(Verb.mv, resolveVerb("ren").verb);
    try std.testing.expectEqualStrings("statu", shortestPrefix("status"));
    try std.testing.expectEqualStrings("stas", shortestPrefix("stash"));
    try std.testing.expectEqualStrings("stat", shortestPrefix("stat"));
    try std.testing.expectEqualStrings("tim", shortestPrefix("timeline"));
    // Too short, non-alphabetic, or matching nothing: unknown.
    try std.testing.expect(resolveVerb("t") == .unknown);
    try std.testing.expect(resolveVerb("-x") == .unknown);
    try std.testing.expect(resolveVerb("florble") == .unknown);
}

test "cli: a short typo is only suggested at distance 1, so 'tim' never suggests 'rm'" {
    try std.testing.expectEqual(@as(?[]const u8, null), didYouMean("xyz"));
    try std.testing.expectEqualStrings("rm", didYouMean("rn").?);
}

test "cli D4: a distant token yields no suggestion" {
    try std.testing.expectEqual(@as(?[]const u8, null), didYouMean("florble"));
    try std.testing.expectEqual(@as(?[]const u8, null), didYouMean(""));
}

test "cli D4: an over-long token is not scored (bounded), yields no suggestion" {
    const long = "i" ** (max_suggest_input + 1);
    try std.testing.expectEqual(@as(?[]const u8, null), didYouMean(long));
}

test "cli D4: editDistance matches known small cases" {
    try std.testing.expectEqual(@as(usize, 0), editDistance("init", "init"));
    try std.testing.expectEqual(@as(usize, 1), editDistance("ini", "init"));
    try std.testing.expectEqual(@as(usize, 2), editDistance("inti", "init"));
}

// --- mv parser (contracts/mv.md B-mv-12/-13/-14, pure; no I/O) --------------

test "cli mv: operands collect in order; --soft/-n are flags, --hard cancels --soft; -- ends options" {
    const gpa = std.testing.allocator;
    const r = try parseMvArgs(gpa, &.{ "a", "b", "c" });
    defer gpa.free(r.ok.operands);
    try std.testing.expectEqual(@as(usize, 3), r.ok.operands.len);
    try std.testing.expectEqualStrings("c", r.ok.operands[2]);
    try std.testing.expect(!r.ok.soft and !r.ok.dry_run);
    const f = try parseMvArgs(gpa, &.{ "--soft", "-n", "x", "y" });
    defer gpa.free(f.ok.operands);
    try std.testing.expect(f.ok.soft and f.ok.dry_run);
    const hd = try parseMvArgs(gpa, &.{ "--soft", "--hard", "x", "y" });
    defer gpa.free(hd.ok.operands);
    try std.testing.expect(!hd.ok.soft);
    const dd = try parseMvArgs(gpa, &.{ "--", "-x", "new" });
    defer gpa.free(dd.ok.operands);
    try std.testing.expectEqualStrings("-x", dd.ok.operands[0]);
    const bad = try parseMvArgs(gpa, &.{ "--force", "a", "b" });
    try std.testing.expect(bad == .unknown_option);
    const h = try parseMvArgs(gpa, &.{ "--help", "--force" });
    defer gpa.free(h.ok.operands);
    try std.testing.expect(h.ok.help);
    const none = try parseMvArgs(gpa, &.{});
    defer gpa.free(none.ok.operands);
    try std.testing.expectEqual(@as(usize, 0), none.ok.operands.len);
}

// --- add parser (contracts/add.md B-add-1/-9/-10, pure; no I/O) -------------

/// Parses `add` args with the test allocator and asserts the ok branch; caller
/// receives the `AddArgs` and must free `args.paths`.
fn parseAddOk(verb_args: []const []const u8) !AddArgs {
    const r = try parseAddArgs(std.testing.allocator, verb_args);
    try std.testing.expect(r == .ok);
    return r.ok;
}

test "cli add B-add-1: multiple positionals accumulate in argument order" {
    const a = try parseAddOk(&.{ "a", "b", "c" });
    defer std.testing.allocator.free(a.paths);
    try std.testing.expectEqual(@as(usize, 3), a.paths.len);
    try std.testing.expectEqualStrings("a", a.paths[0]);
    try std.testing.expectEqualStrings("b", a.paths[1]);
    try std.testing.expectEqualStrings("c", a.paths[2]);
    try std.testing.expect(!a.dry_run and !a.quiet and !a.help);
}

test "cli add B-add-1: -n and --dry-run both set dry_run" {
    inline for (.{ "-n", "--dry-run" }) |flag| {
        const a = try parseAddOk(&.{ flag, "x" });
        defer std.testing.allocator.free(a.paths);
        try std.testing.expect(a.dry_run);
        try std.testing.expectEqual(@as(usize, 1), a.paths.len);
    }
}

test "cli add B-add-1: -q and --quiet both set quiet" {
    inline for (.{ "-q", "--quiet" }) |flag| {
        const a = try parseAddOk(&.{ flag, "x" });
        defer std.testing.allocator.free(a.paths);
        try std.testing.expect(a.quiet);
    }
}

test "cli add B-add-1/-13: -h and --help set help" {
    inline for (.{ "-h", "--help" }) |flag| {
        const a = try parseAddOk(&.{flag});
        defer std.testing.allocator.free(a.paths);
        try std.testing.expect(a.help);
    }
}

test "cli add B-add-1: -- flips to positional-only so a file named -q is reachable" {
    const a = try parseAddOk(&.{ "--", "-q", "x" });
    defer std.testing.allocator.free(a.paths);
    try std.testing.expect(!a.quiet); // -q after -- is a path, not the flag
    try std.testing.expectEqual(@as(usize, 2), a.paths.len);
    try std.testing.expectEqualStrings("-q", a.paths[0]);
    try std.testing.expectEqualStrings("x", a.paths[1]);
}

test "cli add B-add-1: a lone dash is a path, not an option" {
    const a = try parseAddOk(&.{"-"});
    defer std.testing.allocator.free(a.paths);
    try std.testing.expectEqual(@as(usize, 1), a.paths.len);
    try std.testing.expectEqualStrings("-", a.paths[0]);
}

test "cli add B-add-10: an unknown option carries its exact token" {
    const r = try parseAddArgs(std.testing.allocator, &.{ "a", "-x", "b" });
    try std.testing.expect(r == .unknown_option);
    try std.testing.expectEqualStrings("-x", r.unknown_option);
}

test "cli add B-add-13: --help wins over an unknown option regardless of order" {
    // The explicit B-add-13 precedence ("help wins over any other token") beats
    // the B-add-10 unknown-option verdict, whichever token comes first.
    inline for (.{
        &[_][]const u8{ "--help", "-x" },
        &[_][]const u8{ "-x", "--help" },
        &[_][]const u8{ "-x", "-h", "y" },
    }) |argv| {
        const a = try parseAddOk(argv);
        defer std.testing.allocator.free(a.paths);
        try std.testing.expect(a.help);
    }
    // Without --help, the unknown option is still the verdict (unchanged).
    const r = try parseAddArgs(std.testing.allocator, &.{ "-x", "y" });
    try std.testing.expect(r == .unknown_option);
    try std.testing.expectEqualStrings("-x", r.unknown_option);
}

test "cli add B-add-9: no positionals yields an empty paths slice" {
    inline for (.{ &[_][]const u8{}, &[_][]const u8{"-q"}, &[_][]const u8{"--"} }) |argv| {
        const a = try parseAddOk(argv);
        defer std.testing.allocator.free(a.paths);
        try std.testing.expectEqual(@as(usize, 0), a.paths.len);
    }
}

test "cli add B-add-9: usageError renders the no-paths message and hint, returns usage" {
    var buf: [128]u8 = undefined;
    var w = Io.Writer.fixed(&buf);
    var sink: [1]u8 = undefined;
    var wnull = Io.Writer.fixed(&sink);
    const s: Streams = .{ .out = &wnull, .err = &w };
    const code = usageError(s, "add", .no_paths, "");
    try std.testing.expectEqual(Exit.usage, code);
    try std.testing.expectEqualStrings("zit add: no paths given\ntry 'zit add --help'\n", w.buffered());
}

// --- rm parser (contracts/rm.md B-rm-1/-9/-10/-13, pure; no I/O) ------------

/// Parses `rm` args with the test allocator and asserts the ok branch; caller
/// receives the `RmArgs` and must free `args.paths`.
fn parseRmOk(verb_args: []const []const u8) !RmArgs {
    const r = try parseRmArgs(std.testing.allocator, verb_args);
    try std.testing.expect(r == .ok);
    return r.ok;
}

test "cli rm B-rm-1: multiple positionals accumulate in argument order" {
    const a = try parseRmOk(&.{ "a", "b", "c" });
    defer std.testing.allocator.free(a.paths);
    try std.testing.expectEqual(@as(usize, 3), a.paths.len);
    try std.testing.expectEqualStrings("a", a.paths[0]);
    try std.testing.expectEqualStrings("b", a.paths[1]);
    try std.testing.expectEqualStrings("c", a.paths[2]);
    try std.testing.expect(!a.dry_run and !a.hard and !a.quiet and !a.help);
}

test "cli rm B-rm-1: -n and --dry-run both set dry_run" {
    inline for (.{ "-n", "--dry-run" }) |flag| {
        const a = try parseRmOk(&.{ flag, "x" });
        defer std.testing.allocator.free(a.paths);
        try std.testing.expect(a.dry_run);
        try std.testing.expectEqual(@as(usize, 1), a.paths.len);
    }
}

test "cli rm B-rm-1: --hard sets hard (no short form)" {
    const a = try parseRmOk(&.{ "--hard", "x" });
    defer std.testing.allocator.free(a.paths);
    try std.testing.expect(a.hard);
    try std.testing.expectEqual(@as(usize, 1), a.paths.len);
}

test "cli rm B-rm-1: -q and --quiet both set quiet" {
    inline for (.{ "-q", "--quiet" }) |flag| {
        const a = try parseRmOk(&.{ flag, "x" });
        defer std.testing.allocator.free(a.paths);
        try std.testing.expect(a.quiet);
    }
}

test "cli rm B-rm-1/-13: -h and --help set help" {
    inline for (.{ "-h", "--help" }) |flag| {
        const a = try parseRmOk(&.{flag});
        defer std.testing.allocator.free(a.paths);
        try std.testing.expect(a.help);
    }
}

test "cli rm B-rm-1: -- flips to positional-only so a file named -q is reachable" {
    const a = try parseRmOk(&.{ "--", "-q", "--hard" });
    defer std.testing.allocator.free(a.paths);
    try std.testing.expect(!a.quiet and !a.hard); // flags after -- are paths
    try std.testing.expectEqual(@as(usize, 2), a.paths.len);
    try std.testing.expectEqualStrings("-q", a.paths[0]);
    try std.testing.expectEqualStrings("--hard", a.paths[1]);
}

test "cli rm B-rm-1: a lone dash is a path, not an option" {
    const a = try parseRmOk(&.{"-"});
    defer std.testing.allocator.free(a.paths);
    try std.testing.expectEqual(@as(usize, 1), a.paths.len);
    try std.testing.expectEqualStrings("-", a.paths[0]);
}

test "cli rm B-rm-10: an unknown option carries its exact token" {
    const r = try parseRmArgs(std.testing.allocator, &.{ "a", "-x", "b" });
    try std.testing.expect(r == .unknown_option);
    try std.testing.expectEqualStrings("-x", r.unknown_option);
}

test "cli rm B-rm-13: --help wins over an unknown option regardless of order" {
    inline for (.{
        &[_][]const u8{ "--help", "-x" },
        &[_][]const u8{ "-x", "--help" },
        &[_][]const u8{ "-x", "-h", "y" },
    }) |argv| {
        const a = try parseRmOk(argv);
        defer std.testing.allocator.free(a.paths);
        try std.testing.expect(a.help);
    }
    // Without --help, the unknown option is still the verdict (unchanged).
    const r = try parseRmArgs(std.testing.allocator, &.{ "-x", "y" });
    try std.testing.expect(r == .unknown_option);
    try std.testing.expectEqualStrings("-x", r.unknown_option);
}

test "cli rm B-rm-9: no positionals yields an empty paths slice" {
    inline for (.{
        &[_][]const u8{},
        &[_][]const u8{"-q"},
        &[_][]const u8{"--hard"},
        &[_][]const u8{"--"},
    }) |argv| {
        const a = try parseRmOk(argv);
        defer std.testing.allocator.free(a.paths);
        try std.testing.expectEqual(@as(usize, 0), a.paths.len);
    }
}

test "cli rm B-rm-9: usageError renders the no-paths message and hint, returns usage" {
    var buf: [128]u8 = undefined;
    var w = Io.Writer.fixed(&buf);
    var sink: [1]u8 = undefined;
    var wnull = Io.Writer.fixed(&sink);
    const s: Streams = .{ .out = &wnull, .err = &w };
    const code = usageError(s, "rm", .no_paths, "");
    try std.testing.expectEqual(Exit.usage, code);
    try std.testing.expectEqualStrings("zit rm: no paths given\ntry 'zit rm --help'\n", w.buffered());
}

// --- repoRelative (B-add-15 normalization / B-add-6 escape; pure) -----------

/// Resolves `arg` and asserts it yields a `.key`, returning it (caller frees).
fn repoRelKey(root_abs: []const u8, cwd_abs: []const u8, arg: []const u8) ![]const u8 {
    const r = try repoRelative(std.testing.allocator, root_abs, cwd_abs, arg);
    try std.testing.expect(r == .key);
    return r.key;
}

test "cli add B-add-15: ./a.txt, a.txt, sub/../a.txt all normalize to one key" {
    const root = "/repo";
    inline for (.{ "./a.txt", "a.txt", "sub/../a.txt" }) |arg| {
        const key = try repoRelKey(root, root, arg);
        defer std.testing.allocator.free(key);
        try std.testing.expectEqualStrings("a.txt", key);
    }
}

test "cli add B-add-15: a nested path keeps its repo-relative components" {
    const key = try repoRelKey("/repo", "/repo/sub", "b.txt");
    defer std.testing.allocator.free(key);
    try std.testing.expectEqualStrings("sub/b.txt", key);
}

test "cli add B-add-6: a .. climb above the root escapes (.outside)" {
    try std.testing.expect(
        (try repoRelative(std.testing.allocator, "/repo", "/repo", "../x")) == .outside,
    );
}

test "cli add B-add-6: an absolute path outside the root escapes (.outside); inside resolves" {
    try std.testing.expect(
        (try repoRelative(std.testing.allocator, "/repo", "/repo", "/elsewhere/x")) == .outside,
    );
    const key = try repoRelKey("/repo", "/repo", "/repo/inside/x");
    defer std.testing.allocator.free(key);
    try std.testing.expectEqualStrings("inside/x", key);
}

test "cli add B-add-5: the root itself resolves to .root (a directory), not an escape" {
    // `zit add .` at the checkout root: the root is a directory inside the
    // repository, so it must classify as `.root` (→ the directory diagnostic),
    // never `.outside` (→ "outside the repository").
    try std.testing.expect(
        (try repoRelative(std.testing.allocator, "/repo", "/repo", ".")) == .root,
    );
    try std.testing.expect(
        (try repoRelative(std.testing.allocator, "/repo", "/repo", "sub/..")) == .root,
    );
}

// --- commit parser (contracts/commit.md B-commit-1/-18a/-18b, pure; no I/O) --

/// Parses `commit` args with the test allocator and asserts the ok branch;
/// caller receives the `CommitArgs` and must free `args.paths`.
fn parseCommitOk(verb_args: []const []const u8) !CommitArgs {
    const r = try parseCommitArgs(std.testing.allocator, verb_args);
    try std.testing.expect(r == .ok);
    return r.ok;
}

test "cli commit B-commit-1: -m and --message set the message value" {
    inline for (.{ "-m", "--message" }) |flag| {
        const a = try parseCommitOk(&.{ flag, "a fix" });
        defer std.testing.allocator.free(a.paths);
        try std.testing.expectEqualStrings("a fix", a.message.?);
        try std.testing.expectEqual(@as(usize, 0), a.paths.len);
    }
}

test "cli commit B-commit-1: --user sets the author; -q sets quiet" {
    const a = try parseCommitOk(&.{ "-m", "x", "--user", "alice", "-q" });
    defer std.testing.allocator.free(a.paths);
    try std.testing.expectEqualStrings("alice", a.user.?);
    try std.testing.expect(a.quiet);
}

test "cli commit B-commit-1: positionals accumulate in argument order" {
    const a = try parseCommitOk(&.{ "-m", "x", "a", "b", "c" });
    defer std.testing.allocator.free(a.paths);
    try std.testing.expectEqual(@as(usize, 3), a.paths.len);
    try std.testing.expectEqualStrings("a", a.paths[0]);
    try std.testing.expectEqualStrings("b", a.paths[1]);
    try std.testing.expectEqualStrings("c", a.paths[2]);
}

test "cli commit B-commit-1: -- flips to positional-only so a file named -q is reachable" {
    const a = try parseCommitOk(&.{ "-m", "x", "--", "-q", "file" });
    defer std.testing.allocator.free(a.paths);
    try std.testing.expect(!a.quiet); // -q after -- is a path, not the flag
    try std.testing.expectEqual(@as(usize, 2), a.paths.len);
    try std.testing.expectEqualStrings("-q", a.paths[0]);
    try std.testing.expectEqualStrings("file", a.paths[1]);
}

test "cli commit B-commit-1: a lone dash is a path, not an option" {
    const a = try parseCommitOk(&.{ "-m", "x", "-" });
    defer std.testing.allocator.free(a.paths);
    try std.testing.expectEqual(@as(usize, 1), a.paths.len);
    try std.testing.expectEqualStrings("-", a.paths[0]);
}

test "cli commit B-commit-18b: an unknown option carries its exact token" {
    const r = try parseCommitArgs(std.testing.allocator, &.{ "-m", "x", "-z" });
    try std.testing.expect(r == .unknown_option);
    try std.testing.expectEqualStrings("-z", r.unknown_option);
}

test "cli commit B-commit-1/18b: a dangling -m or --user is an unknown option" {
    inline for (.{ "-m", "--message", "--user" }) |flag| {
        const r = try parseCommitArgs(std.testing.allocator, &.{flag});
        try std.testing.expect(r == .unknown_option);
        try std.testing.expectEqualStrings(flag, r.unknown_option);
    }
}

test "cli commit B-commit-18a: --help wins over an unknown option and a missing message" {
    inline for (.{
        &[_][]const u8{ "-z", "--help" },
        &[_][]const u8{ "--help", "-z" },
        &[_][]const u8{"-h"}, // no message, help still wins
        &[_][]const u8{"-m"}, // dangling -m, but no --help here → not this case
    }, 0..) |argv, idx| {
        if (idx == 3) {
            // The dangling-`-m`-without-help control: an unknown option, not ok.
            const r = try parseCommitArgs(std.testing.allocator, argv);
            try std.testing.expect(r == .unknown_option);
        } else {
            const a = try parseCommitOk(argv);
            defer std.testing.allocator.free(a.paths);
            try std.testing.expect(a.help);
        }
    }
}

// --- formatWriterTimestamp (B-commit-7; pure; §4.6.4 writer form) -----------

test "cli commit B-commit-7: a fixed epoch formats to the exact UTC writer form" {
    var buf: [19]u8 = undefined;
    try std.testing.expectEqualStrings("1970-01-01T00:00:00", formatWriterTimestamp(0, &buf));
    var mbuf: [23]u8 = undefined;
    try std.testing.expectEqualStrings("1970-01-01T00:00:00.000", formatWriterTimestampMs(0, 0, &mbuf));
    try std.testing.expectEqualStrings("2009-02-13T23:31:30.287", formatWriterTimestampMs(1234567890, 287, &mbuf));
    try std.testing.expectEqualStrings("2009-02-13T23:31:30.007", formatWriterTimestampMs(1234567890, 7, &mbuf));
    try std.testing.expect(artifact.isWriterTimestamp(formatWriterTimestampMs(1234567890, 999, &mbuf)));
    // 1234567890 == 2009-02-13T23:31:30Z (the well-known Unix epoch milestone).
    try std.testing.expectEqualStrings("2009-02-13T23:31:30", formatWriterTimestamp(1234567890, &buf));
    // Every emitted token satisfies the library's own §4.6.4 writer oracle.
    try std.testing.expect(artifact.isWriterTimestamp(formatWriterTimestamp(1234567890, &buf)));
}

test "cli commit B-commit-7: a far-future / pre-epoch reading is clamped to a 4-digit year" {
    var buf: [19]u8 = undefined;
    try std.testing.expectEqualStrings("9999-12-31T23:59:59", formatWriterTimestamp(max_writer_epoch, &buf));
    try std.testing.expectEqualStrings("9999-12-31T23:59:59", formatWriterTimestamp(std.math.maxInt(i64), &buf));
    try std.testing.expectEqualStrings("1970-01-01T00:00:00", formatWriterTimestamp(-999, &buf));
    try std.testing.expect(artifact.isWriterTimestamp(formatWriterTimestamp(std.math.maxInt(i64), &buf)));
}

// --- composeFCards (B-commit-8/9/11/12, §4.5.3; pure; no I/O) ----------------

/// A regular-file F card at `name`@`hash` (slice-1 shape: no perm, no rename).
fn fc(name: []const u8, hash: ?[]const u8, mode: artifact.FileMode) artifact.FCard {
    return .{ .filename = name, .hash = hash, .perm = null, .old_name = null, .mode = mode };
}

/// Runs `composeFCards` on the test allocator and returns the result; caller
/// frees `result.f`.
fn compose(in: ComposeInput) !ComposeResult {
    return composeFCards(std.testing.allocator, in);
}

test "cli commit B-commit-8 §4.5.3: snapshot sorts the captured set ascending by filename" {
    const captured = [_]artifact.FCard{ fc("b", "hb", .regular), fc("a", "ha", .regular), fc("ab", "hab", .regular) };
    const r = try compose(.{ .parent = &.{}, .captured = &captured, .policy = .snapshot });
    defer std.testing.allocator.free(r.f);
    try std.testing.expectEqual(@as(usize, 3), r.f.len);
    try std.testing.expectEqualStrings("a", r.f[0].filename); // shorter-first tie: "a" < "ab" < "b"
    try std.testing.expectEqualStrings("ab", r.f[1].filename);
    try std.testing.expectEqualStrings("b", r.f[2].filename);
    try std.testing.expect(!r.unchanged);
}

test "cli commit B-commit-9: an empty root snapshot is the no-tracked-files no-op" {
    const r = try compose(.{ .parent = &.{}, .captured = &.{}, .policy = .snapshot });
    defer std.testing.allocator.free(r.f);
    try std.testing.expectEqual(@as(usize, 0), r.f.len);
    try std.testing.expect(r.unchanged);
}

test "cli commit B-commit-9: a child snapshot equal to its parent is the no-changes no-op" {
    const parent = [_]artifact.FCard{ fc("a", "ha", .regular), fc("b", "hb", .regular) };
    // Same name+hash+mode in a different input order → still unchanged.
    const same = [_]artifact.FCard{ fc("b", "hb", .regular), fc("a", "ha", .regular) };
    const r0 = try compose(.{ .parent = &parent, .captured = &same, .policy = .snapshot });
    defer std.testing.allocator.free(r0.f);
    try std.testing.expect(r0.unchanged);

    // A differing hash is a change.
    const diff_hash = [_]artifact.FCard{ fc("a", "ha", .regular), fc("b", "hZ", .regular) };
    const r1 = try compose(.{ .parent = &parent, .captured = &diff_hash, .policy = .snapshot });
    defer std.testing.allocator.free(r1.f);
    try std.testing.expect(!r1.unchanged);

    // A differing mode (same names+hashes) is a change.
    const diff_mode = [_]artifact.FCard{ fc("a", "ha", .regular), fc("b", "hb", .executable) };
    const r2 = try compose(.{ .parent = &parent, .captured = &diff_mode, .policy = .snapshot });
    defer std.testing.allocator.free(r2.f);
    try std.testing.expect(!r2.unchanged);
}

test "cli commit B-commit-11: carry_forward carries unnamed parent files at the parent hash" {
    const parent = [_]artifact.FCard{ fc("a", "h1", .regular), fc("b", "h2", .regular) };
    const captured = [_]artifact.FCard{fc("a", "h3", .regular)}; // only `a` re-hashed
    const r = try compose(.{ .parent = &parent, .captured = &captured, .policy = .carry_forward });
    defer std.testing.allocator.free(r.f);
    try std.testing.expectEqual(@as(usize, 2), r.f.len);
    try std.testing.expectEqualStrings("a", r.f[0].filename);
    try std.testing.expectEqualStrings("h3", r.f[0].hash.?); // replaced
    try std.testing.expectEqualStrings("b", r.f[1].filename);
    try std.testing.expectEqualStrings("h2", r.f[1].hash.?); // carried at parent hash
    try std.testing.expect(!r.unchanged);
}

test "cli commit B-commit-11: carry_forward replaces a named file without duplicating it" {
    const parent = [_]artifact.FCard{fc("a", "h1", .regular)};
    const captured = [_]artifact.FCard{fc("a", "h3", .regular)};
    const r = try compose(.{ .parent = &parent, .captured = &captured, .policy = .carry_forward });
    defer std.testing.allocator.free(r.f);
    try std.testing.expectEqual(@as(usize, 1), r.f.len); // replaced, not duplicated
    try std.testing.expectEqualStrings("h3", r.f[0].hash.?);
}

test "cli commit B-commit-11 artifact §4.7.6: partial rename replaces old parent name" {
    const parent = [_]artifact.FCard{ fc("keep", "hk", .regular), fc("old", "ho", .regular) };
    var renamed = fc("new", "hn", .regular);
    renamed.perm = "w";
    renamed.old_name = "old";
    const captured = [_]artifact.FCard{renamed};
    const r = try compose(.{ .parent = &parent, .captured = &captured, .policy = .carry_forward });
    defer std.testing.allocator.free(r.f);
    try std.testing.expectEqual(@as(usize, 2), r.f.len);
    try std.testing.expectEqualStrings("keep", r.f[0].filename);
    try std.testing.expectEqualStrings("new", r.f[1].filename);
    try std.testing.expectEqualStrings("old", r.f[1].old_name.?);
}

test "cli commit B-commit-11 artifact §4.7.6: carried rename is consumed" {
    var renamed_parent = fc("new", "hn", .regular);
    renamed_parent.perm = "w";
    renamed_parent.old_name = "old";
    const parent = [_]artifact.FCard{ renamed_parent, fc("other", "ho", .regular) };
    const captured = [_]artifact.FCard{fc("other", "changed", .regular)};
    const r = try compose(.{ .parent = &parent, .captured = &captured, .policy = .carry_forward });
    defer std.testing.allocator.free(r.f);
    try std.testing.expectEqual(@as(usize, 2), r.f.len);
    try std.testing.expectEqualStrings("new", r.f[0].filename);
    try std.testing.expect(r.f[0].old_name == null);
    try std.testing.expect(r.f[0].perm == null);
}

test "cli commit B-commit-11: a root partial (empty parent) captures only the named files" {
    const captured = [_]artifact.FCard{fc("a", "h1", .regular)};
    const r = try compose(.{ .parent = &.{}, .captured = &captured, .policy = .carry_forward });
    defer std.testing.allocator.free(r.f);
    try std.testing.expectEqual(@as(usize, 1), r.f.len);
    try std.testing.expectEqualStrings("a", r.f[0].filename);
}

test "cli commit B-commit-8/11 §4.5.3: carry+capture interleave is re-sorted and admission-clean" {
    const parent = [_]artifact.FCard{ fc("a", "ha", .regular), fc("c", "hc", .regular) };
    const captured = [_]artifact.FCard{fc("b", "hb", .regular)};
    const r = try compose(.{ .parent = &parent, .captured = &captured, .policy = .carry_forward });
    defer std.testing.allocator.free(r.f);
    try std.testing.expectEqual(@as(usize, 3), r.f.len);
    try std.testing.expectEqualStrings("a", r.f[0].filename);
    try std.testing.expectEqualStrings("b", r.f[1].filename);
    try std.testing.expectEqualStrings("c", r.f[2].filename);
    // Drive the admission oracle over the result: every card admitted (§4.5.2-r1).
    var adm: artifact.FCardAdmission = .{};
    for (r.f) |card| try std.testing.expect(try adm.admit(card.filename));
}

test "cli commit B-commit-12: a snapshot drops a parent file no longer captured (deletion)" {
    const parent = [_]artifact.FCard{ fc("a", "h1", .regular), fc("b", "h2", .regular) };
    const captured = [_]artifact.FCard{fc("a", "h1", .regular)}; // `b` no longer managed
    const r = try compose(.{ .parent = &parent, .captured = &captured, .policy = .snapshot });
    defer std.testing.allocator.free(r.f);
    try std.testing.expectEqual(@as(usize, 1), r.f.len);
    try std.testing.expectEqualStrings("a", r.f[0].filename);
    try std.testing.expect(!r.unchanged);
}

/// Wraps `composeFCards` for `checkAllAllocationFailures`: allocates and frees
/// the result, so every arena-allocation failure path returns `error.OutOfMemory`
/// with no leak.
fn composeOom(a: std.mem.Allocator, in: ComposeInput) !void {
    const r = try composeFCards(a, in);
    a.free(r.f);
}

test "cli commit B-commit-8: composeFCards is leak-free under every allocation failure" {
    const parent = [_]artifact.FCard{ fc("a", "h1", .regular), fc("c", "hc", .regular) };
    const captured = [_]artifact.FCard{ fc("b", "hb", .regular), fc("a", "h3", .regular) };
    try std.testing.checkAllAllocationFailures(std.testing.allocator, composeOom, .{
        ComposeInput{ .parent = &parent, .captured = &captured, .policy = .carry_forward },
    });
}

// --- composeCommitTags (repostate §6.2 + artifact §4.7; pure; no I/O) --------

/// Runs `composeCommitTags` on an arena and returns the result; the arena owns
/// every card + name, so the caller frees nothing but the arena.
fn commitTags(arena: std.mem.Allocator, in: ComposeCommitTagsInput) ![]artifact.TCard {
    return composeCommitTags(arena, in);
}

test "cli composeCommitTags repostate §6.2: branch-root emits *branch NAME and *sym-NAME propagating tags" {
    var a = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer a.deinit();
    // Table-driven over the RFC branch names: trunk (the root default) and a
    // user branch. Each yields exactly the two §6.2 propagating tags, in
    // full-token order ("*branch" < "*sym-<name>").
    const cases = [_][]const u8{ "trunk", "feature" };
    for (cases) |name| {
        const t = try commitTags(a.allocator(), .{ .branch = name });
        try std.testing.expectEqual(@as(usize, 2), t.len);
        try std.testing.expectEqual(artifact.TSign.propagate, t[0].sign);
        try std.testing.expectEqualStrings("branch", t[0].name);
        try std.testing.expectEqual(@as(?[]const u8, null), t[0].target); // self-referential
        try std.testing.expectEqualStrings(name, t[0].value.?);
        try std.testing.expectEqual(artifact.TSign.propagate, t[1].sign);
        try std.testing.expectEqualStrings("sym-", t[1].name[0..4]);
        try std.testing.expectEqualStrings(name, t[1].name[4..]);
        try std.testing.expectEqual(@as(?[]const u8, null), t[1].target);
        try std.testing.expectEqual(@as(?[]const u8, null), t[1].value); // canonical `T *sym-NAME *`
    }
}

test "cli composeCommitTags repostate §6.2: each inherited active sym-X yields a -sym-X cancel; NAME's own sym is not cancelled" {
    var a = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer a.deinit();
    // Parent on trunk carries the propagating sym-trunk; the caller passes it
    // as a sym to cancel (having excluded sym-feature, which this commit is
    // establishing). Result: *branch feature, *sym-feature, -sym-trunk.
    const parent_syms = [_][]const u8{"sym-trunk"};
    const t = try commitTags(a.allocator(), .{ .branch = "feature", .parent_syms = &parent_syms });
    try std.testing.expectEqual(@as(usize, 3), t.len);
    // Exactly one cancel, naming the parent sym; no cancel of sym-feature.
    var cancels: usize = 0;
    for (t) |card| {
        if (card.sign == .cancel) {
            cancels += 1;
            try std.testing.expectEqualStrings("sym-trunk", card.name);
        }
        try std.testing.expect(!(card.sign == .cancel and std.mem.eql(u8, card.name, "sym-feature")));
    }
    try std.testing.expectEqual(@as(usize, 1), cancels);
}

test "cli composeCommitTags artifact §4.7.16/§4.5.2: cards ordered by full signed token round-trip through artifact.parse" {
    var a = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer a.deinit();
    const parent_syms = [_][]const u8{"sym-trunk"};
    const t = try commitTags(a.allocator(), .{ .branch = "feature", .close = true, .parent_syms = &parent_syms });
    // Order: "*branch" < "*sym-feature" < "+closed" < "-sym-trunk".
    try std.testing.expectEqual(@as(usize, 4), t.len);
    try std.testing.expectEqualStrings("branch", t[0].name);
    try std.testing.expectEqualStrings("sym-feature", t[1].name);
    try std.testing.expectEqualStrings("closed", t[2].name);
    try std.testing.expectEqualStrings("sym-trunk", t[3].name);
    try std.testing.expectEqual(artifact.TSign.add, t[2].sign);
    try std.testing.expectEqual(artifact.TSign.cancel, t[3].sign);

    // Round-trip: a check-in manifest carrying exactly this run must parse back
    // without the §4.5.2 intra-run T check rejecting it (the guarantee the
    // commit self-check relies on).
    try expectTagRunRoundTrips(std.testing.allocator, t);
}

test "cli composeCommitTags artifact §4.7: --close adds a single +closed self-referential singleton" {
    var a = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer a.deinit();
    // No branch, no parent syms: a plain child commit that only closes its leaf.
    const t = try commitTags(a.allocator(), .{ .close = true });
    try std.testing.expectEqual(@as(usize, 1), t.len);
    try std.testing.expectEqual(artifact.TSign.add, t[0].sign);
    try std.testing.expectEqualStrings("closed", t[0].name);
    try std.testing.expectEqual(@as(?[]const u8, null), t[0].target); // self-referential
    try std.testing.expectEqual(@as(?[]const u8, null), t[0].value);
    // A no-flag child commit carries no control tags at all (today's bytes).
    const none = try commitTags(a.allocator(), .{});
    try std.testing.expectEqual(@as(usize, 0), none.len);
}

/// Wraps `composeCommitTags` for `checkAllAllocationFailures`: the arena frees
/// every card + name, so each allocation-failure path returns `error.OutOfMemory`
/// with no leak.
fn composeTagsOom(gpa: std.mem.Allocator) !void {
    var a = std.heap.ArenaAllocator.init(gpa);
    defer a.deinit();
    const parent_syms = [_][]const u8{ "sym-trunk", "sym-old" };
    _ = try composeCommitTags(a.allocator(), .{ .branch = "feature", .close = true, .parent_syms = &parent_syms });
}

test "cli composeCommitTags §8 OOM: leak-free under checkAllAllocationFailures" {
    try std.testing.checkAllAllocationFailures(std.testing.allocator, struct {
        fn run(gpa: std.mem.Allocator) !void {
            return composeTagsOom(gpa);
        }
    }.run, .{});
}

/// Serializes a check-in manifest carrying `tags` (via `artifact.create`) and
/// parses it back, asserting the T run survives the §4.5.2 intra-run check in
/// the exact order given. The commit path depends on this round-trip.
fn expectTagRunRoundTrips(gpa: std.mem.Allocator, tags: []const artifact.TCard) !void {
    const f = [_]artifact.FCard{fc("a.txt", "a" ** 40, .regular)};
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .manifest,
        .a = null,
        .b = null,
        .c = .{ .comment = "m" },
        .d = .{ .timestamp = "2020-01-01T00:00:00" },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = "u" },
        .z = null,
        .w = null,
        .f = &f,
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .t = tags,
    };
    defer m.deinit();
    const bytes = try artifact.create(&m, gpa);
    defer gpa.free(bytes);
    var parsed = try artifact.parse(gpa, bytes);
    defer parsed.deinit();
    try std.testing.expectEqual(tags.len, parsed.t.len);
    for (tags, parsed.t) |want, got| {
        try std.testing.expectEqual(want.sign, got.sign);
        try std.testing.expectEqualStrings(want.name, got.name);
    }
}

// --- parseAmendArgs + composeAmendTags (contracts/amend.md; pure; no I/O) ----

const AMEND_HASH: []const u8 = "a" ** 64; // a valid §4.7.16 64-hex target

test "cli amend B-amend-1: --comment parses; NAME and the override are captured" {
    const r = parseAmendArgs(&.{ "abc123", "--comment", "hello world" });
    try std.testing.expectEqualStrings("abc123", r.ok.name.?);
    try std.testing.expectEqualStrings("hello world", r.ok.comment.?);
    try std.testing.expect(r.ok.user == null and r.ok.date == null and r.ok.branch == null);
}

test "cli amend B-amend-4: several flags parse into one AmendArgs" {
    const r = parseAmendArgs(&.{ "v1", "--user", "alice", "--date", "2020-01-02T03:04:05", "--branch", "feature" });
    try std.testing.expectEqualStrings("v1", r.ok.name.?);
    try std.testing.expectEqualStrings("alice", r.ok.user.?);
    try std.testing.expectEqualStrings("2020-01-02T03:04:05", r.ok.date.?);
    try std.testing.expectEqualStrings("feature", r.ok.branch.?);
}

test "cli amend B-amend-8: a NAME with no override flag is no_override" {
    try std.testing.expect(parseAmendArgs(&.{"v1"}) == .no_override);
}

test "cli amend B-amend-11: an empty value and a repeated flag are usage errors" {
    try std.testing.expect(parseAmendArgs(&.{ "v1", "--comment", "" }) == .empty_value);
    try std.testing.expectEqualStrings("--comment", parseAmendArgs(&.{ "v1", "--comment", "" }).empty_value);
    const rep = parseAmendArgs(&.{ "v1", "--comment", "a", "--comment", "b" });
    try std.testing.expect(rep == .repeated_option);
    try std.testing.expectEqualStrings("--comment", rep.repeated_option);
}

test "cli amend artifact §4.6.4: --date rejects a non-writer-form token (bad_date)" {
    // A bare date with no time is not the §4.6.4 writer form (m8).
    const r = parseAmendArgs(&.{ "v1", "--date", "2020-01-02" });
    try std.testing.expect(r == .bad_date);
    try std.testing.expectEqualStrings("2020-01-02", r.bad_date);
    // A well-formed writer timestamp is accepted.
    try std.testing.expect(parseAmendArgs(&.{ "v1", "--date", "2020-01-02T03:04:05" }) == .ok);
}

test "cli amend B-amend-13/14: --help wins over a bad token; dangling value and second positional are usage errors" {
    // Help wins over an otherwise-invalid trailing token (B-amend-13).
    try std.testing.expect(parseAmendArgs(&.{ "v1", "--bogus", "--help" }).ok.help);
    try std.testing.expect(parseAmendArgs(&.{ "--help", "v1", "v2" }).ok.help);
    // A dangling value flag is an unknown option (B-amend-14).
    try std.testing.expect(parseAmendArgs(&.{ "v1", "--comment" }) == .unknown_option);
    // A second positional is an extra argument.
    const extra = parseAmendArgs(&.{ "v1", "--comment", "x", "v2" });
    try std.testing.expect(extra == .extra_argument);
    try std.testing.expectEqualStrings("v2", extra.extra_argument);
    // An unknown option is named.
    try std.testing.expectEqualStrings("-z", parseAmendArgs(&.{ "v1", "-z" }).unknown_option);
}

test "cli amend artifact §5.4/§4.7.16: override T cards target the check-in hash, never '*'" {
    var a = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer a.deinit();
    const cards = try composeAmendTags(a.allocator(), AMEND_HASH, .{ .comment = "hi" });
    try std.testing.expectEqual(@as(usize, 1), cards.len);
    try std.testing.expectEqual(artifact.TSign.add, cards[0].sign); // singleton `+`
    try std.testing.expectEqualStrings("comment", cards[0].name);
    try std.testing.expectEqualStrings("hi", cards[0].value.?);
    try std.testing.expect(cards[0].target != null); // never the `*` self-reference
    try std.testing.expectEqualStrings(AMEND_HASH, cards[0].target.?);
}

test "cli amend B-amend-4/artifact §4.5.2: a merged T run is one artifact, strictly ascending by full signed token" {
    var a = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer a.deinit();
    const parent_syms = [_][]const u8{"sym-trunk"};
    const cards = try composeAmendTags(a.allocator(), AMEND_HASH, .{
        .comment = "c",
        .user = "u",
        .date = "2020-01-02T03:04:05",
        .branch = "feature",
        .parent_syms = &parent_syms,
    });
    // Sign order `*`<`+`<`-`: *branch, *sym-feature, +comment, +date, +user, -sym-trunk.
    const want = [_]struct { s: artifact.TSign, n: []const u8 }{
        .{ .s = .propagate, .n = "branch" },
        .{ .s = .propagate, .n = "sym-feature" },
        .{ .s = .add, .n = "comment" },
        .{ .s = .add, .n = "date" },
        .{ .s = .add, .n = "user" },
        .{ .s = .cancel, .n = "sym-trunk" },
    };
    try std.testing.expectEqual(want.len, cards.len);
    for (want, cards) |w, c| {
        try std.testing.expectEqual(w.s, c.sign);
        try std.testing.expectEqualStrings(w.n, c.name);
        try std.testing.expectEqualStrings(AMEND_HASH, c.target.?); // all retargeted
    }
}

test "cli amend artifact §5.2: the composed run serializes as a control (D,T,U,Z) and round-trips" {
    const gpa = std.testing.allocator;
    var a = std.heap.ArenaAllocator.init(gpa);
    defer a.deinit();
    const cards = try composeAmendTags(a.allocator(), AMEND_HASH, .{ .comment = "hi", .user = "alice" });
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .control,
        .a = null,
        .b = null,
        .c = null,
        .d = .{ .timestamp = "2020-01-01T00:00:00" },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = "amender" },
        .z = null,
        .w = null,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .t = cards,
    };
    defer m.deinit();
    const bytes = try artifact.create(&m, gpa);
    defer gpa.free(bytes);
    var parsed = try artifact.parse(gpa, bytes);
    defer parsed.deinit();
    // §5.2: exactly a control artifact, carrying D, the T run, U, and Z.
    try std.testing.expectEqual(artifact.ArtifactType.control, parsed.kind);
    try std.testing.expect(parsed.d != null and parsed.u != null and parsed.z != null);
    try std.testing.expectEqual(cards.len, parsed.t.len);
    try std.testing.expect(parsed.c == null and parsed.f.len == 0 and parsed.p.len == 0);
}

test "cli amend §8 OOM: composeAmendTags is leak-free under checkAllAllocationFailures" {
    try std.testing.checkAllAllocationFailures(std.testing.allocator, struct {
        fn run(gpa: std.mem.Allocator) !void {
            var a = std.heap.ArenaAllocator.init(gpa);
            defer a.deinit();
            const parent_syms = [_][]const u8{ "sym-trunk", "sym-old" };
            _ = try composeAmendTags(a.allocator(), AMEND_HASH, .{
                .comment = "c",
                .user = "u",
                .date = "2020-01-02T03:04:05",
                .branch = "feature",
                .parent_syms = &parent_syms,
            });
        }
    }.run, .{});
}

// --- composeTagCards (REGRESSION B6; pure; no I/O) --------------------------

test "cli REGRESSION B6: composeTagCards mints one SINGLETON sym card at the target" {
    const gpa = std.testing.allocator;
    var a = std.heap.ArenaAllocator.init(gpa);
    defer a.deinit();
    const cards = try composeTagCards(a.allocator(), AMEND_HASH, .{ .sign = .add, .name = "v1", .raw = false });
    try std.testing.expectEqual(@as(usize, 1), cards.len);
    // `+`, NOT `*`: a propagating sym would be inherited by every descendant and
    // then resolve to the newest one. This assertion IS the blocker's fix.
    try std.testing.expectEqual(artifact.TSign.add, cards[0].sign);
    try std.testing.expectEqualStrings("sym-v1", cards[0].name);
    try std.testing.expectEqualStrings(AMEND_HASH, cards[0].target.?);
    try std.testing.expect(cards[0].value == null);
}

test "cli: a raw card keeps NAME verbatim and carries its value; a sym card cannot" {
    const gpa = std.testing.allocator;
    var a = std.heap.ArenaAllocator.init(gpa);
    defer a.deinit();
    // The verdict shape quarry needs: a PROPERTY many check-ins may carry, so
    // it must stay out of `sym-` — where two carriers would make the name
    // ambiguous to `resolveName` — and it must carry the run reference.
    const cards = try composeTagCards(a.allocator(), AMEND_HASH, .{
        .sign = .add,
        .name = "ci-pass",
        .raw = true,
        .value = "run-42",
    });
    try std.testing.expectEqual(@as(usize, 1), cards.len);
    try std.testing.expectEqualStrings("ci-pass", cards[0].name);
    try std.testing.expectEqualStrings("run-42", cards[0].value.?);
    try std.testing.expectEqualStrings(AMEND_HASH, cards[0].target.?);
    // Same NAME without --raw lands in the name space instead. The contrast is
    // the whole point of the flag.
    const sym = try composeTagCards(a.allocator(), AMEND_HASH, .{ .sign = .add, .name = "ci-pass", .raw = false });
    try std.testing.expectEqualStrings("sym-ci-pass", sym[0].name);
}

test "cli: cancel mints `-` and propagate mints `*`, each in the chosen namespace" {
    const gpa = std.testing.allocator;
    var a = std.heap.ArenaAllocator.init(gpa);
    defer a.deinit();
    const cancel = try composeTagCards(a.allocator(), AMEND_HASH, .{ .sign = .cancel, .name = "ci-pass", .raw = true });
    try std.testing.expectEqual(artifact.TSign.cancel, cancel[0].sign);
    try std.testing.expectEqualStrings("ci-pass", cancel[0].name);
    const prop = try composeTagCards(a.allocator(), AMEND_HASH, .{ .sign = .propagate, .name = "reviewed", .raw = true });
    try std.testing.expectEqual(artifact.TSign.propagate, prop[0].sign);
    // A cancel in the SYMBOLIC namespace still wears the prefix — otherwise it
    // would cancel a different tag than `tag add` created.
    const sym_cancel = try composeTagCards(a.allocator(), AMEND_HASH, .{ .sign = .cancel, .name = "v1", .raw = false });
    try std.testing.expectEqualStrings("sym-v1", sym_cancel[0].name);
}

test "cli REGRESSION B6: a tag-add run serializes as a control (D,T,U,Z) and round-trips" {
    const gpa = std.testing.allocator;
    var a = std.heap.ArenaAllocator.init(gpa);
    defer a.deinit();
    const cards = try composeTagCards(a.allocator(), AMEND_HASH, .{ .sign = .add, .name = "v1", .raw = false });
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .control,
        .a = null,
        .b = null,
        .c = null,
        .d = .{ .timestamp = "2020-01-01T00:00:00" },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = "tagger" },
        .z = null,
        .w = null,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .t = cards,
    };
    defer m.deinit();
    const bytes = try artifact.create(&m, gpa);
    defer gpa.free(bytes);
    var parsed = try artifact.parse(gpa, bytes);
    defer parsed.deinit();
    try std.testing.expectEqual(artifact.ArtifactType.control, parsed.kind);
    try std.testing.expectEqual(@as(usize, 1), parsed.t.len);
    // The sign survives the wire: `+sym-v1` targeting the check-in, never `*`.
    try std.testing.expectEqual(artifact.TSign.add, parsed.t[0].sign);
    try std.testing.expectEqualStrings("sym-v1", parsed.t[0].name);
    try std.testing.expectEqualStrings(AMEND_HASH, parsed.t[0].target.?);
}

test "cli REGRESSION B6: composeTagCards is leak-free under checkAllAllocationFailures" {
    try std.testing.checkAllAllocationFailures(std.testing.allocator, struct {
        fn run(gpa: std.mem.Allocator) !void {
            var a = std.heap.ArenaAllocator.init(gpa);
            defer a.deinit();
            _ = try composeTagCards(a.allocator(), AMEND_HASH, .{ .sign = .add, .name = "v1", .raw = false });
        }
    }.run, .{});
}

test "cli amend B-amend-12: amend is not a git-reflex verb; the four survivors keep their lessons" {
    // amend dispatches to the real handler, so it teaches no reflex lesson —
    // and `stash` left the table the day it became a real verb.
    try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("amend"));
    try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("stash"));
    // Every surviving reflex entry still resolves to a non-empty lesson.
    // `checkout` left the table when it became an alias of `update`.
    try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("checkout"));
    const survivors = [_][]const u8{ "rebase", "reset", "filter-branch", "fetch" };
    for (survivors) |v| {
        const lesson = reflexLesson(v) orelse return error.TestMissingLesson;
        try std.testing.expect(lesson.len > 0);
    }
    try std.testing.expectEqual(@as(usize, survivors.len), reflex_table.len);
}

test "cli amend B-amend-17: amend joins the Verb enum, resolves, and is not a planned stub" {
    try std.testing.expectEqual(Verb.amend, verbOf("amend"));
    try std.testing.expect(!isPlanned("amend"));
    try std.testing.expectEqualStrings("amend", didYouMean("amnd").?);
}

test "cli parseServeArgs: --fronted is a bare flag, off by default, composable with --port" {
    switch (parseServeArgs(&.{})) {
        .ok => |p| try std.testing.expect(!p.fronted),
        else => return error.TestUnexpectedResult,
    }
    switch (parseServeArgs(&.{"--fronted"})) {
        .ok => |p| {
            try std.testing.expect(p.fronted);
            try std.testing.expectEqual(default_serve_port, p.port);
        },
        else => return error.TestUnexpectedResult,
    }
    switch (parseServeArgs(&.{ "--port", "9", "--fronted" })) {
        .ok => |p| {
            try std.testing.expect(p.fronted);
            try std.testing.expectEqual(@as(u16, 9), p.port);
        },
        else => return error.TestUnexpectedResult,
    }
}

test "cli: --dotfiles parses on every verb that scans the tree" {
    const gpa = std.testing.allocator;
    try std.testing.expect(parseExtrasArgs(&.{"--dotfiles"}).ok.dotfiles);
    try std.testing.expect(!parseExtrasArgs(&.{}).ok.dotfiles);
    try std.testing.expect(parseExtrasArgs(&.{ "--dotfiles", "--help" }).ok.help);
    try std.testing.expect(parseExtrasArgs(&.{"--dotfile"}) == .unknown_option);
    try std.testing.expectEqualStrings("--dotfiles", parseExtrasArgs(&.{ "--", "--dotfiles" }).extra_argument);

    try std.testing.expect(parseStatusArgs(&.{"--dotfiles"}).ok.dotfiles);
    try std.testing.expect(parseCleanArgs(&.{"--dotfiles"}).ok.dotfiles);
    try std.testing.expect(parseAddremoveArgs(&.{"--dotfiles"}).ok.dotfiles);

    const add = try parseAddArgs(gpa, &.{ "--dotfiles", ".hidden" });
    defer gpa.free(add.ok.paths);
    try std.testing.expect(add.ok.dotfiles);
    try std.testing.expectEqual(@as(usize, 1), add.ok.paths.len);
}