Skip to main content

vtcode_commons/
walk.rs

1#![expect(
2    unused_results,
3    reason = "WalkBuilder configuration uses fluent setters only for their mutation side effects."
4)]
5
6//! Shared directory walker helpers built on the `ignore` crate.
7//!
8//! All file traversal in vtcode should go through these builders so that
9//! `.gitignore`, `.ignore`, `.git/exclude`, and the centralized exclusion
10//! constants are applied consistently.
11
12use ignore::{DirEntry, WalkBuilder};
13use std::path::Path;
14
15use crate::exclusions::{DEFAULT_EXCLUDED_DIRS, VTCODE_IGNORE_FILE};
16
17/// Build a multi-threaded [`WalkBuilder`] with sensible defaults.
18///
19/// - Respects `.gitignore`, `.ignore`, `.git/exclude`, and parent ignore files
20/// - Does not follow symlinks
21/// - Uses the `ignore` crate's default thread pool
22///
23/// Callers that need to prune additional directories should use
24/// [`filter_entry`](WalkBuilder::filter_entry) with [`is_excluded_dir`].
25pub fn build_default_walker(root: &Path) -> WalkBuilder {
26    let mut builder = WalkBuilder::new(root);
27    apply_defaults(&mut builder);
28    builder
29}
30
31/// Build a [`WalkBuilder`] with the same defaults as [`build_default_walker`]
32/// plus `threads(1)`.
33///
34/// `threads(1)` only constrains `build_parallel()`: the sequential `build()`
35/// never spawns the `ignore` thread pool (verified against the `ignore 0.4.34`
36/// source, where only `build_parallel` reads the setting), so this helper is
37/// equivalent to [`build_default_walker`] for `.build()` callers. Prefer it
38/// only when the walker may go parallel.
39pub fn build_walker_single_threaded(root: &Path) -> WalkBuilder {
40    let mut builder = WalkBuilder::new(root);
41    builder.threads(1);
42    apply_defaults(&mut builder);
43    builder
44}
45
46/// Apply standard walker defaults to an existing [`WalkBuilder`].
47///
48/// Sets gitignore support, hidden file visibility, and symlink policy.
49/// Callers that need additional customization (e.g., parallel walkers,
50/// symlink following) can call this then override specific settings.
51pub fn apply_defaults(builder: &mut WalkBuilder) {
52    // Respect all standard ignore-file mechanisms.
53    builder.git_ignore(true);
54    builder.git_global(true);
55    builder.git_exclude(true);
56    builder.ignore(true);
57    builder.parents(true);
58
59    // `.vtcodegitignore` mirrors `.gitignore` but is scoped to VT Code's own
60    // file operations. It has higher precedence than the standard ignore files
61    // (including its `!` re-include rules), so a user can whitelist a path that
62    // `.gitignore` prunes.
63    builder.add_custom_ignore_filename(VTCODE_IGNORE_FILE);
64
65    // Do not follow symlinks by default.
66    builder.follow_links(false);
67
68    // Do not skip hidden files by default.  The `ignore` crate skips them
69    // by default, but the previous traversal code did not.  Callers that
70    // want to hide dotfiles should filter them explicitly.
71    builder.hidden(false);
72}
73
74/// Whether a `read_dir` entry is a directory, without paying for a `stat`.
75///
76/// `readdir` already reports each entry's own type, so `Path::is_dir` re-asks
77/// the kernel for a fact in hand (measured ~2 µs/entry vs ~14 ns). A symlink's
78/// own type says nothing about its target, and callers have always judged the
79/// target, so symlinks fall back to the resolving call.
80pub fn entry_is_dir(entry: &std::fs::DirEntry) -> bool {
81    match entry.file_type() {
82        Ok(file_type) if !file_type.is_symlink() => file_type.is_dir(),
83        _ => entry.path().is_dir(),
84    }
85}
86
87/// Whether a `read_dir` entry is a regular file, without paying for a `stat`.
88///
89/// Same reasoning and symlink rule as [`entry_is_dir`].
90pub fn entry_is_file(entry: &std::fs::DirEntry) -> bool {
91    match entry.file_type() {
92        Ok(file_type) if !file_type.is_symlink() => file_type.is_file(),
93        _ => entry.path().is_file(),
94    }
95}
96
97/// Returns `true` if `entry` is a directory whose name appears in
98/// [`DEFAULT_EXCLUDED_DIRS`].
99///
100/// Intended for use inside [`WalkBuilder::filter_entry`] closures:
101///
102/// ```ignore
103/// builder.filter_entry(|entry| !vtcode_commons::walk::is_excluded_dir(entry));
104/// ```
105pub fn is_excluded_dir(entry: &DirEntry) -> bool {
106    if !entry.file_type().is_some_and(|ft| ft.is_dir()) {
107        return false;
108    }
109
110    entry
111        .file_name()
112        .to_str()
113        .is_some_and(|name| DEFAULT_EXCLUDED_DIRS.contains(&name))
114}
115
116#[cfg(test)]
117mod tests {
118    use std::fs;
119
120    use tempfile::tempdir;
121
122    use super::*;
123
124    fn collected_paths(root: &Path) -> Vec<String> {
125        let walker = build_default_walker(root).build();
126        let mut paths = walker
127            .filter_map(Result::ok)
128            .filter(|entry| entry.path() != root)
129            .map(|entry| {
130                entry
131                    .path()
132                    .strip_prefix(root)
133                    .unwrap_or_else(|_| entry.path())
134                    .to_string_lossy()
135                    .into_owned()
136            })
137            .collect::<Vec<_>>();
138        paths.sort();
139        paths
140    }
141
142    #[test]
143    fn entry_type_helpers_match_the_resolving_answer() {
144        let temp = tempdir().expect("tempdir");
145        let root = temp.path();
146        fs::create_dir(root.join("dir")).expect("mkdir");
147        fs::write(root.join("file.txt"), "x").expect("write file");
148
149        for entry in fs::read_dir(root).expect("read_dir").flatten() {
150            let path = entry.path();
151            // The cheap dirent-based answer must agree with the stat-based one.
152            assert_eq!(entry_is_dir(&entry), path.is_dir(), "dir mismatch for {}", path.display());
153            assert_eq!(entry_is_file(&entry), path.is_file(), "file mismatch for {}", path.display());
154        }
155    }
156
157    #[cfg(unix)]
158    #[test]
159    fn entry_type_helpers_resolve_symlink_targets() {
160        use std::os::unix::fs::symlink;
161
162        let temp = tempdir().expect("tempdir");
163        let root = temp.path();
164        fs::create_dir(root.join("real-dir")).expect("mkdir");
165        fs::write(root.join("real-file.txt"), "x").expect("write file");
166        symlink(root.join("real-dir"), root.join("link-dir")).expect("dir symlink");
167        symlink(root.join("real-file.txt"), root.join("link-file")).expect("file symlink");
168
169        for entry in fs::read_dir(root).expect("read_dir").flatten() {
170            let path = entry.path();
171            // A symlink's own type is never a dir/file; the target decides, and
172            // these helpers must keep matching the previous behavior.
173            assert_eq!(entry_is_dir(&entry), path.is_dir(), "dir mismatch for {}", path.display());
174            assert_eq!(entry_is_file(&entry), path.is_file(), "file mismatch for {}", path.display());
175        }
176    }
177
178    #[test]
179    fn default_walker_respects_vtcodegitignore() {
180        let temp = tempdir().expect("tempdir");
181        let root = temp.path();
182        fs::write(root.join(".vtcodegitignore"), "ignored_dir/\nignored_file.txt\n").expect("write ignore file");
183        fs::create_dir(root.join("ignored_dir")).expect("mkdir ignored_dir");
184        fs::write(root.join("ignored_dir/secret.rs"), "x").expect("write secret");
185        fs::write(root.join("ignored_file.txt"), "x").expect("write ignored file");
186        fs::write(root.join("kept.rs"), "x").expect("write kept file");
187
188        let paths = collected_paths(root);
189
190        assert!(paths.contains(&"kept.rs".to_owned()), "kept file should remain: {paths:?}");
191        assert!(!paths.iter().any(|p| p.contains("ignored_file.txt")), "ignored file leaked: {paths:?}");
192        assert!(!paths.iter().any(|p| p.contains("ignored_dir")), "ignored dir leaked: {paths:?}");
193    }
194
195    #[test]
196    fn default_walker_vtcodegitignore_negation_reincludes() {
197        let temp = tempdir().expect("tempdir");
198        let root = temp.path();
199        // Exclude every log, then re-include one. The custom-ignore file's
200        // negation must win over its own earlier pattern (mirrors the repo's
201        // `!README.md` style allow-list).
202        fs::write(root.join(".vtcodegitignore"), "*.log\n!important.log\n").expect("write ignore file");
203        fs::write(root.join("important.log"), "x").expect("write important log");
204        fs::write(root.join("noise.log"), "x").expect("write noise log");
205        fs::write(root.join("kept.rs"), "x").expect("write kept file");
206
207        let paths = collected_paths(root);
208
209        assert!(paths.contains(&"important.log".to_owned()), "negated file should be re-included: {paths:?}");
210        assert!(!paths.iter().any(|p| p.ends_with("noise.log")), "non-negated file leaked: {paths:?}");
211        assert!(paths.contains(&"kept.rs".to_owned()), "unrelated file should remain: {paths:?}");
212    }
213}