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