Artifact da69dd0c4b1549b9c240295c15ce120acab510762e52b3e64bc83a020725bbfb:
- File
src/cli.zig
— part of check-in
[b25e01be95]
at
2026-07-19 19:15:48
on branch iss12
— Add -R/--repo and -n N to the read verbs
-R/--repo PATH names the repository to read instead of the one found by walking up from the current directory. It is lifted out of argv before verb dispatch, so one implementation serves every read verb and each verb's grammar is unchanged. Resolution requires only the .zit store, not the .zit-checkout discovery also demands: reading a repository with no working tree is the point of the option. PATH may name the directory holding .zit or the .zit file itself. Verbs that read or write a working tree refuse -R by name; accepting it and silently operating on the current checkout is the failure worth a diagnostic to prevent. The spelling matches fossil.
-n N (alias --limit N) shows the N most recent entries on timeline and log; 0 means all, as in fossil. This deliberately overloads -n, which remains --dry-run on add and rm, just as fossil overloads it between timeline and clean. A non-numeric or negative value is refused.
An expectation asserting log rejected -n is inverted: it recorded the absence of the limit, not a rule about the grammar.
timeline still defaults to the whole listing, not fossil's 20 entries. (user: dmestas, size: 225720) [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("zit").checkout; // 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("zit").artifact; /// 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 }; /// 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, user_env: ?[]const u8, push_password: ?[]const u8 = null, /// 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_limit, repo_unsupported }; // --------------------------------------------------------------------------- // 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, }; 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, "-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, }; pub const UpdateResult = union(enum) { ok: UpdateArgs, unknown_option: []const u8, extra_argument: []const u8, }; /// 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 (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 }; } /// 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, }; pub const OpenResult = union(enum) { ok: OpenArgs, unknown_option: []const u8, extra_argument: []const u8, }; /// Parses the operand-free `zit open` surface: `-h`/`--help` is the only /// accepted token. Help wins over every other token, valid or not; a bare `-x` /// is an unknown option and anything else is an unexpected argument. 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 (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 }; } /// 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, }; 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 (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, /// 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 (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 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 }; } /// 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, }; 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, }; /// 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 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 (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 } }; } /// 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 } }; } 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` mints a symbolic name for a check-in. Cancel/remove semantics are /// deliberately not here — a full tag lifecycle is a later slice. pub const TagSub = enum { list, add }; /// Parsed `zit tag` arguments. Every string borrows its argv token. `name` is /// set only by `add` (the bare symbolic name, stored `sym-`-prefixed by the /// composer); `checkin` is `list`'s optional positional and `add`'s required /// second one. pub const TagArgs = struct { sub: TagSub = .list, name: ?[]const u8 = null, checkin: ?[]const u8 = null, /// 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` given no NAME positional. no_name, /// `add` given a NAME but no CHECKIN positional. no_checkin, }; /// Positional classifier shared by both parser modes so the subcommand / /// NAME / CHECKIN / extra-argument grammar lives in one place. Positional 0 /// is the subcommand (`list`, its alias `ls`, or `add`); the meaning of the /// rest is the subcommand's: `list` takes an optional CHECKIN at 1, `add` /// takes NAME at 1 and CHECKIN at 2. 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, 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 (self.first_err == null) { self.first_err = .{ .bad_subcommand = tok }; }, 1 => switch (self.sub) { .list => self.checkin = tok, .add => self.name = tok, }, 2 => switch (self.sub) { .list => if (self.first_err == null) { self.first_err = .{ .extra_argument = tok }; }, .add => self.checkin = tok, }, else => if (self.first_err == null) { self.first_err = .{ .extra_argument = tok }; }, } self.npos += 1; } }; /// Parses the `zit tag list|ls [CHECKIN]` / `zit tag add 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 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 (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 = "" }; // `add`'s 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 == .add) { 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, .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 }; } /// 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 {}, .bad_date => s.err.print("zit {s}: invalid date '{s}' (expected YYYY-MM-DDTHH:MM:SS)\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, paths: []const []const u8, quiet: bool = false, help: 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 } }, }; 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. 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, }; /// 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 not among the captured filenames (unnamed /// files carried forward unchanged); (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). The verdict keys on /// filename+hash+mode only; `perm`/`old_name` (set when `mv` records a rename) /// ride through untouched, and a rename changes the filename so it is never a /// no-op anyway — B-commit-9's name+hash+mode identity. 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) { for (in.parent) |pc| { var captured_here = false; for (in.captured) |cc| { if (std.mem.eql(u8, pc.filename, cc.filename)) { captured_here = true; break; } } if (!captured_here) try list.append(arena, pc); } } 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 = "sym-"; /// Value inputs to `composeCommitTags`. `branch` non-null starts (or switches /// to) that branch at this check-in; `parent_syms` are the parent's active /// `sym-*` tag names to cancel so the check-in leaves the parent's branch(es) /// — each already carrying the `sym-` prefix, and EXCLUDING the new branch's /// own `sym-<branch>` (the caller drops it, since this commit re-establishes /// it as a propagating tag). `close` marks the check-in a closed leaf. pub const ComposeCommitTagsInput = struct { branch: ?[]const u8 = null, close: bool = false, parent_syms: []const []const u8 = &.{}, }; /// §4.7.16 full-signed-token order: the sign byte leads (`*` 0x2A < `+` 0x2B < /// `-` 0x2D), then the sign-stripped name (the target never breaks a tie here — /// every card is self-referential `*`). Equivalent to comparing the whole /// `<sign><name>` token with `artifact.compareBytes`, the key §4.7.16 mandates. fn tagTokenLess(_: void, a: artifact.TCard, b: artifact.TCard) bool { const sa = a.sign.byte(); const sb = b.sign.byte(); if (sa != sb) return sa < sb; return artifact.compareBytes(a.name, b.name) == .lt; } /// The one deep, RFC-normative unit of `commit --branch/--close/--tag`, /// extracted pure so it is reusable and unit-tested without a subprocess: it /// builds the check-in's control tag run (`[]TCard`) from value inputs, then /// sorts it into the §4.7.16 wire order. No I/O, no clock, no env; allocates /// only the result array (and each `sym-*` name) on `arena` (value-in/value- /// out, so it lifts verbatim into a future deep `checkin.compose`). /// /// Cards emitted (repostate §6.2 branch creation + artifact §4.7): /// - `branch` set: `T *branch * NAME` and `T *sym-NAME *` (both propagating, /// §6.2) — the branch property plus its symbolic tag; /// - one `T -sym-X *` (cancel) per parent sym X, so the check-in leaves the /// parent's branch(es) (§6.2 sym-cancellation); /// - `close`: `T +closed *` (a self-referential singleton, §4.7 closed leaf). /// /// The result is sorted by the FULL signed name token (§4.7.16/§4.5.2), because /// `artifact.create` emits `Manifest.t` verbatim (no internal sort) and the /// commit self-check re-`parse`s the bytes — a mis-ordered run would trip the /// §4.5.2 intra-run check. A safety build drives that order over the result. pub fn composeCommitTags(arena: std.mem.Allocator, in: ComposeCommitTagsInput) error{OutOfMemory}![]artifact.TCard { var list: std.ArrayList(artifact.TCard) = .empty; errdefer list.deinit(arena); if (in.branch) |name| { std.debug.assert(name.len != 0); try list.append(arena, .{ .sign = .propagate, .name = "branch", .target = null, .value = name }); const sym = try std.mem.concat(arena, u8, &.{ sym_tag_prefix, name }); try list.append(arena, .{ .sign = .propagate, .name = sym, .target = null, .value = null }); } for (in.parent_syms) |x| { std.debug.assert(std.mem.startsWith(u8, x, sym_tag_prefix)); // Negative space: the caller excludes the new branch's own sym, so a // `*sym-NAME` we just added is never also cancelled here. if (in.branch) |name| std.debug.assert(!std.mem.eql(u8, x[sym_tag_prefix.len..], name)); try list.append(arena, .{ .sign = .cancel, .name = x, .target = null, .value = null }); } if (in.close) { try list.append(arena, .{ .sign = .add, .name = "closed", .target = null, .value = null }); } std.mem.sort(artifact.TCard, list.items, {}, tagTokenLess); // Postcondition (§4.7.16/§4.5.2): the run is STRICTLY ascending by the full // signed token, so `artifact.create` emits it verbatim and a re-parse's // intra-run check passes. Distinct by construction (distinct parent syms, // sym-NAME excluded from cancels, one closed/one tag), so a non-strict pair // is a compose bug, not a wire condition. Safety-gated — free in ReleaseFast. if (std.debug.runtime_safety and list.items.len >= 2) { for (list.items[0 .. list.items.len - 1], list.items[1..]) |prev, next| { std.debug.assert(tagTokenLess({}, prev, next)); } } return list.toOwnedSlice(arena); } // --------------------------------------------------------------------------- // `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 = &.{}, }; /// Point every T card at `hash` (the resolved check-in), asserting none was /// already targeted. A CONTROL artifact's T cards MUST target the check-in, never /// the literal `*` self-reference (artifact §5.4): a control never tags itself. pub fn retargetTCards(cards: []artifact.TCard, hash: []const u8) void { std.debug.assert(hash.len == 40 or hash.len == 64); for (cards) |*c| { std.debug.assert(c.target == null); // composed self-referential; retargeted once c.target = hash; } } /// 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; } /// Compose the one CONTROL artifact's T run for `tag add NAME CHECKIN`: a /// single `T +sym-NAME <target_hash>` 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 sign is SINGLETON (`+`), not propagating (`*`), and 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. /// /// One card, so there is no §4.7.16 ordering obligation to discharge — a /// single-element run is trivially strictly ascending. pub fn composeTagAddCards( arena: std.mem.Allocator, target_hash: []const u8, name: []const u8, ) error{OutOfMemory}![]artifact.TCard { std.debug.assert(name.len != 0); // the runner rejects an empty NAME const sym = try std.mem.concat(arena, u8, &.{ sym_tag_prefix, name }); const cards = try arena.alloc(artifact.TCard, 1); cards[0] = .{ .sign = .add, .name = sym, .target = null, .value = null }; 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. 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"; /// If `dir_abs` is a checkout root (both control files present), 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. 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, repo_marker) and hasEntry(io, dir, checkout.filename)) 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. pub const Verb = enum { init, add, rm, mv, status, diff, extras, clean, addremove, commit, log, open, update, merge, revert, undo, redo, leaves, timeline, branch, tag, amend, info, ls, cat, artifact, annotate, bisect, serve, clone, pull, push, sync, remote, pack, 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, "-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` and `amend` write, yet /// they write only to the repository, so they qualify. /// /// 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. .timeline, .log, .leaves, .branch, .tag, .info, .ls, .cat, .artifact, .annotate, .amend => true, // Working-tree verbs: they read or write the checkout the cwd names. .status, .diff, .update, .commit, .revert, .add, .rm, .mv, .merge, .undo, .redo, .extras, .clean, .addremove, .open, .bisect => false, // 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, .pack, .serve => 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."; const lesson_stash = "zit has no stash; record your work in a check-in instead " ++ "('zit stash' is not implemented)."; /// 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 }, .{ .verb = "stash", .lesson = lesson_stash }, }; /// 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. Spellings /// come from the enum tag names. 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] = @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" { // `add` used to be this test's stand-in for "not a subcommand"; it IS one // now (REGRESSION B6), so the case uses a word that still is not. const r = parseTagArgs(&.{"cancel"}); try std.testing.expect(r == .bad_subcommand); try std.testing.expectEqualStrings("cancel", 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 REGRESSION B6: a fourth positional after add is an extra argument" { const r = parseTagArgs(&.{ "add", "v1", "abc", "def" }); try std.testing.expect(r == .extra_argument); try std.testing.expectEqualStrings("def", r.extra_argument); } 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 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)); // 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: 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, .{}); } // --- composeTagAddCards (REGRESSION B6; pure; no I/O) ----------------------- test "cli REGRESSION B6: composeTagAddCards 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 composeTagAddCards(a.allocator(), AMEND_HASH, "v1"); 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 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 composeTagAddCards(a.allocator(), AMEND_HASH, "v1"); 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: composeTagAddCards 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 composeTagAddCards(a.allocator(), AMEND_HASH, "v1"); } }.run, .{}); } test "cli amend B-amend-12: amend is not a git-reflex verb; the six survivors keep their lessons" { // amend dispatches to the real handler, so it teaches no reflex lesson. try std.testing.expectEqual(@as(?[]const u8, null), reflexLesson("amend")); // Every surviving reflex entry still resolves to a non-empty lesson. const survivors = [_][]const u8{ "rebase", "reset", "filter-branch", "checkout", "fetch", "stash" }; 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").?); }