1use std::path::{Path, PathBuf};
2
3use crate::Result;
4
5pub trait Pather: Send + Sync {
14 fn home_dir(&self) -> &Path;
16
17 fn dotfiles_root(&self) -> &Path;
19
20 fn data_dir(&self) -> &Path;
22
23 fn config_dir(&self) -> &Path;
25
26 fn cache_dir(&self) -> &Path;
28
29 fn xdg_config_home(&self) -> &Path;
32
33 fn shell_dir(&self) -> &Path;
35
36 fn pack_path(&self, pack: &str) -> PathBuf {
38 self.dotfiles_root().join(pack)
39 }
40
41 fn pack_data_dir(&self, pack: &str) -> PathBuf {
43 self.data_dir().join("packs").join(pack)
44 }
45
46 fn handler_data_dir(&self, pack: &str, handler: &str) -> PathBuf {
49 self.pack_data_dir(pack).join(handler)
50 }
51
52 fn log_dir(&self) -> PathBuf {
54 self.cache_dir().join("logs")
55 }
56
57 fn init_script_path(&self) -> PathBuf {
59 self.shell_dir().join("dodot-init.sh")
60 }
61
62 fn deployment_map_path(&self) -> PathBuf {
65 self.data_dir().join("deployment-map.tsv")
66 }
67
68 fn probes_shell_init_dir(&self) -> PathBuf {
71 self.data_dir().join("probes").join("shell-init")
72 }
73}
74
75#[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#[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 pub fn builder() -> XdgPatherBuilder {
184 XdgPatherBuilder::default()
185 }
186
187 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
223fn resolve_home() -> PathBuf {
225 std::env::var("HOME")
226 .map(PathBuf::from)
227 .unwrap_or_else(|_| {
228 PathBuf::from("/tmp/dodot-unknown-home")
230 })
231}
232
233fn resolve_dotfiles_root(home: &Path) -> PathBuf {
240 if let Ok(root) = std::env::var("DOTFILES_ROOT") {
242 return expand_tilde(&root, home);
243 }
244
245 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 home.join("dotfiles")
260}
261
262fn 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 #[allow(dead_code)]
379 fn assert_object_safe(_: &dyn Pather) {}
380}