1use std::path::{Path, PathBuf};
44
45use crate::Result;
46
47pub trait Pather: Send + Sync {
56 fn home_dir(&self) -> &Path;
58
59 fn dotfiles_root(&self) -> &Path;
61
62 fn data_dir(&self) -> &Path;
64
65 fn config_dir(&self) -> &Path;
67
68 fn cache_dir(&self) -> &Path;
70
71 fn xdg_config_home(&self) -> &Path;
74
75 fn shell_dir(&self) -> &Path;
77
78 fn pack_path(&self, pack: &str) -> PathBuf {
80 self.dotfiles_root().join(pack)
81 }
82
83 fn pack_data_dir(&self, pack: &str) -> PathBuf {
85 self.data_dir().join("packs").join(pack)
86 }
87
88 fn handler_data_dir(&self, pack: &str, handler: &str) -> PathBuf {
91 self.pack_data_dir(pack).join(handler)
92 }
93
94 fn log_dir(&self) -> PathBuf {
96 self.cache_dir().join("logs")
97 }
98
99 fn init_script_path(&self) -> PathBuf {
101 self.shell_dir().join("dodot-init.sh")
102 }
103
104 fn deployment_map_path(&self) -> PathBuf {
107 self.data_dir().join("deployment-map.tsv")
108 }
109
110 fn last_up_path(&self) -> PathBuf {
115 self.data_dir().join("last-up-at")
116 }
117
118 fn probes_shell_init_dir(&self) -> PathBuf {
121 self.data_dir().join("probes").join("shell-init")
122 }
123}
124
125#[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#[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 pub fn builder() -> XdgPatherBuilder {
234 XdgPatherBuilder::default()
235 }
236
237 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
273fn resolve_home() -> PathBuf {
275 std::env::var("HOME")
276 .map(PathBuf::from)
277 .unwrap_or_else(|_| {
278 PathBuf::from("/tmp/dodot-unknown-home")
280 })
281}
282
283fn resolve_dotfiles_root(home: &Path) -> PathBuf {
290 if let Ok(root) = std::env::var("DOTFILES_ROOT") {
292 return expand_tilde(&root, home);
293 }
294
295 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 home.join("dotfiles")
310}
311
312fn 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 #[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 .build()
443 .unwrap();
444 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 #[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 #[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 #[allow(dead_code)]
505 fn assert_object_safe(_: &dyn Pather) {}
506}