a73x

01b6afb8

refactor: one owner for "where under XDG does this mux file live"

a73x   2026-08-29 10:01

Commit message
refactor: one owner for "where under XDG does this mux file live"

Six copies of the same four lines — `$XDG_*/mux/TAIL` when the variable
is set and non-empty, `$HOME/<sub>/mux/TAIL` otherwise — become
`xdg.pathFrom`, and the four wrappers that read `XDG_STATE_HOME` and
`HOME` for themselves become `xdg.statePath(alloc, tail)`.

Folded: xdg.keyPathFrom, xdg.logPathFrom, xdg.hostCachePathFrom,
wall.statePathFrom, wall.layoutPathFrom, hosts.statePathFrom.
hostCachePathFrom KEEPS its `'/'` refusal, still checked before the
environment so the refusal does not depend on which spelling the box
takes.

Pinned by the tests that already exist and were not rewritten: xdg.zig's
keyPathFrom/logPathFrom/hostCachePathFrom cases (including
UncacheableHost), wall.zig's statePathFrom/layoutPathFrom cases,
hosts.zig's statePathFrom case — every one asserts the exact string.

wall and hosts gain an `xdg` import (layer 0, no layer change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

build.zig
Old New
@@ -194,9 +194,9 @@ const mod_table = [_]ModSpec{
194 .{ .name = "cmd", .path = "src/server/cmd.zig", .layer = 1, .imports = &.{ "engine", "protocol" } }, 194 .{ .name = "cmd", .path = "src/server/cmd.zig", .layer = 1, .imports = &.{ "engine", "protocol" } },
195 // The wall: the TARGET spelling grammar, the ordered list, and the 195 // The wall: the TARGET spelling grammar, the ordered list, and the
196 // state file behind them. One owner for a grammar the hub, the state 196 // state file behind them. One owner for a grammar the hub, the state
197 // file and later the CLI all have to agree on — protocol and nothing 197 // file and later the CLI all have to agree on — protocol plus xdg for
198 // else, so its tests need no hub, no daemon and no socket. 198 // where that file lives, so its tests need no hub, no daemon and no socket.
199 .{ .name = "wall", .path = "src/client/wall.zig", .layer = 1, .imports = &.{"protocol"}, .test_imports = &.{"testtmp"} }, 199 .{ .name = "wall", .path = "src/client/wall.zig", .layer = 1, .imports = &.{ "protocol", "xdg" }, .test_imports = &.{"testtmp"} },
200 // The upgrade vocabulary: the version skew rule and the manifest an 200 // The upgrade vocabulary: the version skew rule and the manifest an
201 // exec-ing daemon leaves for its replacement. Protocol only — the 201 // exec-ing daemon leaves for its replacement. Protocol only — the
202 // manifest is a stranger's format (length-prefixed sections, unknown 202 // manifest is a stranger's format (length-prefixed sections, unknown
@@ -243,7 +243,7 @@ const mod_table = [_]ModSpec{
243 // grammar rather than forking a second one, and wall is layer 1 — so 243 // grammar rather than forking a second one, and wall is layer 1 — so
244 // this sits at 2, above its lender and below every consumer, instead 244 // this sits at 2, above its lender and below every consumer, instead
245 // of flattening the stratum it depends on. 245 // of flattening the stratum it depends on.
246 .{ .name = "hosts", .path = "src/client/hosts.zig", .layer = 2, .imports = &.{"wall"}, .test_imports = &.{"testtmp"} }, 246 .{ .name = "hosts", .path = "src/client/hosts.zig", .layer = 2, .imports = &.{ "wall", "xdg" }, .test_imports = &.{"testtmp"} },
247 // Everything that happens between a user at a terminal and one already 247 // Everything that happens between a user at a terminal and one already
248 // open session: the chord table, the wheel splitter, prediction, the 248 // open session: the chord table, the wheel splitter, prediction, the
249 // side channels, terminal ownership. It sits BELOW client because it 249 // side channels, terminal ownership. It sits BELOW client because it
src/client/hosts.zig
Old New
@@ -10,6 +10,7 @@
10 //! a `mux hosts add` elsewhere drops one of the two lines. 10 //! a `mux hosts add` elsewhere drops one of the two lines.
11 const std = @import("std"); 11 const std = @import("std");
12 const wall = @import("wall"); 12 const wall = @import("wall");
13 const xdg = @import("xdg");
13 14
14 pub const Spec = union(enum) { sock: []const u8, host: []const u8, quic: []const u8 }; 15 pub const Spec = union(enum) { sock: []const u8, host: []const u8, quic: []const u8 };
15 pub const ParseError = error{ HasSession, EmptySpec, BadByte, BadSpelling }; 16 pub const ParseError = error{ HasSession, EmptySpec, BadByte, BadSpelling };
@@ -154,13 +155,11 @@ pub fn forgetMany(
154 } 155 }
155 156
156 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 { 157 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 {
157 return statePathFrom(alloc, std.posix.getenv("XDG_STATE_HOME"), std.posix.getenv("HOME")); 158 return xdg.statePath(alloc, "hosts");
158 } 159 }
159 160
160 pub fn statePathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 { 161 pub fn statePathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 {
161 if (xdg_state_home) |x| if (x.len > 0) return std.fmt.allocPrint(alloc, "{s}/mux/hosts", .{x}); 162 return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "hosts");
162 const h = home orelse return error.NoHome;
163 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/hosts", .{h});
164 } 163 }
165 164
166 /// cliflags hooks for `mux hosts add|rm SPELLING...`, each word validated 165 /// cliflags hooks for `mux hosts add|rm SPELLING...`, each word validated
src/client/wall.zig
Old New
@@ -18,6 +18,7 @@
18 //! claim is made here. 18 //! claim is made here.
19 const std = @import("std"); 19 const std = @import("std");
20 const proto = @import("protocol"); 20 const proto = @import("protocol");
21 const xdg = @import("xdg");
21 22
22 pub const Spec = union(enum) { 23 pub const Spec = union(enum) {
23 sock: []const u8, 24 sock: []const u8,
@@ -302,39 +303,22 @@ pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8)
302 return true; 303 return true;
303 } 304 }
304 305
305 /// The *From split is xdg.zig's pattern for the same reason: setenv is 306 /// The *From split is xdg.zig's pattern: setenv is unsafe in Zig tests.
306 /// unsafe in-process for Zig tests.
307 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 { 307 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 {
308 return statePathFrom(alloc, std.posix.getenv("XDG_STATE_HOME"), std.posix.getenv("HOME")); 308 return xdg.statePath(alloc, "wall");
309 } 309 }
310 310
311 pub fn statePathFrom( 311 pub fn statePathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 {
312 alloc: std.mem.Allocator, 312 return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "wall");
313 xdg_state_home: ?[]const u8, 313 }
314 home: ?[]const u8, 314
315 ) ![]const u8 { 315 /// The layout sidecar sits beside the wall file; same *From split.
316 if (xdg_state_home) |d| if (d.len > 0)
317 return std.fmt.allocPrint(alloc, "{s}/mux/wall", .{d});
318 const h = home orelse return error.NoHome;
319 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/wall", .{h});
320 }
321
322 /// The layout sidecar sits beside the wall file. Same *For-tests split as
323 /// `statePathFrom` for the same reason: setenv is unsafe in-process for Zig
324 /// tests.
325 pub fn layoutPath(alloc: std.mem.Allocator) ![]const u8 { 316 pub fn layoutPath(alloc: std.mem.Allocator) ![]const u8 {
326 return layoutPathFrom(alloc, std.posix.getenv("XDG_STATE_HOME"), std.posix.getenv("HOME")); 317 return xdg.statePath(alloc, "layout");
327 } 318 }
328 319
329 pub fn layoutPathFrom( 320 pub fn layoutPathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 {
330 alloc: std.mem.Allocator, 321 return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "layout");
331 xdg_state_home: ?[]const u8,
332 home: ?[]const u8,
333 ) ![]const u8 {
334 if (xdg_state_home) |d| if (d.len > 0)
335 return std.fmt.allocPrint(alloc, "{s}/mux/layout", .{d});
336 const h = home orelse return error.NoHome;
337 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/layout", .{h});
338 } 322 }
339 323
340 /// One atomic writer for state files: `saveLines` and the layout sidecar 324 /// One atomic writer for state files: `saveLines` and the layout sidecar
src/xdg.zig
Old New
@@ -51,32 +51,33 @@ pub fn pickKey(flag: ?[]const u8, env: ?[]const u8) ?[]const u8 {
51 return if (k.len == 0) null else k; 51 return if (k.len == 0) null else k;
52 } 52 }
53 53
54 pub fn keyPathFrom( 54 /// An empty XDG spelling is unset, not the root: `XDG_STATE_HOME=`
55 alloc: std.mem.Allocator, 55 /// would otherwise put the hosts file at `/mux/hosts`.
56 xdg_config_home: ?[]const u8, 56 pub fn pathFrom(alloc: std.mem.Allocator, xdg_dir: ?[]const u8, home: ?[]const u8, home_sub: []const u8, tail: []const u8) ![]const u8 {
57 home: ?[]const u8, 57 if (xdg_dir) |d| if (d.len > 0)
58 ) ![]const u8 { 58 return std.fmt.allocPrint(alloc, "{s}/mux/{s}", .{ d, tail });
59 if (xdg_config_home) |d| if (d.len > 0)
60 return std.fmt.allocPrint(alloc, "{s}/mux/key", .{d});
61 const h = home orelse return error.NoHome; 59 const h = home orelse return error.NoHome;
62 return std.fmt.allocPrint(alloc, "{s}/.config/mux/key", .{h}); 60 return std.fmt.allocPrint(alloc, "{s}/{s}/mux/{s}", .{ h, home_sub, tail });
61 }
62
63 /// The state directory's share of `pathFrom`, read from the real
64 /// environment.
65 pub fn statePath(alloc: std.mem.Allocator, tail: []const u8) ![]const u8 {
66 return pathFrom(alloc, std.posix.getenv("XDG_STATE_HOME"), std.posix.getenv("HOME"), ".local/state", tail);
67 }
68
69 pub fn keyPathFrom(alloc: std.mem.Allocator, xdg_config_home: ?[]const u8, home: ?[]const u8) ![]const u8 {
70 return pathFrom(alloc, xdg_config_home, home, ".config", "key");
63 } 71 }
64 72
65 /// The daemon's stdout+stderr. Truncated only by `mux d start`; the attach 73 /// Truncated by `mux d start`, appended by auto-starts
66 /// auto-starts append (spawn.zig owns why an attach is not a restart). 74 /// (spawn.zig owns why).
67 pub fn logPath(alloc: std.mem.Allocator) ![]const u8 { 75 pub fn logPath(alloc: std.mem.Allocator) ![]const u8 {
68 return logPathFrom(alloc, std.posix.getenv("XDG_STATE_HOME"), std.posix.getenv("HOME")); 76 return statePath(alloc, "muxd.log");
69 } 77 }
70 78
71 pub fn logPathFrom( 79 pub fn logPathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 {
72 alloc: std.mem.Allocator, 80 return pathFrom(alloc, xdg_state_home, home, ".local/state", "muxd.log");
73 xdg_state_home: ?[]const u8,
74 home: ?[]const u8,
75 ) ![]const u8 {
76 if (xdg_state_home) |d| if (d.len > 0)
77 return std.fmt.allocPrint(alloc, "{s}/mux/muxd.log", .{d});
78 const h = home orelse return error.NoHome;
79 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/muxd.log", .{h});
80 } 81 }
81 82
82 /// Where `mux HOST` remembers the last announce. A host containing a path 83 /// Where `mux HOST` remembers the last announce. A host containing a path
@@ -94,10 +95,9 @@ pub fn hostCachePathFrom(
94 // Checked before the environment, so the refusal does not depend on 95 // Checked before the environment, so the refusal does not depend on
95 // which of the two spellings the caller's box happens to take. 96 // which of the two spellings the caller's box happens to take.
96 if (std.mem.indexOfScalar(u8, host, '/') != null) return error.UncacheableHost; 97 if (std.mem.indexOfScalar(u8, host, '/') != null) return error.UncacheableHost;
97 if (xdg_cache_home) |d| if (d.len > 0) 98 const tail = try std.fmt.allocPrint(alloc, "hosts/{s}", .{host});
98 return std.fmt.allocPrint(alloc, "{s}/mux/hosts/{s}", .{ d, host }); 99 defer alloc.free(tail);
99 const h = home orelse return error.NoHome; 100 return pathFrom(alloc, xdg_cache_home, home, ".cache", tail);
100 return std.fmt.allocPrint(alloc, "{s}/.cache/mux/hosts/{s}", .{ h, host });
101 } 101 }
102 102
103 /// Create `dir` and everything above it, then tighten `dir` itself to 103 /// Create `dir` and everything above it, then tighten `dir` itself to