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 init_script_path(&self) -> PathBuf {
54 self.shell_dir().join("dodot-init.sh")
55 }
56}
57
58#[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#[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 pub fn builder() -> XdgPatherBuilder {
167 XdgPatherBuilder::default()
168 }
169
170 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
206fn resolve_home() -> PathBuf {
208 std::env::var("HOME")
209 .map(PathBuf::from)
210 .unwrap_or_else(|_| {
211 PathBuf::from("/tmp/dodot-unknown-home")
213 })
214}
215
216fn resolve_dotfiles_root(home: &Path) -> PathBuf {
223 if let Ok(root) = std::env::var("DOTFILES_ROOT") {
225 return expand_tilde(&root, home);
226 }
227
228 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 home.join("dotfiles")
243}
244
245fn 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 #[allow(dead_code)]
362 fn assert_object_safe(_: &dyn Pather) {}
363}