1use crate::path::{Path, PathBuf};
4
5#[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 #[must_use]
17 pub fn root(&self) -> &Path {
18 &self.root
19 }
20}
21
22#[derive(Debug)]
24pub struct WalkReport {
25 files: Vec<PathBuf>,
26 failures: Vec<Error>,
27}
28
29impl WalkReport {
30 #[must_use]
32 pub fn files(&self) -> &[PathBuf] {
33 &self.files
34 }
35
36 #[must_use]
38 pub fn failures(&self) -> &[Error] {
39 &self.failures
40 }
41
42 #[must_use]
44 pub fn into_parts(self) -> (Vec<PathBuf>, Vec<Error>) {
45 (self.files, self.failures)
46 }
47}
48
49pub 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#[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#[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}