a73x

src/cli/flags.zig

Ref:   Size: 24.3 KiB   History

//! A flag parser whose schema is a struct: each field name determines the flag
//! spelling and each field type determines whether the flag takes a value.
//! The grammar is `--flag VALUE`, with `--` ending flag parsing. Semantic
//! validation remains with the caller or with a field type that implements
//! `parseCLI`.
// Rationale: `/bin/sh` is the fallback executable name passed as
// argv[0], not a command string interpreted with `sh -c`.

const std = @import("std");

pub const ParseOutcome = union(enum) {
    ok,
    /// `--help` or `-h`, wherever it sits: the caller prints its usage to
    /// stdout and exits 0.
    help,
    /// `--version`, wherever it sits: the caller prints its version to
    /// stdout and exits 0.
    version,
    unknown_arg: []const u8,
    /// A value-taking flag at the end of argv, with nothing left to consume.
    missing_value: []const u8,
    /// The field's type would not hold the word after the flag.
    bad_value: []const u8,
};

/// The simplified outcomes returned by `parseStrict`: invalid syntax, a help
/// request, or a version request.
pub const ParseError = error{ Usage, Help, Version };

/// `parse` as an error union, for the callers that want a `try` instead of
/// a five-arm switch each.
pub fn parseStrict(comptime T: type, dst: *T, args: []const [:0]const u8) ParseError!void {
    switch (parse(T, dst, args)) {
        .ok => {},
        .help => return error.Help,
        .version => return error.Version,
        .unknown_arg, .missing_value, .bad_value => return error.Usage,
    }
}

/// Print the appropriate response for a `parseStrict` error and return its exit
/// code. Callers handle mode-specific errors before calling this function.
pub fn exitFor(e: ParseError, usage: []const u8, prog: []const u8, ver: []const u8) u8 {
    return exitForTo(e, usage, prog, ver, std.posix.STDOUT_FILENO, std.posix.STDERR_FILENO);
}

/// `exitFor` with explicit output descriptors, allowing tests to verify which
/// stream each response uses.
pub fn exitForTo(
    e: ParseError,
    usage: []const u8,
    prog: []const u8,
    ver: []const u8,
    out: std.posix.fd_t,
    err: std.posix.fd_t,
) u8 {
    return switch (e) {
        error.Help => helpTo(out, usage),
        error.Version => versionTo(out, prog, ver),
        // Usage errors are diagnostics; help and version are requested output.
        error.Usage => blk: {
            writeTo(err, usage);
            break :blk 2;
        },
    };
}

/// An optional field is its child type: null is a default, not an arity.
fn Bare(comptime F: type) type {
    return if (@typeInfo(F) == .optional) @typeInfo(F).optional.child else F;
}

/// Parse flags into `dst`. Repeated flags use the last value. Bare words are
/// passed to `T.positional` when present, and otherwise are unknown arguments.
/// Unknown flag-shaped words are passed to `T.extra`, which returns the number
/// of arguments it consumed or zero to reject the flag.
pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) ParseOutcome {
    const fields = @typeInfo(T).@"struct".fields;
    comptime for (fields) |f| {
        if (f.name[0] == '_') continue;
        const B = Bare(f.type);
        // Struct fields take one value only when their type implements
        // `parseCLI`; invalid values return `error.Invalid`.
        const owns = @typeInfo(B) == .@"struct" and @hasDecl(B, "parseCLI");
        if (B != bool and B != []const u8 and @typeInfo(B) != .int and !owns)
            @compileError("cliflags: no arity for field `" ++ f.name ++ "`: " ++ @typeName(f.type));
    };
    if (@hasDecl(T, "aliases")) comptime for (T.aliases) |pair| {
        if (!@hasField(T, pair[1]))
            @compileError("cliflags: alias `" ++ pair[0] ++ "` names no field `" ++ pair[1] ++ "`");
    };

    // Help and version take precedence over syntax errors, even when they occur
    // where a value would otherwise be required. Scanning stops at `--`, after
    // which words are payload; for example, `mux a send -- --help` sends the
    // literal text `--help` to the session.
    for (args) |a| {
        if (std.mem.eql(u8, a, "--")) break;
        if (isHelp(a)) return .help;
        if (isVersion(a)) return .version;
    }

    var i: usize = 0;
    var payload = false;
    while (i < args.len) : (i += 1) {
        const a = args[i];
        if (!payload and std.mem.eql(u8, a, "--")) {
            payload = true;
            continue;
        }
        var known = false;
        if (!payload) inline for (fields) |f| {
            const hit = f.name[0] != '_' and !known and
                (std.mem.eql(u8, a, comptime flagName(f.name)) or aliasHit(T, f.name, a));
            if (hit) {
                known = true;
                const B = Bare(f.type);
                if (B == bool) {
                    @field(dst, f.name) = true;
                } else {
                    if (i + 1 >= args.len) return .{ .missing_value = a };
                    i += 1;
                    @field(dst, f.name) = switch (@typeInfo(B)) {
                        .int => std.fmt.parseInt(B, args[i], 10) catch return .{ .bad_value = a },
                        .@"struct" => B.parseCLI(args[i]) catch return .{ .bad_value = a },
                        else => args[i],
                    };
                }
            }
        };
        if (!known) {
            // Before `--`, only words without a leading dash are positional.
            // After `--`, every word is positional. A hook that returns false
            // leaves the word as an unknown argument.
            if ((payload or (a.len > 0 and a[0] != '-')) and @hasDecl(T, "positional")) {
                if (dst.positional(a)) continue;
            }
            // `T.extra` handles flag grammars that cannot be represented by
            // fields, such as a two-word `--sock PATH` target. Returning zero
            // leaves the flag as an unknown argument.
            if (!payload and a.len > 0 and a[0] == '-' and @hasDecl(T, "extra")) {
                const n = dst.extra(args[i..]);
                if (n > 0) {
                    i += n - 1;
                    continue;
                }
            }
            return .{ .unknown_arg = a };
        }
    }
    return .ok;
}

/// An alias is a second spelling of one field, so it carries no arity of
/// its own: `-A` is `--agent` because `agent` is the field it names.
fn aliasHit(comptime T: type, comptime field: []const u8, a: []const u8) bool {
    if (!@hasDecl(T, "aliases")) return false;
    inline for (T.aliases) |pair| {
        if (comptime !std.mem.eql(u8, pair[1], field)) continue;
        if (std.mem.eql(u8, pair[0], a)) return true;
    }
    return false;
}

pub fn isHelp(a: []const u8) bool {
    return std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h");
}

pub fn isVersion(a: []const u8) bool {
    return std.mem.eql(u8, a, "--version");
}

pub fn help(usage: []const u8) u8 {
    return helpTo(std.posix.STDOUT_FILENO, usage);
}

fn helpTo(fd: std.posix.fd_t, usage: []const u8) u8 {
    // Requested help and version text goes to stdout so it can be piped. Usage
    // errors go to stderr in `exitForTo`.
    writeTo(fd, usage);
    return 0;
}

pub fn version(prog: []const u8, ver: []const u8) u8 {
    return versionTo(std.posix.STDOUT_FILENO, prog, ver);
}

fn versionTo(fd: std.posix.fd_t, prog: []const u8, ver: []const u8) u8 {
    // Both strings are the caller's: cliflags imports nothing at all, and
    // that includes build_options, where the version it prints lives.
    var buf: [64]u8 = undefined;
    writeTo(fd, std.fmt.bufPrint(&buf, "{s} {s}\n", .{ prog, ver }) catch unreachable);
    return 0;
}

/// Retry short writes until all bytes are written or the descriptor fails.
fn writeTo(fd: std.posix.fd_t, bytes: []const u8) void {
    var off: usize = 0;
    while (off < bytes.len) off += std.posix.write(fd, bytes[off..]) catch return;
}

pub fn flagName(comptime field: []const u8) []const u8 {
    comptime var name: []const u8 = "--";
    inline for (field) |c| name = name ++ [_]u8{if (c == '_') '-' else c};
    return name;
}

/// Match a complete flag name, so documenting `--socket` does not satisfy a
/// check for `--sock`.
pub fn documented(name: []const u8, usage: []const u8) bool {
    var at: usize = 0;
    while (std.mem.indexOfPos(u8, usage, at, name)) |i| : (at = i + 1) {
        const end = i + name.len;
        if (end == usage.len) return true;
        switch (usage[end]) {
            ' ', '\n', ']' => return true,
            else => {},
        }
    }
    return false;
}

/// A documented alias, such as `-A`, also documents its underlying field.
fn documentedField(comptime T: type, comptime field: []const u8, comptime usage: []const u8) bool {
    if (documented(flagName(field), usage)) return true;
    if (@hasDecl(T, "aliases")) {
        for (T.aliases) |pair| {
            if (std.mem.eql(u8, pair[1], field) and documented(pair[0], usage)) return true;
        }
    }
    return false;
}

/// Fail compilation when a visible field has no corresponding flag in the
/// usage text. This checks fields against prose, but does not detect prose that
/// names a nonexistent field.
pub fn assertDocumented(comptime T: type, comptime usage: []const u8, comptime hidden: []const []const u8) void {
    @setEvalBranchQuota(200_000);
    comptime for (@typeInfo(T).@"struct".fields) |f| {
        if (f.name[0] == '_') continue;
        var skip = false;
        for (hidden) |h| {
            if (std.mem.eql(u8, h, f.name)) skip = true;
        }
        if (skip) continue;
        if (!documentedField(T, f.name, usage))
            @compileError("cliflags: usage text never names " ++ flagName(f.name));
    };
}

const DemoOptions = struct {
    _cmd: u8 = 0,
    vt: bool = false,
    sock: ?[]const u8 = null,
    // folder rule 5 exemption: This is the fallback shell executable, invoked directly as argv.
    shell: []const u8 = "/bin/sh",
    cols: u16 = 80,
    quic_idle_ms: u32 = 15_000,
};

test "parse: a bool field is a bare flag, a string field takes the next word, an integer field parses it" {
    var o: DemoOptions = .{};
    const args: []const [:0]const u8 = &.{ "--vt", "--sock", "/tmp/x.sock", "--shell", "/bin/dash", "--cols", "120" };
    try std.testing.expect(parse(DemoOptions, &o, args) == .ok);
    try std.testing.expect(o.vt);
    try std.testing.expectEqualStrings("/tmp/x.sock", o.sock.?);
    try std.testing.expectEqualStrings("/bin/dash", o.shell);
    try std.testing.expectEqual(@as(u16, 120), o.cols);

    // Untouched fields keep the struct's own defaults.
    var d: DemoOptions = .{};
    try std.testing.expect(parse(DemoOptions, &d, &.{}) == .ok);
    try std.testing.expect(!d.vt);
    try std.testing.expect(d.sock == null);
    try std.testing.expectEqual(@as(u32, 15_000), d.quic_idle_ms);
}

test "parse: a value flag at the end of argv is missing_value, not unknown_arg" {
    var o: DemoOptions = .{};
    const r = parse(DemoOptions, &o, &.{"--sock"});
    try std.testing.expect(r == .missing_value);
    try std.testing.expectEqualStrings("--sock", r.missing_value);
}

test "parse: an unknown flag names itself" {
    var o: DemoOptions = .{};
    const r = parse(DemoOptions, &o, &.{ "--vt", "--wat" });
    try std.testing.expect(r == .unknown_arg);
    try std.testing.expectEqualStrings("--wat", r.unknown_arg);

    // A bare word is a mistake too: there are no positional arguments here.
    var p: DemoOptions = .{};
    try std.testing.expect(parse(DemoOptions, &p, &.{"run"}) == .unknown_arg);
}

test "parse: --help and -h are the help outcome, before or after other flags" {
    var o: DemoOptions = .{};
    try std.testing.expect(parse(DemoOptions, &o, &.{"--help"}) == .help);
    try std.testing.expect(parse(DemoOptions, &o, &.{"-h"}) == .help);
    try std.testing.expect(parse(DemoOptions, &o, &.{ "--vt", "--help" }) == .help);
    try std.testing.expect(parse(DemoOptions, &o, &.{ "--help", "--wat" }) == .help);
    // Even where a value would be read: help outranks the grammar.
    try std.testing.expect(parse(DemoOptions, &o, &.{ "--sock", "--help" }) == .help);
}

test "parse: a leading-underscore field is not a flag" {
    var o: DemoOptions = .{};
    const r = parse(DemoOptions, &o, &.{ "--cmd", "run" });
    try std.testing.expect(r == .unknown_arg);
    try std.testing.expectEqualStrings("--cmd", r.unknown_arg);
    try std.testing.expectEqual(@as(u8, 0), o._cmd);
}

test "parse: a flag given twice, the last wins" {
    var o: DemoOptions = .{};
    try std.testing.expect(parse(DemoOptions, &o, &.{ "--cols", "100", "--cols", "42" }) == .ok);
    try std.testing.expectEqual(@as(u16, 42), o.cols);
}

test "parse: a value rejected by the field type is bad_value naming the flag" {
    var o: DemoOptions = .{};
    const r = parse(DemoOptions, &o, &.{ "--cols", "wide" });
    try std.testing.expect(r == .bad_value);
    try std.testing.expectEqualStrings("--cols", r.bad_value);

    // Out of the field's range, and negative into an unsigned, are the same
    // mistake: the flag cannot hold what was typed.
    var p: DemoOptions = .{};
    try std.testing.expect(parse(DemoOptions, &p, &.{ "--cols", "99999" }) == .bad_value);
    try std.testing.expect(parse(DemoOptions, &p, &.{ "--quic-idle-ms", "-5" }) == .bad_value);
}

test "parseStrict: syntax errors become Usage; help and version remain distinct" {
    var o: DemoOptions = .{};
    try parseStrict(DemoOptions, &o, &.{ "--vt", "--cols", "120" });
    try std.testing.expect(o.vt);
    try std.testing.expectEqual(@as(u16, 120), o.cols);

    try std.testing.expectError(error.Usage, parseStrict(DemoOptions, &o, &.{"--wat"}));
    try std.testing.expectError(error.Usage, parseStrict(DemoOptions, &o, &.{"--sock"}));
    try std.testing.expectError(error.Usage, parseStrict(DemoOptions, &o, &.{ "--cols", "wide" }));
    try std.testing.expectError(error.Help, parseStrict(DemoOptions, &o, &.{"-h"}));
    try std.testing.expectError(error.Version, parseStrict(DemoOptions, &o, &.{"--version"}));
}

test "exitFor: requested output uses stdout rc 0; usage errors use stderr rc 2" {
    // Capture explicit descriptors so the test verifies stream selection as
    // well as content. This matters to `mux a`, whose stdout must contain only
    // its single JSON response.
    const Run = struct {
        out: []const u8,
        err: []const u8,
        rc: u8,

        fn of(e: ParseError, usage: []const u8, ob: []u8, eb: []u8) !@This() {
            const o = try std.posix.pipe();
            const r = try std.posix.pipe();
            const rc = exitForTo(e, usage, "mux", "0", o[1], r[1]);
            std.posix.close(o[1]);
            std.posix.close(r[1]);
            defer std.posix.close(o[0]);
            defer std.posix.close(r[0]);
            return .{
                .out = ob[0..try std.posix.read(o[0], ob)],
                .err = eb[0..try std.posix.read(r[0], eb)],
                .rc = rc,
            };
        }
    };
    var ob: [256]u8 = undefined;
    var eb: [256]u8 = undefined;
    const page = "usage: demo [--sock PATH]\n";

    const refused = try Run.of(error.Usage, page, &ob, &eb);
    try std.testing.expectEqualStrings(page, refused.err);
    try std.testing.expectEqualStrings("", refused.out);
    try std.testing.expectEqual(@as(u8, 2), refused.rc);

    const asked = try Run.of(error.Help, page, &ob, &eb);
    try std.testing.expectEqualStrings(page, asked.out);
    try std.testing.expectEqualStrings("", asked.err);
    try std.testing.expectEqual(@as(u8, 0), asked.rc);

    const ver = try Run.of(error.Version, page, &ob, &eb);
    try std.testing.expectEqualStrings("mux 0\n", ver.out);
    try std.testing.expectEqualStrings("", ver.err);
    try std.testing.expectEqual(@as(u8, 0), ver.rc);
}

test "flagName: underscores become dashes" {
    try std.testing.expectEqualStrings("--quic-idle-ms", flagName("quic_idle_ms"));
    try std.testing.expectEqualStrings("--vt", flagName("vt"));
}

test "documented: a prefix of a longer flag does not count" {
    try std.testing.expect(!documented("--sock", "  mux d start [--socket PATH]\n"));
    try std.testing.expect(documented("--sock", "  mux d start [--sock PATH]\n"));
    try std.testing.expect(documented("--vt", "  mux d dump [--vt]\n"));
    try std.testing.expect(documented("--vt", "  mux d dump --vt\n"));
    try std.testing.expect(documented("--vt", "  mux d dump --vt"));
    try std.testing.expect(!documented("--rows", "nothing here\n"));
}

test "assertDocumented: every visible flag is named in the prose" {
    const text =
        \\  demo [--vt] [--sock PATH] [--cols N] [--quic-idle-ms N]
        \\
    ;
    // `shell` stands in for the daemon's machine-written flags: hidden from the
    // prose on purpose, so the assertion must not demand it.
    comptime assertDocumented(DemoOptions, text, &.{"shell"});
}

const PositionalOptions = struct {
    vt: bool = false,
    agent: bool = false,
    sock: ?[]const u8 = null,
    _host: ?[]const u8 = null,
    _refused: bool = false,

    pub const aliases = .{ .{ "-A", "agent" }, .{ "-s", "sock" } };

    pub fn positional(self: *PositionalOptions, word: []const u8) bool {
        if (word.len == 0) return false;
        if (word[0] == '!') {
            self._refused = true;
            return false;
        }
        self._host = word;
        return true;
    }
};

test "parse: a bare word goes to the program's positional hook" {
    var o: PositionalOptions = .{};
    try std.testing.expect(parse(PositionalOptions, &o, &.{ "--vt", "vm1" }) == .ok);
    try std.testing.expectEqualStrings("vm1", o._host.?);

    // A positional hook that returns false leaves the word as unknown after
    // recording that it was inspected.
    var r: PositionalOptions = .{};
    const bad = parse(PositionalOptions, &r, &.{"!nope"});
    try std.testing.expect(bad == .unknown_arg);
    try std.testing.expectEqualStrings("!nope", bad.unknown_arg);
    try std.testing.expect(r._refused);

    // A dashed word is never offered: an unnamed flag is a mistake, and the
    // hook must not get the chance to read it as a value.
    var d: PositionalOptions = .{};
    try std.testing.expect(parse(PositionalOptions, &d, &.{"--wat"}) == .unknown_arg);
    try std.testing.expect(d._host == null);
}

test "parse: an alias is the field's flag, with the field's arity" {
    var o: PositionalOptions = .{};
    try std.testing.expect(parse(PositionalOptions, &o, &.{ "-A", "-s", "/tmp/x.sock" }) == .ok);
    try std.testing.expect(o.agent);
    try std.testing.expectEqualStrings("/tmp/x.sock", o.sock.?);

    // The long spelling still works, and an alias that takes a value is
    // missing_value at the end of argv like the flag it stands for.
    var l: PositionalOptions = .{};
    try std.testing.expect(parse(PositionalOptions, &l, &.{"--agent"}) == .ok);
    try std.testing.expect(l.agent);
    try std.testing.expect(parse(PositionalOptions, &l, &.{"-s"}) == .missing_value);

    // Aliases belong only to the type that declares them.
    var demo: DemoOptions = .{};
    try std.testing.expect(parse(DemoOptions, &demo, &.{"-A"}) == .unknown_arg);
}

test "parse: --version is its own outcome, wherever it sits" {
    var o: DemoOptions = .{};
    try std.testing.expect(parse(DemoOptions, &o, &.{"--version"}) == .version);
    try std.testing.expect(parse(DemoOptions, &o, &.{ "--vt", "--version" }) == .version);
    // Version takes precedence over an adjacent unknown flag or missing value.
    try std.testing.expect(parse(DemoOptions, &o, &.{ "--version", "--wat" }) == .version);
    try std.testing.expect(parse(DemoOptions, &o, &.{ "--sock", "--version" }) == .version);
}

test "parse: a bare -- ends the flags and every word after it is payload" {
    var o: PositionalOptions = .{};
    try std.testing.expect(parse(PositionalOptions, &o, &.{ "--vt", "--", "--vt" }) == .ok);
    // The flag before the fence was read; the same word after it was not.
    try std.testing.expect(o.vt);
    try std.testing.expectEqualStrings("--vt", o._host.?);

    // A struct with no positional hook has nowhere to put payload, so the
    // fence buys it nothing: the word after is still a word it cannot take.
    var d: DemoOptions = .{};
    const bad = parse(DemoOptions, &d, &.{ "--", "run" });
    try std.testing.expect(bad == .unknown_arg);
    try std.testing.expectEqualStrings("run", bad.unknown_arg);
}

test "parse: --help after -- is payload, not a request for the usage" {
    var o: PositionalOptions = .{};
    try std.testing.expect(parse(PositionalOptions, &o, &.{ "--", "--help" }) == .ok);
    try std.testing.expectEqualStrings("--help", o._host.?);

    var v: PositionalOptions = .{};
    try std.testing.expect(parse(PositionalOptions, &v, &.{ "--", "--version" }) == .ok);
    try std.testing.expectEqualStrings("--version", v._host.?);

    // Before `--`, the same word is still parsed as help.
    var b: PositionalOptions = .{};
    try std.testing.expect(parse(PositionalOptions, &b, &.{ "--help", "--", "x" }) == .help);
}

const ExtraOptions = struct {
    vt: bool = false,
    _sock: ?[]const u8 = null,
    _seen: ?[]const u8 = null,

    /// Model the wall grammar, where `--sock PATH` is one target represented by
    /// two command-line arguments. No other flag is accepted by this hook.
    pub fn extra(self: *ExtraOptions, rest: []const [:0]const u8) usize {
        self._seen = rest[0];
        if (!std.mem.eql(u8, rest[0], "--sock")) return 0;
        if (rest.len < 2) return 0;
        self._sock = rest[1];
        return 2;
    }
};

test "parse: a flag the table does not own goes to the program's extra hook" {
    var o: ExtraOptions = .{};
    try std.testing.expect(parse(ExtraOptions, &o, &.{ "--sock", "/tmp/x.sock", "--vt" }) == .ok);
    try std.testing.expectEqualStrings("/tmp/x.sock", o._sock.?);
    // Parsing resumes after every argument consumed by the hook.
    try std.testing.expect(o.vt);

    // Returning zero reports the original flag as unknown.
    var r: ExtraOptions = .{};
    const bad = parse(ExtraOptions, &r, &.{"--wat"});
    try std.testing.expect(bad == .unknown_arg);
    try std.testing.expectEqualStrings("--wat", bad.unknown_arg);
    try std.testing.expectEqualStrings("--wat", r._seen.?);

    // Past the fence the hook is not consulted: payload is not a flag.
    var p: ExtraOptions = .{};
    const fenced = parse(ExtraOptions, &p, &.{ "--", "--sock", "/tmp/y.sock" });
    try std.testing.expect(fenced == .unknown_arg);
    try std.testing.expect(p._sock == null);

    // Without the decl, an unowned flag is unknown as it always was.
    var d: DemoOptions = .{};
    try std.testing.expect(parse(DemoOptions, &d, &.{"--wat"}) == .unknown_arg);
}

test "assertDocumented: an alias documents its field" {
    const text =
        \\  demo [--vt] [-A] [--sock PATH]
        \\
    ;
    comptime assertDocumented(PositionalOptions, text, &.{});
}

test "parse: a field type with parseCLI validates its value as bad_value" {
    // Model `quic.IdleMs`: validation travels with the field type instead of
    // being duplicated in each caller.
    const Port = struct {
        n: u16 = 8080,

        pub fn parseCLI(s: []const u8) error{Invalid}!@This() {
            const n = std.fmt.parseInt(u16, s, 10) catch return error.Invalid;
            if (n == 0) return error.Invalid;
            return .{ .n = n };
        }
    };
    const Typed = struct { port: Port = .{}, alt: ?Port = null };

    var o: Typed = .{};
    try std.testing.expect(parse(Typed, &o, &.{ "--port", "9000", "--alt", "81" }) == .ok);
    try std.testing.expectEqual(@as(u16, 9000), o.port.n);
    try std.testing.expectEqual(@as(u16, 81), o.alt.?.n);

    // Untouched fields retain their declared defaults: the type's value for the
    // required field and null for the optional field.
    var d: Typed = .{};
    try std.testing.expect(parse(Typed, &d, &.{}) == .ok);
    try std.testing.expectEqual(@as(u16, 8080), d.port.n);
    try std.testing.expect(d.alt == null);

    // Zero fits in u16 but fails `parseCLI`; the result identifies the flag
    // whose value was invalid.
    const r = parse(Typed, &d, &.{ "--port", "0" });
    try std.testing.expect(r == .bad_value);
    try std.testing.expectEqualStrings("--port", r.bad_value);
    try std.testing.expect(parse(Typed, &d, &.{ "--port", "wat" }) == .bad_value);
    // A rejected value leaves the field at its default.
    try std.testing.expectEqual(@as(u16, 8080), d.port.n);

    // It takes one value the way a string field does, so at the end of argv
    // it is missing_value and not a bare flag quietly set.
    try std.testing.expect(parse(Typed, &d, &.{"--port"}) == .missing_value);
}