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