Skip to main content

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(&current) 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}