Skip to main content

rumdl_lib/utils/
upward_walk.rs

1//! Upward directory traversal with shared stop semantics.
2//!
3//! Config and project-root discovery all walk from a starting directory toward
4//! the filesystem root, probing each directory on the way. The stop conditions
5//! are the subtle part and live here, in one place:
6//!
7//! - **Home boundary (exclusive):** the walk ends *before* yielding the home
8//!   directory. A config in `$HOME` is user-level, not a project config, and
9//!   must reach the loader only through the user-config fallback.
10//! - **Git root (inclusive):** a directory containing `.git` is yielded and
11//!   then the walk ends, so a config in the repository root is still found.
12//! - **Stop root (inclusive):** an explicit directory (e.g. a project root) is
13//!   yielded and then the walk ends.
14//! - **Depth cap:** a guard against runaway traversal.
15//!
16//! Boundary comparisons canonicalize both sides: on Windows the walked path
17//! and the boundary can be different representations of the same directory
18//! (8.3 short names vs `\\?\` long names), and on Unix symlinks differ.
19//! Comparing raw forms would never match and the walk would overshoot. When
20//! canonicalization fails (path no longer exists), the raw forms are compared
21//! as a fallback. Yielded paths keep their original representation; only the
22//! stop checks canonicalize.
23
24use std::path::{Path, PathBuf};
25
26/// Maximum number of directories visited before the walk gives up.
27const MAX_DEPTH: usize = 100;
28
29/// A directory the walk compares itself against, with its canonical form
30/// resolved once at construction.
31struct Boundary {
32    raw: PathBuf,
33    canonical: Option<PathBuf>,
34}
35
36impl Boundary {
37    fn new(raw: PathBuf) -> Self {
38        let canonical = std::fs::canonicalize(&raw).ok();
39        Self { raw, canonical }
40    }
41
42    /// Whether `dir` is this boundary, comparing canonically with a raw
43    /// fallback when canonicalization fails.
44    fn matches(&self, dir: &Path) -> bool {
45        match (&self.canonical, std::fs::canonicalize(dir).ok()) {
46            (Some(boundary), Some(current)) => boundary == &current,
47            _ => self.raw == dir,
48        }
49    }
50}
51
52/// Iterator over a directory and its ancestors, ending at the configured
53/// stop conditions. See the module docs for the stop semantics.
54pub struct UpwardWalk {
55    next: Option<PathBuf>,
56    remaining: usize,
57    exclusive_stop: Option<Boundary>,
58    stop_at_git_root: bool,
59    inclusive_stop: Option<Boundary>,
60    always_yield_start: bool,
61    started: bool,
62}
63
64impl UpwardWalk {
65    /// Start a walk at `start`. Relative paths are resolved against the
66    /// current directory first, so `parent()` traversal sees the full
67    /// ancestor chain instead of running out at `""`.
68    pub fn new(start: &Path) -> Self {
69        Self {
70            next: Some(absolutize(start)),
71            remaining: MAX_DEPTH,
72            exclusive_stop: None,
73            stop_at_git_root: false,
74            inclusive_stop: None,
75            always_yield_start: false,
76            started: false,
77        }
78    }
79
80    /// End the walk *before* yielding `boundary` (typically the home
81    /// directory). `None` leaves the walk unbounded.
82    pub fn stop_below(mut self, boundary: Option<PathBuf>) -> Self {
83        self.exclusive_stop = boundary.map(Boundary::new);
84        self
85    }
86
87    /// Yield a directory containing `.git`, then end the walk.
88    pub fn stop_at_git_root(mut self) -> Self {
89        self.stop_at_git_root = true;
90        self
91    }
92
93    /// Yield `root` itself, then end the walk.
94    pub fn stop_at(mut self, root: &Path) -> Self {
95        self.inclusive_stop = Some(Boundary::new(root.to_path_buf()));
96        self
97    }
98
99    /// Yield the start directory even when it is the exclusive boundary; the
100    /// walk still ends there, so ancestors of the boundary are never probed.
101    ///
102    /// The exclusive boundary guards against the walk *escaping into* home
103    /// territory from a project below it. The start directory is different: it
104    /// is an explicitly chosen context (the cwd of a CLI run), so a config
105    /// there is a project config by intent, even when that directory happens to
106    /// be `$HOME` (pre-commit.ci sets `HOME` to the git checkout).
107    pub fn always_yield_start(mut self) -> Self {
108        self.always_yield_start = true;
109        self
110    }
111}
112
113impl Iterator for UpwardWalk {
114    type Item = PathBuf;
115
116    fn next(&mut self) -> Option<PathBuf> {
117        let current = self.next.take()?;
118        if self.remaining == 0 {
119            log::debug!("[rumdl-config] Maximum upward traversal depth reached");
120            return None;
121        }
122        self.remaining -= 1;
123
124        let is_start = !self.started;
125        self.started = true;
126
127        if let Some(boundary) = &self.exclusive_stop
128            && boundary.matches(&current)
129        {
130            if !(is_start && self.always_yield_start) {
131                return None;
132            }
133            // The start is spared from the boundary, but the walk must still
134            // end here: directories above the boundary are never probed.
135            return Some(current);
136        }
137
138        let stop_after = (self.stop_at_git_root && current.join(".git").exists())
139            || self.inclusive_stop.as_ref().is_some_and(|b| b.matches(&current));
140        if !stop_after {
141            self.next = current.parent().map(Path::to_path_buf);
142        }
143
144        Some(current)
145    }
146}
147
148/// Resolve a possibly-relative path against the current directory without
149/// canonicalizing, so the representation (symlinks, Windows short names) is
150/// preserved.
151pub fn absolutize(path: &Path) -> PathBuf {
152    if path.is_relative() {
153        std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
154    } else {
155        path.to_path_buf()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use std::fs;
163    use tempfile::tempdir;
164
165    #[test]
166    fn walks_from_start_to_filesystem_root_by_default() {
167        let temp = tempdir().unwrap();
168        let nested = temp.path().join("a").join("b");
169        fs::create_dir_all(&nested).unwrap();
170
171        let visited: Vec<PathBuf> = UpwardWalk::new(&nested).collect();
172        assert_eq!(visited[0], nested);
173        assert_eq!(visited[1], temp.path().join("a"));
174        assert_eq!(visited[2], temp.path());
175        let last = visited.last().unwrap();
176        assert!(last.parent().is_none(), "walk should end at the filesystem root");
177    }
178
179    #[test]
180    fn git_root_is_yielded_then_walk_ends() {
181        let temp = tempdir().unwrap();
182        let repo = temp.path().join("repo");
183        let nested = repo.join("docs");
184        fs::create_dir_all(repo.join(".git")).unwrap();
185        fs::create_dir_all(&nested).unwrap();
186
187        let visited: Vec<PathBuf> = UpwardWalk::new(&nested).stop_at_git_root().collect();
188        assert_eq!(visited, vec![nested, repo]);
189    }
190
191    #[test]
192    fn home_boundary_is_not_yielded() {
193        let temp = tempdir().unwrap();
194        let home = temp.path().join("home");
195        let project = home.join("project");
196        fs::create_dir_all(&project).unwrap();
197
198        let visited: Vec<PathBuf> = UpwardWalk::new(&project).stop_below(Some(home.clone())).collect();
199        assert_eq!(visited, vec![project], "the home directory itself must not be probed");
200    }
201
202    #[test]
203    fn stop_root_is_yielded_then_walk_ends() {
204        let temp = tempdir().unwrap();
205        let root = temp.path().join("project");
206        let nested = root.join("docs").join("api");
207        fs::create_dir_all(&nested).unwrap();
208
209        let visited: Vec<PathBuf> = UpwardWalk::new(&nested).stop_at(&root).collect();
210        assert_eq!(visited, vec![nested, root.join("docs"), root]);
211    }
212
213    #[test]
214    fn start_equal_to_stop_root_yields_exactly_the_root() {
215        let temp = tempdir().unwrap();
216        let root = temp.path().join("project");
217        fs::create_dir_all(&root).unwrap();
218
219        let visited: Vec<PathBuf> = UpwardWalk::new(&root).stop_at(&root).collect();
220        assert_eq!(visited, vec![root]);
221    }
222
223    // Uses Unix symlinks; Windows symlink creation requires elevated privileges.
224    #[cfg(unix)]
225    #[test]
226    fn stop_root_matches_through_differing_path_representations() {
227        let temp = tempdir().unwrap();
228        let real_root = temp.path().join("real");
229        let nested = real_root.join("docs");
230        fs::create_dir_all(&nested).unwrap();
231        let link = temp.path().join("link");
232        if std::os::unix::fs::symlink(&real_root, &link).is_err() {
233            return;
234        }
235
236        // The walk runs over the real path while the stop root is the symlink:
237        // raw comparison would never match, canonical comparison must.
238        let visited: Vec<PathBuf> = UpwardWalk::new(&nested).stop_at(&link).collect();
239        assert_eq!(visited, vec![nested, real_root]);
240    }
241
242    #[cfg(unix)]
243    #[test]
244    fn home_boundary_matches_through_differing_path_representations() {
245        let temp = tempdir().unwrap();
246        let real_home = temp.path().join("real-home");
247        let project = real_home.join("project");
248        fs::create_dir_all(&project).unwrap();
249        let link = temp.path().join("link-home");
250        if std::os::unix::fs::symlink(&real_home, &link).is_err() {
251            return;
252        }
253
254        let visited: Vec<PathBuf> = UpwardWalk::new(&project).stop_below(Some(link)).collect();
255        assert_eq!(visited, vec![project]);
256    }
257
258    #[test]
259    fn boundary_comparison_falls_back_to_raw_paths_when_canonicalization_fails() {
260        let temp = tempdir().unwrap();
261        let ghost = temp.path().join("does-not-exist");
262        let nested = temp.path().join("a");
263        fs::create_dir_all(&nested).unwrap();
264
265        // A nonexistent boundary can't canonicalize; the raw fallback must
266        // still terminate the walk if the raw forms match.
267        let visited: Vec<PathBuf> = UpwardWalk::new(&nested).stop_at(temp.path()).collect();
268        assert_eq!(visited, vec![nested.clone(), temp.path().to_path_buf()]);
269        let visited: Vec<PathBuf> = UpwardWalk::new(&nested).stop_below(Some(ghost)).collect();
270        assert!(
271            visited.contains(&nested),
272            "unrelated ghost boundary must not stop the walk early"
273        );
274    }
275
276    #[test]
277    fn start_equal_to_exclusive_boundary_yields_nothing_by_default() {
278        let temp = tempdir().unwrap();
279        let home = temp.path().join("home");
280        fs::create_dir_all(&home).unwrap();
281
282        let visited: Vec<PathBuf> = UpwardWalk::new(&home).stop_below(Some(home.clone())).collect();
283        assert!(
284            visited.is_empty(),
285            "without the start exemption, a walk starting at the boundary must yield nothing"
286        );
287    }
288
289    #[test]
290    fn start_equal_to_exclusive_boundary_is_yielded_with_always_yield_start() {
291        let temp = tempdir().unwrap();
292        let home = temp.path().join("home");
293        fs::create_dir_all(&home).unwrap();
294
295        let visited: Vec<PathBuf> = UpwardWalk::new(&home)
296            .stop_below(Some(home.clone()))
297            .always_yield_start()
298            .collect();
299        assert_eq!(
300            visited,
301            vec![home],
302            "the start directory must be probed even when it is the boundary, and the walk must end there"
303        );
304    }
305
306    #[test]
307    fn always_yield_start_does_not_exempt_ancestors_from_the_boundary() {
308        let temp = tempdir().unwrap();
309        let home = temp.path().join("home");
310        let project = home.join("project");
311        fs::create_dir_all(&project).unwrap();
312
313        let visited: Vec<PathBuf> = UpwardWalk::new(&project)
314            .stop_below(Some(home.clone()))
315            .always_yield_start()
316            .collect();
317        assert_eq!(
318            visited,
319            vec![project],
320            "the exemption applies only to the start directory; the boundary still blocks ancestors"
321        );
322    }
323
324    // Uses Unix symlinks; Windows symlink creation requires elevated privileges.
325    #[cfg(unix)]
326    #[test]
327    fn always_yield_start_matches_boundary_through_differing_path_representations() {
328        let temp = tempdir().unwrap();
329        let real_home = temp.path().join("real-home");
330        fs::create_dir_all(&real_home).unwrap();
331        let link = temp.path().join("link-home");
332        if std::os::unix::fs::symlink(&real_home, &link).is_err() {
333            return;
334        }
335
336        // Start at the real path with the symlink as boundary: the canonical
337        // comparison must recognize them as the same directory, yield the start,
338        // and end the walk there (never probing above the home directory).
339        let visited: Vec<PathBuf> = UpwardWalk::new(&real_home)
340            .stop_below(Some(link))
341            .always_yield_start()
342            .collect();
343        assert_eq!(visited, vec![real_home]);
344    }
345
346    #[test]
347    fn depth_cap_bounds_the_walk() {
348        let temp = tempdir().unwrap();
349        let visited: Vec<PathBuf> = UpwardWalk::new(temp.path()).collect();
350        assert!(visited.len() <= MAX_DEPTH);
351    }
352
353    #[test]
354    fn relative_start_is_resolved_against_the_current_directory() {
355        let visited: Vec<PathBuf> = UpwardWalk::new(Path::new("src")).take(2).collect();
356        assert!(visited[0].is_absolute(), "relative starts must be absolutized");
357        assert!(visited[0].ends_with("src"));
358    }
359}