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    /// Shell scripts directory (e.g. `~/.local/share/dodot/shell`).
76    fn shell_dir(&self) -> &Path;
77
78    /// Absolute path to a pack's source directory.
79    fn pack_path(&self, pack: &str) -> PathBuf {
80        self.dotfiles_root().join(pack)
81    }
82
83    /// Data directory for a specific pack (e.g. `.../data/packs/{pack}`).
84    fn pack_data_dir(&self, pack: &str) -> PathBuf {
85        self.data_dir().join("packs").join(pack)
86    }
87
88    /// Data directory for a specific handler within a pack
89    /// (e.g. `.../data/packs/{pack}/{handler}`).
90    fn handler_data_dir(&self, pack: &str, handler: &str) -> PathBuf {
91        self.pack_data_dir(pack).join(handler)
92    }
93
94    /// Log directory for dodot (e.g. `~/.cache/dodot/logs`).
95    fn log_dir(&self) -> PathBuf {
96        self.cache_dir().join("logs")
97    }
98
99    /// Path to the generated shell init script.
100    fn init_script_path(&self) -> PathBuf {
101        self.shell_dir().join("dodot-init.sh")
102    }
103
104    /// Path to the deployment map TSV, overwritten on every `up` / `down`.
105    /// See `docs/proposals/profiling.lex` §3.2.
106    fn deployment_map_path(&self) -> PathBuf {
107        self.data_dir().join("deployment-map.tsv")
108    }
109
110    /// Path to a single-line file recording the unix timestamp of the
111    /// most recent successful `dodot up`. Used by `dodot probe
112    /// shell-init` to flag profiles captured before that `up` as stale.
113    /// Absent until the first `up` runs.
114    fn last_up_path(&self) -> PathBuf {
115        self.data_dir().join("last-up-at")
116    }
117
118    /// Directory where shell-init profile reports are written, one TSV
119    /// per shell start. See `docs/proposals/profiling.lex` §3.1.
120    fn probes_shell_init_dir(&self) -> PathBuf {
121        self.data_dir().join("probes").join("shell-init")
122    }
123}
124
125/// XDG-compliant path resolver.
126///
127/// Reads standard environment variables (`HOME`, `XDG_DATA_HOME`, etc.)
128/// and the dodot-specific `DOTFILES_ROOT`. All paths can also be set
129/// explicitly via the builder for testing.
130#[derive(Debug, Clone)]
131pub struct XdgPather {
132    home: PathBuf,
133    dotfiles_root: PathBuf,
134    data_dir: PathBuf,
135    config_dir: PathBuf,
136    cache_dir: PathBuf,
137    xdg_config_home: PathBuf,
138    shell_dir: PathBuf,
139}
140
141/// Builder for [`XdgPather`].
142///
143/// All fields are optional. Unset fields are resolved from environment
144/// variables or XDG defaults.
145#[derive(Debug, Default)]
146pub struct XdgPatherBuilder {
147    home: Option<PathBuf>,
148    dotfiles_root: Option<PathBuf>,
149    data_dir: Option<PathBuf>,
150    config_dir: Option<PathBuf>,
151    cache_dir: Option<PathBuf>,
152    xdg_config_home: Option<PathBuf>,
153}
154
155impl XdgPatherBuilder {
156    pub fn home(mut self, path: impl Into<PathBuf>) -> Self {
157        self.home = Some(path.into());
158        self
159    }
160
161    pub fn dotfiles_root(mut self, path: impl Into<PathBuf>) -> Self {
162        self.dotfiles_root = Some(path.into());
163        self
164    }
165
166    pub fn data_dir(mut self, path: impl Into<PathBuf>) -> Self {
167        self.data_dir = Some(path.into());
168        self
169    }
170
171    pub fn config_dir(mut self, path: impl Into<PathBuf>) -> Self {
172        self.config_dir = Some(path.into());
173        self
174    }
175
176    pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
177        self.cache_dir = Some(path.into());
178        self
179    }
180
181    pub fn xdg_config_home(mut self, path: impl Into<PathBuf>) -> Self {
182        self.xdg_config_home = Some(path.into());
183        self
184    }
185
186    pub fn build(self) -> Result<XdgPather> {
187        let home = self.home.unwrap_or_else(resolve_home);
188
189        let dotfiles_root = self
190            .dotfiles_root
191            .unwrap_or_else(|| resolve_dotfiles_root(&home));
192
193        let xdg_config_home = self.xdg_config_home.unwrap_or_else(|| {
194            std::env::var("XDG_CONFIG_HOME")
195                .map(PathBuf::from)
196                .unwrap_or_else(|_| home.join(".config"))
197        });
198
199        let data_dir = self.data_dir.unwrap_or_else(|| {
200            let xdg_data = std::env::var("XDG_DATA_HOME")
201                .map(PathBuf::from)
202                .unwrap_or_else(|_| home.join(".local").join("share"));
203            xdg_data.join("dodot")
204        });
205
206        let config_dir = self
207            .config_dir
208            .unwrap_or_else(|| xdg_config_home.join("dodot"));
209
210        let cache_dir = self.cache_dir.unwrap_or_else(|| {
211            let xdg_cache = std::env::var("XDG_CACHE_HOME")
212                .map(PathBuf::from)
213                .unwrap_or_else(|_| home.join(".cache"));
214            xdg_cache.join("dodot")
215        });
216
217        let shell_dir = data_dir.join("shell");
218
219        Ok(XdgPather {
220            home,
221            dotfiles_root,
222            data_dir,
223            config_dir,
224            cache_dir,
225            xdg_config_home,
226            shell_dir,
227        })
228    }
229}
230
231impl XdgPather {
232    /// Creates a builder for configuring an `XdgPather`.
233    pub fn builder() -> XdgPatherBuilder {
234        XdgPatherBuilder::default()
235    }
236
237    /// Creates an `XdgPather` using environment variables and XDG defaults.
238    pub fn from_env() -> Result<Self> {
239        Self::builder().build()
240    }
241}
242
243impl Pather for XdgPather {
244    fn home_dir(&self) -> &Path {
245        &self.home
246    }
247
248    fn dotfiles_root(&self) -> &Path {
249        &self.dotfiles_root
250    }
251
252    fn data_dir(&self) -> &Path {
253        &self.data_dir
254    }
255
256    fn config_dir(&self) -> &Path {
257        &self.config_dir
258    }
259
260    fn cache_dir(&self) -> &Path {
261        &self.cache_dir
262    }
263
264    fn xdg_config_home(&self) -> &Path {
265        &self.xdg_config_home
266    }
267
268    fn shell_dir(&self) -> &Path {
269        &self.shell_dir
270    }
271}
272
273/// Resolve `HOME` from environment, falling back to the `dirs` approach.
274fn resolve_home() -> PathBuf {
275    std::env::var("HOME")
276        .map(PathBuf::from)
277        .unwrap_or_else(|_| {
278            // Last resort fallback
279            PathBuf::from("/tmp/dodot-unknown-home")
280        })
281}
282
283/// Resolve the dotfiles root directory.
284///
285/// Priority:
286/// 1. `DOTFILES_ROOT` environment variable
287/// 2. Git repository root (`git rev-parse --show-toplevel`)
288/// 3. `$HOME/dotfiles` fallback
289fn resolve_dotfiles_root(home: &Path) -> PathBuf {
290    // 1. Explicit env var
291    if let Ok(root) = std::env::var("DOTFILES_ROOT") {
292        return expand_tilde(&root, home);
293    }
294
295    // 2. Git toplevel
296    if let Ok(output) = std::process::Command::new("git")
297        .args(["rev-parse", "--show-toplevel"])
298        .output()
299    {
300        if output.status.success() {
301            let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
302            if !toplevel.is_empty() {
303                return PathBuf::from(toplevel);
304            }
305        }
306    }
307
308    // 3. Fallback
309    home.join("dotfiles")
310}
311
312/// Expand a leading `~` to the home directory.
313fn expand_tilde(path: &str, home: &Path) -> PathBuf {
314    if let Some(rest) = path.strip_prefix("~/") {
315        home.join(rest)
316    } else if path == "~" {
317        home.to_path_buf()
318    } else {
319        PathBuf::from(path)
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn builder_explicit_paths() {
329        let pather = XdgPather::builder()
330            .home("/test/home")
331            .dotfiles_root("/test/home/dotfiles")
332            .data_dir("/test/data/dodot")
333            .config_dir("/test/config/dodot")
334            .cache_dir("/test/cache/dodot")
335            .xdg_config_home("/test/home/.config")
336            .build()
337            .unwrap();
338
339        assert_eq!(pather.home_dir(), Path::new("/test/home"));
340        assert_eq!(pather.dotfiles_root(), Path::new("/test/home/dotfiles"));
341        assert_eq!(pather.data_dir(), Path::new("/test/data/dodot"));
342        assert_eq!(pather.config_dir(), Path::new("/test/config/dodot"));
343        assert_eq!(pather.cache_dir(), Path::new("/test/cache/dodot"));
344        assert_eq!(pather.xdg_config_home(), Path::new("/test/home/.config"));
345    }
346
347    #[test]
348    fn shell_dir_derived_from_data_dir() {
349        let pather = XdgPather::builder()
350            .home("/h")
351            .dotfiles_root("/h/dots")
352            .data_dir("/h/data/dodot")
353            .build()
354            .unwrap();
355
356        assert_eq!(pather.shell_dir(), Path::new("/h/data/dodot/shell"));
357    }
358
359    #[test]
360    fn pack_path_joins_dotfiles_root() {
361        let pather = XdgPather::builder()
362            .home("/h")
363            .dotfiles_root("/h/dotfiles")
364            .build()
365            .unwrap();
366
367        assert_eq!(pather.pack_path("vim"), PathBuf::from("/h/dotfiles/vim"));
368    }
369
370    #[test]
371    fn pack_data_dir_structure() {
372        let pather = XdgPather::builder()
373            .home("/h")
374            .data_dir("/h/data/dodot")
375            .build()
376            .unwrap();
377
378        assert_eq!(
379            pather.pack_data_dir("vim"),
380            PathBuf::from("/h/data/dodot/packs/vim")
381        );
382    }
383
384    #[test]
385    fn handler_data_dir_structure() {
386        let pather = XdgPather::builder()
387            .home("/h")
388            .data_dir("/h/data/dodot")
389            .build()
390            .unwrap();
391
392        assert_eq!(
393            pather.handler_data_dir("vim", "symlink"),
394            PathBuf::from("/h/data/dodot/packs/vim/symlink")
395        );
396    }
397
398    #[test]
399    fn init_script_path() {
400        let pather = XdgPather::builder()
401            .home("/h")
402            .data_dir("/h/data/dodot")
403            .build()
404            .unwrap();
405
406        assert_eq!(
407            pather.init_script_path(),
408            PathBuf::from("/h/data/dodot/shell/dodot-init.sh")
409        );
410    }
411
412    #[test]
413    fn expand_tilde_cases() {
414        let home = Path::new("/home/alice");
415        assert_eq!(
416            expand_tilde("~/dotfiles", home),
417            PathBuf::from("/home/alice/dotfiles")
418        );
419        assert_eq!(expand_tilde("~", home), PathBuf::from("/home/alice"));
420        assert_eq!(
421            expand_tilde("/absolute/path", home),
422            PathBuf::from("/absolute/path")
423        );
424        assert_eq!(expand_tilde("relative", home), PathBuf::from("relative"));
425    }
426
427    /// Default-XDG nesting: with no explicit `xdg_config_home`, the
428    /// builder defaults to `$HOME/.config`. Adopt's inference relies on
429    /// XDG being checked *before* HOME (longest-prefix wins) precisely
430    /// because of this nesting; pin the layout so a future change that
431    /// flips the default to `$HOME/Library/...` (macOS) or somewhere
432    /// outside HOME forces a deliberate update to the inference rules.
433    #[test]
434    fn default_xdg_config_home_is_nested_under_home() {
435        let pather = XdgPather::builder()
436            .home("/u")
437            .dotfiles_root("/u/dotfiles")
438            .data_dir("/u/.local/share/dodot")
439            .config_dir("/u/.config/dodot")
440            .cache_dir("/u/.cache/dodot")
441            // No xdg_config_home set; falls back to env or `$HOME/.config`.
442            .build()
443            .unwrap();
444        // The default fallback (no `XDG_CONFIG_HOME` env) is `$HOME/.config`.
445        // The assertion has to tolerate a user-set `XDG_CONFIG_HOME` since
446        // tests inherit the ambient env — `cargo test` from a developer
447        // shell with the env set would otherwise fail spuriously. The
448        // disjunct below means: either XDG nests under HOME (the default
449        // case the invariant talks about), OR the env override is set
450        // (the user opted out of the default; adopt's inference handles
451        // that case via root canonicalization, separate code path).
452        let xdg = pather.xdg_config_home();
453        let home = pather.home_dir();
454        assert!(
455            xdg.starts_with(home) || std::env::var("XDG_CONFIG_HOME").is_ok(),
456            "default xdg_config_home `{}` is not nested under home `{}` \
457             — adopt's inference assumes XDG ⊆ HOME on the default config; \
458             update both if this changes",
459            xdg.display(),
460            home.display()
461        );
462    }
463
464    /// Explicit `xdg_config_home(...)` takes precedence over env / defaults.
465    /// Critical for the test environment, where adopt-inference tests pin
466    /// XDG to a non-default location so prefix matches are unambiguous.
467    #[test]
468    fn explicit_xdg_config_home_overrides_default() {
469        let pather = XdgPather::builder()
470            .home("/u")
471            .dotfiles_root("/u/dotfiles")
472            .xdg_config_home("/somewhere/else/.config")
473            .build()
474            .unwrap();
475        assert_eq!(
476            pather.xdg_config_home(),
477            Path::new("/somewhere/else/.config")
478        );
479    }
480
481    /// Each accessor returns a stable, distinct subdir layout. Adopt's
482    /// auto-create path lands the new pack at `dotfiles_root/<pack>`,
483    /// and the data layer keeps state at `data_dir/packs/<pack>/...`;
484    /// these must not alias.
485    #[test]
486    fn dotfiles_root_and_data_dir_are_distinct_namespaces() {
487        let pather = XdgPather::builder()
488            .home("/u")
489            .dotfiles_root("/u/dotfiles")
490            .data_dir("/u/.local/share/dodot")
491            .build()
492            .unwrap();
493        let pack_dir = pather.pack_path("nvim");
494        let pack_data = pather.pack_data_dir("nvim");
495        assert!(
496            !pack_dir.starts_with(&pack_data) && !pack_data.starts_with(&pack_dir),
497            "pack_path `{}` and pack_data_dir `{}` overlap",
498            pack_dir.display(),
499            pack_data.display(),
500        );
501    }
502
503    // Compile-time check: Pather must be object-safe
504    #[allow(dead_code)]
505    fn assert_object_safe(_: &dyn Pather) {}
506}