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