2ce4a84a
refactor: wall.Argv is the one argv collector for muxweb and mux wall
a73x 2026-08-27 07:11
Commit message
src/cli/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -471,54 +471,19 @@ pub fn main() !u8 { | |||
| 471 | } | 471 | } |
| 472 | } | 472 | } |
| 473 | 473 | ||
| 474 | /// `mux wall`: gather spellings (argv, or with none the saved wall — the | ||
| 475 | /// attach history mux itself writes, muxweb's too via argv and POST | ||
| 476 | /// /tiles), resolve each through the one grammar, hand the lot to | ||
| 477 | /// wallview.run. Resolution allocates into an arena because run() never | ||
| 478 | /// returns on the success path (it exits the process — see wallview.run); | ||
| 479 | /// only the early usage-error paths come back through the defers here. | ||
| 480 | /// `mux wall`'s own line: two flags, and every other word a tile. Shares | 474 | /// `mux wall`'s own line: two flags, and every other word a tile. Shares |
| 481 | /// `usage` with the attach line, so the prose that documents `--key` there | 475 | /// `usage` with the attach line, so the prose that documents `--key` there |
| 482 | /// documents it here. | 476 | /// documents it here. |
| 483 | const WallOpts = struct { | 477 | const WallOpts = struct { |
| 484 | key: ?[]const u8 = null, | 478 | key: ?[]const u8 = null, |
| 485 | quic_idle_ms: u32 = client.quic_idle_ms_default, | 479 | quic_idle_ms: u32 = client.quic_idle_ms_default, |
| 486 | _arena: std.mem.Allocator, | 480 | _argv: wall.Argv, |
| 487 | _tiles: std.ArrayList([]const u8) = .empty, | ||
| 488 | /// A hook answers yes or no, so one that failed for a reason has | ||
| 489 | /// nowhere to say so: wallMain owns every message this command prints. | ||
| 490 | _err: ?anyerror = null, | ||
| 491 | |||
| 492 | pub fn positional(self: *WallOpts, word: []const u8) bool { | ||
| 493 | const copy = self._arena.dupe(u8, word) catch |e| { | ||
| 494 | self._err = e; | ||
| 495 | return false; | ||
| 496 | }; | ||
| 497 | self._tiles.append(self._arena, copy) catch |e| { | ||
| 498 | self._err = e; | ||
| 499 | return false; | ||
| 500 | }; | ||
| 501 | return true; | ||
| 502 | } | ||
| 503 | 481 | ||
| 504 | /// `--sock` is not one of this command's own flags, so wall may claim | 482 | pub fn positional(self: *WallOpts, w: []const u8) bool { |
| 505 | /// it and its path as one spelling — muxweb's dialect, accepted here | 483 | return self._argv.positional(w); |
| 506 | /// too, and joined by wall so both binaries read one grammar. | 484 | } |
| 507 | pub fn extra(self: *WallOpts, rest: []const [:0]const u8) usize { | 485 | pub fn extra(self: *WallOpts, rest: []const [:0]const u8) usize { |
| 508 | const n = wall.spellingFromArgv(self._arena, rest, 0) catch |err| switch (err) { | 486 | return self._argv.extra(rest); |
| 509 | // Not a target this command refuses but one it never saw: | ||
| 510 | // left for cliflags to name as the unknown flag it is. | ||
| 511 | error.FlagLikeTarget => return 0, | ||
| 512 | else => |e| { | ||
| 513 | self._err = e; | ||
| 514 | return 0; | ||
| 515 | }, | ||
| 516 | }; | ||
| 517 | self._tiles.append(self._arena, n.spelling) catch |e| { | ||
| 518 | self._err = e; | ||
| 519 | return 0; | ||
| 520 | }; | ||
| 521 | return n.consumed; | ||
| 522 | } | 487 | } |
| 523 | }; | 488 | }; |
| 524 | 489 | ||
| @@ -526,6 +491,12 @@ comptime { | |||
| 526 | cliflags.assertDocumented(WallOpts, usage, &.{}); | 491 | cliflags.assertDocumented(WallOpts, usage, &.{}); |
| 527 | } | 492 | } |
| 528 | 493 | ||
| 494 | /// `mux wall`: gather spellings (argv, or with none the saved wall — the | ||
| 495 | /// attach history mux itself writes, muxweb's too via argv and POST | ||
| 496 | /// /tiles), resolve each through the one grammar, hand the lot to | ||
| 497 | /// wallview.run. Resolution allocates into an arena because run() never | ||
| 498 | /// returns on the success path (it exits the process — see wallview.run); | ||
| 499 | /// only the early usage-error paths come back through the defers here. | ||
| 529 | fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 { | 500 | fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 { |
| 530 | var arena_state = std.heap.ArenaAllocator.init(alloc); | 501 | var arena_state = std.heap.ArenaAllocator.init(alloc); |
| 531 | defer arena_state.deinit(); | 502 | defer arena_state.deinit(); |
| @@ -540,18 +511,16 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 { | |||
| 540 | (std.mem.eql(u8, args[0], "add") or std.mem.eql(u8, args[0], "rm"))) | 511 | (std.mem.eql(u8, args[0], "add") or std.mem.eql(u8, args[0], "rm"))) |
| 541 | return wallEdit(arena, args[0], args[1..]); | 512 | return wallEdit(arena, args[0], args[1..]); |
| 542 | 513 | ||
| 543 | var w_opts = WallOpts{ ._arena = arena }; | 514 | var w_opts = WallOpts{ ._argv = .{ .alloc = arena } }; |
| 544 | const outcome = cliflags.parse(WallOpts, &w_opts, args); | 515 | const outcome = cliflags.parse(WallOpts, &w_opts, args); |
| 545 | // Read before the outcome: a hook that refused for a REASON has already | 516 | // Read before the outcome: a hook that refused for a REASON has already |
| 546 | // named it, and that reason outranks the bare "unknown word" cliflags | 517 | // named it, and that reason outranks the bare "unknown word" cliflags |
| 547 | // saw when the hook said no. | 518 | // saw when the hook said no. |
| 548 | if (w_opts._err) |e| switch (e) { | 519 | if (w_opts._argv.err) |e| { |
| 549 | error.MissingSockPath => { | 520 | if (e.err == error.OutOfMemory) return e.err; |
| 550 | std.debug.print("mux: wall target '--sock' names no path\n", .{}); | 521 | std.debug.print("mux: wall target '{s}': {s}\n", .{ e.word, wall.reason(e.err) }); |
| 551 | return 2; | 522 | return 2; |
| 552 | }, | 523 | } |
| 553 | else => return e, | ||
| 554 | }; | ||
| 555 | switch (outcome) { | 524 | switch (outcome) { |
| 556 | .ok => {}, | 525 | .ok => {}, |
| 557 | .help => { | 526 | .help => { |
| @@ -583,7 +552,7 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 { | |||
| 583 | } | 552 | } |
| 584 | const key = w_opts.key; | 553 | const key = w_opts.key; |
| 585 | const idle_ms = w_opts.quic_idle_ms; | 554 | const idle_ms = w_opts.quic_idle_ms; |
| 586 | var spellings = w_opts._tiles; | 555 | var spellings = w_opts._argv.tiles; |
| 587 | 556 | ||
| 588 | var from_file = false; | 557 | var from_file = false; |
| 589 | if (spellings.items.len == 0) { | 558 | if (spellings.items.len == 0) { |
| @@ -628,18 +597,6 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 { | |||
| 628 | return wallview.run(arena, resolved, .{ .hydrated = from_file }); | 597 | return wallview.run(arena, resolved, .{ .hydrated = from_file }); |
| 629 | } | 598 | } |
| 630 | 599 | ||
| 631 | /// Why one spelling cannot be a tile, in the words the hub already uses | ||
| 632 | /// (webhub_main.addSpelling): one grammar, one vocabulary for refusing it. | ||
| 633 | fn spellingReason(err: anyerror) []const u8 { | ||
| 634 | return switch (err) { | ||
| 635 | error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", | ||
| 636 | error.EmptySpec => "empty target", | ||
| 637 | error.BadByte => "control byte in target", | ||
| 638 | error.SockPathTooLong => "socket path too long to bind", | ||
| 639 | else => @errorName(err), | ||
| 640 | }; | ||
| 641 | } | ||
| 642 | |||
| 643 | /// `mux wall add|rm SPELLING...`: file operations only, neither verb dials. | 600 | /// `mux wall add|rm SPELLING...`: file operations only, neither verb dials. |
| 644 | fn wallEdit( | 601 | fn wallEdit( |
| 645 | arena: std.mem.Allocator, | 602 | arena: std.mem.Allocator, |
| @@ -679,11 +636,11 @@ fn wallEdit( | |||
| 679 | // bound is a tile that could never dial, and ADD time is the only | 636 | // bound is a tile that could never dial, and ADD time is the only |
| 680 | // moment the user is still looking at what they typed. | 637 | // moment the user is still looking at what they typed. |
| 681 | const p = wall.parseSpelling(s) catch |err| { | 638 | const p = wall.parseSpelling(s) catch |err| { |
| 682 | std.debug.print("mux: wall add: {s}: {s}\n", .{ s, spellingReason(err) }); | 639 | std.debug.print("mux: wall add: {s}: {s}\n", .{ s, wall.reason(err) }); |
| 683 | return 2; | 640 | return 2; |
| 684 | }; | 641 | }; |
| 685 | if (p.spec == .sock and p.spec.sock.len > sockpath.max_sun_path) { | 642 | if (p.spec == .sock and p.spec.sock.len > sockpath.max_sun_path) { |
| 686 | std.debug.print("mux: wall add: {s}: {s}\n", .{ s, spellingReason(error.SockPathTooLong) }); | 643 | std.debug.print("mux: wall add: {s}: {s}\n", .{ s, wall.reason(error.SockPathTooLong) }); |
| 687 | return 2; | 644 | return 2; |
| 688 | } | 645 | } |
| 689 | }; | 646 | }; |
| @@ -806,6 +763,23 @@ test "parseArgs: naming two transports is a conflict, however it is spelled" { | |||
| 806 | try std.testing.expectEqualStrings("ssh b", v2.attach.via.?); | 763 | try std.testing.expectEqualStrings("ssh b", v2.attach.via.?); |
| 807 | } | 764 | } |
| 808 | 765 | ||
| 766 | test "wall: a bad spelling is refused at parse, before any tile is dialed" { | ||
| 767 | // It used to survive the parse and die in the resolve loop, one | ||
| 768 | // spelling among many, after the wall file had already been read. The | ||
| 769 | // hub refused the same word at usage altitude; now both mouths do, | ||
| 770 | // through wall.Argv, in wall.reason's words. | ||
| 771 | try std.testing.expectEqual( | ||
| 772 | @as(u8, 2), | ||
| 773 | try wallMain(std.testing.allocator, &[_][:0]const u8{"h#bad name"}), | ||
| 774 | ); | ||
| 775 | // A flag-shaped word is still the unknown flag cliflags names, not a | ||
| 776 | // tile this refused: the two refusals have different owners. | ||
| 777 | try std.testing.expectEqual( | ||
| 778 | @as(u8, 2), | ||
| 779 | try wallMain(std.testing.allocator, &[_][:0]const u8{ "-A", "host" }), | ||
| 780 | ); | ||
| 781 | } | ||
| 782 | |||
| 809 | test "parseArgs: unknown flags and valueless flags are usage errors" { | 783 | test "parseArgs: unknown flags and valueless flags are usage errors" { |
| 810 | try std.testing.expect(parse(&.{ "mux", "--wat" }) == .usage_error); | 784 | try std.testing.expect(parse(&.{ "mux", "--wat" }) == .usage_error); |
| 811 | try std.testing.expect(parse(&.{ "mux", "-x" }) == .usage_error); | 785 | try std.testing.expect(parse(&.{ "mux", "-x" }) == .usage_error); |
src/cli/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -41,30 +41,6 @@ const usage = | |||
| 41 | \\ | 41 | \\ |
| 42 | ; | 42 | ; |
| 43 | 43 | ||
| 44 | /// Refused at usage altitude; downstream it is a rejected attach in | ||
| 45 | /// one tile, unexplained. | ||
| 46 | fn addSpelling( | ||
| 47 | alloc: std.mem.Allocator, | ||
| 48 | list: *std.ArrayList([]const u8), | ||
| 49 | spelling: []const u8, | ||
| 50 | ) ParseError!void { | ||
| 51 | // The ONE grammar: argv, the state file and POST /tiles are all read | ||
| 52 | // with this, so what argv accepts is exactly what the page can add. | ||
| 53 | // The message names the tile — with several targets on the line, | ||
| 54 | // `usage` alone would not say which. | ||
| 55 | _ = wall.parseSpelling(spelling) catch |err| { | ||
| 56 | std.debug.print("muxweb: tile {s}: {s}\n", .{ spelling, switch (err) { | ||
| 57 | error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", | ||
| 58 | error.EmptySpec => "empty target", | ||
| 59 | error.BadByte => "control byte in target", | ||
| 60 | } }); | ||
| 61 | return error.Usage; | ||
| 62 | }; | ||
| 63 | const copy = try alloc.dupe(u8, spelling); | ||
| 64 | errdefer alloc.free(copy); | ||
| 65 | try list.append(alloc, copy); | ||
| 66 | } | ||
| 67 | |||
| 68 | const Parsed = struct { | 44 | const Parsed = struct { |
| 69 | /// One wall spelling per tile, in argv order — the same string that | 45 | /// One wall spelling per tile, in argv order — the same string that |
| 70 | /// reaches the state file, the resolver and the page's label. Owned | 46 | /// reaches the state file, the resolver and the page's label. Owned |
| @@ -96,39 +72,13 @@ const Opts = struct { | |||
| 96 | port: u16 = webhub.default_port, | 72 | port: u16 = webhub.default_port, |
| 97 | key: ?[]const u8 = null, | 73 | key: ?[]const u8 = null, |
| 98 | quic_idle_ms: u32 = client.quic_idle_ms_default, | 74 | quic_idle_ms: u32 = client.quic_idle_ms_default, |
| 99 | _alloc: std.mem.Allocator, | 75 | _argv: wall.Argv, |
| 100 | _tiles: std.ArrayList([]const u8) = .empty, | ||
| 101 | /// A hook answers yes or no, so a hook that fails for a reason has | ||
| 102 | /// nowhere to say so: it leaves it here for `parseArgs` to re-raise. | ||
| 103 | _err: ?ParseError = null, | ||
| 104 | |||
| 105 | /// Bare HOST and quic:// are already wall spellings verbatim. | ||
| 106 | pub fn positional(self: *Opts, word: []const u8) bool { | ||
| 107 | addSpelling(self._alloc, &self._tiles, word) catch |e| { | ||
| 108 | self._err = e; | ||
| 109 | return false; | ||
| 110 | }; | ||
| 111 | return true; | ||
| 112 | } | ||
| 113 | 76 | ||
| 114 | /// `--sock PATH` is one tile in two words, a grammar no flag table can | 77 | pub fn positional(self: *Opts, w: []const u8) bool { |
| 115 | /// hold. wall owns the join so `mux wall` accepts the same two forms. | 78 | return self._argv.positional(w); |
| 79 | } | ||
| 116 | pub fn extra(self: *Opts, rest: []const [:0]const u8) usize { | 80 | pub fn extra(self: *Opts, rest: []const [:0]const u8) usize { |
| 117 | const n = wall.spellingFromArgv(self._alloc, rest, 0) catch |err| switch (err) { | 81 | return self._argv.extra(rest); |
| 118 | // Not a tile this program refuses, but one it never saw: left | ||
| 119 | // for cliflags to name as the unknown flag it is. | ||
| 120 | error.FlagLikeTarget => return 0, | ||
| 121 | else => |e| { | ||
| 122 | self._err = if (e == error.MissingSockPath) error.Usage else error.OutOfMemory; | ||
| 123 | return 0; | ||
| 124 | }, | ||
| 125 | }; | ||
| 126 | defer self._alloc.free(n.spelling); | ||
| 127 | addSpelling(self._alloc, &self._tiles, n.spelling) catch |e| { | ||
| 128 | self._err = e; | ||
| 129 | return 0; | ||
| 130 | }; | ||
| 131 | return n.consumed; | ||
| 132 | } | 82 | } |
| 133 | }; | 83 | }; |
| 134 | 84 | ||
| @@ -148,18 +98,23 @@ fn parseArgs( | |||
| 148 | args: []const [:0]const u8, | 98 | args: []const [:0]const u8, |
| 149 | env_key: ?[]const u8, | 99 | env_key: ?[]const u8, |
| 150 | ) ParseError!ParseResult { | 100 | ) ParseError!ParseResult { |
| 151 | var o = Opts{ ._alloc = alloc }; | 101 | var o = Opts{ ._argv = .{ .alloc = alloc } }; |
| 152 | var p = Parsed{ .tiles = .empty }; | 102 | var p = Parsed{ .tiles = .empty }; |
| 153 | errdefer { | 103 | errdefer { |
| 154 | p.tiles = o._tiles; | 104 | p.tiles = o._argv.tiles; |
| 155 | p.deinit(alloc); | 105 | p.deinit(alloc); |
| 156 | } | 106 | } |
| 157 | 107 | ||
| 158 | const outcome = cliflags.parse(Opts, &o, args[1..]); | 108 | const outcome = cliflags.parse(Opts, &o, args[1..]); |
| 159 | // Read before the outcome: a hook that refused for a REASON has already | 109 | // Read before the outcome: a hook that refused for a REASON has already |
| 160 | // named it, and that reason outranks the bare "unknown word" cliflags | 110 | // named it, and that reason outranks the bare "unknown word" cliflags |
| 161 | // saw when the hook said no. | 111 | // saw when the hook said no. The message names the tile — with several |
| 162 | if (o._err) |e| return e; | 112 | // targets on the line, `usage` alone would not say which. |
| 113 | if (o._argv.err) |e| { | ||
| 114 | if (e.err == error.OutOfMemory) return error.OutOfMemory; | ||
| 115 | std.debug.print("muxweb: tile {s}: {s}\n", .{ e.word, wall.reason(e.err) }); | ||
| 116 | return error.Usage; | ||
| 117 | } | ||
| 163 | switch (outcome) { | 118 | switch (outcome) { |
| 164 | .ok => {}, | 119 | .ok => {}, |
| 165 | .unknown_arg, .missing_value, .bad_number => return error.Usage, | 120 | .unknown_arg, .missing_value, .bad_number => return error.Usage, |
| @@ -167,7 +122,7 @@ fn parseArgs( | |||
| 167 | // themselves: errdefer does not run on the way out with a result | 122 | // themselves: errdefer does not run on the way out with a result |
| 168 | // in hand. | 123 | // in hand. |
| 169 | .help, .version => { | 124 | .help, .version => { |
| 170 | p.tiles = o._tiles; | 125 | p.tiles = o._argv.tiles; |
| 171 | p.deinit(alloc); | 126 | p.deinit(alloc); |
| 172 | return if (outcome == .help) .help else .version; | 127 | return if (outcome == .help) .help else .version; |
| 173 | }, | 128 | }, |
| @@ -182,7 +137,7 @@ fn parseArgs( | |||
| 182 | // No targets is not a usage error any more: it asks for the wall the | 137 | // No targets is not a usage error any more: it asks for the wall the |
| 183 | // last run persisted. main decides what an empty argv means; the parse | 138 | // last run persisted. main decides what an empty argv means; the parse |
| 184 | // only reports what was on the line. | 139 | // only reports what was on the line. |
| 185 | p.tiles = o._tiles; | 140 | p.tiles = o._argv.tiles; |
| 186 | p.port = o.port; | 141 | p.port = o.port; |
| 187 | p.idle_ms = o.quic_idle_ms; | 142 | p.idle_ms = o.quic_idle_ms; |
| 188 | p.key = xdg.pickKey(o.key, env_key); | 143 | p.key = xdg.pickKey(o.key, env_key); |
src/wall.zig
| Old | New | ||
|---|---|---|---|
| @@ -98,6 +98,77 @@ pub fn spellingFromArgv( | |||
| 98 | return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 }; | 98 | return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 }; |
| 99 | } | 99 | } |
| 100 | 100 | ||
| 101 | /// Why one spelling cannot be a tile, in ONE vocabulary: argv, the state | ||
| 102 | /// file and the hub's POST body are refused in the same words, so a user | ||
| 103 | /// who learns the message in one mouth reads it in the others. Errors | ||
| 104 | /// from outside this grammar (the resolver's) keep an arm here for the | ||
| 105 | /// same reason: the caller printing it does not care which layer said no. | ||
| 106 | pub fn reason(err: anyerror) []const u8 { | ||
| 107 | return switch (err) { | ||
| 108 | error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", | ||
| 109 | error.EmptySpec => "empty target", | ||
| 110 | error.BadByte => "control byte in target", | ||
| 111 | error.MissingSockPath => "names no path", | ||
| 112 | error.SockPathTooLong => "socket path too long to bind", | ||
| 113 | else => @errorName(err), | ||
| 114 | }; | ||
| 115 | } | ||
| 116 | |||
| 117 | /// The argv side of the grammar, one collector for both binaries' flag | ||
| 118 | /// tables: bare words and `--sock PATH` become owned spellings, each | ||
| 119 | /// validated HERE at usage altitude rather than downstream as one tile | ||
| 120 | /// that will not attach. `positional` and `extra` are the names cliflags | ||
| 121 | /// finds by reflection. | ||
| 122 | pub const Argv = struct { | ||
| 123 | alloc: std.mem.Allocator, | ||
| 124 | tiles: std.ArrayList([]const u8) = .empty, | ||
| 125 | /// A hook answers yes or no, so one that refused for a REASON has | ||
| 126 | /// nowhere to say so: it leaves the word and the why for the caller, | ||
| 127 | /// whose message can then name WHICH tile of several was refused. | ||
| 128 | err: ?struct { word: []const u8, err: (ArgvError || ParseError) } = null, | ||
| 129 | |||
| 130 | pub fn deinit(self: *Argv) void { | ||
| 131 | for (self.tiles.items) |t| self.alloc.free(t); | ||
| 132 | self.tiles.deinit(self.alloc); | ||
| 133 | } | ||
| 134 | |||
| 135 | /// Bare HOST and quic:// are already wall spellings verbatim. | ||
| 136 | pub fn positional(self: *Argv, word: []const u8) bool { | ||
| 137 | return self.take(word); | ||
| 138 | } | ||
| 139 | |||
| 140 | /// `--sock PATH` is one tile in two words, a grammar no flag table can | ||
| 141 | /// hold. A flag-shaped word is refused with NO record: it is not a | ||
| 142 | /// target this program rejects but one it never saw, left for cliflags | ||
| 143 | /// to name as the unknown flag it is. | ||
| 144 | pub fn extra(self: *Argv, rest: []const [:0]const u8) usize { | ||
| 145 | const n = spellingFromArgv(self.alloc, rest, 0) catch |e| { | ||
| 146 | if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e); | ||
| 147 | return 0; | ||
| 148 | }; | ||
| 149 | defer self.alloc.free(n.spelling); | ||
| 150 | return if (self.take(n.spelling)) n.consumed else 0; | ||
| 151 | } | ||
| 152 | |||
| 153 | fn take(self: *Argv, spelling: []const u8) bool { | ||
| 154 | // Appended before it is judged, so a refusal's `word` points into a | ||
| 155 | // copy this list owns: `--sock PATH` is joined into the caller's | ||
| 156 | // temporary, and a message naming it must not outlive that. | ||
| 157 | const copy = self.alloc.dupe(u8, spelling) catch return self.refuse("", error.OutOfMemory); | ||
| 158 | self.tiles.append(self.alloc, copy) catch { | ||
| 159 | self.alloc.free(copy); | ||
| 160 | return self.refuse("", error.OutOfMemory); | ||
| 161 | }; | ||
| 162 | _ = parseSpelling(copy) catch |e| return self.refuse(copy, e); | ||
| 163 | return true; | ||
| 164 | } | ||
| 165 | |||
| 166 | fn refuse(self: *Argv, word: []const u8, e: (ArgvError || ParseError)) bool { | ||
| 167 | self.err = .{ .word = word, .err = e }; | ||
| 168 | return false; | ||
| 169 | } | ||
| 170 | }; | ||
| 171 | |||
| 101 | pub const Wall = struct { | 172 | pub const Wall = struct { |
| 102 | /// Owned copies, wall order. The spelling IS the label downstream. | 173 | /// Owned copies, wall order. The spelling IS the label downstream. |
| 103 | targets: std.ArrayList([]u8) = .empty, | 174 | targets: std.ArrayList([]u8) = .empty, |
| @@ -291,6 +362,53 @@ pub fn saveLayout(path: []const u8, bytes: []const u8) !void { | |||
| 291 | return saveBytes(path, bytes); | 362 | return saveBytes(path, bytes); |
| 292 | } | 363 | } |
| 293 | 364 | ||
| 365 | test "Argv: bare words and both --sock dialects all become owned spellings" { | ||
| 366 | var a = Argv{ .alloc = std.testing.allocator }; | ||
| 367 | defer a.deinit(); | ||
| 368 | try std.testing.expect(a.positional("box1")); | ||
| 369 | try std.testing.expectEqual(@as(usize, 2), a.extra(&[_][:0]const u8{ "--sock", "/tmp/a.sock" })); | ||
| 370 | try std.testing.expectEqual(@as(usize, 1), a.extra(&[_][:0]const u8{"--sock /tmp/b.sock#w"})); | ||
| 371 | try std.testing.expect(a.err == null); | ||
| 372 | try std.testing.expectEqual(@as(usize, 3), a.tiles.items.len); | ||
| 373 | try std.testing.expectEqualStrings("box1", a.tiles.items[0]); | ||
| 374 | // The two-word dialect is JOINED: one spelling from here on, prefix | ||
| 375 | // included, because that string is the label and the wall line too. | ||
| 376 | try std.testing.expectEqualStrings("--sock /tmp/a.sock", a.tiles.items[1]); | ||
| 377 | try std.testing.expectEqualStrings("--sock /tmp/b.sock#w", a.tiles.items[2]); | ||
| 378 | } | ||
| 379 | |||
| 380 | test "Argv: a refusal records WHICH word and why; a flag-shaped word records nothing" { | ||
| 381 | var a = Argv{ .alloc = std.testing.allocator }; | ||
| 382 | defer a.deinit(); | ||
| 383 | try std.testing.expect(!a.positional("h#bad name")); | ||
| 384 | try std.testing.expectEqualStrings("h#bad name", a.err.?.word); | ||
| 385 | try std.testing.expect(a.err.?.err == error.BadSession); | ||
| 386 | |||
| 387 | // The joined spelling is what the message names, and it must still be | ||
| 388 | // readable after `extra` freed the temporary it was joined into. | ||
| 389 | var b = Argv{ .alloc = std.testing.allocator }; | ||
| 390 | defer b.deinit(); | ||
| 391 | try std.testing.expectEqual(@as(usize, 0), b.extra(&[_][:0]const u8{ "--sock", "/tmp/x#bad name" })); | ||
| 392 | try std.testing.expectEqualStrings("--sock /tmp/x#bad name", b.err.?.word); | ||
| 393 | try std.testing.expect(b.err.?.err == error.BadSession); | ||
| 394 | |||
| 395 | // A trailing `--sock` names no path — a usage mistake this collector | ||
| 396 | // owns, so it IS recorded, against the flag word the user typed. | ||
| 397 | var c = Argv{ .alloc = std.testing.allocator }; | ||
| 398 | defer c.deinit(); | ||
| 399 | try std.testing.expectEqual(@as(usize, 0), c.extra(&[_][:0]const u8{"--sock"})); | ||
| 400 | try std.testing.expectEqualStrings("--sock", c.err.?.word); | ||
| 401 | try std.testing.expect(c.err.?.err == error.MissingSockPath); | ||
| 402 | |||
| 403 | // `-A` is not a target this refuses but one it never saw: no record, | ||
| 404 | // so cliflags names it the unknown flag it is. | ||
| 405 | var d = Argv{ .alloc = std.testing.allocator }; | ||
| 406 | defer d.deinit(); | ||
| 407 | try std.testing.expectEqual(@as(usize, 0), d.extra(&[_][:0]const u8{ "-A", "host" })); | ||
| 408 | try std.testing.expect(d.err == null); | ||
| 409 | try std.testing.expectEqual(@as(usize, 0), d.tiles.items.len); | ||
| 410 | } | ||
| 411 | |||
| 294 | test "spellingFromArgv: both --sock dialects reach the same spelling" { | 412 | test "spellingFromArgv: both --sock dialects reach the same spelling" { |
| 295 | const alloc = std.testing.allocator; | 413 | const alloc = std.testing.allocator; |
| 296 | const argv = [_][:0]const u8{ "--sock", "/tmp/x.sock#b", "--sock /tmp/x.sock#b", "host#b", "quic://h:4433#b" }; | 414 | const argv = [_][:0]const u8{ "--sock", "/tmp/x.sock#b", "--sock /tmp/x.sock#b", "host#b", "quic://h:4433#b" }; |