Skip to main content

dodot_lib/paths/
mod.rs

1use std::path::{Path, PathBuf};
2
3use crate::Result;
4
5/// Provides all path calculations for dodot.
6///
7/// Every path that dodot uses -- XDG directories, pack locations,
8/// handler data directories -- is computed through this trait. This
9/// keeps path logic centralised and makes testing straightforward:
10/// construct a `Pather` whose directories all live under a temp dir.
11///
12/// Use `&dyn Pather` (trait objects) throughout the codebase.
13pub trait Pather: Send + Sync {
14    /// The user's home directory (e.g. `/home/alice`).
15    fn home_dir(&self) -> &Path;
16
17    /// Root of the dotfiles repository.
18    fn dotfiles_root(&self) -> &Path;
19
20    /// XDG data directory for dodot (e.g. `~/.local/share/dodot`).
21    fn data_dir(&self) -> &Path;
22
23    /// XDG config directory for dodot (e.g. `~/.config/dodot`).
24    fn config_dir(&self) -> &Path;
25
26    /// XDG cache directory for dodot (e.g. `~/.cache/dodot`).
27    fn cache_dir(&self) -> &Path;
28
29    /// XDG config home (e.g. `~/.config`). Used by symlink handler
30    /// for subdirectory target mapping.
31    fn xdg_config_home(&self) -> &Path;
32
33    /// Shell scripts directory (e.g. `~/.local/share/dodot/shell`).
34    fn shell_dir(&self) -> &Path;
35
36    /// Absolute path to a pack's source directory.
37    fn pack_path(&self, pack: &str) -> PathBuf {
38        self.dotfiles_root().join(pack)
39    }
40
41    /// Data directory for a specific pack (e.g. `.../data/packs/{pack}`).
42    fn pack_data_dir(&self, pack: &str) -> PathBuf {
43        self.data_dir().join("packs").join(pack)
44    }
45
46    /// Data directory for a specific handler within a pack
47    /// (e.g. `.../data/packs/{pack}/{handler}`).
48    fn handler_data_dir(&self, pack: &str, handler: &str) -> PathBuf {
49        self.pack_data_dir(pack).join(handler)
50    }
51
52    /// Log directory for dodot (e.g. `~/.cache/dodot/logs`).
53    fn log_dir(&self) -> PathBuf {
54        self.cache_dir().join("logs")
55    }
56
57    /// Path to the generated shell init script.
58    fn init_script_path(&self) -> PathBuf {
59        self.shell_dir().join("dodot-init.sh")
60    }
61
62    /// Path to the deployment map TSV, overwritten on every `up` / `down`.
63    /// See `docs/proposals/profiling.lex` §3.2.
64    fn deployment_map_path(&self) -> PathBuf {
65        self.data_dir().join("deployment-map.tsv")
66    }
67
68    /// Directory where shell-init profile reports are written, one TSV
69    /// per shell start. See `docs/proposals/profiling.lex` §3.1.
70    fn probes_shell_init_dir(&self) -> PathBuf {
71        self.data_dir().join("probes").join("shell-init")
72    }
73}
74
75/// XDG-compliant path resolver.
76///
77/// Reads standard environment variables (`HOME`, `XDG_DATA_HOME`, etc.)
78/// and the dodot-specific `DOTFILES_ROOT`. All paths can also be set
79/// explicitly via the builder for testing.
80#[derive(Debug, Clone)]
81pub struct XdgPather {
82    home: PathBuf,
83    dotfiles_root: PathBuf,
84    data_dir: PathBuf,
85    config_dir: PathBuf,
86    cache_dir: PathBuf,
87    xdg_config_home: PathBuf,
88    shell_dir: PathBuf,
89}
90
91/// Builder for [`XdgPather`].
92///
93/// All fields are optional. Unset fields are resolved from environment
94/// variables or XDG defaults.
95#[derive(Debug, Default)]
96pub struct XdgPatherBuilder {
97    home: Option<PathBuf>,
98    dotfiles_root: Option<PathBuf>,
99    data_dir: Option<PathBuf>,
100    config_dir: Option<PathBuf>,
101    cache_dir: Option<PathBuf>,
102    xdg_config_home: Option<PathBuf>,
103}
104
105impl XdgPatherBuilder {
106    pub fn home(mut self, path: impl Into<PathBuf>) -> Self {
107        self.home = Some(path.into());
108        self
109    }
110
111    pub fn dotfiles_root(mut self, path: impl Into<PathBuf>) -> Self {
112        self.dotfiles_root = Some(path.into());
113        self
114    }
115
116    pub fn data_dir(mut self, path: impl Into<PathBuf>) -> Self {
117        self.data_dir = Some(path.into());
118        self
119    }
120
121    pub fn config_dir(mut self, path: impl Into<PathBuf>) -> Self {
122        self.config_dir = Some(path.into());
123        self
124    }
125
126    pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
127        self.cache_dir = Some(path.into());
128        self
129    }
130
131    pub fn xdg_config_home(mut self, path: impl Into<PathBuf>) -> Self {
132        self.xdg_config_home = Some(path.into());
133        self
134    }
135
136    pub fn build(self) -> Result<XdgPather> {
137        let home = self.home.unwrap_or_else(resolve_home);
138
139        let dotfiles_root = self
140            .dotfiles_root
141            .unwrap_or_else(|| resolve_dotfiles_root(&home));
142
143        let xdg_config_home = self.xdg_config_home.unwrap_or_else(|| {
144            std::env::var("XDG_CONFIG_HOME")
145                .map(PathBuf::from)
146                .unwrap_or_else(|_| home.join(".config"))
147        });
148
149        let data_dir = self.data_dir.unwrap_or_else(|| {
150            let xdg_data = std::env::var("XDG_DATA_HOME")
151                .map(PathBuf::from)
152                .unwrap_or_else(|_| home.join(".local").join("share"));
153            xdg_data.join("dodot")
154        });
155
156        let config_dir = self
157            .config_dir
158            .unwrap_or_else(|| xdg_config_home.join("dodot"));
159
160        let cache_dir = self.cache_dir.unwrap_or_else(|| {
161            let xdg_cache = std::env::var("XDG_CACHE_HOME")
162                .map(PathBuf::from)
163                .unwrap_or_else(|_| home.join(".cache"));
164            xdg_cache.join("dodot")
165        });
166
167        let shell_dir = data_dir.join("shell");
168
169        Ok(XdgPather {
170            home,
171            dotfiles_root,
172            data_dir,
173            config_dir,
174            cache_dir,
175            xdg_config_home,
176            shell_dir,
177        })
178    }
179}
180
181impl XdgPather {
182    /// Creates a builder for configuring an `XdgPather`.
183    pub fn builder() -> XdgPatherBuilder {
184        XdgPatherBuilder::default()
185    }
186
187    /// Creates an `XdgPather` using environment variables and XDG defaults.
188    pub fn from_env() -> Result<Self> {
189        Self::builder().build()
190    }
191}
192
193impl Pather for XdgPather {
194    fn home_dir(&self) -> &Path {
195        &self.home
196    }
197
198    fn dotfiles_root(&self) -> &Path {
199        &self.dotfiles_root
200    }
201
202    fn data_dir(&self) -> &Path {
203        &self.data_dir
204    }
205
206    fn config_dir(&self) -> &Path {
207        &self.config_dir
208    }
209
210    fn cache_dir(&self) -> &Path {
211        &self.cache_dir
212    }
213
214    fn xdg_config_home(&self) -> &Path {
215        &self.xdg_config_home
216    }
217
218    fn shell_dir(&self) -> &Path {
219        &self.shell_dir
220    }
221}
222
223/// Resolve `HOME` from environment, falling back to the `dirs` approach.
224fn resolve_home() -> PathBuf {
225    std::env::var("HOME")
226        .map(PathBuf::from)
227        .unwrap_or_else(|_| {
228            // Last resort fallback
229            PathBuf::from("/tmp/dodot-unknown-home")
230        })
231}
232
233/// Resolve the dotfiles root directory.
234///
235/// Priority:
236/// 1. `DOTFILES_ROOT` environment variable
237/// 2. Git repository root (`git rev-parse --show-toplevel`)
238/// 3. `$HOME/dotfiles` fallback
239fn resolve_dotfiles_root(home: &Path) -> PathBuf {
240    // 1. Explicit env var
241    if let Ok(root) = std::env::var("DOTFILES_ROOT") {
242        return expand_tilde(&root, home);
243    }
244
245    // 2. Git toplevel
246    if let Ok(output) = std::process::Command::new("git")
247        .args(["rev-parse", "--show-toplevel"])
248        .output()
249    {
250        if output.status.success() {
251            let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
252            if !toplevel.is_empty() {
253                return PathBuf::from(toplevel);
254            }
255        }
256    }
257
258    // 3. Fallback
259    home.join("dotfiles")
260}
261
262/// Expand a leading `~` to the home directory.
263fn expand_tilde(path: &str, home: &Path) -> PathBuf {
264    if let Some(rest) = path.strip_prefix("~/") {
265        home.join(rest)
266    } else if path == "~" {
267        home.to_path_buf()
268    } else {
269        PathBuf::from(path)
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn builder_explicit_paths() {
279        let pather = XdgPather::builder()
280            .home("/test/home")
281            .dotfiles_root("/test/home/dotfiles")
282            .data_dir("/test/data/dodot")
283            .config_dir("/test/config/dodot")
284            .cache_dir("/test/cache/dodot")
285            .xdg_config_home("/test/home/.config")
286            .build()
287            .unwrap();
288
289        assert_eq!(pather.home_dir(), Path::new("/test/home"));
290        assert_eq!(pather.dotfiles_root(), Path::new("/test/home/dotfiles"));
291        assert_eq!(pather.data_dir(), Path::new("/test/data/dodot"));
292        assert_eq!(pather.config_dir(), Path::new("/test/config/dodot"));
293        assert_eq!(pather.cache_dir(), Path::new("/test/cache/dodot"));
294        assert_eq!(pather.xdg_config_home(), Path::new("/test/home/.config"));
295    }
296
297    #[test]
298    fn shell_dir_derived_from_data_dir() {
299        let pather = XdgPather::builder()
300            .home("/h")
301            .dotfiles_root("/h/dots")
302            .data_dir("/h/data/dodot")
303            .build()
304            .unwrap();
305
306        assert_eq!(pather.shell_dir(), Path::new("/h/data/dodot/shell"));
307    }
308
309    #[test]
310    fn pack_path_joins_dotfiles_root() {
311        let pather = XdgPather::builder()
312            .home("/h")
313            .dotfiles_root("/h/dotfiles")
314            .build()
315            .unwrap();
316
317        assert_eq!(pather.pack_path("vim"), PathBuf::from("/h/dotfiles/vim"));
318    }
319
320    #[test]
321    fn pack_data_dir_structure() {
322        let pather = XdgPather::builder()
323            .home("/h")
324            .data_dir("/h/data/dodot")
325            .build()
326            .unwrap();
327
328        assert_eq!(
329            pather.pack_data_dir("vim"),
330            PathBuf::from("/h/data/dodot/packs/vim")
331        );
332    }
333
334    #[test]
335    fn handler_data_dir_structure() {
336        let pather = XdgPather::builder()
337            .home("/h")
338            .data_dir("/h/data/dodot")
339            .build()
340            .unwrap();
341
342        assert_eq!(
343            pather.handler_data_dir("vim", "symlink"),
344            PathBuf::from("/h/data/dodot/packs/vim/symlink")
345        );
346    }
347
348    #[test]
349    fn init_script_path() {
350        let pather = XdgPather::builder()
351            .home("/h")
352            .data_dir("/h/data/dodot")
353            .build()
354            .unwrap();
355
356        assert_eq!(
357            pather.init_script_path(),
358            PathBuf::from("/h/data/dodot/shell/dodot-init.sh")
359        );
360    }
361
362    #[test]
363    fn expand_tilde_cases() {
364        let home = Path::new("/home/alice");
365        assert_eq!(
366            expand_tilde("~/dotfiles", home),
367            PathBuf::from("/home/alice/dotfiles")
368        );
369        assert_eq!(expand_tilde("~", home), PathBuf::from("/home/alice"));
370        assert_eq!(
371            expand_tilde("/absolute/path", home),
372            PathBuf::from("/absolute/path")
373        );
374        assert_eq!(expand_tilde("relative", home), PathBuf::from("relative"));
375    }
376
377    // Compile-time check: Pather must be object-safe
378    #[allow(dead_code)]
379    fn assert_object_safe(_: &dyn Pather) {}
380}