a73x

a8c489d9

refactor: muxweb parses to an Opts or an error, not a three-armed union

a73x   2026-08-27 08:06

Commit message
refactor: muxweb parses to an Opts or an error, not a three-armed union

Only one outcome carries anything back, so the other two stop being
arms of a result and become what they already were beside `error.Usage`
— errors. The errdefer that owned the tile list on the refusing path now
owns it on every path that does not serve, and main handles all four in
one catch instead of a catch and a switch.

src/cli/webhub_main.zig
Old New
@@ -64,28 +64,21 @@ const Parsed = struct {
64 } 64 }
65 }; 65 };
66 66
67 const ParseResult = union(enum) {
68 serve: Parsed,
69 version,
70 help,
71 };
72
73 comptime { 67 comptime {
74 cliflags.assertDocumented(Parsed, usage, &.{}); 68 cliflags.assertDocumented(Parsed, usage, &.{});
75 } 69 }
76 70
77 /// A usage mistake is an ERROR, not a third result: it is the one outcome 71 /// Only a serving line hands anything back, so everything else is an
78 /// with nothing to hand back, and saying so lets the single errdefer own 72 /// error: the single errdefer then owns the tile list on every path that
79 /// the tile list on every refusing path. As a value it wanted a 73 /// does not serve. As union arms they wanted a `deinit` beside each
80 /// `tiles.deinit` beside every refusing return, each one a chance to 74 /// refusing return, every one of them a chance to forget.
81 /// forget. 75 const ParseError = error{ Usage, Help, Version } || std.mem.Allocator.Error;
82 const ParseError = error{Usage} || std.mem.Allocator.Error;
83 76
84 fn parseArgs( 77 fn parseArgs(
85 alloc: std.mem.Allocator, 78 alloc: std.mem.Allocator,
86 args: []const [:0]const u8, 79 args: []const [:0]const u8,
87 env_key: ?[]const u8, 80 env_key: ?[]const u8,
88 ) ParseError!ParseResult { 81 ) ParseError!Parsed {
89 var p = Parsed{ ._argv = .{ .alloc = alloc } }; 82 var p = Parsed{ ._argv = .{ .alloc = alloc } };
90 errdefer p.deinit(); 83 errdefer p.deinit();
91 84
@@ -102,13 +95,8 @@ fn parseArgs(
102 switch (outcome) { 95 switch (outcome) {
103 .ok => {}, 96 .ok => {},
104 .unknown_arg, .missing_value, .bad_value => return error.Usage, 97 .unknown_arg, .missing_value, .bad_value => return error.Usage,
105 // The two non-error early returns, so the two that still free for 98 .help => return error.Help,
106 // themselves: errdefer does not run on the way out with a result 99 .version => return error.Version,
107 // in hand.
108 .help, .version => {
109 p.deinit();
110 return if (outcome == .help) .help else .version;
111 },
112 } 100 }
113 // Port 0 asks the kernel to choose, and the hub prints the port it was 101 // Port 0 asks the kernel to choose, and the hub prints the port it was
114 // asked for as the door to open — a door nobody could find. Refused 102 // asked for as the door to open — a door nobody could find. Refused
@@ -120,7 +108,7 @@ fn parseArgs(
120 // last run persisted. main decides what an empty argv means; the parse 108 // last run persisted. main decides what an empty argv means; the parse
121 // only reports what was on the line. 109 // only reports what was on the line.
122 p.key = xdg.pickKey(p.key, env_key); 110 p.key = xdg.pickKey(p.key, env_key);
123 return .{ .serve = p }; 111 return p;
124 } 112 }
125 113
126 pub fn main() !u8 { 114 pub fn main() !u8 {
@@ -132,18 +120,15 @@ pub fn main() !u8 {
132 const args = try std.process.argsAlloc(alloc); 120 const args = try std.process.argsAlloc(alloc);
133 defer std.process.argsFree(alloc, args); 121 defer std.process.argsFree(alloc, args);
134 122
135 const result = parseArgs(alloc, args, std.posix.getenv(xdg.key_env)) catch |err| switch (err) { 123 var parsed = parseArgs(alloc, args, std.posix.getenv(xdg.key_env)) catch |err| switch (err) {
136 error.Usage => { 124 error.Usage => {
137 std.debug.print("{s}", .{usage}); 125 std.debug.print("{s}", .{usage});
138 return 2; 126 return 2;
139 }, 127 },
128 error.Help => return cliflags.help(usage),
129 error.Version => return cliflags.version("muxweb", build_options.version),
140 else => |e| return e, 130 else => |e| return e,
141 }; 131 };
142 var parsed = switch (result) {
143 .help => return cliflags.help(usage),
144 .version => return cliflags.version("muxweb", build_options.version),
145 .serve => |p| p,
146 };
147 defer parsed.deinit(); 132 defer parsed.deinit();
148 133
149 // An arena, because every string built here lives exactly as long as 134 // An arena, because every string built here lives exactly as long as
@@ -256,7 +241,7 @@ test "parse: three spellings become three tiles in argv order, port and key bind
256 const args = [_][:0]const u8{ 241 const args = [_][:0]const u8{
257 "muxweb", "box1", "--sock", "/tmp/a.sock", "quic://h:4433", "--key", "/k", "--port", "8000", 242 "muxweb", "box1", "--sock", "/tmp/a.sock", "quic://h:4433", "--key", "/k", "--port", "8000",
258 }; 243 };
259 var r = (try parseArgs(alloc, &args, null)).serve; 244 var r = try parseArgs(alloc, &args, null);
260 defer r.deinit(); 245 defer r.deinit();
261 try std.testing.expectEqual(@as(usize, 3), r._argv.tiles.items.len); 246 try std.testing.expectEqual(@as(usize, 3), r._argv.tiles.items.len);
262 try std.testing.expectEqualStrings("box1", r._argv.tiles.items[0]); 247 try std.testing.expectEqualStrings("box1", r._argv.tiles.items[0]);
@@ -272,7 +257,7 @@ test "parse: a quoted '--sock PATH#SESSION' is the same tile as the two-argument
272 const alloc = std.testing.allocator; 257 const alloc = std.testing.allocator;
273 // The wall file's own spelling, pasted straight onto the command line: 258 // The wall file's own spelling, pasted straight onto the command line:
274 // muxweb used to refuse it while `mux wall` required it. 259 // muxweb used to refuse it while `mux wall` required it.
275 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock /tmp/a.sock#b" }, null)).serve; 260 var r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock /tmp/a.sock#b" }, null);
276 defer r.deinit(); 261 defer r.deinit();
277 try std.testing.expectEqual(@as(usize, 1), r._argv.tiles.items.len); 262 try std.testing.expectEqual(@as(usize, 1), r._argv.tiles.items.len);
278 try std.testing.expectEqualStrings("--sock /tmp/a.sock#b", r._argv.tiles.items[0]); 263 try std.testing.expectEqualStrings("--sock /tmp/a.sock#b", r._argv.tiles.items[0]);
@@ -283,7 +268,7 @@ test "parse: zero targets, bad flags, and flag-beats-env" {
283 // No targets is an empty argv wall, not a refusal: restore-from-file 268 // No targets is an empty argv wall, not a refusal: restore-from-file
284 // semantics live in main, which is the only place that can read a file. 269 // semantics live in main, which is the only place that can read a file.
285 { 270 {
286 var r = (try parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null)).serve; 271 var r = try parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null);
287 defer r.deinit(); 272 defer r.deinit();
288 try std.testing.expectEqual(@as(usize, 0), r._argv.tiles.items.len); 273 try std.testing.expectEqual(@as(usize, 0), r._argv.tiles.items.len);
289 } 274 }
@@ -310,24 +295,24 @@ test "parse: zero targets, bad flags, and flag-beats-env" {
310 // ...and an ordinary port still binds, so the refusal is the zero and 295 // ...and an ordinary port still binds, so the refusal is the zero and
311 // not the flag. 296 // not the flag.
312 { 297 {
313 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "1" }, null)).serve; 298 var r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "1" }, null);
314 defer r.deinit(); 299 defer r.deinit();
315 try std.testing.expectEqual(@as(u16, 1), r.port); 300 try std.testing.expectEqual(@as(u16, 1), r.port);
316 } 301 }
317 // Env fills in when --key is absent; --key wins when both are set. 302 // Env fills in when --key is absent; --key wins when both are set.
318 { 303 {
319 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "/env-key")).serve; 304 var r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "/env-key");
320 defer r.deinit(); 305 defer r.deinit();
321 try std.testing.expectEqualStrings("/env-key", r.key.?); 306 try std.testing.expectEqualStrings("/env-key", r.key.?);
322 } 307 }
323 { 308 {
324 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--key", "/flag-key" }, "/env-key")).serve; 309 var r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--key", "/flag-key" }, "/env-key");
325 defer r.deinit(); 310 defer r.deinit();
326 try std.testing.expectEqualStrings("/flag-key", r.key.?); 311 try std.testing.expectEqualStrings("/flag-key", r.key.?);
327 } 312 }
328 // Empty either way means unset. 313 // Empty either way means unset.
329 { 314 {
330 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "")).serve; 315 var r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "");
331 defer r.deinit(); 316 defer r.deinit();
332 try std.testing.expectEqual(@as(?[]const u8, null), r.key); 317 try std.testing.expectEqual(@as(?[]const u8, null), r.key);
333 } 318 }
@@ -338,7 +323,7 @@ test "tiles: the spelling reaches the wall verbatim, `#NAME` and all" {
338 const args = [_][:0]const u8{ 323 const args = [_][:0]const u8{
339 "muxweb", "host#b", "quic://h:1#b", "--sock", "/tmp/x#b", "plainhost", "a#b#c", 324 "muxweb", "host#b", "quic://h:1#b", "--sock", "/tmp/x#b", "plainhost", "a#b#c",
340 }; 325 };
341 var r = (try parseArgs(alloc, &args, null)).serve; 326 var r = try parseArgs(alloc, &args, null);
342 defer r.deinit(); 327 defer r.deinit();
343 try std.testing.expectEqual(@as(usize, 5), r._argv.tiles.items.len); 328 try std.testing.expectEqual(@as(usize, 5), r._argv.tiles.items.len);
344 329
@@ -385,12 +370,12 @@ test "tiles: a bad session name after # is still a usage error at parse" {
385 370
386 test "help is an answer, not a refusal, and -- fences the tiles from the flags" { 371 test "help is an answer, not a refusal, and -- fences the tiles from the flags" {
387 const alloc = std.testing.allocator; 372 const alloc = std.testing.allocator;
388 try std.testing.expect(try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--help" }, null) == .help); 373 try std.testing.expectError(error.Help, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--help" }, null));
389 try std.testing.expect(try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "-h" }, null) == .help); 374 try std.testing.expectError(error.Help, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "-h" }, null));
390 375
391 // Past `--` a word is a tile whatever it is spelled like: the escape a 376 // Past `--` a word is a tile whatever it is spelled like: the escape a
392 // host whose name reads as a flag would otherwise have none of. 377 // host whose name reads as a flag would otherwise have none of.
393 var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--", "host" }, null)).serve; 378 var r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--", "host" }, null);
394 defer r.deinit(); 379 defer r.deinit();
395 try std.testing.expectEqual(@as(usize, 1), r._argv.tiles.items.len); 380 try std.testing.expectEqual(@as(usize, 1), r._argv.tiles.items.len);
396 try std.testing.expectEqualStrings("host", r._argv.tiles.items[0]); 381 try std.testing.expectEqualStrings("host", r._argv.tiles.items[0]);
@@ -398,8 +383,7 @@ test "help is an answer, not a refusal, and -- fences the tiles from the flags"
398 383
399 test "version short-circuits everything else on the line" { 384 test "version short-circuits everything else on the line" {
400 const alloc = std.testing.allocator; 385 const alloc = std.testing.allocator;
401 const r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--version", "--bogus" }, null); 386 try std.testing.expectError(error.Version, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--version", "--bogus" }, null));
402 try std.testing.expect(r == .version);
403 } 387 }
404 388
405 // Forces semantic analysis of every pub decl under `zig build test`, so an 389 // Forces semantic analysis of every pub decl under `zig build test`, so an