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/// One-shot `.yuiignore` test for a single path under `source`.
44///
45/// Builds a fresh `YuiIgnoreStack`, pushes every directory between
46/// `source` and `path.parent()` (so a deeply-nested `.yuiignore`
47/// participates), then asks the stack. Use this when you have a
48/// single candidate path to check (e.g. manual `absorb`'s
49/// mount-derived candidate); for recursive walks, push/pop on the
50/// hot path with a single long-lived `YuiIgnoreStack` instead.
51///
52/// Patterns use full gitignore syntax: glob (`*`, `**`), negation
53/// (`!`), trailing-slash dir-only matching, comments (`#`). Paths
54/// outside `source` short-circuit to `false`.
55///
56/// If an ancestor directory is itself ignored, we return `true`
57/// immediately rather than descending into its `.yuiignore` — the
58/// recursive walkers (`walk_and_link`, `classify_walk_inner`) skip
59/// ignored subtrees entirely, so they never see the inner rules.
60/// Honouring inner whitelists here would let manual `absorb` pick a
61/// path that apply / status would never have linked. (Caught in PR
62/// #50 review.)
63pub fn is_ignored_at(source: &Utf8Path, path: &Utf8Path, is_dir: bool) -> crate::Result<bool> {
64    let Ok(rel) = path.strip_prefix(source) else {
65        return Ok(false);
66    };
67    let mut stack = YuiIgnoreStack::new();
68    stack.push_dir(source)?;
69    let mut cur = source.to_owned();
70    for component in rel.components() {
71        let Utf8Component::Normal(c) = component else {
72            continue;
73        };
74        cur.push(c);
75        if cur == path {
76            break;
77        }
78        if stack.is_ignored(&cur, /* is_dir */ true) {
79            return Ok(true);
80        }
81        stack.push_dir(&cur)?;
82    }
83    Ok(stack.is_ignored(path, is_dir))
84}
85
86/// Build a source-tree walker that skips repo plumbing.
87///
88/// Excluded directory names anywhere in the tree:
89///   - `.yui/` — yui's own state and backup mirror; can grow huge.
90///   - `.git/` — git plumbing of the dotfiles repo itself. The
91///     check is on the basename, so a `home/.config/git/` (note:
92///     no leading dot) inside the dotfiles is NOT excluded — only
93///     the literal `.git`.
94///
95/// `git_ignore(false)` / `ignore(false)` keep `.gitignore` /
96/// `.ignore` rules from swallowing legitimate `.tera` / `.yuilink`
97/// files deeper in the tree. `.yuiignore` is registered as a
98/// custom ignore filename so the walker honours nested rules
99/// (every subdir that has a `.yuiignore` adds its patterns scoped
100/// to that subtree, like git does with `.gitignore`). The manual
101/// recursive walks in `cmd.rs` use the `YuiIgnoreStack` companion
102/// type to get the same behaviour.
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.add_custom_ignore_filename(".yuiignore");
107    b.filter_entry(|entry| {
108        let name = entry.file_name();
109        name != ".yui" && name != ".git"
110    });
111    b
112}
113
114/// Stack of `.yuiignore` matchers for manual recursive walks. Each
115/// frame remembers the directory it was loaded from + the parsed
116/// matcher; testing a path walks innermost → outermost so a deeper
117/// `.yuiignore` overrides a shallower one (gitignore semantics).
118///
119/// Walkers `push_dir(d)` before iterating `d`'s entries and
120/// `pop_dir(d)` once they're done with the subtree. The same
121/// `YuiIgnoreStack` instance is threaded through the whole walk so
122/// the stack stays consistent across recursion.
123#[derive(Debug, Default)]
124pub struct YuiIgnoreStack {
125    layers: Vec<(Utf8PathBuf, ignore::gitignore::Gitignore)>,
126}
127
128impl YuiIgnoreStack {
129    pub fn new() -> Self {
130        Self::default()
131    }
132
133    /// Load `.yuiignore` from `dir` (if present) and push its rules
134    /// as a new layer. No-op when the file is absent.
135    pub fn push_dir(&mut self, dir: &Utf8Path) -> crate::Result<()> {
136        let path = dir.join(".yuiignore");
137        if !path.is_file() {
138            return Ok(());
139        }
140        let mut builder = ignore::gitignore::GitignoreBuilder::new(dir);
141        if let Some(e) = builder.add(path.as_std_path()) {
142            return Err(crate::Error::Config(format!("parsing {path}: {e}")));
143        }
144        let gi = builder
145            .build()
146            .map_err(|e| crate::Error::Config(format!("building {path}: {e}")))?;
147        self.layers.push((dir.to_owned(), gi));
148        Ok(())
149    }
150
151    /// Pop the top layer if it was loaded from `dir`. Pairs with
152    /// `push_dir` — calling it on a directory that didn't push a
153    /// layer is a no-op.
154    pub fn pop_dir(&mut self, dir: &Utf8Path) {
155        if matches!(self.layers.last(), Some((p, _)) if p == dir) {
156            self.layers.pop();
157        }
158    }
159
160    /// Decide whether `path` should be ignored. Walks frames inside
161    /// → outside; the first decisive match (Ignore or Whitelist)
162    /// wins, so a deeper `.yuiignore` can both exclude *and*
163    /// re-include paths the parent missed.
164    pub fn is_ignored(&self, path: &Utf8Path, is_dir: bool) -> bool {
165        for (anchor, gi) in self.layers.iter().rev() {
166            let Ok(rel) = path.strip_prefix(anchor) else {
167                continue;
168            };
169            match gi.matched_path_or_any_parents(rel.as_std_path(), is_dir) {
170                ignore::Match::Ignore(_) => return true,
171                ignore::Match::Whitelist(_) => return false,
172                ignore::Match::None => continue,
173            }
174        }
175        false
176    }
177}
178
179/// Mirror an absolute target path into a backup directory, dropping the drive
180/// colon on Windows so the path is filesystem-safe.
181///
182/// ```text
183///   C:\Users\u\foo.yml + .yui/backup → .yui/backup/C/Users/u/foo.yml
184///   /home/u/foo.yml    + .yui/backup → .yui/backup/home/u/foo.yml
185/// ```
186pub fn mirror_into_backup(backup_root: &Utf8Path, abs_target: &Utf8Path) -> Utf8PathBuf {
187    let mut out = backup_root.to_path_buf();
188    for component in abs_target.components() {
189        match component {
190            Utf8Component::Prefix(p) => {
191                let s = p.as_str().trim_end_matches(':');
192                if !s.is_empty() {
193                    out.push(s);
194                }
195            }
196            Utf8Component::RootDir | Utf8Component::CurDir => {}
197            Utf8Component::ParentDir => {}
198            Utf8Component::Normal(s) => {
199                out.push(s);
200            }
201        }
202    }
203    out
204}
205
206/// Append a timestamp before the extension.
207///
208/// ```text
209///   foo/bar.yml     + ts → foo/bar_<ts>.yml
210///   foo/bar         + ts → foo/bar_<ts>
211///   foo/.gitconfig  + ts → foo/.gitconfig_<ts>      (treat dotfiles as stem-only)
212/// ```
213pub fn append_timestamp(path: &Utf8Path, ts: &str) -> Utf8PathBuf {
214    let parent = path.parent().map(Utf8PathBuf::from).unwrap_or_default();
215    let file_name = path.file_name().unwrap_or("");
216
217    let (stem, ext) = match (path.file_stem(), path.extension()) {
218        (Some(stem), Some(ext)) if !file_name.starts_with('.') => (stem, Some(ext)),
219        _ => (file_name, None),
220    };
221
222    let new_name = match ext {
223        Some(ext) => format!("{stem}_{ts}.{ext}"),
224        None => format!("{stem}_{ts}"),
225    };
226    parent.join(new_name)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn mirror_unix_absolute() {
235        let r = mirror_into_backup(
236            Utf8Path::new("/dotfiles/.yui/backup"),
237            Utf8Path::new("/home/u/.config/foo.toml"),
238        );
239        assert_eq!(
240            r,
241            Utf8PathBuf::from("/dotfiles/.yui/backup/home/u/.config/foo.toml")
242        );
243    }
244
245    #[test]
246    fn append_with_extension() {
247        let r = append_timestamp(Utf8Path::new("a/b.yml"), "20260429_143022123");
248        assert_eq!(r, Utf8PathBuf::from("a/b_20260429_143022123.yml"));
249    }
250
251    #[test]
252    fn append_no_extension() {
253        let r = append_timestamp(Utf8Path::new("a/b"), "20260429_143022123");
254        assert_eq!(r, Utf8PathBuf::from("a/b_20260429_143022123"));
255    }
256
257    #[test]
258    fn append_dotfile() {
259        let r = append_timestamp(Utf8Path::new(".gitconfig"), "20260429_143022123");
260        assert_eq!(r, Utf8PathBuf::from(".gitconfig_20260429_143022123"));
261    }
262
263    #[test]
264    fn tilde_slash_expands() {
265        let home = Utf8Path::new("/h/u");
266        assert_eq!(
267            expand_tilde_with("~/foo", home),
268            Utf8PathBuf::from("/h/u/foo")
269        );
270        assert_eq!(
271            expand_tilde_with("~/.config/nvim", home),
272            Utf8PathBuf::from("/h/u/.config/nvim")
273        );
274    }
275
276    #[test]
277    fn tilde_backslash_expands_for_windows_input() {
278        // Tera renders may emit Windows-style separators; accept both.
279        let home = Utf8Path::new("C:/Users/u");
280        assert_eq!(
281            expand_tilde_with("~\\foo", home),
282            Utf8PathBuf::from("C:/Users/u/foo")
283        );
284    }
285
286    #[test]
287    fn lone_tilde_is_home() {
288        let home = Utf8Path::new("/h/u");
289        assert_eq!(expand_tilde_with("~", home), Utf8PathBuf::from("/h/u"));
290    }
291
292    #[test]
293    fn tilde_user_form_is_untouched() {
294        let home = Utf8Path::new("/h/u");
295        // We don't support `~root/...` style; leave it for the caller to
296        // see a useful error (file not found) rather than silently lying.
297        assert_eq!(
298            expand_tilde_with("~root/foo", home),
299            Utf8PathBuf::from("~root/foo")
300        );
301    }
302
303    #[test]
304    fn yui_ignore_stack_root_only() {
305        let tmp = tempfile::TempDir::new().unwrap();
306        let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
307        std::fs::write(root.join(".yuiignore"), "*.lock\n").unwrap();
308        let mut stack = YuiIgnoreStack::new();
309        stack.push_dir(&root).unwrap();
310        assert!(stack.is_ignored(&root.join("foo.lock"), false));
311        assert!(!stack.is_ignored(&root.join("foo.txt"), false));
312        stack.pop_dir(&root);
313        // After pop the matcher is gone — same path is no longer ignored.
314        assert!(!stack.is_ignored(&root.join("foo.lock"), false));
315    }
316
317    #[test]
318    fn yui_ignore_stack_nested_overrides_parent() {
319        let tmp = tempfile::TempDir::new().unwrap();
320        let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
321        let inner = root.join("inner");
322        std::fs::create_dir_all(&inner).unwrap();
323        std::fs::write(root.join(".yuiignore"), "*.lock\n").unwrap();
324        // Nested re-includes everything via `!*.lock`.
325        std::fs::write(inner.join(".yuiignore"), "!*.lock\n").unwrap();
326
327        let mut stack = YuiIgnoreStack::new();
328        stack.push_dir(&root).unwrap();
329        assert!(stack.is_ignored(&root.join("a.lock"), false));
330        stack.push_dir(&inner).unwrap();
331        assert!(
332            !stack.is_ignored(&inner.join("a.lock"), false),
333            "deeper layer's whitelist should win"
334        );
335        stack.pop_dir(&inner);
336        // After leaving inner, root rule applies again.
337        assert!(stack.is_ignored(&root.join("b.lock"), false));
338    }
339
340    #[test]
341    fn yui_ignore_stack_pop_only_matches_top() {
342        // pop_dir for a directory that didn't push anything is a no-op,
343        // so a missing `.yuiignore` doesn't desync the stack.
344        let tmp = tempfile::TempDir::new().unwrap();
345        let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
346        std::fs::write(root.join(".yuiignore"), "*.lock\n").unwrap();
347        let no_ignore = root.join("plain");
348        std::fs::create_dir_all(&no_ignore).unwrap();
349
350        let mut stack = YuiIgnoreStack::new();
351        stack.push_dir(&root).unwrap();
352        stack.push_dir(&no_ignore).unwrap(); // no .yuiignore, no-op
353        stack.pop_dir(&no_ignore); // no-op
354        // Root layer is still in place.
355        assert!(stack.is_ignored(&root.join("a.lock"), false));
356    }
357
358    /// A nested `!negation` cannot un-ignore a path whose ancestor
359    /// directory is itself excluded — the recursive walkers never
360    /// descend that subtree, so `is_ignored_at` must agree. (PR #50
361    /// review caught this gap.)
362    #[test]
363    fn is_ignored_at_short_circuits_on_ignored_ancestor() {
364        let tmp = tempfile::TempDir::new().unwrap();
365        let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
366        let keepers = root.join("home").join("keepers");
367        std::fs::create_dir_all(&keepers).unwrap();
368        // Root excludes the entire `home/keepers/` dir.
369        std::fs::write(root.join(".yuiignore"), "home/keepers/\n").unwrap();
370        // Inner negation tries to re-include a single file.
371        std::fs::write(keepers.join(".yuiignore"), "!wanted.lock\n").unwrap();
372        // The walkers never descend into keepers/, so manual absorb
373        // must agree the file is ignored.
374        assert!(is_ignored_at(&root, &keepers.join("wanted.lock"), false).unwrap());
375    }
376
377    #[test]
378    fn is_ignored_at_walks_intermediate_yuiignores() {
379        let tmp = tempfile::TempDir::new().unwrap();
380        let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap();
381        let mid = root.join("mid");
382        let leaf = mid.join("leaf");
383        std::fs::create_dir_all(&leaf).unwrap();
384        std::fs::write(mid.join(".yuiignore"), "secret*\n").unwrap();
385        // mid/.yuiignore must be picked up when checking leaf/secret.txt
386        assert!(is_ignored_at(&root, &leaf.join("secret.txt"), false).unwrap());
387        assert!(!is_ignored_at(&root, &leaf.join("public.txt"), false).unwrap());
388        // Path outside the source root is not ignored.
389        let outside =
390            Utf8PathBuf::from_path_buf(tmp.path().parent().unwrap().to_path_buf()).unwrap();
391        assert!(!is_ignored_at(&root, &outside.join("anywhere"), false).unwrap());
392    }
393
394    #[test]
395    fn no_tilde_unchanged() {
396        let home = Utf8Path::new("/h/u");
397        assert_eq!(
398            expand_tilde_with("/abs/path", home),
399            Utf8PathBuf::from("/abs/path")
400        );
401        assert_eq!(
402            expand_tilde_with("rel/path", home),
403            Utf8PathBuf::from("rel/path")
404        );
405        // Mid-string `~` is not a home reference (matches POSIX/bash behaviour).
406        assert_eq!(
407            expand_tilde_with("/foo/~/bar", home),
408            Utf8PathBuf::from("/foo/~/bar")
409        );
410    }
411}