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/// Load `$source/.yuiignore` as a gitignore-style matcher.
44///
45/// Returns an empty matcher when the file is absent (so `is_ignored`
46/// becomes a no-op). Patterns use full gitignore syntax: glob (`*`,
47/// `**`), negation (`!`), trailing-slash dir-only matching, comments
48/// (`#`).
49///
50/// Currently only the repo-root `.yuiignore` is honored — nested
51/// `.yuiignore` files inside subdirectories are not yet walked. (The
52/// 95% case is "exclude `**/lock.json` once at the top".) If you need
53/// per-subtree rules, file an issue with the use case.
54pub fn load_yuiignore(source: &Utf8Path) -> crate::Result<ignore::gitignore::Gitignore> {
55    let path = source.join(".yuiignore");
56    if !path.is_file() {
57        return Ok(ignore::gitignore::Gitignore::empty());
58    }
59    let mut builder = ignore::gitignore::GitignoreBuilder::new(source);
60    if let Some(e) = builder.add(path.as_std_path()) {
61        return Err(crate::Error::Config(format!("parsing {path}: {e}")));
62    }
63    builder
64        .build()
65        .map_err(|e| crate::Error::Config(format!("building .yuiignore: {e}")))
66}
67
68/// Test a path against the loaded `.yuiignore` matcher.
69///
70/// `path` is treated relative to `source` (gitignore convention). Paths
71/// that don't live under `source` can't possibly match a source-rooted
72/// rule, so they short-circuit to `false`. Without this guard, an
73/// absolute path passed through `unwrap_or(path)` would land on the
74/// matcher as an absolute, which `Gitignore` would test using its
75/// rightmost component — leading to spurious matches for paths outside
76/// the repo. (Caught in PR #19 review.)
77///
78/// Uses `matched_path_or_any_parents` so an ignored ancestor directory
79/// causes the descendant file to be ignored too.
80pub fn is_ignored(
81    gi: &ignore::gitignore::Gitignore,
82    source: &Utf8Path,
83    path: &Utf8Path,
84    is_dir: bool,
85) -> bool {
86    let Ok(rel) = path.strip_prefix(source) else {
87        return false;
88    };
89    matches!(
90        gi.matched_path_or_any_parents(rel.as_std_path(), is_dir),
91        ignore::Match::Ignore(_)
92    )
93}
94
95/// Build a source-tree walker that skips yui's internal `.yui/` directory.
96///
97/// `.yui/backup/` can grow huge over time, and `.yui/rendered/` (future)
98/// would also live here — neither is part of the user's dotfiles, and
99/// walking them slows render / list / absorb-find by a lot. We also keep
100/// `.gitignore` / `.ignore` filtering disabled (`git_ignore(false)`,
101/// `ignore(false)`) so a user's unrelated ignore rules don't swallow
102/// legitimate `.tera` / `.yuilink` files deeper in the tree.
103pub fn source_walker(source: &Utf8Path) -> ignore::WalkBuilder {
104    let mut b = ignore::WalkBuilder::new(source);
105    b.hidden(false).git_ignore(false).ignore(false);
106    b.filter_entry(|entry| entry.file_name() != ".yui");
107    b
108}
109
110/// Mirror an absolute target path into a backup directory, dropping the drive
111/// colon on Windows so the path is filesystem-safe.
112///
113/// ```text
114///   C:\Users\u\foo.yml + .yui/backup → .yui/backup/C/Users/u/foo.yml
115///   /home/u/foo.yml    + .yui/backup → .yui/backup/home/u/foo.yml
116/// ```
117pub fn mirror_into_backup(backup_root: &Utf8Path, abs_target: &Utf8Path) -> Utf8PathBuf {
118    let mut out = backup_root.to_path_buf();
119    for component in abs_target.components() {
120        match component {
121            Utf8Component::Prefix(p) => {
122                let s = p.as_str().trim_end_matches(':');
123                if !s.is_empty() {
124                    out.push(s);
125                }
126            }
127            Utf8Component::RootDir | Utf8Component::CurDir => {}
128            Utf8Component::ParentDir => {}
129            Utf8Component::Normal(s) => {
130                out.push(s);
131            }
132        }
133    }
134    out
135}
136
137/// Append a timestamp before the extension.
138///
139/// ```text
140///   foo/bar.yml     + ts → foo/bar_<ts>.yml
141///   foo/bar         + ts → foo/bar_<ts>
142///   foo/.gitconfig  + ts → foo/.gitconfig_<ts>      (treat dotfiles as stem-only)
143/// ```
144pub fn append_timestamp(path: &Utf8Path, ts: &str) -> Utf8PathBuf {
145    let parent = path.parent().map(Utf8PathBuf::from).unwrap_or_default();
146    let file_name = path.file_name().unwrap_or("");
147
148    let (stem, ext) = match (path.file_stem(), path.extension()) {
149        (Some(stem), Some(ext)) if !file_name.starts_with('.') => (stem, Some(ext)),
150        _ => (file_name, None),
151    };
152
153    let new_name = match ext {
154        Some(ext) => format!("{stem}_{ts}.{ext}"),
155        None => format!("{stem}_{ts}"),
156    };
157    parent.join(new_name)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn mirror_unix_absolute() {
166        let r = mirror_into_backup(
167            Utf8Path::new("/dotfiles/.yui/backup"),
168            Utf8Path::new("/home/u/.config/foo.toml"),
169        );
170        assert_eq!(
171            r,
172            Utf8PathBuf::from("/dotfiles/.yui/backup/home/u/.config/foo.toml")
173        );
174    }
175
176    #[test]
177    fn append_with_extension() {
178        let r = append_timestamp(Utf8Path::new("a/b.yml"), "20260429_143022123");
179        assert_eq!(r, Utf8PathBuf::from("a/b_20260429_143022123.yml"));
180    }
181
182    #[test]
183    fn append_no_extension() {
184        let r = append_timestamp(Utf8Path::new("a/b"), "20260429_143022123");
185        assert_eq!(r, Utf8PathBuf::from("a/b_20260429_143022123"));
186    }
187
188    #[test]
189    fn append_dotfile() {
190        let r = append_timestamp(Utf8Path::new(".gitconfig"), "20260429_143022123");
191        assert_eq!(r, Utf8PathBuf::from(".gitconfig_20260429_143022123"));
192    }
193
194    #[test]
195    fn tilde_slash_expands() {
196        let home = Utf8Path::new("/h/u");
197        assert_eq!(
198            expand_tilde_with("~/foo", home),
199            Utf8PathBuf::from("/h/u/foo")
200        );
201        assert_eq!(
202            expand_tilde_with("~/.config/nvim", home),
203            Utf8PathBuf::from("/h/u/.config/nvim")
204        );
205    }
206
207    #[test]
208    fn tilde_backslash_expands_for_windows_input() {
209        // Tera renders may emit Windows-style separators; accept both.
210        let home = Utf8Path::new("C:/Users/u");
211        assert_eq!(
212            expand_tilde_with("~\\foo", home),
213            Utf8PathBuf::from("C:/Users/u/foo")
214        );
215    }
216
217    #[test]
218    fn lone_tilde_is_home() {
219        let home = Utf8Path::new("/h/u");
220        assert_eq!(expand_tilde_with("~", home), Utf8PathBuf::from("/h/u"));
221    }
222
223    #[test]
224    fn tilde_user_form_is_untouched() {
225        let home = Utf8Path::new("/h/u");
226        // We don't support `~root/...` style; leave it for the caller to
227        // see a useful error (file not found) rather than silently lying.
228        assert_eq!(
229            expand_tilde_with("~root/foo", home),
230            Utf8PathBuf::from("~root/foo")
231        );
232    }
233
234    #[test]
235    fn no_tilde_unchanged() {
236        let home = Utf8Path::new("/h/u");
237        assert_eq!(
238            expand_tilde_with("/abs/path", home),
239            Utf8PathBuf::from("/abs/path")
240        );
241        assert_eq!(
242            expand_tilde_with("rel/path", home),
243            Utf8PathBuf::from("rel/path")
244        );
245        // Mid-string `~` is not a home reference (matches POSIX/bash behaviour).
246        assert_eq!(
247            expand_tilde_with("/foo/~/bar", home),
248            Utf8PathBuf::from("/foo/~/bar")
249        );
250    }
251}