//! zit — CLI-layer verb runner (NOT part of the embeddable `zit` library).
//!
//! Compiled only into the `zit` executable (imported by main.zig); never
//! re-exported from root.zig, so it stays outside the library. It holds the
//! reusable, verb-agnostic mechanism every future verb shares: the uniform
//! exit codes, the stdout/stderr discipline, and a labeled-switch flag
//! scanner. No verb-specific effect logic lives here — that is main.zig's
//! `runInit` (contracts/init.md B-init-1, B-init-10, B-init-12).
const std = @import("std");
const Io = std.Io;
// The working-state record's fixed name (`.zit-checkout`). Imported for the
// authoritative constant used by the shared repo-root probe below; checkout.zig
// itself depends only on std, so this adds no cycle.
const checkout = @import("checkout.zig");
/// 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 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. `unknown_option`
/// and `extra_argument` carry an offending token; `no_paths` (add's B-add-9
/// "no paths given") carries none. General over the verb — no per-verb
/// special-casing lives in the runner.
pub const UsageKind = enum { unknown_option, extra_argument, no_paths };
/// Scans `verb_args` (everything after the verb) into a `ParseResult`
/// (B-init-1). Pure: no library calls, no I/O, no allocation. A labeled
/// switch drives the two parser states — `.scan` honors flags, `--` switches
/// to `.positional_only` where every remaining token is a positional (so a
/// directory literally named `-q` is reachable). Recognized flags set state;
/// the first non-flag becomes the directory; a second positional or an
/// unrecognized `-x` option is a usage error carrying its token.
pub fn parseInitArgs(verb_args: []const []const u8) ParseResult {
const Mode = enum { scan, positional_only };
var parsed: Parsed = .{};
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, "-q") or std.mem.eql(u8, tok, "--quiet")) {
parsed.quiet = true;
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] == '-') return .{ .unknown_option = tok };
if (parsed.directory == null) {
parsed.directory = tok;
} else {
return .{ .extra_argument = tok };
}
continue :state .scan;
},
.positional_only => {
if (i >= verb_args.len) break :state;
const tok = verb_args[i];
i += 1;
if (parsed.directory == null) {
parsed.directory = tok;
} else {
return .{ .extra_argument = tok };
}
continue :state .positional_only;
},
}
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 offending token: the whole invocation is short a required path.
.no_paths => s.err.print("zit {s}: no paths given\n", .{verb}) catch {},
.unknown_option, .extra_argument => {
const label = switch (kind) {
.unknown_option => "unknown option",
.extra_argument => "unexpected argument",
.no_paths => 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). A
/// labeled switch drives the two parser states — `.scan` honors flags, `--`
/// switches to `.positional_only` where every remaining token is a path (so a
/// file literally named `-q` is reachable). `-h/--help`, `-n/--dry-run`,
/// `-q/--quiet` are recognized in scan mode; any other `-x` (not a lone `-`) is
/// an unknown option carrying its token; a lone `-` and every bare word is a
/// path (paths accumulate, order preserved). The only allocation is the
/// slice-of-borrows collecting the paths.
///
/// The whole token list is scanned before any verdict is returned so that
/// `--help` wins over an unknown option regardless of order (B-add-13's explicit
/// precedence: help wins over any other token). The first unknown option is
/// remembered and only becomes the result when no `--help` was seen anywhere.
pub fn parseAddArgs(gpa: std.mem.Allocator, verb_args: []const []const u8) error{OutOfMemory}!AddResult {
const Mode = enum { scan, positional_only };
var paths: std.ArrayList([]const u8) = .empty;
errdefer paths.deinit(gpa);
var args: AddArgs = .{ .paths = &.{} };
var first_unknown: ?[]const u8 = 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")) {
args.help = true;
continue :state .scan;
}
if (std.mem.eql(u8, tok, "-n") or std.mem.eql(u8, tok, "--dry-run")) {
args.dry_run = true;
continue :state .scan;
}
if (std.mem.eql(u8, tok, "-q") or std.mem.eql(u8, tok, "--quiet")) {
args.quiet = true;
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 path.
// Record (don't yet return) the first unknown so a later `--help`
// can still win — the scan continues either way (B-add-13).
if (tok.len > 1 and tok[0] == '-') {
if (first_unknown == null) first_unknown = tok;
continue :state .scan;
}
try paths.append(gpa, tok);
continue :state .scan;
},
.positional_only => {
if (i >= verb_args.len) break :state;
try paths.append(gpa, verb_args[i]);
i += 1;
continue :state .positional_only;
},
}
// Help wins over everything, including an unknown option (B-add-13); only
// when no `--help` appeared does an unknown option become the verdict.
if (!args.help) {
if (first_unknown) |tok| {
paths.deinit(gpa);
return .{ .unknown_option = tok };
}
}
args.paths = try paths.toOwnedSlice(gpa);
return .{ .ok = args };
}
/// 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;
}
/// 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, ".zit") 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 };
}
// ---------------------------------------------------------------------------
// 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 },
.{ .verb = "amend", .lesson = lesson_immutable },
.{ .verb = "reset", .lesson = lesson_immutable },
.{ .verb = "filter-branch", .lesson = lesson_immutable },
.{ .verb = "checkout", .lesson = lesson_checkout },
.{ .verb = "push", .lesson = lesson_sync },
.{ .verb = "pull", .lesson = lesson_sync },
.{ .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". Concretely, `init
/// --help` points the user at their first `zit commit`, so `commit` lives here;
/// `status`/`clone` are planned too.
const planned_verbs = [_][]const u8{ "commit", "status", "clone" };
/// 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. Kept
/// beside the suggester (not derived from main's `Verb` enum) so the pure DX
/// mechanism has no dependency back on the dispatcher.
const known_verbs = [_][]const u8{ "init", "add", "version", "help" };
/// The full set a typo can be a near-miss of: the dispatched verbs plus the
/// planned ones, so `comit` suggests the canonical `commit` rather than
/// dead-ending. `known_verbs` stays the real-handler list; suggestion is the
/// only place the two sets merge.
const suggestable_verbs = known_verbs ++ planned_verbs;
/// Longest suggestable verb — bounds the edit-distance DP row so the scan needs
/// no allocation (TigerStyle: explicit fixed bound).
const max_known_verb_len = blk: {
var m: usize = 0;
for (suggestable_verbs) |k| m = @max(m, k.len);
break :blk m;
};
/// A token longer than this is plainly not a typo of a short verb; skipping it
/// keeps the scan bounded and avoids a useless suggestion (TigerStyle: bound).
const max_suggest_input = 128;
/// If `verb` is a near-miss (edit distance 1..2) of a real command, returns the
/// closest one so the diagnostic can say "did you mean 'init'?". Returns null
/// when nothing is close enough — a distant typo gets the plain command list,
/// not a misleading guess. Pure.
pub fn didYouMean(verb: []const u8) ?[]const u8 {
if (verb.len == 0 or verb.len > max_suggest_input) return null;
var best: ?[]const u8 = null;
var best_dist: usize = std.math.maxInt(usize);
for (suggestable_verbs) |k| {
const d = editDistance(verb, k);
if (d < best_dist) {
best_dist = d;
best = k;
}
}
// 0 cannot occur (an exact match is not an unknown command); require 1..2 so
// only a plausible typo is suggested.
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(),
);
}
// --- 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: every planned verb is recognized as planned (help-text invariant)" {
// `commit` is named in init's help text; `status`/`clone` are planned too.
// Each must resolve as planned so no help-mentioned verb dead-ends.
for ([_][]const u8{ "commit", "status", "clone" }) |v| {
try std.testing.expect(isPlanned(v));
}
// A git-reflex verb and a real command are not planned.
try std.testing.expect(!isPlanned("rebase"));
try std.testing.expect(!isPlanned("init"));
}
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").?);
}
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 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"));
}
// --- 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());
}
// --- 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,
);
}