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
63/// XDG-compliant path resolver.
64///
65/// Reads standard environment variables (`HOME`, `XDG_DATA_HOME`, etc.)
66/// and the dodot-specific `DOTFILES_ROOT`. All paths can also be set
67/// explicitly via the builder for testing.
68#[derive(Debug, Clone)]
69pub struct XdgPather {
70    home: PathBuf,
71    dotfiles_root: PathBuf,
72    data_dir: PathBuf,
73    config_dir: PathBuf,
74    cache_dir: PathBuf,
75    xdg_config_home: PathBuf,
76    shell_dir: PathBuf,
77}
78
79/// Builder for [`XdgPather`].
80///
81/// All fields are optional. Unset fields are resolved from environment
82/// variables or XDG defaults.
83#[derive(Debug, Default)]
84pub struct XdgPatherBuilder {
85    home: Option<PathBuf>,
86    dotfiles_root: Option<PathBuf>,
87    data_dir: Option<PathBuf>,
88    config_dir: Option<PathBuf>,
89    cache_dir: Option<PathBuf>,
90    xdg_config_home: Option<PathBuf>,
91}
92
93impl XdgPatherBuilder {
94    pub fn home(mut self, path: impl Into<PathBuf>) -> Self {
95        self.home = Some(path.into());
96        self
97    }
98
99    pub fn dotfiles_root(mut self, path: impl Into<PathBuf>) -> Self {
100        self.dotfiles_root = Some(path.into());
101        self
102    }
103
104    pub fn data_dir(mut self, path: impl Into<PathBuf>) -> Self {
105        self.data_dir = Some(path.into());
106        self
107    }
108
109    pub fn config_dir(mut self, path: impl Into<PathBuf>) -> Self {
110        self.config_dir = Some(path.into());
111        self
112    }
113
114    pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
115        self.cache_dir = Some(path.into());
116        self
117    }
118
119    pub fn xdg_config_home(mut self, path: impl Into<PathBuf>) -> Self {
120        self.xdg_config_home = Some(path.into());
121        self
122    }
123
124    pub fn build(self) -> Result<XdgPather> {
125        let home = self.home.unwrap_or_else(resolve_home);
126
127        let dotfiles_root = self
128            .dotfiles_root
129            .unwrap_or_else(|| resolve_dotfiles_root(&home));
130
131        let xdg_config_home = self.xdg_config_home.unwrap_or_else(|| {
132            std::env::var("XDG_CONFIG_HOME")
133                .map(PathBuf::from)
134                .unwrap_or_else(|_| home.join(".config"))
135        });
136
137        let data_dir = self.data_dir.unwrap_or_else(|| {
138            let xdg_data = std::env::var("XDG_DATA_HOME")
139                .map(PathBuf::from)
140                .unwrap_or_else(|_| home.join(".local").join("share"));
141            xdg_data.join("dodot")
142        });
143
144        let config_dir = self
145            .config_dir
146            .unwrap_or_else(|| xdg_config_home.join("dodot"));
147
148        let cache_dir = self.cache_dir.unwrap_or_else(|| {
149            let xdg_cache = std::env::var("XDG_CACHE_HOME")
150                .map(PathBuf::from)
151                .unwrap_or_else(|_| home.join(".cache"));
152            xdg_cache.join("dodot")
153        });
154
155        let shell_dir = data_dir.join("shell");
156
157        Ok(XdgPather {
158            home,
159            dotfiles_root,
160            data_dir,
161            config_dir,
162            cache_dir,
163            xdg_config_home,
164            shell_dir,
165        })
166    }
167}
168
169impl XdgPather {
170    /// Creates a builder for configuring an `XdgPather`.
171    pub fn builder() -> XdgPatherBuilder {
172        XdgPatherBuilder::default()
173    }
174
175    /// Creates an `XdgPather` using environment variables and XDG defaults.
176    pub fn from_env() -> Result<Self> {
177        Self::builder().build()
178    }
179}
180
181impl Pather for XdgPather {
182    fn home_dir(&self) -> &Path {
183        &self.home
184    }
185
186    fn dotfiles_root(&self) -> &Path {
187        &self.dotfiles_root
188    }
189
190    fn data_dir(&self) -> &Path {
191        &self.data_dir
192    }
193
194    fn config_dir(&self) -> &Path {
195        &self.config_dir
196    }
197
198    fn cache_dir(&self) -> &Path {
199        &self.cache_dir
200    }
201
202    fn xdg_config_home(&self) -> &Path {
203        &self.xdg_config_home
204    }
205
206    fn shell_dir(&self) -> &Path {
207        &self.shell_dir
208    }
209}
210
211/// Resolve `HOME` from environment, falling back to the `dirs` approach.
212fn resolve_home() -> PathBuf {
213    std::env::var("HOME")
214        .map(PathBuf::from)
215        .unwrap_or_else(|_| {
216            // Last resort fallback
217            PathBuf::from("/tmp/dodot-unknown-home")
218        })
219}
220
221/// Resolve the dotfiles root directory.
222///
223/// Priority:
224/// 1. `DOTFILES_ROOT` environment variable
225/// 2. Git repository root (`git rev-parse --show-toplevel`)
226/// 3. `$HOME/dotfiles` fallback
227fn resolve_dotfiles_root(home: &Path) -> PathBuf {
228    // 1. Explicit env var
229    if let Ok(root) = std::env::var("DOTFILES_ROOT") {
230        return expand_tilde(&root, home);
231    }
232
233    // 2. Git toplevel
234    if let Ok(output) = std::process::Command::new("git")
235        .args(["rev-parse", "--show-toplevel"])
236        .output()
237    {
238        if output.status.success() {
239            let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
240            if !toplevel.is_empty() {
241                return PathBuf::from(toplevel);
242            }
243        }
244    }
245
246    // 3. Fallback
247    home.join("dotfiles")
248}
249
250/// Expand a leading `~` to the home directory.
251fn expand_tilde(path: &str, home: &Path) -> PathBuf {
252    if let Some(rest) = path.strip_prefix("~/") {
253        home.join(rest)
254    } else if path == "~" {
255        home.to_path_buf()
256    } else {
257        PathBuf::from(path)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn builder_explicit_paths() {
267        let pather = XdgPather::builder()
268            .home("/test/home")
269            .dotfiles_root("/test/home/dotfiles")
270            .data_dir("/test/data/dodot")
271            .config_dir("/test/config/dodot")
272            .cache_dir("/test/cache/dodot")
273            .xdg_config_home("/test/home/.config")
274            .build()
275            .unwrap();
276
277        assert_eq!(pather.home_dir(), Path::new("/test/home"));
278        assert_eq!(pather.dotfiles_root(), Path::new("/test/home/dotfiles"));
279        assert_eq!(pather.data_dir(), Path::new("/test/data/dodot"));
280        assert_eq!(pather.config_dir(), Path::new("/test/config/dodot"));
281        assert_eq!(pather.cache_dir(), Path::new("/test/cache/dodot"));
282        assert_eq!(pather.xdg_config_home(), Path::new("/test/home/.config"));
283    }
284
285    #[test]
286    fn shell_dir_derived_from_data_dir() {
287        let pather = XdgPather::builder()
288            .home("/h")
289            .dotfiles_root("/h/dots")
290            .data_dir("/h/data/dodot")
291            .build()
292            .unwrap();
293
294        assert_eq!(pather.shell_dir(), Path::new("/h/data/dodot/shell"));
295    }
296
297    #[test]
298    fn pack_path_joins_dotfiles_root() {
299        let pather = XdgPather::builder()
300            .home("/h")
301            .dotfiles_root("/h/dotfiles")
302            .build()
303            .unwrap();
304
305        assert_eq!(pather.pack_path("vim"), PathBuf::from("/h/dotfiles/vim"));
306    }
307
308    #[test]
309    fn pack_data_dir_structure() {
310        let pather = XdgPather::builder()
311            .home("/h")
312            .data_dir("/h/data/dodot")
313            .build()
314            .unwrap();
315
316        assert_eq!(
317            pather.pack_data_dir("vim"),
318            PathBuf::from("/h/data/dodot/packs/vim")
319        );
320    }
321
322    #[test]
323    fn handler_data_dir_structure() {
324        let pather = XdgPather::builder()
325            .home("/h")
326            .data_dir("/h/data/dodot")
327            .build()
328            .unwrap();
329
330        assert_eq!(
331            pather.handler_data_dir("vim", "symlink"),
332            PathBuf::from("/h/data/dodot/packs/vim/symlink")
333        );
334    }
335
336    #[test]
337    fn init_script_path() {
338        let pather = XdgPather::builder()
339            .home("/h")
340            .data_dir("/h/data/dodot")
341            .build()
342            .unwrap();
343
344        assert_eq!(
345            pather.init_script_path(),
346            PathBuf::from("/h/data/dodot/shell/dodot-init.sh")
347        );
348    }
349
350    #[test]
351    fn expand_tilde_cases() {
352        let home = Path::new("/home/alice");
353        assert_eq!(
354            expand_tilde("~/dotfiles", home),
355            PathBuf::from("/home/alice/dotfiles")
356        );
357        assert_eq!(expand_tilde("~", home), PathBuf::from("/home/alice"));
358        assert_eq!(
359            expand_tilde("/absolute/path", home),
360            PathBuf::from("/absolute/path")
361        );
362        assert_eq!(expand_tilde("relative", home), PathBuf::from("relative"));
363    }
364
365    // Compile-time check: Pather must be object-safe
366    #[allow(dead_code)]
367    fn assert_object_safe(_: &dyn Pather) {}
368}