a73x

694fffd5

refactor: one owner for the parse-outcome switch and the usage exit

a73x   2026-08-29 10:01

Commit message
refactor: one owner for the parse-outcome switch and the usage exit

`cliflags.parse`'s five-arm outcome switch and the Help/Version/Usage exit
were written out once per mode. `cliflags` owns both now: `parseStrict` is
`parse` as an error union, `exitFor` is what a mode's `main` returns for a
line that produced no options, and `ParseError` is the set they share.

mux (`mux_main`), `mux a` (`muxa`) and `mux web` (`webhub_main`) each drop
their copy of the switch and their three exit arms; muxa's own `ParseError`
decl and webhub's error-set literal are gone with them. `mux d` keeps its
own `Usage` union — its wording per refusal is not this shape.

Kept: mux's `Conflict` arm (its own refusal, answered before the rest are
handed on), webhub's `OutOfMemory` passthrough, webhub's hook-error-first
reading order, and muxa's usage on stderr — `exitFor` writes it there so
that mode's stdout stays one JSON object per invocation.

Pinned by `parseStrict`/`exitFor` tests in flags.zig plus every existing
--help/--version/refusal test in the three modes. The exitFor test swaps
stdout for /dev/null: `zig build test` speaks its protocol down stdout, so
a usage page written there wedges the runner.

src/cli/flags.zig
Old New
@@ -26,6 +26,51 @@ pub const Outcome = union(enum) {
26 bad_value: []const u8, 26 bad_value: []const u8,
27 }; 27 };
28 28
29 /// The three answers a mode's `main` gives for a line that produced no
30 /// options: one refusal for every way of mistyping a flag, because the
31 /// answer to all of them is the same usage page, and the two questions that
32 /// are output rather than diagnostics.
33 pub const ParseError = error{ Usage, Help, Version };
34
35 /// `parse` as an error union, for the callers that want a `try` instead of
36 /// a five-arm switch each.
37 pub fn parseStrict(comptime T: type, dst: *T, args: []const [:0]const u8) ParseError!void {
38 switch (parse(T, dst, args)) {
39 .ok => {},
40 .help => return error.Help,
41 .version => return error.Version,
42 .unknown_arg, .missing_value, .bad_value => return error.Usage,
43 }
44 }
45
46 /// The exit a mode makes of a `parseStrict` refusal. A mode with a refusal
47 /// of its own answers that one first and hands the rest here.
48 pub fn exitFor(e: ParseError, usage: []const u8, prog: []const u8, ver: []const u8) u8 {
49 return exitForTo(e, usage, prog, ver, std.posix.STDOUT_FILENO, std.posix.STDERR_FILENO);
50 }
51
52 /// `exitFor` against named fds, so a test can assert WHICH fd an arm
53 /// chose, over pipes it owns and the runner does not.
54 pub fn exitForTo(
55 e: ParseError,
56 usage: []const u8,
57 prog: []const u8,
58 ver: []const u8,
59 out: std.posix.fd_t,
60 err: std.posix.fd_t,
61 ) u8 {
62 return switch (e) {
63 error.Help => helpTo(out, usage),
64 error.Version => versionTo(out, prog, ver),
65 // stderr, like every refusal (`helpTo` says why the other two
66 // are not).
67 error.Usage => blk: {
68 writeTo(err, usage);
69 break :blk 2;
70 },
71 };
72 }
73
29 /// An optional field is its child type: null is a default, not an arity. 74 /// An optional field is its child type: null is a default, not an arity.
30 fn Bare(comptime F: type) type { 75 fn Bare(comptime F: type) type {
31 return if (@typeInfo(F) == .optional) @typeInfo(F).optional.child else F; 76 return if (@typeInfo(F) == .optional) @typeInfo(F).optional.child else F;
@@ -138,25 +183,33 @@ pub fn isVersion(a: []const u8) bool {
138 } 183 }
139 184
140 pub fn help(usage: []const u8) u8 { 185 pub fn help(usage: []const u8) u8 {
186 return helpTo(std.posix.STDOUT_FILENO, usage);
187 }
188
189 fn helpTo(fd: std.posix.fd_t, usage: []const u8) u8 {
141 // stdout, unlike every refusal: a usage someone ASKED for is output, 190 // stdout, unlike every refusal: a usage someone ASKED for is output,
142 // and they may well have piped it into a pager. version shares both 191 // and they may well have piped it into a pager. version shares both
143 // the rule and the reason. 192 // the rule and the reason.
144 writeOut(usage); 193 writeTo(fd, usage);
145 return 0; 194 return 0;
146 } 195 }
147 196
148 pub fn version(prog: []const u8, ver: []const u8) u8 { 197 pub fn version(prog: []const u8, ver: []const u8) u8 {
198 return versionTo(std.posix.STDOUT_FILENO, prog, ver);
199 }
200
201 fn versionTo(fd: std.posix.fd_t, prog: []const u8, ver: []const u8) u8 {
149 // Both strings are the caller's: cliflags is layer 0 and cannot see 202 // Both strings are the caller's: cliflags is layer 0 and cannot see
150 // build_options, where the version it prints lives. 203 // build_options, where the version it prints lives.
151 var buf: [64]u8 = undefined; 204 var buf: [64]u8 = undefined;
152 writeOut(std.fmt.bufPrint(&buf, "{s} {s}\n", .{ prog, ver }) catch unreachable); 205 writeTo(fd, std.fmt.bufPrint(&buf, "{s} {s}\n", .{ prog, ver }) catch unreachable);
153 return 0; 206 return 0;
154 } 207 }
155 208
156 /// A short write is not an error: a usage page down a pipe can take two. 209 /// A short write is not an error: a usage page down a pipe can take two.
157 fn writeOut(bytes: []const u8) void { 210 fn writeTo(fd: std.posix.fd_t, bytes: []const u8) void {
158 var off: usize = 0; 211 var off: usize = 0;
159 while (off < bytes.len) off += std.posix.write(std.posix.STDOUT_FILENO, bytes[off..]) catch return; 212 while (off < bytes.len) off += std.posix.write(fd, bytes[off..]) catch return;
160 } 213 }
161 214
162 pub fn flagName(comptime field: []const u8) []const u8 { 215 pub fn flagName(comptime field: []const u8) []const u8 {
@@ -290,6 +343,64 @@ test "parse: a value the field's type refuses is bad_value naming the flag" {
290 try std.testing.expect(parse(Demo, &p, &.{ "--quic-idle-ms", "-5" }) == .bad_value); 343 try std.testing.expect(parse(Demo, &p, &.{ "--quic-idle-ms", "-5" }) == .bad_value);
291 } 344 }
292 345
346 test "parseStrict: every way of mistyping a flag is one Usage; help and version keep their own" {
347 var o: Demo = .{};
348 try parseStrict(Demo, &o, &.{ "--vt", "--cols", "120" });
349 try std.testing.expect(o.vt);
350 try std.testing.expectEqual(@as(u16, 120), o.cols);
351
352 try std.testing.expectError(error.Usage, parseStrict(Demo, &o, &.{"--wat"}));
353 try std.testing.expectError(error.Usage, parseStrict(Demo, &o, &.{"--sock"}));
354 try std.testing.expectError(error.Usage, parseStrict(Demo, &o, &.{ "--cols", "wide" }));
355 try std.testing.expectError(error.Help, parseStrict(Demo, &o, &.{"-h"}));
356 try std.testing.expectError(error.Version, parseStrict(Demo, &o, &.{"--version"}));
357 }
358
359 test "exitFor: an answer the user asked for is stdout rc 0, a refusal is stderr rc 2" {
360 // Pipes the test owns, never the process's own fds: the fd each arm
361 // picks is the only choice `exitFor` makes, so a run that writes to
362 // fd 1 and looks away asserts nothing. A usage page on stdout would
363 // put a second thing in `mux a`'s one-JSON-object stream.
364 const Run = struct {
365 out: []const u8,
366 err: []const u8,
367 rc: u8,
368
369 fn of(e: ParseError, usage: []const u8, ob: []u8, eb: []u8) !@This() {
370 const o = try std.posix.pipe();
371 const r = try std.posix.pipe();
372 const rc = exitForTo(e, usage, "mux", "0", o[1], r[1]);
373 std.posix.close(o[1]);
374 std.posix.close(r[1]);
375 defer std.posix.close(o[0]);
376 defer std.posix.close(r[0]);
377 return .{
378 .out = ob[0..try std.posix.read(o[0], ob)],
379 .err = eb[0..try std.posix.read(r[0], eb)],
380 .rc = rc,
381 };
382 }
383 };
384 var ob: [256]u8 = undefined;
385 var eb: [256]u8 = undefined;
386 const page = "usage: demo [--sock PATH]\n";
387
388 const refused = try Run.of(error.Usage, page, &ob, &eb);
389 try std.testing.expectEqualStrings(page, refused.err);
390 try std.testing.expectEqualStrings("", refused.out);
391 try std.testing.expectEqual(@as(u8, 2), refused.rc);
392
393 const asked = try Run.of(error.Help, page, &ob, &eb);
394 try std.testing.expectEqualStrings(page, asked.out);
395 try std.testing.expectEqualStrings("", asked.err);
396 try std.testing.expectEqual(@as(u8, 0), asked.rc);
397
398 const ver = try Run.of(error.Version, page, &ob, &eb);
399 try std.testing.expectEqualStrings("mux 0\n", ver.out);
400 try std.testing.expectEqualStrings("", ver.err);
401 try std.testing.expectEqual(@as(u8, 0), ver.rc);
402 }
403
293 test "flagName: underscores become dashes" { 404 test "flagName: underscores become dashes" {
294 try std.testing.expectEqualStrings("--quic-idle-ms", flagName("quic_idle_ms")); 405 try std.testing.expectEqualStrings("--quic-idle-ms", flagName("quic_idle_ms"));
295 try std.testing.expectEqualStrings("--vt", flagName("vt")); 406 try std.testing.expectEqualStrings("--vt", flagName("vt"));
src/cli/mux_main.zig
Old New
@@ -100,11 +100,10 @@ const ParseResult = union(enum) {
100 quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32, session: []const u8 = "", agent: bool = false }, 100 quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32, session: []const u8 = "", agent: bool = false },
101 }; 101 };
102 102
103 /// Help and Version short-circuit the rest of the parse rather than being 103 /// Conflict is this mode's own refusal: more than one transport named, a
104 /// reconciled with it, and main answers both on stdout with an exit 0 — 104 /// request that cannot be honoured rather than one to reconcile. The other
105 /// unlike the two refusals. Conflict is more than one transport named: a 105 /// three are `cliflags.exitFor`'s.
106 /// request that cannot be honoured rather than one to reconcile. 106 const ParseError = cliflags.ParseError || error{Conflict};
107 const ParseError = error{ Usage, Help, Version, Conflict };
108 107
109 /// `SSH_AGENTC_REQUEST_IDENTITIES` in the ssh-agent framing: a 4-byte 108 /// `SSH_AGENTC_REQUEST_IDENTITIES` in the ssh-agent framing: a 4-byte
110 /// big-endian length, then the message type. `ssh-add -l` sends exactly 109 /// big-endian length, then the message type. `ssh-add -l` sends exactly
@@ -207,12 +206,7 @@ comptime {
207 206
208 fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseError!ParseResult { 207 fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseError!ParseResult {
209 var o: Opts = .{}; 208 var o: Opts = .{};
210 switch (cliflags.parse(Opts, &o, args[1..])) { 209 try cliflags.parseStrict(Opts, &o, args[1..]);
211 .ok => {},
212 .help => return error.Help,
213 .version => return error.Version,
214 .unknown_arg, .missing_value, .bad_value => return error.Usage,
215 }
216 210
217 // Every pairing is two transports for one session, and two bare words 211 // Every pairing is two transports for one session, and two bare words
218 // are a pairing too — which is why positional counts, not latches. 212 // are a pairing too — which is why positional counts, not latches.
@@ -267,12 +261,6 @@ pub fn main(args: []const [:0]const u8) !u8 {
267 if (args.len == 1) return wallOfHosts(alloc); 261 if (args.len == 1) return wallOfHosts(alloc);
268 262
269 const parsed = parseArgs(args, std.posix.getenv(xdg.key_env)) catch |e| switch (e) { 263 const parsed = parseArgs(args, std.posix.getenv(xdg.key_env)) catch |e| switch (e) {
270 error.Version => return cliflags.version("mux", build_options.version),
271 error.Help => return cliflags.help(usage),
272 error.Usage => {
273 std.debug.print("{s}", .{usage});
274 return 2;
275 },
276 error.Conflict => { 264 error.Conflict => {
277 std.debug.print( 265 std.debug.print(
278 "mux: name one transport: HOST, --sock, --via or quic://\n{s}", 266 "mux: name one transport: HOST, --sock, --via or quic://\n{s}",
@@ -280,6 +268,7 @@ pub fn main(args: []const [:0]const u8) !u8 {
280 ); 268 );
281 return 2; 269 return 2;
282 }, 270 },
271 else => |pe| return cliflags.exitFor(pe, usage, "mux", build_options.version),
283 }; 272 };
284 273
285 // `-A` is a promise, and a client with no agent behind it cannot keep 274 // `-A` is a promise, and a client with no agent behind it cannot keep
src/cli/muxa.zig
Old New
@@ -91,16 +91,9 @@ comptime {
91 cliflags.assertDocumented(Opts, usage, &.{}); 91 cliflags.assertDocumented(Opts, usage, &.{});
92 } 92 }
93 93
94 const ParseError = error{ Usage, Help, Version }; 94 fn parseArgs(args: []const [:0]const u8) cliflags.ParseError!Opts {
95
96 fn parseArgs(args: []const [:0]const u8) ParseError!Opts {
97 var o: Opts = .{}; 95 var o: Opts = .{};
98 switch (cliflags.parse(Opts, &o, args[1..])) { 96 try cliflags.parseStrict(Opts, &o, args[1..]);
99 .ok => {},
100 .help => return error.Help,
101 .version => return error.Version,
102 .unknown_arg, .missing_value, .bad_value => return error.Usage,
103 }
104 if (o._verb == null) return error.Usage; 97 if (o._verb == null) return error.Usage;
105 98
106 // Name ONE transport. A `--sock` silently ignored beside a `--quic` 99 // Name ONE transport. A `--sock` silently ignored beside a `--quic`
@@ -1203,18 +1196,11 @@ pub fn main(args: []const [:0]const u8) !u8 {
1203 defer arena_state.deinit(); 1196 defer arena_state.deinit();
1204 const alloc = arena_state.allocator(); 1197 const alloc = arena_state.allocator();
1205 1198
1206 const o = parseArgs(args) catch |e| switch (e) { 1199 // Help and version go to stdout — the one place this mode otherwise
1207 // Usage is diagnostic, so it goes to stderr: stdout stays strictly 1200 // reserves for JSON, and the only two verbs allowed to spend it. The
1208 // one JSON object per invocation, even on the argument-error path. 1201 // usage is a diagnostic and goes to stderr, so stdout stays strictly one
1209 error.Usage => { 1202 // JSON object per invocation even on the argument-error path.
1210 proto.writeAllFd(std.posix.STDERR_FILENO, usage) catch {}; 1203 const o = parseArgs(args) catch |e| return cliflags.exitFor(e, usage, "mux", build_options.version);
1211 return 2;
1212 },
1213 // Help and version go to stdout — the one place this mode otherwise
1214 // reserves for JSON, and the only two verbs allowed to spend it.
1215 error.Help => return cliflags.help(usage),
1216 error.Version => return cliflags.version("mux", build_options.version),
1217 };
1218 1204
1219 // Started BEFORE the connect, not after: over QUIC the handshake is 1205 // Started BEFORE the connect, not after: over QUIC the handshake is
1220 // part of the round trip the caller bounded, and a `--timeout` that 1206 // part of the round trip the caller bounded, and a `--timeout` that
src/cli/webhub_main.zig
Old New
@@ -72,7 +72,7 @@ comptime {
72 /// error: the single errdefer then owns the tile list on every path that 72 /// error: the single errdefer then owns the tile list on every path that
73 /// does not serve. As union arms they wanted a `deinit` beside each 73 /// does not serve. As union arms they wanted a `deinit` beside each
74 /// refusing return, every one of them a chance to forget. 74 /// refusing return, every one of them a chance to forget.
75 const ParseError = error{ Usage, Help, Version } || std.mem.Allocator.Error; 75 const ParseError = cliflags.ParseError || std.mem.Allocator.Error;
76 76
77 fn parseArgs( 77 fn parseArgs(
78 alloc: std.mem.Allocator, 78 alloc: std.mem.Allocator,
@@ -82,7 +82,7 @@ fn parseArgs(
82 var p = Parsed{ ._argv = .{ .alloc = alloc } }; 82 var p = Parsed{ ._argv = .{ .alloc = alloc } };
83 errdefer p.deinit(); 83 errdefer p.deinit();
84 84
85 const outcome = cliflags.parse(Parsed, &p, args[1..]); 85 const outcome = cliflags.parseStrict(Parsed, &p, args[1..]);
86 // Read before the outcome: a hook that refused for a REASON has already 86 // Read before the outcome: a hook that refused for a REASON has already
87 // named it, and that reason outranks the bare "unknown word" cliflags 87 // named it, and that reason outranks the bare "unknown word" cliflags
88 // saw when the hook said no. The message names the tile — with several 88 // saw when the hook said no. The message names the tile — with several
@@ -92,12 +92,7 @@ fn parseArgs(
92 std.debug.print("mux web: tile {s}: {s}\n", .{ e.word, wall.reason(e.err) }); 92 std.debug.print("mux web: tile {s}: {s}\n", .{ e.word, wall.reason(e.err) });
93 return error.Usage; 93 return error.Usage;
94 } 94 }
95 switch (outcome) { 95 try outcome;
96 .ok => {},
97 .unknown_arg, .missing_value, .bad_value => return error.Usage,
98 .help => return error.Help,
99 .version => return error.Version,
100 }
101 // Port 0 asks the kernel to choose, and the hub prints the port it was 96 // Port 0 asks the kernel to choose, and the hub prints the port it was
102 // asked for as the door to open — a door nobody could find. Refused 97 // asked for as the door to open — a door nobody could find. Refused
103 // like `--quic-idle-ms 0` and for the same reason: the number inverts 98 // like `--quic-idle-ms 0` and for the same reason: the number inverts
@@ -119,13 +114,8 @@ pub fn main(args: []const [:0]const u8) !u8 {
119 const alloc = gpa.allocator(); 114 const alloc = gpa.allocator();
120 115
121 var parsed = parseArgs(alloc, args, std.posix.getenv(xdg.key_env)) catch |err| switch (err) { 116 var parsed = parseArgs(alloc, args, std.posix.getenv(xdg.key_env)) catch |err| switch (err) {
122 error.Usage => { 117 error.OutOfMemory => return err,
123 std.debug.print("{s}", .{usage}); 118 else => |e| return cliflags.exitFor(e, usage, "mux", build_options.version),
124 return 2;
125 },
126 error.Help => return cliflags.help(usage),
127 error.Version => return cliflags.version("mux", build_options.version),
128 else => |e| return e,
129 }; 119 };
130 defer parsed.deinit(); 120 defer parsed.deinit();
131 121