Artifact 5085667e346feb30d7f0ae3b8529807633ff6bd2ecb5513c81e90e36d67cd6d9:
- File
src/cli.zig
— part of check-in
[580d0691c7]
at
2026-08-17 14:41:42
on branch trunk
— tarball: write a check-in's files as a reproducible gzip tar (#95)
The effective-set walk already resolves sub-manifests and delta chains in canonical order, so this is that walk with an archive writer. Members carry the F card's permission, so an executable stays executable. Two runs of one version are byte-identical: history supplies the order and the timestamps, and no uid, gid, or build host is recorded. A file the store does not hold is refused by name -- a partial archive looks exactly like success. (user: dmestas, size: 309510) [annotate] [blame] [check-ins using]
//! 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"); // 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 (#66). no_new = 3, /// `zit frames`: prefix validation failed — the store was rewritten /// (pack / shun reclaim) and the consumer's cursor is dead (#66). cursor_invalid = 4, }; /// 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, /// #75 — 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. 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. pub const TimelineArgs = struct { limit: ?usize = null, 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 (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 (#93). 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; while (i < verb_args.len) : (i += 1) { const tok = verb_args[i]; if (std.mem.eql(u8, tok, "--long")) { parsed.long = true; } else if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } 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 (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 (#57). no_sync: 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; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (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 (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 (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 (std.mem.eql(u8, tok, "--dense")) { parsed.dense = true; } else if (std.mem.eql(u8, tok, "--no-sync")) { parsed.no_sync = true; } else if (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, /// #59 — `--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; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (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; } parsed.manifest = canonicalManifest(value) orelse { if (first_err == null) first_err = .{ .bad_manifest = value }; continue; }; } } else if (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 (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 (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 (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 (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, special: ?SpecialMerge = null, help: bool = false, /// `--no-sync` suppresses the autosync pull for this invocation (#57). no_sync: bool = false, }; 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, }; /// 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; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (std.mem.eql(u8, tok, "--cherrypick")) { if (parsed.special == .backout and conflict == null) conflict = tok; parsed.special = .cherrypick; } else if (std.mem.eql(u8, tok, "--backout")) { if (parsed.special == .cherrypick and conflict == null) conflict = tok; parsed.special = .backout; } else if (std.mem.eql(u8, tok, "--no-sync")) { parsed.no_sync = true; } else if (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 }; 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: ?[]const u8 = null, new: ?[]const u8 = null, help: bool = false, }; pub const MvResult = union(enum) { ok: MvArgs, unknown_option: []const u8, extra_argument: []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(verb_args: []const []const u8) MvResult { var parsed: MvArgs = .{}; var slots: [2][]const u8 = undefined; const spec = [_]FlagSpec{ .{ .long = "--help", .short = "-h", .target = .{ .flag = &parsed.help } }, }; const scan = scanFlags(verb_args, &spec, &slots); // Help wins over an unknown option or an extra argument (B-mv-12). if (parsed.help) return .{ .ok = .{ .help = true } }; 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.old = slots[0]; if (scan.positional_count >= 2) parsed.new = slots[1]; return .{ .ok = parsed }; } /// Parsed `zit info NAME` arguments. The one positional is a symbolic NAME /// (repo-state §8) borrowed from argv — deliberately a distinct type from /// `LogArgs`, whose positional is a full artifact ID; `null` lets the runner /// render the missing-name usage error after help has had its chance to win. pub const InfoArgs = struct { name: ?[]const u8 = null, /// Bypass the `.zit-cache` memo and force a full-log re-derive. no_cache: bool = false, help: bool = false, }; pub const InfoResult = union(enum) { ok: InfoArgs, unknown_option: []const u8, extra_argument: []const u8, }; /// Parses the `zit info NAME [--no-cache] [-h|--help]` surface: one symbolic /// name plus the cache-bypass 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 is retained for the diagnostic. pub fn parseInfoArgs(verb_args: []const []const u8) InfoResult { var parsed: InfoArgs = .{}; var first_err: ?InfoResult = null; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (std.mem.eql(u8, tok, "--no-cache")) { parsed.no_cache = true; } else if (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 (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 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 (#96). Opt-in, so the default /// two-field output stays byte-identical for whatever already parses it. long: 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] [--no-cache] [-h|--help]`: one optional symbolic /// version plus the cache-bypass 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 is retained for the diagnostic. pub fn parseLsArgs(verb_args: []const []const u8) LsResult { var parsed: LsArgs = .{}; var first_err: ?LsResult = null; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (std.mem.eql(u8, tok, "--no-cache")) { parsed.no_cache = true; } else if (std.mem.eql(u8, tok, "--long")) { parsed.long = true; } else if (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 (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; 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; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (std.mem.eql(u8, tok, "--no-cache")) { parsed.no_cache = true; } else if (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 (#95). /// 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 = ""; for (verb_args) |tok| { if (pending) |slot| { pending = null; if (tok.len == 0) { if (first_err == null) first_err = .{ .empty_value = pending_flag }; continue; } slot.* = tok; continue; } if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; continue; } if (std.mem.eql(u8, tok, "--list")) { parsed.list = true; continue; } if (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 (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 #95: 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 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 10-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, }; 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; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (std.mem.eql(u8, tok, "--full")) { parsed.full = true; } else if (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 (first_err) |e| return e; return .{ .ok = parsed }; } /// Parsed `zit frames [--since CURSOR] [--verify TOKEN]` arguments (#66). /// 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, 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; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (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 (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 (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; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (std.mem.eql(u8, tok, "--raw")) { parsed.raw = true; } else if (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, 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] [-h|--help]`. Pure: no library calls, I/O, or /// allocation. `--port` consumes the following token, parsed as a base-10 u16; /// `-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, "--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; while (i < verb_args.len) : (i += 1) { const tok = verb_args[i]; if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (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 (std.mem.eql(u8, tok, "--rebuild")) { parsed.rebuild = true; } else if (std.mem.eql(u8, tok, "--quiet")) { parsed.quiet = true; } else if (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; while (i < verb_args.len) : (i += 1) { const tok = verb_args[i]; if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (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 (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 (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 (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 (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 = "", help: bool = false, /// `--once`: do not save the clone URL as the default remote (#56; /// 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`. 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 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 first_err: ?CloneResult = null; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { help = true; } else if (std.mem.eql(u8, tok, "--once")) { once = true; } else if (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 (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 (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 (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; const d = dir orelse return .no_dir; return .{ .ok = .{ .url = u, .dir = d, .once = once, .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 = "", 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 help = false; var first_err: ?PullResult = null; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { help = true; } else if (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 } }; } /// Parsed `zit push URL [--user U] [--password P]` arguments. `url` borrows an /// argv token — the sole required positional (never empty on `.ok`). `user` and /// `password` (sync §6) are null when absent: a null `user` is an anonymous /// push; 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, /// No repository URL positional was given. no_url, }; /// 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; while (i < verb_args.len) : (i += 1) { const tok = verb_args[i]; if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { help = true; } else if (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 (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 (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 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; while (i < verb_args.len) : (i += 1) { const tok = verb_args[i]; if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { help = true; } else if (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 (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 (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, }; /// 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; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { help = true; } else if (std.mem.eql(u8, tok, "--unset")) { unset = true; } else if (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.? }; return .{ .ok = .{ .url = url orelse "", .unset = unset } }; } /// Parsed `zit settings [NAME [VALUE]]` arguments (#57). 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, /// #83 `--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, }; 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; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (std.mem.eql(u8, tok, "--global")) { parsed.global = true; } else if (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 }; 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` (#61): 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, 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] [--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; while (i < verb_args.len) : (i += 1) { const tok = verb_args[i]; if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (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 (std.mem.eql(u8, tok, "--replay-all")) { parsed.replay_all = true; } else if (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 (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 (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] [--force]` arguments (#61). The mode is REQUIRED and exclusive: /// the compatibility consequence is permanent, so the parser refuses to /// default it. `--k`/`--force` belong 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, force: bool = false, 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; while (i < verb_args.len) : (i += 1) { const tok = verb_args[i]; if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (std.mem.eql(u8, tok, "--compatible")) { if (parsed.mode == .submanifest) { if (first_err == null) first_err = .both_modes; continue; } parsed.mode = .compatible; } else if (std.mem.eql(u8, tok, "--submanifest")) { if (parsed.mode == .compatible) { if (first_err == null) first_err = .both_modes; continue; } parsed.mode = .submanifest; } else if (std.mem.eql(u8, tok, "--force")) { parsed.force = true; } else if (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 (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; for (verb_args) |tok| { if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) { parsed.help = true; } else if (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 BranchArgs = struct { no_cache: bool = false, help: 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 (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 }; } /// 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, 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")) { parsed.dry_run = true; continue :state .scan; } if (std.mem.eql(u8, tok, "--force")) { parsed.force = 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, 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 (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)\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, 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 } }, }; 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, 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 (#57; fossil's flag). allow_fork: bool = false, /// `--no-sync` suppresses autosync for this one invocation (#57). 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, /// #72 — clearsign the manifest through the operator's PGP command /// before storing (fossil's `clearsign` behavior). sign: bool = false, /// #82 — 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 = "--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 } }, }; 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, }; /// 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 } }, }; 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. /// #75 — 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 (#75's 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]; } 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; while (i < verb_args.len) { const tok = verb_args[i]; i += 1; if (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 (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 (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. /// /// #59 — 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 (#70). 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. /// /// #59 — 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 { 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, frames, bisect, serve, clone, pull, push, sync, remote, settings, pack, submanifest, checkpoint, cone, worktrees, import_git, import_fossil, export_git, shun, stash, verify, wiki, ticket, forum, technote, trust, config, tarball, version, help, unknown }; /// Resolve a command-line token to its `Verb`. Spellings come from the enum /// tag names, never a second transcription; `-h` and `--help` are the only /// aliases (both name `help`). An unrecognized token is `.unknown`, the /// total-switch catch-all. Pure; bounded by the fixed `Verb` value count. pub fn verbOf(name: []const u8) Verb { if (std.mem.eql(u8, name, "import-git")) return .import_git; if (std.mem.eql(u8, name, "import-fossil")) return .import_fossil; if (std.mem.eql(u8, name, "export-git")) return .export_git; if (std.mem.eql(u8, name, "-h") or std.mem.eql(u8, name, "--help")) return .help; inline for (comptime std.enums.values(Verb)) |v| { if (v == .unknown) continue; if (std.mem.eql(u8, name, @tagName(v))) return v; } return .unknown; } /// 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) { // 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, .verify => true, // `frames` is the incremental-consumer read seam (#66): pure store // interrogation, the -R grant is its primary use (a bare indexer). .frames => true, // `worktrees` (#70) 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, // #71 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 => true, // #95 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 => true, // `stat` interrogates only the store, and since #85 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` (#82) 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, .merge, .undo, .redo, .extras, .clean, .addremove, .open, .bisect, .amend, .stash, .cone, .trust => false, // `diff` is granted for its `--from`/`--to` mode ONLY (#69): 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, // 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. .init, .clone, .pull, .push, .sync, .remote, .settings, .config, .pack, .serve, .import_git, .import_fossil, .export_git => false, // `submanifest` is granted for `analyze` ONLY (#61): 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, // 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_checkout = "zit will use 'zit update' to change the working check-out; branches are " ++ "propagating tags, not refs you detach onto."; 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 = "checkout", .lesson = lesson_checkout }, .{ .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] = if (v == .import_git) "import-git" else if (v == .export_git) "export-git" else @tagName(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; /// git verbs whose *word* differs enough from the zit verb that edit distance /// never connects them, yet whose *meaning* is exactly a zit verb. Mapped by /// name so `zit remove`/`zit delete` still land on "did you mean 'rm'?" rather /// than dead-ending. Kept tiny and explicit — a synonym table, not a fuzzy guess. const verb_aliases = [_]struct { alias: []const u8, canonical: []const u8 }{ .{ .alias = "remove", .canonical = "rm" }, .{ .alias = "delete", .canonical = "rm" }, }; /// 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; for (verb_aliases) |a| { if (std.mem.eql(u8, a.alias, verb)) return a.canonical; } 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. if (best_dist >= 1 and best_dist <= 2) 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, -- is an unknown option" { // 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. { const r = parseLogArgs(&.{ "--", "abc" }); try std.testing.expect(r == .unknown_option); try std.testing.expectEqualStrings("--", r.unknown_option); } // `-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. inline for (.{ parseCleanArgs, parseAddremoveArgs }) |parse| { const r = parse(&.{ "-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 not aliases" { inline for (.{ "-f", "-n", "--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` at #80. `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 #80: 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 #80: 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: --no-cache sets no_cache and keeps the name positional" { const r = parseInfoArgs(&.{ "trunk", "--no-cache" }); try std.testing.expect(r == .ok); try std.testing.expectEqualStrings("trunk", r.ok.name.?); try std.testing.expect(r.ok.no_cache); const d = parseInfoArgs(&.{"trunk"}); try std.testing.expect(!d.ok.no_cache); } 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/repo"}) == .no_dir); } 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: a missing URL is a usage error; --help wins; a second positional is extra; a dangling value flag is unknown" { try std.testing.expect(parsePushArgs(&.{}) == .no_url); 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(&.{ "url", "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 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 (#61: `analyze -R` measures a bare // store); the RUNNER refuses it for every settings-writing action, the // same mode-conditional shape `diff` uses (#69). Pinned end to end by // the "analyze -R #61" 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 D4: the git synonyms 'remove'/'delete' suggest 'rm'" { // Edit distance never connects "remove"/"delete" to "rm"; the alias table // carries the meaning across so a git user is redirected, not dead-ended. try std.testing.expectEqualStrings("rm", didYouMean("remove").?); try std.testing.expectEqualStrings("rm", didYouMean("delete").?); } 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 B-mv-14: exactly two operands parse to OLD and NEW" { const r = parseMvArgs(&.{ "old.txt", "new.txt" }); try std.testing.expect(r == .ok); try std.testing.expectEqualStrings("old.txt", r.ok.old.?); try std.testing.expectEqualStrings("new.txt", r.ok.new.?); try std.testing.expect(!r.ok.help); } test "cli mv B-mv-12: --help wins over positionals and offenses" { inline for (.{ &[_][]const u8{ "-h", "a", "b" }, &[_][]const u8{ "a", "b", "--help" }, &[_][]const u8{ "--force", "--help", "a", "b" }, &[_][]const u8{ "a", "b", "c", "-h" }, // help beats the extra-argument offense }) |argv| { const r = parseMvArgs(argv); try std.testing.expect(r == .ok); try std.testing.expect(r.ok.help); } } test "cli mv B-mv-13: an unknown option is retained as the first offense" { const r = parseMvArgs(&.{ "--force", "a", "b" }); try std.testing.expect(r == .unknown_option); try std.testing.expectEqualStrings("--force", r.unknown_option); } test "cli mv B-mv-14: fewer than two operands leaves an operand null" { const none = parseMvArgs(&.{}); try std.testing.expect(none == .ok and none.ok.old == null and none.ok.new == null); const one = parseMvArgs(&.{"only"}); try std.testing.expect(one == .ok and one.ok.new == null); try std.testing.expectEqualStrings("only", one.ok.old.?); } test "cli mv B-mv-14: a third positional is an extra_argument (mv takes exactly two)" { const r = parseMvArgs(&.{ "a", "b", "c" }); try std.testing.expect(r == .extra_argument); try std.testing.expectEqualStrings("c", r.extra_argument); // A token past NEW after `--` is still an extra argument. const r2 = parseMvArgs(&.{ "--", "a", "b", "c" }); try std.testing.expect(r2 == .extra_argument); try std.testing.expectEqualStrings("c", r2.extra_argument); } test "cli mv B-mv-14: -- flips to positional-only so a file named -x is an operand" { const r = parseMvArgs(&.{ "--", "-x", "new" }); try std.testing.expect(r == .ok); try std.testing.expectEqualStrings("-x", r.ok.old.?); try std.testing.expectEqualStrings("new", r.ok.new.?); } // --- 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 + #80; 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 #80: 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 #80: 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 five 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. const survivors = [_][]const u8{ "rebase", "reset", "filter-branch", "checkout", "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").?); }