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