Skip to main content

wowlab_fs/
walk.rs

1//! Reviewed recursive repository traversal.
2
3use crate::path::{Path, PathBuf};
4
5/// Failure while traversing a repository tree.
6#[derive(Debug, thiserror::Error)]
7#[error("failed to walk repository {}: {source}", root.display())]
8pub struct Error {
9    root: PathBuf,
10    #[source]
11    source: ignore::Error,
12}
13
14impl Error {
15    /// Return the traversal root.
16    #[must_use]
17    pub fn root(&self) -> &Path {
18        &self.root
19    }
20}
21
22/// Completed workspace-source traversal, including every entry failure.
23#[derive(Debug)]
24pub struct WalkReport {
25    files: Vec<PathBuf>,
26    failures: Vec<Error>,
27}
28
29impl WalkReport {
30    /// Return discovered regular files in stable path order.
31    #[must_use]
32    pub fn files(&self) -> &[PathBuf] {
33        &self.files
34    }
35
36    /// Return every failure observed while continuing the traversal.
37    #[must_use]
38    pub fn failures(&self) -> &[Error] {
39        &self.failures
40    }
41
42    /// Consume the report into its discovered files and failures.
43    #[must_use]
44    pub fn into_parts(self) -> (Vec<PathBuf>, Vec<Error>) {
45        (self.files, self.failures)
46    }
47}
48
49/// Discover regular files below a repository root in stable path order.
50///
51/// Hidden and Git-ignored entries are omitted while local and parent ignore files are honored without consulting user-specific global excludes.
52///
53/// # Errors
54///
55/// Returns an error when the root or any traversed entry cannot be read.
56pub fn repository_files(root: &Path) -> Result<Vec<PathBuf>, Error> {
57    let mut builder = ignore::WalkBuilder::new(root);
58
59    builder
60        .hidden(true)
61        .parents(true)
62        .git_ignore(true)
63        .require_git(false)
64        .git_global(false)
65        .git_exclude(false);
66
67    let mut paths = Vec::new();
68
69    for entry in builder.build() {
70        let entry = entry.map_err(|source| Error {
71            root: root.to_path_buf(),
72            source,
73        })?;
74
75        if entry.file_type().is_some_and(|kind| kind.is_file()) {
76            paths.push(PathBuf::from(entry.into_path()));
77        }
78    }
79
80    paths.sort();
81
82    Ok(paths)
83}
84
85/// Discover the complete source-file view used by workspace tooling.
86///
87/// Hidden source files are included, Git-ignored files are omitted, and the repository's `.git` directory is pruned.
88/// Local and parent ignore files are honored without consulting user-specific global excludes.
89///
90/// Unlike [`repository_files`], traversal continues after entry failures and returns every failure alongside the discovered regular files.
91#[must_use]
92pub fn workspace_source_files(root: &Path) -> WalkReport {
93    let git_directory = root.join(".git");
94    let mut builder = ignore::WalkBuilder::new(root);
95
96    builder
97        .hidden(false)
98        .parents(true)
99        .git_ignore(true)
100        .require_git(false)
101        .git_global(false)
102        .git_exclude(false)
103        .filter_entry(move |entry| !entry.path().starts_with(&git_directory));
104
105    let mut files = Vec::new();
106    let mut failures = Vec::new();
107
108    for entry in builder.build() {
109        match entry {
110            Ok(entry) if entry.file_type().is_some_and(|kind| kind.is_file()) => {
111                files.push(PathBuf::from(entry.into_path()));
112            }
113            Ok(_) => {}
114            Err(source) => failures.push(Error {
115                root: root.to_path_buf(),
116                source,
117            }),
118        }
119    }
120
121    files.sort();
122    failures.sort_by_cached_key(ToString::to_string);
123
124    WalkReport { files, failures }
125}
126
127/// Discover visible source files selected by extension.
128///
129/// Hidden, Git-ignored, and project-ignored entries are omitted.
130/// Local and parent ignore files are honored without consulting user-specific excludes.
131///
132/// Traversal continues after entry failures and returns files and failures together in stable order.
133#[must_use]
134pub fn visible_source_files(
135    root: &Path,
136    extensions: &[&str],
137    project_ignore_filename: &str,
138) -> WalkReport {
139    let mut builder = ignore::WalkBuilder::new(root);
140
141    builder
142        .hidden(true)
143        .parents(true)
144        .git_ignore(true)
145        .require_git(false)
146        .git_global(false)
147        .git_exclude(false)
148        .add_custom_ignore_filename(project_ignore_filename);
149
150    let mut files = Vec::new();
151    let mut failures = Vec::new();
152
153    for entry in builder.build() {
154        match entry {
155            Ok(entry)
156                if entry.file_type().is_some_and(|kind| kind.is_file())
157                    && entry.path().extension().is_some_and(|extension| {
158                        extensions.iter().any(|expected| extension == *expected)
159                    }) =>
160            {
161                files.push(PathBuf::from(entry.into_path()));
162            }
163            Ok(_) => {}
164            Err(source) => failures.push(Error {
165                root: root.to_path_buf(),
166                source,
167            }),
168        }
169    }
170
171    files.sort();
172    files.dedup();
173    failures.sort_by_cached_key(ToString::to_string);
174
175    WalkReport { files, failures }
176}
177
178#[cfg(all(test, not(target_family = "wasm")))]
179mod tests {
180    use googletest::prelude::*;
181
182    use super::{repository_files, visible_source_files, workspace_source_files};
183    use crate::{directory, file, temporary::Directory};
184
185    #[gtest]
186    fn repository_walk_is_sorted_and_honors_gitignore() -> Result<()> {
187        let directory = Directory::new().or_fail()?;
188        let root = directory.path();
189
190        directory::ensure(&root.join("nested")).or_fail()?;
191        file::write_text(&root.join(".gitignore"), "ignored.toml\n").or_fail()?;
192        file::write_text(&root.join("nested/b.toml"), "").or_fail()?;
193        file::write_text(&root.join("a.toml"), "").or_fail()?;
194        file::write_text(&root.join("ignored.toml"), "").or_fail()?;
195
196        verify_eq!(
197            repository_files(root).or_fail()?,
198            vec![root.join("a.toml"), root.join("nested/b.toml")]
199        )?;
200
201        Ok(())
202    }
203
204    #[gtest]
205    fn workspace_walk_has_stable_source_policy() -> Result<()> {
206        let directory = Directory::new().or_fail()?;
207        let root = directory.path();
208
209        directory::ensure(&root.join(".git/objects")).or_fail()?;
210        directory::ensure(&root.join(".hidden")).or_fail()?;
211        file::write_text(&root.join(".gitignore"), "ignored.rs\n").or_fail()?;
212        file::write_text(&root.join(".git/objects/internal"), "").or_fail()?;
213        file::write_text(&root.join(".hidden/source.rs"), "").or_fail()?;
214        file::write_text(&root.join("z.rs"), "").or_fail()?;
215        file::write_text(&root.join("a.rs"), "").or_fail()?;
216        file::write_text(&root.join("ignored.rs"), "").or_fail()?;
217
218        let report = workspace_source_files(root);
219
220        verify_that!(report.failures(), is_empty())?;
221        verify_eq!(
222            report.files(),
223            &[
224                root.join(".gitignore"),
225                root.join(".hidden/source.rs"),
226                root.join("a.rs"),
227                root.join("z.rs"),
228            ]
229        )?;
230
231        Ok(())
232    }
233
234    #[gtest]
235    fn workspace_walk_exposes_entry_failures() -> Result<()> {
236        let directory = Directory::new().or_fail()?;
237        let missing = directory.path().join("missing");
238        let report = workspace_source_files(&missing);
239        let missing = missing.display().to_string();
240
241        verify_that!(report.files(), is_empty())?;
242        verify_that!(report.failures(), not(is_empty()))?;
243        verify_that!(
244            report.failures()[0].to_string().as_str(),
245            contains_substring(missing.as_str())
246        )?;
247
248        Ok(())
249    }
250
251    #[gtest]
252    fn visible_source_walk_applies_project_policy() -> Result<()> {
253        let directory = Directory::new().or_fail()?;
254        let root = directory.path();
255
256        directory::ensure(&root.join(".hidden")).or_fail()?;
257        directory::ensure(&root.join("directory.rs")).or_fail()?;
258        directory::ensure(&root.join("nested")).or_fail()?;
259        file::write_text(&root.join(".gitignore"), "git-ignored.rs\n").or_fail()?;
260        file::write_text(&root.join(".tidyignore"), "tidy-ignored.rs\n").or_fail()?;
261        file::write_text(&root.join(".hidden/source.rs"), "").or_fail()?;
262        file::write_text(&root.join("git-ignored.rs"), "").or_fail()?;
263        file::write_text(&root.join("tidy-ignored.rs"), "").or_fail()?;
264        file::write_text(&root.join("nested/z.rs"), "").or_fail()?;
265        file::write_text(&root.join("a.rs"), "").or_fail()?;
266        file::write_text(&root.join("source.toml"), "").or_fail()?;
267
268        let report = visible_source_files(root, &["rs"], ".tidyignore");
269
270        verify_that!(report.failures(), is_empty())?;
271        verify_eq!(
272            report.files(),
273            &[root.join("a.rs"), root.join("nested/z.rs")]
274        )?;
275
276        Ok(())
277    }
278
279    #[gtest]
280    fn visible_source_walk_honors_parent_ignore_files() -> Result<()> {
281        let directory = Directory::new().or_fail()?;
282        let root = directory.path();
283        let child = root.join("child");
284
285        directory::ensure(&child).or_fail()?;
286        file::write_text(&root.join(".gitignore"), "parent-ignored.rs\n").or_fail()?;
287        file::write_text(&child.join("parent-ignored.rs"), "").or_fail()?;
288        file::write_text(&child.join("source.rs"), "").or_fail()?;
289
290        let report = visible_source_files(&child, &["rs"], ".tidyignore");
291
292        verify_that!(report.failures(), is_empty())?;
293        verify_eq!(report.files(), &[child.join("source.rs")])?;
294
295        Ok(())
296    }
297
298    #[gtest]
299    fn visible_source_walk_exposes_missing_root() -> Result<()> {
300        let directory = Directory::new().or_fail()?;
301        let missing = directory.path().join("missing");
302        let report = visible_source_files(&missing, &["rs"], ".tidyignore");
303
304        verify_that!(report.files(), is_empty())?;
305        verify_that!(report.failures(), not(is_empty()))?;
306
307        Ok(())
308    }
309}