Skip to main content

yui/
paths.rs

1//! Path utilities for backup-mirroring, timestamp suffixing, and
2//! cross-platform tilde expansion.
3
4use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
5
6/// Expand a leading `~` or `~/...` to the user's home directory.
7///
8/// Smooths over the `$HOME` (Unix) vs `$USERPROFILE` (Windows) split so
9/// `dst = "~/.config"` works on every platform without writing a Tera
10/// `env(...)` call. Home is resolved via [`home_dir`].
11///
12/// `~user` (other-user homes) is left untouched — we don't support that
13/// form. If `$HOME` / `$USERPROFILE` are both unset the input is also
14/// returned verbatim (better to surface a "no such path" error later than
15/// silently substitute an empty string).
16pub fn expand_tilde(s: &str) -> Utf8PathBuf {
17    match home_dir() {
18        Some(home) => expand_tilde_with(s, &home),
19        None => Utf8PathBuf::from(s),
20    }
21}
22
23/// Same as [`expand_tilde`] but with an explicit home path — used in tests
24/// to avoid touching the process-wide `HOME` env var.
25pub fn expand_tilde_with(s: &str, home: &Utf8Path) -> Utf8PathBuf {
26    if let Some(rest) = s.strip_prefix("~/").or_else(|| s.strip_prefix("~\\")) {
27        home.join(rest)
28    } else if s == "~" {
29        home.to_path_buf()
30    } else {
31        Utf8PathBuf::from(s)
32    }
33}
34
35/// `$HOME` (Unix) or `$USERPROFILE` (Windows), or `None` if neither is set.
36pub fn home_dir() -> Option<Utf8PathBuf> {
37    std::env::var("HOME")
38        .ok()
39        .or_else(|| std::env::var("USERPROFILE").ok())
40        .map(Utf8PathBuf::from)
41}
42
43/// Build a source-tree walker that skips yui's internal `.yui/` directory.
44///
45/// `.yui/backup/` can grow huge over time, and `.yui/rendered/` (future)
46/// would also live here — neither is part of the user's dotfiles, and
47/// walking them slows render / list / absorb-find by a lot. We also keep
48/// `.gitignore` / `.ignore` filtering disabled (`git_ignore(false)`,
49/// `ignore(false)`) so a user's unrelated ignore rules don't swallow
50/// legitimate `.tera` / `.yuilink` files deeper in the tree.
51pub fn source_walker(source: &Utf8Path) -> ignore::WalkBuilder {
52    let mut b = ignore::WalkBuilder::new(source);
53    b.hidden(false).git_ignore(false).ignore(false);
54    b.filter_entry(|entry| entry.file_name() != ".yui");
55    b
56}
57
58/// Mirror an absolute target path into a backup directory, dropping the drive
59/// colon on Windows so the path is filesystem-safe.
60///
61/// ```text
62///   C:\Users\u\foo.yml + .yui/backup → .yui/backup/C/Users/u/foo.yml
63///   /home/u/foo.yml    + .yui/backup → .yui/backup/home/u/foo.yml
64/// ```
65pub fn mirror_into_backup(backup_root: &Utf8Path, abs_target: &Utf8Path) -> Utf8PathBuf {
66    let mut out = backup_root.to_path_buf();
67    for component in abs_target.components() {
68        match component {
69            Utf8Component::Prefix(p) => {
70                let s = p.as_str().trim_end_matches(':');
71                if !s.is_empty() {
72                    out.push(s);
73                }
74            }
75            Utf8Component::RootDir | Utf8Component::CurDir => {}
76            Utf8Component::ParentDir => {}
77            Utf8Component::Normal(s) => {
78                out.push(s);
79            }
80        }
81    }
82    out
83}
84
85/// Append a timestamp before the extension.
86///
87/// ```text
88///   foo/bar.yml     + ts → foo/bar_<ts>.yml
89///   foo/bar         + ts → foo/bar_<ts>
90///   foo/.gitconfig  + ts → foo/.gitconfig_<ts>      (treat dotfiles as stem-only)
91/// ```
92pub fn append_timestamp(path: &Utf8Path, ts: &str) -> Utf8PathBuf {
93    let parent = path.parent().map(Utf8PathBuf::from).unwrap_or_default();
94    let file_name = path.file_name().unwrap_or("");
95
96    let (stem, ext) = match (path.file_stem(), path.extension()) {
97        (Some(stem), Some(ext)) if !file_name.starts_with('.') => (stem, Some(ext)),
98        _ => (file_name, None),
99    };
100
101    let new_name = match ext {
102        Some(ext) => format!("{stem}_{ts}.{ext}"),
103        None => format!("{stem}_{ts}"),
104    };
105    parent.join(new_name)
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn mirror_unix_absolute() {
114        let r = mirror_into_backup(
115            Utf8Path::new("/dotfiles/.yui/backup"),
116            Utf8Path::new("/home/u/.config/foo.toml"),
117        );
118        assert_eq!(
119            r,
120            Utf8PathBuf::from("/dotfiles/.yui/backup/home/u/.config/foo.toml")
121        );
122    }
123
124    #[test]
125    fn append_with_extension() {
126        let r = append_timestamp(Utf8Path::new("a/b.yml"), "20260429_143022123");
127        assert_eq!(r, Utf8PathBuf::from("a/b_20260429_143022123.yml"));
128    }
129
130    #[test]
131    fn append_no_extension() {
132        let r = append_timestamp(Utf8Path::new("a/b"), "20260429_143022123");
133        assert_eq!(r, Utf8PathBuf::from("a/b_20260429_143022123"));
134    }
135
136    #[test]
137    fn append_dotfile() {
138        let r = append_timestamp(Utf8Path::new(".gitconfig"), "20260429_143022123");
139        assert_eq!(r, Utf8PathBuf::from(".gitconfig_20260429_143022123"));
140    }
141
142    #[test]
143    fn tilde_slash_expands() {
144        let home = Utf8Path::new("/h/u");
145        assert_eq!(
146            expand_tilde_with("~/foo", home),
147            Utf8PathBuf::from("/h/u/foo")
148        );
149        assert_eq!(
150            expand_tilde_with("~/.config/nvim", home),
151            Utf8PathBuf::from("/h/u/.config/nvim")
152        );
153    }
154
155    #[test]
156    fn tilde_backslash_expands_for_windows_input() {
157        // Tera renders may emit Windows-style separators; accept both.
158        let home = Utf8Path::new("C:/Users/u");
159        assert_eq!(
160            expand_tilde_with("~\\foo", home),
161            Utf8PathBuf::from("C:/Users/u/foo")
162        );
163    }
164
165    #[test]
166    fn lone_tilde_is_home() {
167        let home = Utf8Path::new("/h/u");
168        assert_eq!(expand_tilde_with("~", home), Utf8PathBuf::from("/h/u"));
169    }
170
171    #[test]
172    fn tilde_user_form_is_untouched() {
173        let home = Utf8Path::new("/h/u");
174        // We don't support `~root/...` style; leave it for the caller to
175        // see a useful error (file not found) rather than silently lying.
176        assert_eq!(
177            expand_tilde_with("~root/foo", home),
178            Utf8PathBuf::from("~root/foo")
179        );
180    }
181
182    #[test]
183    fn no_tilde_unchanged() {
184        let home = Utf8Path::new("/h/u");
185        assert_eq!(
186            expand_tilde_with("/abs/path", home),
187            Utf8PathBuf::from("/abs/path")
188        );
189        assert_eq!(
190            expand_tilde_with("rel/path", home),
191            Utf8PathBuf::from("rel/path")
192        );
193        // Mid-string `~` is not a home reference (matches POSIX/bash behaviour).
194        assert_eq!(
195            expand_tilde_with("/foo/~/bar", home),
196            Utf8PathBuf::from("/foo/~/bar")
197        );
198    }
199}