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
63#[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#[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 pub fn builder() -> XdgPatherBuilder {
172 XdgPatherBuilder::default()
173 }
174
175 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
211fn resolve_home() -> PathBuf {
213 std::env::var("HOME")
214 .map(PathBuf::from)
215 .unwrap_or_else(|_| {
216 PathBuf::from("/tmp/dodot-unknown-home")
218 })
219}
220
221fn resolve_dotfiles_root(home: &Path) -> PathBuf {
228 if let Ok(root) = std::env::var("DOTFILES_ROOT") {
230 return expand_tilde(&root, home);
231 }
232
233 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 home.join("dotfiles")
248}
249
250fn 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 #[allow(dead_code)]
367 fn assert_object_safe(_: &dyn Pather) {}
368}