dodot_lib/paths/mod.rs
1//! Path resolution for dodot.
2//!
3//! `Pather` is dodot's single source of truth for *every* filesystem
4//! coordinate the rest of the codebase touches: `$HOME`, the dotfiles
5//! repo root, the XDG data/config/cache directories, and per-pack and
6//! per-handler subdirectories. Two reasons it's a trait, not free
7//! functions:
8//!
9//! 1. **Testability.** Constructing a `Pather` whose roots all live
10//! under a `tempfile::TempDir` lets every command run end-to-end
11//! against a real filesystem without ever touching the user's
12//! actual `$HOME`. The `testing::TempEnvironment` builder does
13//! exactly this.
14//!
15//! 2. **Centralisation of OS-shaped policy.** The XDG fallback chain,
16//! the `DOTFILES_ROOT` env-var lookup, and (planned, per
17//! `docs/proposals/macos-paths.lex`) the macOS `app_support_dir`
18//! selection all live in one place. The resolver, the symlink
19//! handler, and `adopt`'s source-path inference all consult the
20//! same accessors — drift between them is impossible by construction.
21//!
22//! ## Adopt source-root invariants
23//!
24//! The inference function in `commands::adopt::infer` needs *stable
25//! root strings* it can prefix-match against canonicalised source
26//! paths. The accessors exposed here meet two requirements that make
27//! that work safely:
28//!
29//! - `home_dir()` and `xdg_config_home()` return paths that
30//! `std::fs::canonicalize` resolves to themselves on a real
31//! filesystem (they're real directories, not synthetic constants).
32//! This is what makes the `/var` ↔ `/private/var` macOS equivalence
33//! collapse cleanly when both a source and a root are canonicalised
34//! before comparison.
35//!
36//! - On the default config (no `XDG_CONFIG_HOME` set), `xdg_config_home()`
37//! is `home_dir().join(".config")` — i.e. *nested under* `$HOME`.
38//! Inference must check the more-specific (XDG) root before HOME so
39//! `~/.config/nvim/init.lua` matches XDG, not "nested under HOME".
40//! That's enforced by the inference function, not by `Pather`, but
41//! the nesting shape originates here.
42
43use std::path::{Path, PathBuf};
44
45use crate::Result;
46
47/// Provides all path calculations for dodot.
48///
49/// Every path that dodot uses — XDG directories, pack locations,
50/// handler data directories — is computed through this trait. This
51/// keeps path logic centralised and makes testing straightforward:
52/// construct a `Pather` whose directories all live under a temp dir.
53///
54/// Use `&dyn Pather` (trait objects) throughout the codebase.
55pub trait Pather: Send + Sync {
56 /// The user's home directory (e.g. `/home/alice`).
57 fn home_dir(&self) -> &Path;
58
59 /// Root of the dotfiles repository.
60 fn dotfiles_root(&self) -> &Path;
61
62 /// XDG data directory for dodot (e.g. `~/.local/share/dodot`).
63 fn data_dir(&self) -> &Path;
64
65 /// XDG config directory for dodot (e.g. `~/.config/dodot`).
66 fn config_dir(&self) -> &Path;
67
68 /// XDG cache directory for dodot (e.g. `~/.cache/dodot`).
69 fn cache_dir(&self) -> &Path;
70
71 /// XDG config home (e.g. `~/.config`). Used by symlink handler
72 /// for subdirectory target mapping.
73 fn xdg_config_home(&self) -> &Path;
74
75 /// Application-support root, the third filesystem coordinate the
76 /// symlink resolver understands.
77 ///
78 /// On macOS this resolves to `$HOME/Library/Application Support` by
79 /// default, the canonical home for GUI app config. On Linux and
80 /// other platforms it resolves to `xdg_config_home()` so the `_app/`
81 /// prefix and `app_aliases` route through `~/.config` —
82 /// indistinguishable from `_xdg/` on those platforms but the
83 /// mechanism stays platform-agnostic.
84 ///
85 /// The OS check lives only in [`XdgPatherBuilder::build`]; the
86 /// resolver operates on textual prefixes alone. See
87 /// `docs/proposals/macos-paths.lex` §2.1.
88 fn app_support_dir(&self) -> &Path;
89
90 /// Shell scripts directory (e.g. `~/.local/share/dodot/shell`).
91 fn shell_dir(&self) -> &Path;
92
93 /// Absolute path to a pack's source directory.
94 fn pack_path(&self, pack: &str) -> PathBuf {
95 self.dotfiles_root().join(pack)
96 }
97
98 /// Data directory for a specific pack (e.g. `.../data/packs/{pack}`).
99 fn pack_data_dir(&self, pack: &str) -> PathBuf {
100 self.data_dir().join("packs").join(pack)
101 }
102
103 /// Data directory for a specific handler within a pack
104 /// (e.g. `.../data/packs/{pack}/{handler}`).
105 fn handler_data_dir(&self, pack: &str, handler: &str) -> PathBuf {
106 self.pack_data_dir(pack).join(handler)
107 }
108
109 /// Log directory for dodot (e.g. `~/.cache/dodot/logs`).
110 fn log_dir(&self) -> PathBuf {
111 self.cache_dir().join("logs")
112 }
113
114 /// Path to the generated shell init script.
115 fn init_script_path(&self) -> PathBuf {
116 self.shell_dir().join("dodot-init.sh")
117 }
118
119 /// Path to the deployment map TSV, overwritten on every `up` / `down`.
120 /// See `docs/proposals/profiling.lex` §3.2.
121 fn deployment_map_path(&self) -> PathBuf {
122 self.data_dir().join("deployment-map.tsv")
123 }
124
125 /// Path to a single-line file recording the unix timestamp of the
126 /// most recent successful `dodot up`. Used by `dodot probe
127 /// shell-init` to flag profiles captured before that `up` as stale.
128 /// Absent until the first `up` runs.
129 fn last_up_path(&self) -> PathBuf {
130 self.data_dir().join("last-up-at")
131 }
132
133 /// Directory where shell-init profile reports are written, one TSV
134 /// per shell start. See `docs/proposals/profiling.lex` §3.1.
135 fn probes_shell_init_dir(&self) -> PathBuf {
136 self.data_dir().join("probes").join("shell-init")
137 }
138
139 /// On-disk cache for homebrew-cask probe data. One JSON file per
140 /// cask token; TTL-based invalidation. See
141 /// `docs/proposals/macos-paths.lex` §8.2.
142 ///
143 /// Lives under `cache_dir` (not `data_dir`) because the contents are
144 /// rederivable — losing them is fine, the next probe re-runs `brew
145 /// info`. Co-located with future probe caches under `probes/`.
146 fn probes_brew_cache_dir(&self) -> PathBuf {
147 self.cache_dir().join("probes").join("brew")
148 }
149
150 /// Persistent record of prompts the user has dismissed (e.g.
151 /// onboarding hints, install offers). Content-agnostic: callers
152 /// pass opaque keys, the registry just tracks dismissed/active.
153 /// Lives under `data_dir` (not `cache_dir`) because losing it
154 /// would re-prompt the user — preference state, not cache.
155 fn prompts_path(&self) -> PathBuf {
156 self.data_dir().join("prompts.json")
157 }
158
159 /// Per-file baseline cache used by the preprocessing pipeline to
160 /// detect divergence and drive cache-backed reverse-merge.
161 ///
162 /// Layout: `<cache_dir>/preprocessor/<pack>/<handler>/<filename>.json`.
163 /// One JSON file per processed file, written on every successful
164 /// expansion in `dodot up`.
165 ///
166 /// Lives under `cache_dir` because the contents are rederivable —
167 /// losing them just forces the next `dodot up` to re-render and
168 /// re-baseline. See `docs/proposals/preprocessing-pipeline.lex` §5.2.
169 fn preprocessor_baseline_path(&self, pack: &str, handler: &str, filename: &str) -> PathBuf {
170 self.cache_dir()
171 .join("preprocessor")
172 .join(pack)
173 .join(handler)
174 .join(format!("{filename}.json"))
175 }
176
177 /// Per-file secrets sidecar that lives next to the baseline JSON.
178 ///
179 /// Layout: `<cache_dir>/preprocessor/<pack>/<handler>/<filename>.secret.json`.
180 /// Empty / absent when the file's last render contained no
181 /// `secret(...)` calls; populated otherwise. The sidecar carries
182 /// `secret_line_ranges` (start, end, reference) per the schema in
183 /// `docs/proposals/secrets.lex` §3.3 — burgertocow's mask
184 /// (issue arthur-debert/burgertocow#13) reads the line ranges,
185 /// the dry-run preview reads them to render `[SECRET: <ref>]`
186 /// placeholders.
187 ///
188 /// Same lifecycle as the baseline file: rederivable, lives under
189 /// `cache_dir`. The "no migration" guardrail in §3.3 applies —
190 /// pre-secrets baselines simply have no sidecar (treated as
191 /// empty mask), and adding the file is additive.
192 fn preprocessor_secrets_sidecar_path(
193 &self,
194 pack: &str,
195 handler: &str,
196 filename: &str,
197 ) -> PathBuf {
198 self.cache_dir()
199 .join("preprocessor")
200 .join(pack)
201 .join(handler)
202 .join(format!("{filename}.secret.json"))
203 }
204
205 /// Root of the preprocessor baseline cache for a given pack and
206 /// handler — mostly useful for cache-cleanup operations like
207 /// `dodot down` and tests that want to scan an entire handler's
208 /// baselines.
209 fn preprocessor_baseline_dir(&self, pack: &str, handler: &str) -> PathBuf {
210 self.cache_dir()
211 .join("preprocessor")
212 .join(pack)
213 .join(handler)
214 }
215}
216
217/// XDG-compliant path resolver.
218///
219/// Reads standard environment variables (`HOME`, `XDG_DATA_HOME`, etc.)
220/// and the dodot-specific `DOTFILES_ROOT`. All paths can also be set
221/// explicitly via the builder for testing.
222#[derive(Debug, Clone)]
223pub struct XdgPather {
224 home: PathBuf,
225 dotfiles_root: PathBuf,
226 data_dir: PathBuf,
227 config_dir: PathBuf,
228 cache_dir: PathBuf,
229 xdg_config_home: PathBuf,
230 app_support_dir: PathBuf,
231 shell_dir: PathBuf,
232}
233
234/// Builder for [`XdgPather`].
235///
236/// All fields are optional. Unset fields are resolved from environment
237/// variables or XDG defaults.
238#[derive(Debug, Default)]
239pub struct XdgPatherBuilder {
240 home: Option<PathBuf>,
241 dotfiles_root: Option<PathBuf>,
242 data_dir: Option<PathBuf>,
243 config_dir: Option<PathBuf>,
244 cache_dir: Option<PathBuf>,
245 xdg_config_home: Option<PathBuf>,
246 app_support_dir: Option<PathBuf>,
247}
248
249impl XdgPatherBuilder {
250 pub fn home(mut self, path: impl Into<PathBuf>) -> Self {
251 self.home = Some(path.into());
252 self
253 }
254
255 pub fn dotfiles_root(mut self, path: impl Into<PathBuf>) -> Self {
256 self.dotfiles_root = Some(path.into());
257 self
258 }
259
260 pub fn data_dir(mut self, path: impl Into<PathBuf>) -> Self {
261 self.data_dir = Some(path.into());
262 self
263 }
264
265 pub fn config_dir(mut self, path: impl Into<PathBuf>) -> Self {
266 self.config_dir = Some(path.into());
267 self
268 }
269
270 pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
271 self.cache_dir = Some(path.into());
272 self
273 }
274
275 pub fn xdg_config_home(mut self, path: impl Into<PathBuf>) -> Self {
276 self.xdg_config_home = Some(path.into());
277 self
278 }
279
280 /// Override the application-support root.
281 ///
282 /// Tests pin this to a non-default location so prefix matches are
283 /// deterministic across platforms. End users may also flip this
284 /// (typically via the `app_uses_library` config key, which is
285 /// ultimately what wires through here) to opt into Linux-style
286 /// `~/.config` placement on macOS.
287 pub fn app_support_dir(mut self, path: impl Into<PathBuf>) -> Self {
288 self.app_support_dir = Some(path.into());
289 self
290 }
291
292 pub fn build(self) -> Result<XdgPather> {
293 let home = self.home.unwrap_or_else(resolve_home);
294
295 let dotfiles_root = self
296 .dotfiles_root
297 .unwrap_or_else(|| resolve_dotfiles_root(&home));
298
299 let xdg_config_home = self.xdg_config_home.unwrap_or_else(|| {
300 std::env::var("XDG_CONFIG_HOME")
301 .map(PathBuf::from)
302 .unwrap_or_else(|_| home.join(".config"))
303 });
304
305 let data_dir = self.data_dir.unwrap_or_else(|| {
306 let xdg_data = std::env::var("XDG_DATA_HOME")
307 .map(PathBuf::from)
308 .unwrap_or_else(|_| home.join(".local").join("share"));
309 xdg_data.join("dodot")
310 });
311
312 let config_dir = self
313 .config_dir
314 .unwrap_or_else(|| xdg_config_home.join("dodot"));
315
316 let cache_dir = self.cache_dir.unwrap_or_else(|| {
317 let xdg_cache = std::env::var("XDG_CACHE_HOME")
318 .map(PathBuf::from)
319 .unwrap_or_else(|_| home.join(".cache"));
320 xdg_cache.join("dodot")
321 });
322
323 let shell_dir = data_dir.join("shell");
324
325 // Application-support root: macOS routes to `~/Library/Application Support`,
326 // every other platform falls through to `xdg_config_home`. The OS
327 // branch lives here exclusively; the resolver only sees a path.
328 let app_support_dir = self.app_support_dir.unwrap_or_else(|| {
329 if cfg!(target_os = "macos") {
330 home.join("Library").join("Application Support")
331 } else {
332 xdg_config_home.clone()
333 }
334 });
335
336 Ok(XdgPather {
337 home,
338 dotfiles_root,
339 data_dir,
340 config_dir,
341 cache_dir,
342 xdg_config_home,
343 app_support_dir,
344 shell_dir,
345 })
346 }
347}
348
349impl XdgPather {
350 /// Creates a builder for configuring an `XdgPather`.
351 pub fn builder() -> XdgPatherBuilder {
352 XdgPatherBuilder::default()
353 }
354
355 /// Creates an `XdgPather` using environment variables and XDG defaults.
356 pub fn from_env() -> Result<Self> {
357 Self::builder().build()
358 }
359}
360
361impl Pather for XdgPather {
362 fn home_dir(&self) -> &Path {
363 &self.home
364 }
365
366 fn dotfiles_root(&self) -> &Path {
367 &self.dotfiles_root
368 }
369
370 fn data_dir(&self) -> &Path {
371 &self.data_dir
372 }
373
374 fn config_dir(&self) -> &Path {
375 &self.config_dir
376 }
377
378 fn cache_dir(&self) -> &Path {
379 &self.cache_dir
380 }
381
382 fn xdg_config_home(&self) -> &Path {
383 &self.xdg_config_home
384 }
385
386 fn app_support_dir(&self) -> &Path {
387 &self.app_support_dir
388 }
389
390 fn shell_dir(&self) -> &Path {
391 &self.shell_dir
392 }
393}
394
395/// Resolve `HOME` from environment, falling back to the `dirs` approach.
396fn resolve_home() -> PathBuf {
397 std::env::var("HOME")
398 .map(PathBuf::from)
399 .unwrap_or_else(|_| {
400 // Last resort fallback
401 PathBuf::from("/tmp/dodot-unknown-home")
402 })
403}
404
405/// Resolve the dotfiles root directory.
406///
407/// Priority:
408/// 1. `DOTFILES_ROOT` environment variable
409/// 2. Git repository root (`git rev-parse --show-toplevel`)
410/// 3. `$HOME/dotfiles` fallback
411fn resolve_dotfiles_root(home: &Path) -> PathBuf {
412 // 1. Explicit env var
413 if let Ok(root) = std::env::var("DOTFILES_ROOT") {
414 return expand_tilde(&root, home);
415 }
416
417 // 2. Git toplevel
418 if let Ok(output) = std::process::Command::new("git")
419 .args(["rev-parse", "--show-toplevel"])
420 .output()
421 {
422 if output.status.success() {
423 let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
424 if !toplevel.is_empty() {
425 return PathBuf::from(toplevel);
426 }
427 }
428 }
429
430 // 3. Fallback
431 home.join("dotfiles")
432}
433
434/// Expand a leading `~` to the home directory.
435fn expand_tilde(path: &str, home: &Path) -> PathBuf {
436 if let Some(rest) = path.strip_prefix("~/") {
437 home.join(rest)
438 } else if path == "~" {
439 home.to_path_buf()
440 } else {
441 PathBuf::from(path)
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn builder_explicit_paths() {
451 let pather = XdgPather::builder()
452 .home("/test/home")
453 .dotfiles_root("/test/home/dotfiles")
454 .data_dir("/test/data/dodot")
455 .config_dir("/test/config/dodot")
456 .cache_dir("/test/cache/dodot")
457 .xdg_config_home("/test/home/.config")
458 .build()
459 .unwrap();
460
461 assert_eq!(pather.home_dir(), Path::new("/test/home"));
462 assert_eq!(pather.dotfiles_root(), Path::new("/test/home/dotfiles"));
463 assert_eq!(pather.data_dir(), Path::new("/test/data/dodot"));
464 assert_eq!(pather.config_dir(), Path::new("/test/config/dodot"));
465 assert_eq!(pather.cache_dir(), Path::new("/test/cache/dodot"));
466 assert_eq!(pather.xdg_config_home(), Path::new("/test/home/.config"));
467 }
468
469 #[test]
470 fn shell_dir_derived_from_data_dir() {
471 let pather = XdgPather::builder()
472 .home("/h")
473 .dotfiles_root("/h/dots")
474 .data_dir("/h/data/dodot")
475 .build()
476 .unwrap();
477
478 assert_eq!(pather.shell_dir(), Path::new("/h/data/dodot/shell"));
479 }
480
481 #[test]
482 fn pack_path_joins_dotfiles_root() {
483 let pather = XdgPather::builder()
484 .home("/h")
485 .dotfiles_root("/h/dotfiles")
486 .build()
487 .unwrap();
488
489 assert_eq!(pather.pack_path("vim"), PathBuf::from("/h/dotfiles/vim"));
490 }
491
492 #[test]
493 fn pack_data_dir_structure() {
494 let pather = XdgPather::builder()
495 .home("/h")
496 .data_dir("/h/data/dodot")
497 .build()
498 .unwrap();
499
500 assert_eq!(
501 pather.pack_data_dir("vim"),
502 PathBuf::from("/h/data/dodot/packs/vim")
503 );
504 }
505
506 #[test]
507 fn handler_data_dir_structure() {
508 let pather = XdgPather::builder()
509 .home("/h")
510 .data_dir("/h/data/dodot")
511 .build()
512 .unwrap();
513
514 assert_eq!(
515 pather.handler_data_dir("vim", "symlink"),
516 PathBuf::from("/h/data/dodot/packs/vim/symlink")
517 );
518 }
519
520 #[test]
521 fn init_script_path() {
522 let pather = XdgPather::builder()
523 .home("/h")
524 .data_dir("/h/data/dodot")
525 .build()
526 .unwrap();
527
528 assert_eq!(
529 pather.init_script_path(),
530 PathBuf::from("/h/data/dodot/shell/dodot-init.sh")
531 );
532 }
533
534 #[test]
535 fn expand_tilde_cases() {
536 let home = Path::new("/home/alice");
537 assert_eq!(
538 expand_tilde("~/dotfiles", home),
539 PathBuf::from("/home/alice/dotfiles")
540 );
541 assert_eq!(expand_tilde("~", home), PathBuf::from("/home/alice"));
542 assert_eq!(
543 expand_tilde("/absolute/path", home),
544 PathBuf::from("/absolute/path")
545 );
546 assert_eq!(expand_tilde("relative", home), PathBuf::from("relative"));
547 }
548
549 /// Default-XDG nesting: with no explicit `xdg_config_home`, the
550 /// builder defaults to `$HOME/.config`. Adopt's inference relies on
551 /// XDG being checked *before* HOME (longest-prefix wins) precisely
552 /// because of this nesting; pin the layout so a future change that
553 /// flips the default to `$HOME/Library/...` (macOS) or somewhere
554 /// outside HOME forces a deliberate update to the inference rules.
555 #[test]
556 fn default_xdg_config_home_is_nested_under_home() {
557 let pather = XdgPather::builder()
558 .home("/u")
559 .dotfiles_root("/u/dotfiles")
560 .data_dir("/u/.local/share/dodot")
561 .config_dir("/u/.config/dodot")
562 .cache_dir("/u/.cache/dodot")
563 // No xdg_config_home set; falls back to env or `$HOME/.config`.
564 .build()
565 .unwrap();
566 // The default fallback (no `XDG_CONFIG_HOME` env) is `$HOME/.config`.
567 // The assertion has to tolerate a user-set `XDG_CONFIG_HOME` since
568 // tests inherit the ambient env — `cargo test` from a developer
569 // shell with the env set would otherwise fail spuriously. The
570 // disjunct below means: either XDG nests under HOME (the default
571 // case the invariant talks about), OR the env override is set
572 // (the user opted out of the default; adopt's inference handles
573 // that case via root canonicalization, separate code path).
574 let xdg = pather.xdg_config_home();
575 let home = pather.home_dir();
576 assert!(
577 xdg.starts_with(home) || std::env::var("XDG_CONFIG_HOME").is_ok(),
578 "default xdg_config_home `{}` is not nested under home `{}` \
579 — adopt's inference assumes XDG ⊆ HOME on the default config; \
580 update both if this changes",
581 xdg.display(),
582 home.display()
583 );
584 }
585
586 /// Explicit `xdg_config_home(...)` takes precedence over env / defaults.
587 /// Critical for the test environment, where adopt-inference tests pin
588 /// XDG to a non-default location so prefix matches are unambiguous.
589 #[test]
590 fn explicit_xdg_config_home_overrides_default() {
591 let pather = XdgPather::builder()
592 .home("/u")
593 .dotfiles_root("/u/dotfiles")
594 .xdg_config_home("/somewhere/else/.config")
595 .build()
596 .unwrap();
597 assert_eq!(
598 pather.xdg_config_home(),
599 Path::new("/somewhere/else/.config")
600 );
601 }
602
603 /// Each accessor returns a stable, distinct subdir layout. Adopt's
604 /// auto-create path lands the new pack at `dotfiles_root/<pack>`,
605 /// and the data layer keeps state at `data_dir/packs/<pack>/...`;
606 /// these must not alias.
607 #[test]
608 fn dotfiles_root_and_data_dir_are_distinct_namespaces() {
609 let pather = XdgPather::builder()
610 .home("/u")
611 .dotfiles_root("/u/dotfiles")
612 .data_dir("/u/.local/share/dodot")
613 .build()
614 .unwrap();
615 let pack_dir = pather.pack_path("nvim");
616 let pack_data = pather.pack_data_dir("nvim");
617 assert!(
618 !pack_dir.starts_with(&pack_data) && !pack_data.starts_with(&pack_dir),
619 "pack_path `{}` and pack_data_dir `{}` overlap",
620 pack_dir.display(),
621 pack_data.display(),
622 );
623 }
624
625 /// Explicit `app_support_dir(...)` overrides the platform default.
626 /// Tests rely on this to pin the third coordinate at a known
627 /// non-XDG, non-HOME location so the resolver's `_app/` rule has
628 /// somewhere unambiguous to land.
629 #[test]
630 fn explicit_app_support_dir_overrides_default() {
631 let pather = XdgPather::builder()
632 .home("/u")
633 .dotfiles_root("/u/dotfiles")
634 .xdg_config_home("/u/.config")
635 .app_support_dir("/u/Library/Application Support")
636 .build()
637 .unwrap();
638 assert_eq!(
639 pather.app_support_dir(),
640 Path::new("/u/Library/Application Support")
641 );
642 }
643
644 /// Default app_support_dir on Linux/non-macOS collapses to xdg_config_home.
645 /// On macOS it points under `$HOME/Library/Application Support`.
646 /// We don't `cfg!` the assertion here because the explicit-builder
647 /// test above pins the override path; this test exercises the
648 /// implicit default and the platform branch together.
649 #[test]
650 fn default_app_support_dir_is_platform_aware() {
651 let pather = XdgPather::builder()
652 .home("/u")
653 .dotfiles_root("/u/dotfiles")
654 .xdg_config_home("/u/.config")
655 .build()
656 .unwrap();
657 if cfg!(target_os = "macos") {
658 assert_eq!(
659 pather.app_support_dir(),
660 Path::new("/u/Library/Application Support"),
661 "macOS default should route under $HOME/Library/Application Support"
662 );
663 } else {
664 assert_eq!(
665 pather.app_support_dir(),
666 pather.xdg_config_home(),
667 "non-macOS default should collapse to xdg_config_home"
668 );
669 }
670 }
671
672 // Compile-time check: Pather must be object-safe
673 #[allow(dead_code)]
674 fn assert_object_safe(_: &dyn Pather) {}
675}