//! 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;
/// 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,
};
/// Which of the two B-init-10 usage errors to render.
pub const UsageKind = enum { unknown_option, extra_argument };
/// 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 {
const label = switch (kind) {
.unknown_option => "unknown option",
.extra_argument => "unexpected argument",
};
s.err.print("zit {s}: {s} '{s}'\n", .{ verb, label, token }) catch {};
s.err.print("try 'zit {s} --help'\n", .{verb}) catch {};
return .usage;
}
// ---------------------------------------------------------------------------
// 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", "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"));
}