Skip to main content

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    /// Root of the preprocessor baseline cache for a given pack and
178    /// handler — mostly useful for cache-cleanup operations like
179    /// `dodot down` and tests that want to scan an entire handler's
180    /// baselines.
181    fn preprocessor_baseline_dir(&self, pack: &str, handler: &str) -> PathBuf {
182        self.cache_dir()
183            .join("preprocessor")
184            .join(pack)
185            .join(handler)
186    }
187}
188
189/// XDG-compliant path resolver.
190///
191/// Reads standard environment variables (`HOME`, `XDG_DATA_HOME`, etc.)
192/// and the dodot-specific `DOTFILES_ROOT`. All paths can also be set
193/// explicitly via the builder for testing.
194#[derive(Debug, Clone)]
195pub struct XdgPather {
196    home: PathBuf,
197    dotfiles_root: PathBuf,
198    data_dir: PathBuf,
199    config_dir: PathBuf,
200    cache_dir: PathBuf,
201    xdg_config_home: PathBuf,
202    app_support_dir: PathBuf,
203    shell_dir: PathBuf,
204}
205
206/// Builder for [`XdgPather`].
207///
208/// All fields are optional. Unset fields are resolved from environment
209/// variables or XDG defaults.
210#[derive(Debug, Default)]
211pub struct XdgPatherBuilder {
212    home: Option<PathBuf>,
213    dotfiles_root: Option<PathBuf>,
214    data_dir: Option<PathBuf>,
215    config_dir: Option<PathBuf>,
216    cache_dir: Option<PathBuf>,
217    xdg_config_home: Option<PathBuf>,
218    app_support_dir: Option<PathBuf>,
219}
220
221impl XdgPatherBuilder {
222    pub fn home(mut self, path: impl Into<PathBuf>) -> Self {
223        self.home = Some(path.into());
224        self
225    }
226
227    pub fn dotfiles_root(mut self, path: impl Into<PathBuf>) -> Self {
228        self.dotfiles_root = Some(path.into());
229        self
230    }
231
232    pub fn data_dir(mut self, path: impl Into<PathBuf>) -> Self {
233        self.data_dir = Some(path.into());
234        self
235    }
236
237    pub fn config_dir(mut self, path: impl Into<PathBuf>) -> Self {
238        self.config_dir = Some(path.into());
239        self
240    }
241
242    pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
243        self.cache_dir = Some(path.into());
244        self
245    }
246
247    pub fn xdg_config_home(mut self, path: impl Into<PathBuf>) -> Self {
248        self.xdg_config_home = Some(path.into());
249        self
250    }
251
252    /// Override the application-support root.
253    ///
254    /// Tests pin this to a non-default location so prefix matches are
255    /// deterministic across platforms. End users may also flip this
256    /// (typically via the `app_uses_library` config key, which is
257    /// ultimately what wires through here) to opt into Linux-style
258    /// `~/.config` placement on macOS.
259    pub fn app_support_dir(mut self, path: impl Into<PathBuf>) -> Self {
260        self.app_support_dir = Some(path.into());
261        self
262    }
263
264    pub fn build(self) -> Result<XdgPather> {
265        let home = self.home.unwrap_or_else(resolve_home);
266
267        let dotfiles_root = self
268            .dotfiles_root
269            .unwrap_or_else(|| resolve_dotfiles_root(&home));
270
271        let xdg_config_home = self.xdg_config_home.unwrap_or_else(|| {
272            std::env::var("XDG_CONFIG_HOME")
273                .map(PathBuf::from)
274                .unwrap_or_else(|_| home.join(".config"))
275        });
276
277        let data_dir = self.data_dir.unwrap_or_else(|| {
278            let xdg_data = std::env::var("XDG_DATA_HOME")
279                .map(PathBuf::from)
280                .unwrap_or_else(|_| home.join(".local").join("share"));
281            xdg_data.join("dodot")
282        });
283
284        let config_dir = self
285            .config_dir
286            .unwrap_or_else(|| xdg_config_home.join("dodot"));
287
288        let cache_dir = self.cache_dir.unwrap_or_else(|| {
289            let xdg_cache = std::env::var("XDG_CACHE_HOME")
290                .map(PathBuf::from)
291                .unwrap_or_else(|_| home.join(".cache"));
292            xdg_cache.join("dodot")
293        });
294
295        let shell_dir = data_dir.join("shell");
296
297        // Application-support root: macOS routes to `~/Library/Application Support`,
298        // every other platform falls through to `xdg_config_home`. The OS
299        // branch lives here exclusively; the resolver only sees a path.
300        let app_support_dir = self.app_support_dir.unwrap_or_else(|| {
301            if cfg!(target_os = "macos") {
302                home.join("Library").join("Application Support")
303            } else {
304                xdg_config_home.clone()
305            }
306        });
307
308        Ok(XdgPather {
309            home,
310            dotfiles_root,
311            data_dir,
312            config_dir,
313            cache_dir,
314            xdg_config_home,
315            app_support_dir,
316            shell_dir,
317        })
318    }
319}
320
321impl XdgPather {
322    /// Creates a builder for configuring an `XdgPather`.
323    pub fn builder() -> XdgPatherBuilder {
324        XdgPatherBuilder::default()
325    }
326
327    /// Creates an `XdgPather` using environment variables and XDG defaults.
328    pub fn from_env() -> Result<Self> {
329        Self::builder().build()
330    }
331}
332
333impl Pather for XdgPather {
334    fn home_dir(&self) -> &Path {
335        &self.home
336    }
337
338    fn dotfiles_root(&self) -> &Path {
339        &self.dotfiles_root
340    }
341
342    fn data_dir(&self) -> &Path {
343        &self.data_dir
344    }
345
346    fn config_dir(&self) -> &Path {
347        &self.config_dir
348    }
349
350    fn cache_dir(&self) -> &Path {
351        &self.cache_dir
352    }
353
354    fn xdg_config_home(&self) -> &Path {
355        &self.xdg_config_home
356    }
357
358    fn app_support_dir(&self) -> &Path {
359        &self.app_support_dir
360    }
361
362    fn shell_dir(&self) -> &Path {
363        &self.shell_dir
364    }
365}
366
367/// Resolve `HOME` from environment, falling back to the `dirs` approach.
368fn resolve_home() -> PathBuf {
369    std::env::var("HOME")
370        .map(PathBuf::from)
371        .unwrap_or_else(|_| {
372            // Last resort fallback
373            PathBuf::from("/tmp/dodot-unknown-home")
374        })
375}
376
377/// Resolve the dotfiles root directory.
378///
379/// Priority:
380/// 1. `DOTFILES_ROOT` environment variable
381/// 2. Git repository root (`git rev-parse --show-toplevel`)
382/// 3. `$HOME/dotfiles` fallback
383fn resolve_dotfiles_root(home: &Path) -> PathBuf {
384    // 1. Explicit env var
385    if let Ok(root) = std::env::var("DOTFILES_ROOT") {
386        return expand_tilde(&root, home);
387    }
388
389    // 2. Git toplevel
390    if let Ok(output) = std::process::Command::new("git")
391        .args(["rev-parse", "--show-toplevel"])
392        .output()
393    {
394        if output.status.success() {
395            let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
396            if !toplevel.is_empty() {
397                return PathBuf::from(toplevel);
398            }
399        }
400    }
401
402    // 3. Fallback
403    home.join("dotfiles")
404}
405
406/// Expand a leading `~` to the home directory.
407fn expand_tilde(path: &str, home: &Path) -> PathBuf {
408    if let Some(rest) = path.strip_prefix("~/") {
409        home.join(rest)
410    } else if path == "~" {
411        home.to_path_buf()
412    } else {
413        PathBuf::from(path)
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn builder_explicit_paths() {
423        let pather = XdgPather::builder()
424            .home("/test/home")
425            .dotfiles_root("/test/home/dotfiles")
426            .data_dir("/test/data/dodot")
427            .config_dir("/test/config/dodot")
428            .cache_dir("/test/cache/dodot")
429            .xdg_config_home("/test/home/.config")
430            .build()
431            .unwrap();
432
433        assert_eq!(pather.home_dir(), Path::new("/test/home"));
434        assert_eq!(pather.dotfiles_root(), Path::new("/test/home/dotfiles"));
435        assert_eq!(pather.data_dir(), Path::new("/test/data/dodot"));
436        assert_eq!(pather.config_dir(), Path::new("/test/config/dodot"));
437        assert_eq!(pather.cache_dir(), Path::new("/test/cache/dodot"));
438        assert_eq!(pather.xdg_config_home(), Path::new("/test/home/.config"));
439    }
440
441    #[test]
442    fn shell_dir_derived_from_data_dir() {
443        let pather = XdgPather::builder()
444            .home("/h")
445            .dotfiles_root("/h/dots")
446            .data_dir("/h/data/dodot")
447            .build()
448            .unwrap();
449
450        assert_eq!(pather.shell_dir(), Path::new("/h/data/dodot/shell"));
451    }
452
453    #[test]
454    fn pack_path_joins_dotfiles_root() {
455        let pather = XdgPather::builder()
456            .home("/h")
457            .dotfiles_root("/h/dotfiles")
458            .build()
459            .unwrap();
460
461        assert_eq!(pather.pack_path("vim"), PathBuf::from("/h/dotfiles/vim"));
462    }
463
464    #[test]
465    fn pack_data_dir_structure() {
466        let pather = XdgPather::builder()
467            .home("/h")
468            .data_dir("/h/data/dodot")
469            .build()
470            .unwrap();
471
472        assert_eq!(
473            pather.pack_data_dir("vim"),
474            PathBuf::from("/h/data/dodot/packs/vim")
475        );
476    }
477
478    #[test]
479    fn handler_data_dir_structure() {
480        let pather = XdgPather::builder()
481            .home("/h")
482            .data_dir("/h/data/dodot")
483            .build()
484            .unwrap();
485
486        assert_eq!(
487            pather.handler_data_dir("vim", "symlink"),
488            PathBuf::from("/h/data/dodot/packs/vim/symlink")
489        );
490    }
491
492    #[test]
493    fn init_script_path() {
494        let pather = XdgPather::builder()
495            .home("/h")
496            .data_dir("/h/data/dodot")
497            .build()
498            .unwrap();
499
500        assert_eq!(
501            pather.init_script_path(),
502            PathBuf::from("/h/data/dodot/shell/dodot-init.sh")
503        );
504    }
505
506    #[test]
507    fn expand_tilde_cases() {
508        let home = Path::new("/home/alice");
509        assert_eq!(
510            expand_tilde("~/dotfiles", home),
511            PathBuf::from("/home/alice/dotfiles")
512        );
513        assert_eq!(expand_tilde("~", home), PathBuf::from("/home/alice"));
514        assert_eq!(
515            expand_tilde("/absolute/path", home),
516            PathBuf::from("/absolute/path")
517        );
518        assert_eq!(expand_tilde("relative", home), PathBuf::from("relative"));
519    }
520
521    /// Default-XDG nesting: with no explicit `xdg_config_home`, the
522    /// builder defaults to `$HOME/.config`. Adopt's inference relies on
523    /// XDG being checked *before* HOME (longest-prefix wins) precisely
524    /// because of this nesting; pin the layout so a future change that
525    /// flips the default to `$HOME/Library/...` (macOS) or somewhere
526    /// outside HOME forces a deliberate update to the inference rules.
527    #[test]
528    fn default_xdg_config_home_is_nested_under_home() {
529        let pather = XdgPather::builder()
530            .home("/u")
531            .dotfiles_root("/u/dotfiles")
532            .data_dir("/u/.local/share/dodot")
533            .config_dir("/u/.config/dodot")
534            .cache_dir("/u/.cache/dodot")
535            // No xdg_config_home set; falls back to env or `$HOME/.config`.
536            .build()
537            .unwrap();
538        // The default fallback (no `XDG_CONFIG_HOME` env) is `$HOME/.config`.
539        // The assertion has to tolerate a user-set `XDG_CONFIG_HOME` since
540        // tests inherit the ambient env — `cargo test` from a developer
541        // shell with the env set would otherwise fail spuriously. The
542        // disjunct below means: either XDG nests under HOME (the default
543        // case the invariant talks about), OR the env override is set
544        // (the user opted out of the default; adopt's inference handles
545        // that case via root canonicalization, separate code path).
546        let xdg = pather.xdg_config_home();
547        let home = pather.home_dir();
548        assert!(
549            xdg.starts_with(home) || std::env::var("XDG_CONFIG_HOME").is_ok(),
550            "default xdg_config_home `{}` is not nested under home `{}` \
551             — adopt's inference assumes XDG ⊆ HOME on the default config; \
552             update both if this changes",
553            xdg.display(),
554            home.display()
555        );
556    }
557
558    /// Explicit `xdg_config_home(...)` takes precedence over env / defaults.
559    /// Critical for the test environment, where adopt-inference tests pin
560    /// XDG to a non-default location so prefix matches are unambiguous.
561    #[test]
562    fn explicit_xdg_config_home_overrides_default() {
563        let pather = XdgPather::builder()
564            .home("/u")
565            .dotfiles_root("/u/dotfiles")
566            .xdg_config_home("/somewhere/else/.config")
567            .build()
568            .unwrap();
569        assert_eq!(
570            pather.xdg_config_home(),
571            Path::new("/somewhere/else/.config")
572        );
573    }
574
575    /// Each accessor returns a stable, distinct subdir layout. Adopt's
576    /// auto-create path lands the new pack at `dotfiles_root/<pack>`,
577    /// and the data layer keeps state at `data_dir/packs/<pack>/...`;
578    /// these must not alias.
579    #[test]
580    fn dotfiles_root_and_data_dir_are_distinct_namespaces() {
581        let pather = XdgPather::builder()
582            .home("/u")
583            .dotfiles_root("/u/dotfiles")
584            .data_dir("/u/.local/share/dodot")
585            .build()
586            .unwrap();
587        let pack_dir = pather.pack_path("nvim");
588        let pack_data = pather.pack_data_dir("nvim");
589        assert!(
590            !pack_dir.starts_with(&pack_data) && !pack_data.starts_with(&pack_dir),
591            "pack_path `{}` and pack_data_dir `{}` overlap",
592            pack_dir.display(),
593            pack_data.display(),
594        );
595    }
596
597    /// Explicit `app_support_dir(...)` overrides the platform default.
598    /// Tests rely on this to pin the third coordinate at a known
599    /// non-XDG, non-HOME location so the resolver's `_app/` rule has
600    /// somewhere unambiguous to land.
601    #[test]
602    fn explicit_app_support_dir_overrides_default() {
603        let pather = XdgPather::builder()
604            .home("/u")
605            .dotfiles_root("/u/dotfiles")
606            .xdg_config_home("/u/.config")
607            .app_support_dir("/u/Library/Application Support")
608            .build()
609            .unwrap();
610        assert_eq!(
611            pather.app_support_dir(),
612            Path::new("/u/Library/Application Support")
613        );
614    }
615
616    /// Default app_support_dir on Linux/non-macOS collapses to xdg_config_home.
617    /// On macOS it points under `$HOME/Library/Application Support`.
618    /// We don't `cfg!` the assertion here because the explicit-builder
619    /// test above pins the override path; this test exercises the
620    /// implicit default and the platform branch together.
621    #[test]
622    fn default_app_support_dir_is_platform_aware() {
623        let pather = XdgPather::builder()
624            .home("/u")
625            .dotfiles_root("/u/dotfiles")
626            .xdg_config_home("/u/.config")
627            .build()
628            .unwrap();
629        if cfg!(target_os = "macos") {
630            assert_eq!(
631                pather.app_support_dir(),
632                Path::new("/u/Library/Application Support"),
633                "macOS default should route under $HOME/Library/Application Support"
634            );
635        } else {
636            assert_eq!(
637                pather.app_support_dir(),
638                pather.xdg_config_home(),
639                "non-macOS default should collapse to xdg_config_home"
640            );
641        }
642    }
643
644    // Compile-time check: Pather must be object-safe
645    #[allow(dead_code)]
646    fn assert_object_safe(_: &dyn Pather) {}
647}