rumdl_lib/utils/
upward_walk.rs1use std::path::{Path, PathBuf};
25
26const MAX_DEPTH: usize = 100;
28
29struct 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 fn matches(&self, dir: &Path) -> bool {
45 match (&self.canonical, std::fs::canonicalize(dir).ok()) {
46 (Some(boundary), Some(current)) => boundary == ¤t,
47 _ => self.raw == dir,
48 }
49 }
50}
51
52pub 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 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 pub fn stop_below(mut self, boundary: Option<PathBuf>) -> Self {
83 self.exclusive_stop = boundary.map(Boundary::new);
84 self
85 }
86
87 pub fn stop_at_git_root(mut self) -> Self {
89 self.stop_at_git_root = true;
90 self
91 }
92
93 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 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(¤t)
129 {
130 if !(is_start && self.always_yield_start) {
131 return None;
132 }
133 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(¤t));
140 if !stop_after {
141 self.next = current.parent().map(Path::to_path_buf);
142 }
143
144 Some(current)
145 }
146}
147
148pub 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 #[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 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 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 #[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 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}