brink_source_tree/walk.rs
1//! The shared recursive directory walk (issue #1433).
2//!
3//! [`crate::is_ignored_dir`] has existed since #1402, but only as a
4//! *predicate*: every recursive traversal in the workspace still wrote its
5//! own `read_dir` recursion and had to remember to call the predicate at the
6//! right moment. It didn't — five separate issues (#1370, #1381, #1402,
7//! #1415, #1424) each fixed one traversal that had skipped the check,
8//! because nothing structural made pruning the default. The predicate was
9//! never the hard part; remembering to call it was.
10//!
11//! [`Walk`] closes that by applying the policy **by construction**: it is
12//! the only recursive `read_dir` loop in the workspace's library code, so a
13//! *new* traversal is pruned the moment it is written, with nothing to
14//! remember.
15//!
16//! This is host-side code (it touches the real filesystem), sitting here
17//! rather than in `brink-driver` because it is the enforcement half of a
18//! policy this crate already owns. Like `RealFs`/`GitRev`, it is never
19//! *constructed* on a wasm-reachable path — the crate link is not the
20//! constraint (see the [module docs](crate)).
21//!
22//! # Issue #1407: escape hatch, gitignore-awareness, diagnostic
23//!
24//! Before #1407, [`Walk`] deliberately offered **no unpruned mode at all** —
25//! a project legitimately keeping sources under a directory named `target/`,
26//! `.git/`, or `node_modules/` had no way to opt out, got no error, and got
27//! no file. #1407 closes that gap with three decisions:
28//!
29//! 1. **Escape hatch: [`Walk::allow`].** Un-prunes specific directory names
30//! for one `Walk` — the one legal way to widen past the by-construction
31//! policy (every other builder, [`Walk::prune_also`], can only narrow
32//! further). `brink-driver`'s `RealFs` wires this to a new `brink.toml`
33//! key, `[project] unprune-dirs`
34//! (`brink_project_config::ProjectConfig::unprune_dirs`) — an explicit,
35//! checked-in, per-project override, not an environment variable or CLI
36//! flag, so the escape hatch itself stays a deterministic-compilation
37//! input (#1306): the same tree, compiled by anyone, unprunes the same
38//! directories.
39//! 2. **Gitignore-awareness: deliberately NOT implemented.** `.gitignore` is
40//! not consulted anywhere in this crate, and that is a decision, not an
41//! oversight. Two reasons, both rooted in #1306 (discovery is a
42//! deterministic-compilation input):
43//! - `.gitignore` resolution is not fully determined by the *tracked*
44//! contents of a repository — a local uncommitted edit to `.gitignore`,
45//! a per-clone `.git/info/exclude`, and a user's global
46//! `core.excludesFile` can all change what it matches, so two checkouts
47//! of byte-identical tracked source could discover a different file set
48//! and silently compile differently. `unprune-dirs` avoids exactly this:
49//! it lives in `brink.toml`, which is itself tracked, versioned source —
50//! the same input on every clone.
51//! - Correctly implementing gitignore's matching semantics (nested
52//! `.gitignore` files, `!`-negation, anchoring, `.git/info/exclude`,
53//! global excludes) is a substantial, easy-to-get-subtly-wrong
54//! reimplementation of git's own resolution logic; a divergence would
55//! itself become a silent, hard-to-diagnose "files came and went"
56//! determinism bug — the same failure class #1306 exists to prevent, not
57//! a fix for it.
58//!
59//! The actual pain point the issue names — a legitimately-authored source
60//! file going silently missing — is closed by items 1 and 3 instead,
61//! without taking on either cost.
62//! 3. **Diagnostic: [`Walk::warn_on_pruned_sources`] /
63//! [`Walk::pruned_with_sources`].** A caller opts a `Walk` into watching
64//! for source-shaped files (by extension) sitting inside a pruned
65//! directory; after the walk is drained, [`Walk::pruned_with_sources`]
66//! names every pruned directory that plausibly held something the author
67//! wanted. The check is **bounded, not exhaustive** — a depth cap
68//! ([`PRUNED_SCAN_MAX_DEPTH`]) and a total-entry budget
69//! ([`PRUNED_SCAN_MAX_ENTRIES`]), whichever is hit first — deliberately
70//! short of a full recursive descent, so noticing a stray source file
71//! inside a huge `target/` never turns a cheap prune into an expensive
72//! walk of the very tree being skipped. The depth cap is chosen to cover
73//! `node_modules/<package>/lib.brink` (two levels below the pruned
74//! directory), the shape an npm-style dependency tree actually uses —
75//! a same-directory drop like `node_modules/vendor.brink` was always
76//! covered, but that's a less faithful stand-in for how vendored source
77//! trees are actually laid out.
78//!
79//! Every pruned directory is still skipped exactly as before unless
80//! [`Walk::allow`] names it — items 1 and 3 change what the walk *reports*,
81//! never what it silently does by default.
82
83use std::ffi::{OsStr, OsString};
84use std::fs;
85use std::io;
86use std::path::{Path, PathBuf};
87
88use crate::is_ignored_dir;
89
90/// One entry yielded by a [`Walk`]: a path plus the file type the directory
91/// listing reported for it.
92///
93/// The file type is the one from [`fs::DirEntry::file_type`], which does
94/// **not** follow symlinks — a symlink is neither [`is_dir`](Self::is_dir)
95/// nor [`is_file`](Self::is_file), so a symlinked directory is yielded as a
96/// plain entry and never descended into. That bounds the walk: a symlink
97/// cycle cannot make it run forever (CLAUDE.md's guard-against-unbounded-
98/// growth rule).
99#[derive(Debug)]
100pub struct WalkEntry {
101 path: PathBuf,
102 file_type: fs::FileType,
103}
104
105impl WalkEntry {
106 /// The entry's full path — the walk's root joined with everything
107 /// descended through to reach it.
108 #[must_use]
109 pub fn path(&self) -> &Path {
110 &self.path
111 }
112
113 /// Consume the entry for its path, avoiding a clone when the path is all
114 /// the caller wanted.
115 #[must_use]
116 pub fn into_path(self) -> PathBuf {
117 self.path
118 }
119
120 /// The entry's own file name (its last path component).
121 #[must_use]
122 pub fn file_name(&self) -> &OsStr {
123 self.path.file_name().unwrap_or_else(|| OsStr::new(""))
124 }
125
126 /// Whether this entry is a directory the walk will descend into.
127 #[must_use]
128 pub fn is_dir(&self) -> bool {
129 self.file_type.is_dir()
130 }
131
132 /// Whether this entry is a regular file. False for symlinks — see
133 /// [`WalkEntry`]'s own doc.
134 #[must_use]
135 pub fn is_file(&self) -> bool {
136 self.file_type.is_file()
137 }
138
139 /// The unfollowed file type the directory listing reported.
140 #[must_use]
141 pub fn file_type(&self) -> fs::FileType {
142 self.file_type
143 }
144}
145
146/// A stack slot: either a directory still to be expanded, or an item ready
147/// to be yielded.
148#[derive(Debug)]
149enum Pending {
150 Descend(PathBuf),
151 Item(io::Result<WalkEntry>),
152}
153
154/// Recursive directory walk that prunes [`crate::IGNORED_DIR_NAMES`]
155/// (`target/`, `.git/`, `node_modules/`) **by construction** — there is no
156/// way to construct one that descends into them (issue #1433; see the
157/// [module docs](self)).
158///
159/// # Contract
160///
161/// - **Pre-order, depth-first**: a directory is yielded before its contents.
162/// - **Deterministic**: entries within each directory are visited sorted by
163/// file name, never in filesystem iteration order, which is unspecified
164/// and varies between runs (CLAUDE.md's determinism rule). Note that a
165/// pre-order traversal of per-directory-sorted entries is *not* the same
166/// as a globally sorted list of paths (`a.brink` sorts before `a/z.brink`,
167/// but the walk yields `a/` and its contents first) — a caller that needs
168/// globally sorted output sorts the collected result itself.
169/// - **The root is never pruned**: the policy is applied to entries found
170/// *while descending*, never to the root the caller handed in, so a
171/// workspace legitimately rooted at e.g. `node_modules/vendor-ink` still
172/// walks its own contents (issue #1424). The root itself is not yielded.
173/// - **A pruned directory is neither yielded nor descended into.**
174/// - **Errors are per-item, and the walk continues**: an unreadable
175/// directory or entry yields one `Err` and the traversal moves on to the
176/// next branch, so a caller can choose between propagating (`?` in a
177/// `Result`-returning function) and skipping (`.flatten()`).
178///
179/// # Examples
180///
181/// ```
182/// # use brink_source_tree::Walk;
183/// # fn demo(root: &std::path::Path) -> std::io::Result<Vec<std::path::PathBuf>> {
184/// let mut inks = Vec::new();
185/// for entry in Walk::new(root) {
186/// let entry = entry?;
187/// if entry.is_file() && entry.path().extension().is_some_and(|e| e == "ink") {
188/// inks.push(entry.into_path());
189/// }
190/// }
191/// # Ok(inks)
192/// # }
193/// ```
194#[derive(Debug)]
195pub struct Walk {
196 stack: Vec<Pending>,
197 also_pruned: Vec<OsString>,
198 allowed: Vec<OsString>,
199 watch_extensions: Vec<OsString>,
200 pruned_with_sources: Vec<PathBuf>,
201}
202
203/// Bound on how many levels below a pruned directory
204/// [`Walk::warn_on_pruned_sources`]'s diagnostic scan will descend, counting
205/// the pruned directory's own immediate children as depth 1. `3` comfortably
206/// covers `node_modules/<package>/lib.brink` (depth 2) — the shape an
207/// npm-style dependency tree actually uses — while still bounding the scan:
208/// noticing a stray source file inside a huge pruned `target/` must never
209/// turn a cheap prune into an expensive walk of the tree being skipped. See
210/// also [`PRUNED_SCAN_MAX_ENTRIES`], which bounds a *wide* pruned directory
211/// the same way this bounds a *deep* one.
212const PRUNED_SCAN_MAX_DEPTH: usize = 3;
213
214/// Bound on the total number of directory entries
215/// [`Walk::warn_on_pruned_sources`]'s diagnostic scan will read while
216/// looking inside one pruned directory, on top of
217/// [`PRUNED_SCAN_MAX_DEPTH`] — whichever limit is hit first stops the scan.
218/// Protects against a pruned directory that is wide rather than deep (many
219/// siblings at a shallow depth) from the same unbounded-scan risk.
220const PRUNED_SCAN_MAX_ENTRIES: usize = 256;
221
222impl Walk {
223 /// Start a pruned walk rooted at `root`. The
224 /// [`IGNORED_DIR_NAMES`](crate::IGNORED_DIR_NAMES) policy applies with
225 /// no opt-in and no opt-out — unless [`Walk::allow`] names an entry
226 /// explicitly (issue #1407).
227 #[must_use]
228 pub fn new(root: impl Into<PathBuf>) -> Self {
229 Self {
230 stack: vec![Pending::Descend(root.into())],
231 also_pruned: Vec::new(),
232 allowed: Vec::new(),
233 watch_extensions: Vec::new(),
234 pruned_with_sources: Vec::new(),
235 }
236 }
237
238 /// Prune these additional directory names on top of the standing policy
239 /// — strictly narrowing, never widening on its own (there is no way for
240 /// `prune_also` itself to un-prune an
241 /// [`IGNORED_DIR_NAMES`](crate::IGNORED_DIR_NAMES) entry; see
242 /// [`Walk::allow`] for the one builder that can). For callers with a
243 /// fixture-layout convention of their own, e.g. the test harness's
244 /// `oracle/`/`episodes/` case directories.
245 #[must_use]
246 pub fn prune_also<I, S>(mut self, names: I) -> Self
247 where
248 I: IntoIterator<Item = S>,
249 S: Into<OsString>,
250 {
251 self.also_pruned.extend(names.into_iter().map(Into::into));
252 self
253 }
254
255 /// Un-prune these directory names for this `Walk` — the escape hatch
256 /// issue #1407 asked for. A name passed here is never pruned, regardless
257 /// of the standing [`IGNORED_DIR_NAMES`](crate::IGNORED_DIR_NAMES)
258 /// policy or [`Walk::prune_also`]; this is the one legal way to widen a
259 /// `Walk` past its by-construction pruning (see the [module docs](self))
260 /// — every other constructor/builder can only narrow further.
261 /// `brink-driver`'s `RealFs` wires this to `brink.toml`'s
262 /// `[project] unprune-dirs`, so the widening stays an explicit,
263 /// checked-in per-project choice rather than something ambient.
264 #[must_use]
265 pub fn allow<I, S>(mut self, names: I) -> Self
266 where
267 I: IntoIterator<Item = S>,
268 S: Into<OsString>,
269 {
270 self.allowed.extend(names.into_iter().map(Into::into));
271 self
272 }
273
274 /// Watch for pruned directories that plausibly held a source file (issue
275 /// #1407's diagnostic half): after the walk is drained,
276 /// [`Walk::pruned_with_sources`] names every pruned directory that,
277 /// within [`PRUNED_SCAN_MAX_DEPTH`]/[`PRUNED_SCAN_MAX_ENTRIES`] of
278 /// itself, contains a file with one of these extensions (e.g.
279 /// `"brink"`, no leading dot). Every pruned directory is still skipped
280 /// exactly as before — this only makes [`Walk::pruned_with_sources`]
281 /// non-empty; it never changes what is yielded.
282 ///
283 /// The check is deliberately bounded, not a full recursive descent — see
284 /// [`PRUNED_SCAN_MAX_DEPTH`] — so flagging a stray source file inside a
285 /// huge pruned `target/` never turns a cheap prune into an expensive
286 /// walk of the very tree being skipped.
287 #[must_use]
288 pub fn warn_on_pruned_sources<I, S>(mut self, extensions: I) -> Self
289 where
290 I: IntoIterator<Item = S>,
291 S: Into<OsString>,
292 {
293 self.watch_extensions
294 .extend(extensions.into_iter().map(Into::into));
295 self
296 }
297
298 /// Every pruned directory this walk has skipped so far that, within the
299 /// bounded scan described on [`Walk::warn_on_pruned_sources`], contains
300 /// a file with one of the extensions passed to it — empty unless that
301 /// builder was called. Populated incrementally as iteration proceeds (a
302 /// directory not yet reached hasn't been checked yet), so read this only
303 /// after the walk has been fully drained.
304 #[must_use]
305 pub fn pruned_with_sources(&self) -> &[PathBuf] {
306 &self.pruned_with_sources
307 }
308
309 /// Whether a directory named `name`, found while descending, is pruned.
310 /// [`Walk::allow`] takes priority over both the standing policy and
311 /// [`Walk::prune_also`] — an allowed name is never pruned by this `Walk`.
312 fn is_pruned(&self, name: &OsStr) -> bool {
313 if self.allowed.iter().any(|allowed| allowed == name) {
314 return false;
315 }
316 is_ignored_dir(name) || self.also_pruned.iter().any(|pruned| pruned == name)
317 }
318
319 /// Whether `dir` contains, within [`PRUNED_SCAN_MAX_DEPTH`] levels of
320 /// itself and [`PRUNED_SCAN_MAX_ENTRIES`] directory entries total
321 /// (whichever limit is hit first — see [`Walk::warn_on_pruned_sources`]),
322 /// a *file* whose extension is one of `self.watch_extensions`. Only a
323 /// file matches: a subdirectory that happens to be named e.g.
324 /// `vendor.brink` is not a source file and must not trip the diagnostic.
325 /// An unreadable directory along the way is skipped rather than
326 /// propagated — this is a best-effort diagnostic check on a directory
327 /// the walk has already decided to skip, not a traversal step that must
328 /// succeed.
329 fn contains_watched_extension_within_budget(&self, dir: &Path) -> bool {
330 // `depth` counts levels below `dir` itself, so `dir`'s own immediate
331 // children are depth 1 — matching `PRUNED_SCAN_MAX_DEPTH`'s doc.
332 let mut stack = vec![(dir.to_path_buf(), 1_usize)];
333 let mut visited = 0_usize;
334 while let Some((current, depth)) = stack.pop() {
335 let Ok(entries) = fs::read_dir(¤t) else {
336 continue;
337 };
338 for entry in entries.flatten() {
339 visited += 1;
340 if visited > PRUNED_SCAN_MAX_ENTRIES {
341 return false;
342 }
343 let Ok(file_type) = entry.file_type() else {
344 continue;
345 };
346 if file_type.is_file() {
347 let matches = entry
348 .path()
349 .extension()
350 .is_some_and(|ext| self.watch_extensions.iter().any(|w| w == ext));
351 if matches {
352 return true;
353 }
354 } else if file_type.is_dir() && depth < PRUNED_SCAN_MAX_DEPTH {
355 stack.push((entry.path(), depth + 1));
356 }
357 }
358 }
359 false
360 }
361
362 /// List `dir`'s entries, sorted by file name, with pruned directories
363 /// already dropped. A failure to read the directory itself is one error
364 /// for the whole directory; a failure to stat a single entry is an error
365 /// for that entry alone. Takes `&mut self` (not `&self`) because a
366 /// pruned directory that contains a watched extension within budget is
367 /// recorded into `self.pruned_with_sources` as it's found.
368 fn children(&mut self, dir: &Path) -> io::Result<Vec<Pending>> {
369 let mut entries = fs::read_dir(dir)?.collect::<io::Result<Vec<_>>>()?;
370 entries.sort_by_key(fs::DirEntry::file_name);
371 let mut pending = Vec::with_capacity(entries.len());
372 for entry in entries {
373 match entry.file_type() {
374 Ok(file_type) => {
375 if file_type.is_dir() && self.is_pruned(&entry.file_name()) {
376 if !self.watch_extensions.is_empty()
377 && self.contains_watched_extension_within_budget(&entry.path())
378 {
379 self.pruned_with_sources.push(entry.path());
380 }
381 continue;
382 }
383 pending.push(Pending::Item(Ok(WalkEntry {
384 path: entry.path(),
385 file_type,
386 })));
387 }
388 Err(err) => pending.push(Pending::Item(Err(err))),
389 }
390 }
391 Ok(pending)
392 }
393}
394
395impl Iterator for Walk {
396 type Item = io::Result<WalkEntry>;
397
398 fn next(&mut self) -> Option<Self::Item> {
399 loop {
400 match self.stack.pop()? {
401 Pending::Item(Ok(entry)) => {
402 if entry.is_dir() {
403 // Descend before the remaining siblings on the stack
404 // — that is what makes this pre-order.
405 self.stack.push(Pending::Descend(entry.path.clone()));
406 }
407 return Some(Ok(entry));
408 }
409 Pending::Item(Err(err)) => return Some(Err(err)),
410 Pending::Descend(dir) => match self.children(&dir) {
411 Ok(children) => self.stack.extend(children.into_iter().rev()),
412 Err(err) => return Some(Err(err)),
413 },
414 }
415 }
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use std::sync::atomic::{AtomicU64, Ordering};
423 use std::time::{SystemTime, UNIX_EPOCH};
424
425 /// A fresh, empty temp directory, unique per call (pid + counter +
426 /// nanoseconds) so parallel test runs never collide. Mirrors the same
427 /// helper in `brink-driver`'s `source_tree` tests — this crate is an L0
428 /// leaf with no dev-dependencies.
429 fn temp_dir(label: &str) -> PathBuf {
430 static COUNTER: AtomicU64 = AtomicU64::new(0);
431 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
432 let nanos = SystemTime::now()
433 .duration_since(UNIX_EPOCH)
434 .map(|d| d.as_nanos())
435 .unwrap_or_default();
436 let dir = std::env::temp_dir().join(format!(
437 "brink-walk-test-{label}-{}-{n}-{nanos}",
438 std::process::id()
439 ));
440 fs::create_dir_all(&dir).expect("create temp dir");
441 dir
442 }
443
444 /// Collect a walk's entries as root-relative `/`-joined strings, so
445 /// assertions read as a literal expected traversal.
446 fn relative(root: &Path, walk: Walk) -> Vec<String> {
447 walk.map(|entry| {
448 let entry = entry.expect("entry reads");
449 entry
450 .path()
451 .strip_prefix(root)
452 .expect("entry is under root")
453 .components()
454 .map(|c| c.as_os_str().to_string_lossy().into_owned())
455 .collect::<Vec<_>>()
456 .join("/")
457 })
458 .collect()
459 }
460
461 fn write(path: PathBuf, contents: &str) {
462 if let Some(parent) = path.parent() {
463 fs::create_dir_all(parent).expect("mkdir parent");
464 }
465 fs::write(path, contents).expect("write fixture file");
466 }
467
468 /// The whole point of the helper: a walk written with no prune code of
469 /// its own still prunes every [`crate::IGNORED_DIR_NAMES`] directory,
470 /// at any depth, including files sitting directly inside one.
471 #[test]
472 fn walk_prunes_ignored_dirs_by_construction() {
473 let root = temp_dir("prune");
474 write(root.join("main.ink"), "main");
475 write(root.join("target/stray.ink"), "stray");
476 write(root.join("target/debug/build.ink"), "build");
477 write(root.join(".git/HEAD"), "ref");
478 write(root.join(".git/objects/pack.ink"), "pack");
479 write(root.join("node_modules/pkg/index.ink"), "pkg");
480 write(root.join("src/nested/deep/target/out.ink"), "deep");
481 write(root.join("src/nested/deep/keep.ink"), "keep");
482
483 assert_eq!(
484 relative(&root, Walk::new(&root)),
485 vec![
486 "main.ink",
487 "src",
488 "src/nested",
489 "src/nested/deep",
490 "src/nested/deep/keep.ink",
491 ],
492 );
493
494 fs::remove_dir_all(&root).expect("cleanup temp dir");
495 }
496
497 /// Pruning is name-equality on the directory entry, matching
498 /// [`crate::is_ignored_dir`] — a directory whose name merely starts with
499 /// an ignored name is walked normally, and a *file* named `target` is
500 /// yielded rather than skipped.
501 #[test]
502 fn walk_prunes_by_exact_directory_name_only() {
503 let root = temp_dir("prune-exact");
504 write(root.join("targets/a.ink"), "a");
505 write(root.join("target.brink"), "not a dir");
506 write(root.join("my-node_modules/b.ink"), "b");
507
508 assert_eq!(
509 relative(&root, Walk::new(&root)),
510 vec![
511 "my-node_modules",
512 "my-node_modules/b.ink",
513 "target.brink",
514 "targets",
515 "targets/a.ink",
516 ],
517 );
518
519 fs::remove_dir_all(&root).expect("cleanup temp dir");
520 }
521
522 /// The root is never tested against the policy (issue #1424): a walk
523 /// rooted *at* an ignored-named directory still enumerates its contents,
524 /// while a genuinely nested ignored directory below it is still pruned.
525 #[test]
526 fn walk_never_prunes_its_own_root() {
527 let wrapper = temp_dir("prune-root");
528 let root = wrapper.join("node_modules/vendor-ink");
529 write(root.join("main.ink"), "main");
530 write(root.join("target/debug/build.ink"), "build");
531
532 assert_eq!(relative(&root, Walk::new(&root)), vec!["main.ink"]);
533
534 fs::remove_dir_all(&wrapper).expect("cleanup temp dir");
535 }
536
537 /// Traversal is pre-order and per-directory sorted regardless of the
538 /// order entries were created on disk.
539 #[test]
540 fn walk_is_pre_order_and_sorted_despite_hostile_creation_order() {
541 let root = temp_dir("order");
542 write(root.join("z.ink"), "z");
543 write(root.join("b/z.ink"), "bz");
544 write(root.join("b/a.ink"), "ba");
545 write(root.join("a.ink"), "a");
546 write(root.join("b/c/inner.ink"), "inner");
547
548 assert_eq!(
549 relative(&root, Walk::new(&root)),
550 vec![
551 "a.ink",
552 "b",
553 "b/a.ink",
554 "b/c",
555 "b/c/inner.ink",
556 "b/z.ink",
557 "z.ink",
558 ],
559 );
560
561 fs::remove_dir_all(&root).expect("cleanup temp dir");
562 }
563
564 /// `prune_also` narrows further, on top of (never instead of) the
565 /// standing policy.
566 #[test]
567 fn walk_prune_also_narrows_on_top_of_the_standing_policy() {
568 let root = temp_dir("prune-also");
569 write(root.join("case/story.ink"), "story");
570 write(root.join("case/oracle/e0.oracle.json"), "{}");
571 write(root.join("case/target/out.ink"), "out");
572
573 assert_eq!(
574 relative(&root, Walk::new(&root).prune_also(["oracle"])),
575 vec!["case", "case/story.ink"],
576 );
577
578 fs::remove_dir_all(&root).expect("cleanup temp dir");
579 }
580
581 /// `allow` un-prunes a standing [`crate::IGNORED_DIR_NAMES`] entry (issue
582 /// #1407's escape hatch): a `node_modules/` directory that would
583 /// otherwise be pruned entirely is walked and yielded like any other
584 /// directory once its name is passed to `allow`, while a *sibling*
585 /// ignored directory not named in `allow` (`target/`) is still pruned.
586 #[test]
587 fn walk_allow_unprunes_a_named_standing_policy_entry() {
588 let root = temp_dir("allow");
589 write(root.join("main.ink"), "main");
590 write(root.join("node_modules/vendor-ink/lib.ink"), "vendored");
591 write(root.join("target/stray.ink"), "stray");
592
593 assert_eq!(
594 relative(&root, Walk::new(&root).allow(["node_modules"])),
595 vec![
596 "main.ink",
597 "node_modules",
598 "node_modules/vendor-ink",
599 "node_modules/vendor-ink/lib.ink",
600 ],
601 "node_modules/ must be un-pruned by `allow`, target/ must stay pruned"
602 );
603
604 fs::remove_dir_all(&root).expect("cleanup temp dir");
605 }
606
607 /// `warn_on_pruned_sources` + `pruned_with_sources`: a pruned directory
608 /// whose immediate children include a watched-extension file is reported
609 /// once drained; a pruned directory with no matching file is not, and
610 /// neither report changes what the walk actually yields (still nothing
611 /// from inside either pruned directory).
612 #[test]
613 fn walk_pruned_with_sources_reports_only_directories_shallowly_holding_watched_files() {
614 let root = temp_dir("pruned-with-sources");
615 write(root.join("main.ink"), "main");
616 write(root.join("node_modules/stray.ink"), "stray");
617 write(root.join(".git/HEAD"), "ref");
618
619 let mut walk = Walk::new(&root).warn_on_pruned_sources(["ink"]);
620 let yielded: Vec<String> = relative_lossy(&root, walk.by_ref());
621
622 assert_eq!(
623 yielded,
624 vec!["main.ink"],
625 "reporting a pruned directory must not change what is yielded"
626 );
627
628 let pruned: Vec<String> = walk
629 .pruned_with_sources()
630 .iter()
631 .map(|p| {
632 p.strip_prefix(&root)
633 .expect("pruned path is under root")
634 .to_string_lossy()
635 .into_owned()
636 })
637 .collect();
638 assert_eq!(
639 pruned,
640 vec!["node_modules"],
641 "only node_modules/ shallowly holds a watched .ink file; .git/ (HEAD, no \
642 extension) must not be reported"
643 );
644
645 fs::remove_dir_all(&root).expect("cleanup temp dir");
646 }
647
648 /// `warn_on_pruned_sources` must find a watched file one level below a
649 /// pruned directory's immediate children — the exact
650 /// `node_modules/<package>/lib.ink` shape an npm-style dependency tree
651 /// actually uses (issue #1407's review finding: the original
652 /// immediate-children-only check missed precisely this shape, which is
653 /// also what this crate's own escape-hatch fixtures and the CLI
654 /// integration tests use for `unprune-dirs`).
655 #[test]
656 fn walk_pruned_with_sources_detects_a_file_nested_one_level_inside_the_pruned_directory() {
657 let root = temp_dir("pruned-with-sources-nested");
658 write(root.join("main.ink"), "main");
659 write(root.join("node_modules/pkg/nested.ink"), "nested");
660
661 let mut walk = Walk::new(&root).warn_on_pruned_sources(["ink"]);
662 let _: Vec<String> = relative_lossy(&root, walk.by_ref());
663
664 let pruned: Vec<String> = walk
665 .pruned_with_sources()
666 .iter()
667 .map(|p| {
668 p.strip_prefix(&root)
669 .expect("pruned path is under root")
670 .to_string_lossy()
671 .into_owned()
672 })
673 .collect();
674 assert_eq!(
675 pruned,
676 vec!["node_modules"],
677 "node_modules/pkg/nested.ink is within the bounded scan and must be found, got {:?}",
678 walk.pruned_with_sources()
679 );
680
681 fs::remove_dir_all(&root).expect("cleanup temp dir");
682 }
683
684 /// The scan is bounded, not a full recursive descent: a watched file
685 /// sitting deeper than [`PRUNED_SCAN_MAX_DEPTH`] levels below the pruned
686 /// directory is not detected. Documents the deliberate bound (never an
687 /// expensive unbounded scan of a skipped subtree).
688 #[test]
689 fn walk_pruned_with_sources_is_bounded_by_depth() {
690 let root = temp_dir("pruned-with-sources-too-deep");
691 write(root.join("main.ink"), "main");
692 write(root.join("node_modules/a/b/c/d/too-deep.ink"), "deep");
693
694 let mut walk = Walk::new(&root).warn_on_pruned_sources(["ink"]);
695 let _: Vec<String> = relative_lossy(&root, walk.by_ref());
696
697 assert!(
698 walk.pruned_with_sources().is_empty(),
699 "a watched file past PRUNED_SCAN_MAX_DEPTH must not be found, got {:?}",
700 walk.pruned_with_sources()
701 );
702
703 fs::remove_dir_all(&root).expect("cleanup temp dir");
704 }
705
706 /// A *directory* named with a watched extension (e.g. `vendor.ink/`)
707 /// must not itself trip the diagnostic — `warn_on_pruned_sources`
708 /// watches for source *files*, and its own doc says so ("include a file
709 /// with one of these extensions"). Issue #1407's review finding: the
710 /// original check matched on `Path::extension()` alone, so a directory
711 /// with a source-shaped name falsely counted as "held a source file".
712 #[test]
713 fn walk_pruned_with_sources_ignores_a_directory_named_with_a_watched_extension() {
714 let root = temp_dir("pruned-with-sources-dir-name");
715 write(root.join("main.ink"), "main");
716 // A directory, not a file, spelled like a watched extension.
717 fs::create_dir_all(root.join("node_modules/vendor.ink")).expect("mkdir vendor.ink dir");
718
719 let mut walk = Walk::new(&root).warn_on_pruned_sources(["ink"]);
720 let _: Vec<String> = relative_lossy(&root, walk.by_ref());
721
722 assert!(
723 walk.pruned_with_sources().is_empty(),
724 "a directory named vendor.ink/ must not trip the diagnostic, got {:?}",
725 walk.pruned_with_sources()
726 );
727
728 fs::remove_dir_all(&root).expect("cleanup temp dir");
729 }
730
731 /// With no call to `warn_on_pruned_sources`, `pruned_with_sources` stays
732 /// empty even though pruned directories with matching files exist — the
733 /// diagnostic is opt-in, never ambient.
734 #[test]
735 fn walk_pruned_with_sources_is_empty_when_never_requested() {
736 let root = temp_dir("pruned-with-sources-opt-in");
737 write(root.join("main.ink"), "main");
738 write(root.join("node_modules/stray.ink"), "stray");
739
740 let mut walk = Walk::new(&root);
741 let _: Vec<String> = relative_lossy(&root, walk.by_ref());
742
743 assert!(walk.pruned_with_sources().is_empty());
744
745 fs::remove_dir_all(&root).expect("cleanup temp dir");
746 }
747
748 /// A nonexistent root yields exactly one error and then ends — callers
749 /// that `?` it get the I/O error, callers that `.flatten()` get an empty
750 /// walk, and neither loops.
751 #[test]
752 fn walk_of_a_missing_root_yields_one_error_then_ends() {
753 let wrapper = temp_dir("missing");
754 let root = wrapper.join("nope");
755
756 let mut walk = Walk::new(&root);
757 let first = walk.next().expect("one item");
758 assert_eq!(
759 first.expect_err("missing root is an error").kind(),
760 io::ErrorKind::NotFound
761 );
762 assert!(
763 walk.next().is_none(),
764 "the walk must not loop after an error"
765 );
766
767 assert_eq!(Walk::new(&root).flatten().count(), 0);
768
769 fs::remove_dir_all(&wrapper).expect("cleanup temp dir");
770 }
771
772 /// An unreadable subdirectory doesn't abort the whole walk: it yields
773 /// one error, and the remaining siblings are still visited (a caller
774 /// using `.flatten()` simply skips the branch).
775 #[cfg(unix)]
776 #[test]
777 fn walk_continues_past_an_unreadable_subdirectory() {
778 use std::os::unix::fs::PermissionsExt;
779
780 let root = temp_dir("unreadable");
781 write(root.join("a/keep.ink"), "keep");
782 fs::create_dir_all(root.join("b")).expect("mkdir b");
783 write(root.join("c/also-keep.ink"), "also");
784 fs::set_permissions(root.join("b"), fs::Permissions::from_mode(0o000))
785 .expect("chmod b unreadable");
786
787 let entries: Vec<String> = relative_lossy(&root, Walk::new(&root));
788
789 // Restore permissions before asserting so cleanup always works.
790 fs::set_permissions(root.join("b"), fs::Permissions::from_mode(0o755))
791 .expect("restore b permissions");
792
793 assert_eq!(
794 entries,
795 vec!["a", "a/keep.ink", "b", "c", "c/also-keep.ink"],
796 "the unreadable branch is skipped, later siblings still walked"
797 );
798
799 fs::remove_dir_all(&root).expect("cleanup temp dir");
800 }
801
802 /// Documents the disclosed behavior delta from `Path::is_dir()`-based
803 /// hand-rolled walks (the LSP's pre-#1433 `collect_ink_files`): a
804 /// [`WalkEntry`]'s kind comes from `DirEntry::file_type`, which does not
805 /// follow symlinks, so a symlinked directory is yielded once as a plain
806 /// (non-dir) entry and never descended into — bounding the walk against
807 /// symlink cycles — while a symlinked `.ink` file is still yielded (with
808 /// `is_dir() == false`, `is_file() == false`), which is exactly what
809 /// lets a caller filtering on `!entry.is_dir()` (as `collect_ink_files`
810 /// does) still admit it.
811 #[cfg(unix)]
812 #[test]
813 fn walk_does_not_descend_into_a_symlinked_directory_but_admits_a_symlinked_file() {
814 use std::os::unix::fs::symlink;
815
816 let root = temp_dir("symlink");
817 write(root.join("real/nested.ink"), "nested");
818 write(root.join("real-file.ink"), "real");
819 symlink(root.join("real"), root.join("link-dir")).expect("symlink dir");
820 symlink(root.join("real-file.ink"), root.join("link-file.ink")).expect("symlink file");
821
822 let entries: Vec<(String, bool, bool)> = Walk::new(&root)
823 .map(|entry| {
824 let entry = entry.expect("entry reads");
825 (
826 entry
827 .path()
828 .strip_prefix(&root)
829 .expect("entry is under root")
830 .to_string_lossy()
831 .into_owned(),
832 entry.is_dir(),
833 entry.is_file(),
834 )
835 })
836 .collect();
837
838 assert_eq!(
839 entries,
840 vec![
841 ("link-dir".to_string(), false, false),
842 ("link-file.ink".to_string(), false, false),
843 ("real".to_string(), true, false),
844 ("real/nested.ink".to_string(), false, true),
845 ("real-file.ink".to_string(), false, true),
846 ],
847 "the symlinked directory is yielded once and never descended into; \
848 the symlinked file is still yielded, with is_dir()==false"
849 );
850
851 fs::remove_dir_all(&root).expect("cleanup temp dir");
852 }
853
854 /// [`relative`] but dropping errored entries — for fixtures that
855 /// deliberately contain an unreadable branch, or for a caller that needs
856 /// to keep the `Walk` alive afterward (via `walk.by_ref()`) to read
857 /// state it accumulated during iteration (e.g. `pruned_with_sources`).
858 fn relative_lossy(
859 root: &Path,
860 walk: impl Iterator<Item = io::Result<WalkEntry>>,
861 ) -> Vec<String> {
862 walk.flatten()
863 .map(|entry| {
864 entry
865 .path()
866 .strip_prefix(root)
867 .expect("entry is under root")
868 .components()
869 .map(|c| c.as_os_str().to_string_lossy().into_owned())
870 .collect::<Vec<_>>()
871 .join("/")
872 })
873 .collect()
874 }
875
876 /// Directories are distinguishable from files, and `file_name` reports
877 /// the entry's own last component.
878 #[test]
879 fn walk_entry_reports_kind_and_file_name() {
880 let root = temp_dir("entry-kind");
881 write(root.join("dir/file.ink"), "f");
882
883 let entries: Vec<(String, bool, bool)> = Walk::new(&root)
884 .map(|entry| {
885 let entry = entry.expect("entry reads");
886 (
887 entry.file_name().to_string_lossy().into_owned(),
888 entry.is_dir(),
889 entry.is_file(),
890 )
891 })
892 .collect();
893
894 assert_eq!(
895 entries,
896 vec![
897 ("dir".to_string(), true, false),
898 ("file.ink".to_string(), false, true),
899 ],
900 );
901
902 fs::remove_dir_all(&root).expect("cleanup temp dir");
903 }
904}