1use crate::Language;
22use anyhow::Result;
23use std::path::{Path, PathBuf};
24
25const SKIP_DIRS: &[&str] = &[
29 "target",
30 "node_modules",
31 "vendor",
32 "dist",
33 "build",
34 "site-packages",
35 "third_party",
36];
37
38const SKIP_SUFFIXES: &[&str] = &[".min.js", ".min.css", ".bundle.js", "-lock.json"];
43
44#[derive(Debug, Clone, Default)]
46pub struct IndexFilter {
47 include: Vec<String>,
48}
49
50impl IndexFilter {
51 pub fn new(include: &[String]) -> Self {
54 Self {
55 include: include
56 .iter()
57 .map(|p| p.trim_end_matches('/').replace('\\', "/"))
58 .filter(|p| !p.is_empty())
59 .collect(),
60 }
61 }
62
63 fn is_included(&self, rel: &str) -> bool {
66 self.include.iter().any(|prefix| {
67 rel == prefix
68 || (rel.len() > prefix.len()
69 && rel.starts_with(prefix.as_str())
70 && rel.as_bytes()[prefix.len()] == b'/')
71 })
72 }
73
74 pub fn is_indexable(&self, rel: &str) -> bool {
84 if self.is_included(rel) {
85 return true;
86 }
87 if rel
88 .split('/')
89 .any(|part| part.starts_with('.') || SKIP_DIRS.contains(&part))
90 {
91 return false;
92 }
93 let name = rel.rsplit('/').next().unwrap_or(rel);
94 !SKIP_SUFFIXES.iter().any(|suffix| name.ends_with(suffix))
95 }
96
97 pub fn should_descend(&self, rel: &str) -> bool {
104 self.is_indexable(rel)
105 || self.include.iter().any(|p| {
106 p.len() > rel.len() && p.starts_with(rel) && p.as_bytes()[rel.len()] == b'/'
107 })
108 }
109}
110
111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
113pub struct IgnoredCounts {
114 pub files: usize,
116 pub dirs: usize,
120}
121
122pub fn source_files(
127 dir: &Path,
128 filter: &IndexFilter,
129) -> Result<(Vec<(PathBuf, Language)>, IgnoredCounts)> {
130 let repo = git2::Repository::open(dir).ok();
133 let mut out = Vec::new();
134 let mut ignored = IgnoredCounts::default();
135 source_files_inner(dir, dir, repo.as_ref(), filter, &mut out, &mut ignored)?;
136 out.sort_by(|a, b| a.0.cmp(&b.0));
137 Ok((out, ignored))
138}
139
140pub fn stale_entries<'a>(
151 root: &Path,
152 filter: &IndexFilter,
153 paths: impl Iterator<Item = &'a str>,
154) -> Vec<String> {
155 let repo = git2::Repository::open(root).ok();
156 paths
157 .filter(|rel| !filter.is_indexable(rel) || git_ignores(repo.as_ref(), rel))
158 .map(str::to_string)
159 .collect()
160}
161
162fn git_ignores(repo: Option<&git2::Repository>, rel: &str) -> bool {
165 repo.is_some_and(|r| r.is_path_ignored(Path::new(rel)).unwrap_or(false))
166}
167
168fn source_files_inner(
169 root: &Path,
170 dir: &Path,
171 repo: Option<&git2::Repository>,
172 filter: &IndexFilter,
173 out: &mut Vec<(PathBuf, Language)>,
174 ignored: &mut IgnoredCounts,
175) -> Result<()> {
176 for entry in std::fs::read_dir(dir)? {
177 let entry = entry?;
178 let ft = entry.file_type()?;
179 let path = entry.path();
180 let rel = path
181 .strip_prefix(root)
182 .unwrap_or(&path)
183 .to_string_lossy()
184 .replace('\\', "/");
185
186 if ft.is_dir() {
187 if !filter.should_descend(&rel) || git_ignores(repo, &rel) {
188 ignored.dirs += 1;
189 continue;
190 }
191 source_files_inner(root, &path, repo, filter, out, ignored)?;
192 } else if ft.is_file() {
193 let Some(language) = path
194 .extension()
195 .and_then(|e| e.to_str())
196 .and_then(Language::from_extension)
197 else {
198 continue; };
200 if !filter.is_indexable(&rel) || git_ignores(repo, &rel) {
201 ignored.files += 1;
202 continue;
203 }
204 out.push((path, language));
205 }
206 }
207 Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use std::fs;
214 use tempfile::TempDir;
215
216 fn filter() -> IndexFilter {
217 IndexFilter::default()
218 }
219
220 #[test]
223 fn indexes_ordinary_source_paths() {
224 assert!(filter().is_indexable("crates/gonzalo-cli/src/lib.rs"));
225 assert!(filter().is_indexable("main.rs"));
226 }
227
228 #[test]
229 fn skips_build_and_dependency_directories() {
230 for rel in [
231 "target/debug/foo.rs",
232 "node_modules/left-pad/index.js",
233 "vendor/github.com/x/y.go",
234 "dist/app.js",
235 "build/generated.rs",
236 "third_party/lib/a.c",
237 "x/site-packages/pkg/mod.py",
238 ] {
239 assert!(!filter().is_indexable(rel), "{rel} should be skipped");
240 }
241 }
242
243 #[test]
244 fn skips_hidden_directories() {
245 assert!(!filter().is_indexable(".git/config.rs"));
246 assert!(!filter().is_indexable(".github/scripts/x.py"));
247 }
248
249 #[test]
250 fn matches_directory_names_as_whole_components() {
251 assert!(filter().is_indexable("rebuild/main.rs"));
253 assert!(filter().is_indexable("distribution/mod.rs"));
254 assert!(filter().is_indexable("vendored_docs/notes.py"));
255 }
256
257 #[test]
258 fn skips_minified_and_generated_files() {
259 for rel in [
260 "docs/guide/mermaid.min.js",
261 "assets/site.min.css",
262 "static/app.bundle.js",
263 "package-lock.json",
264 ] {
265 assert!(!filter().is_indexable(rel), "{rel} should be skipped");
266 }
267 }
268
269 #[test]
270 fn does_not_confuse_a_minified_suffix_with_a_normal_name() {
271 assert!(filter().is_indexable("src/mermaid.js"));
272 assert!(filter().is_indexable("src/bundle.js"));
273 }
274
275 #[test]
278 fn an_include_prefix_readmits_a_vendored_path() {
279 let f = IndexFilter::new(&["vendor/mylib".to_string()]);
280 assert!(f.is_indexable("vendor/mylib/core.go"));
281 assert!(!f.is_indexable("vendor/other/core.go"));
283 }
284
285 #[test]
286 fn an_include_prefix_readmits_a_single_file() {
287 let f = IndexFilter::new(&["docs/guide/mermaid.min.js".to_string()]);
288 assert!(f.is_indexable("docs/guide/mermaid.min.js"));
289 assert!(!f.is_indexable("docs/guide/other.min.js"));
290 }
291
292 #[test]
293 fn an_include_prefix_matches_whole_components_only() {
294 let f = IndexFilter::new(&["build/keep".to_string()]);
295 assert!(f.is_indexable("build/keep/a.rs"));
296 assert!(!f.is_indexable("build/keepsake/a.rs"));
298 }
299
300 #[test]
301 fn a_trailing_slash_in_an_include_is_tolerated() {
302 let f = IndexFilter::new(&["vendor/mylib/".to_string()]);
303 assert!(f.is_indexable("vendor/mylib/core.go"));
304 }
305
306 fn write(dir: &Path, rel: &str, body: &str) {
309 let p = dir.join(rel);
310 fs::create_dir_all(p.parent().unwrap()).unwrap();
311 fs::write(p, body).unwrap();
312 }
313
314 fn rels(files: &[(PathBuf, Language)], root: &Path) -> Vec<String> {
315 files
316 .iter()
317 .map(|(p, _)| {
318 p.strip_prefix(root)
319 .unwrap()
320 .to_string_lossy()
321 .replace('\\', "/")
322 })
323 .collect()
324 }
325
326 #[test]
327 fn walk_skips_vendored_files_in_a_plain_directory_tree() {
328 let dir = TempDir::new().unwrap();
329 write(dir.path(), "src/lib.rs", "fn a() {}");
330 write(dir.path(), "docs/mermaid.min.js", "var a=1;");
331 write(dir.path(), "node_modules/x/index.js", "var b=2;");
332
333 let (files, ignored) = source_files(dir.path(), &filter()).unwrap();
334 assert_eq!(rels(&files, dir.path()), vec!["src/lib.rs"]);
335 assert_eq!(ignored.files, 1, "the .min.js");
336 assert_eq!(ignored.dirs, 1, "node_modules pruned without descending");
337 }
338
339 #[test]
340 fn walk_honours_gitignore_in_a_git_repository() {
341 let dir = TempDir::new().unwrap();
342 let repo = git2::Repository::init(dir.path()).unwrap();
343 write(dir.path(), ".gitignore", "docs/guide/book/\n");
344 write(dir.path(), "src/lib.rs", "fn a() {}");
345 write(dir.path(), "docs/guide/book/highlight.js", "var a=1;");
347 drop(repo);
348
349 let (files, ignored) = source_files(dir.path(), &filter()).unwrap();
350 assert_eq!(rels(&files, dir.path()), vec!["src/lib.rs"]);
351 assert_eq!(ignored.dirs, 2);
354 }
355
356 #[test]
357 fn walk_descends_into_a_skipped_directory_to_reach_an_include() {
358 let dir = TempDir::new().unwrap();
359 write(dir.path(), "vendor/mylib/core.js", "var a=1;");
360 write(dir.path(), "vendor/other/core.js", "var b=2;");
361
362 let f = IndexFilter::new(&["vendor/mylib".to_string()]);
363 let (files, _) = source_files(dir.path(), &f).unwrap();
364 assert_eq!(rels(&files, dir.path()), vec!["vendor/mylib/core.js"]);
367 }
368
369 #[test]
370 fn walk_produces_the_same_view_whether_or_not_build_output_exists() {
371 let build = |with_output: bool| {
374 let dir = TempDir::new().unwrap();
375 git2::Repository::init(dir.path()).unwrap();
376 write(dir.path(), ".gitignore", "book/\n");
377 write(dir.path(), "src/lib.rs", "fn a() {}");
378 if with_output {
379 write(dir.path(), "book/gen.js", "var a=1;");
380 write(dir.path(), "book/other.js", "var b=2;");
381 }
382 let (files, _) = source_files(dir.path(), &filter()).unwrap();
383 rels(&files, dir.path())
384 };
385 assert_eq!(build(true), build(false));
386 }
387
388 #[test]
389 fn walk_still_works_without_a_repository() {
390 let dir = TempDir::new().unwrap();
391 write(dir.path(), "a.rs", "fn a() {}");
392 write(dir.path(), "target/b.rs", "fn b() {}");
393 let (files, _) = source_files(dir.path(), &filter()).unwrap();
394 assert_eq!(rels(&files, dir.path()), vec!["a.rs"]);
395 }
396
397 #[test]
398 fn walk_include_overrides_a_builtin_rule_but_not_gitignore() {
399 let dir = TempDir::new().unwrap();
400 git2::Repository::init(dir.path()).unwrap();
401 write(dir.path(), ".gitignore", "generated/\n");
402 write(dir.path(), "vendor/mylib/core.js", "var a=1;");
403 write(dir.path(), "generated/out.js", "var b=2;");
404
405 let f = IndexFilter::new(&["vendor/mylib".to_string(), "generated".to_string()]);
406 let (files, _) = source_files(dir.path(), &f).unwrap();
407 assert_eq!(rels(&files, dir.path()), vec!["vendor/mylib/core.js"]);
410 }
411
412 #[test]
415 fn stale_entries_finds_paths_the_builtin_rules_now_reject() {
416 let dir = TempDir::new().unwrap();
417 let paths = ["src/lib.rs", "docs/mermaid.min.js", "node_modules/x/i.js"];
418 let stale = stale_entries(dir.path(), &filter(), paths.into_iter());
419 assert_eq!(stale, vec!["docs/mermaid.min.js", "node_modules/x/i.js"]);
420 }
421
422 #[test]
423 fn stale_entries_finds_gitignored_paths_too() {
424 let dir = TempDir::new().unwrap();
427 git2::Repository::init(dir.path()).unwrap();
428 write(dir.path(), ".gitignore", "docs/guide/book/\n");
429 let paths = ["src/lib.rs", "docs/guide/book/highlight.js"];
430 let stale = stale_entries(dir.path(), &filter(), paths.into_iter());
431 assert_eq!(stale, vec!["docs/guide/book/highlight.js"]);
432 }
433
434 #[test]
435 fn stale_entries_keeps_paths_that_are_still_admitted() {
436 let dir = TempDir::new().unwrap();
437 let paths = ["src/lib.rs", "crates/a/src/main.rs"];
438 assert!(stale_entries(dir.path(), &filter(), paths.into_iter()).is_empty());
439 }
440
441 #[test]
442 fn stale_entries_respects_an_include_override() {
443 let dir = TempDir::new().unwrap();
444 let f = IndexFilter::new(&["vendor/mylib".to_string()]);
445 let paths = ["vendor/mylib/core.js", "vendor/other/core.js"];
446 let stale = stale_entries(dir.path(), &f, paths.into_iter());
447 assert_eq!(stale, vec!["vendor/other/core.js"]);
448 }
449
450 #[test]
451 fn walk_ignores_files_with_no_known_language_without_counting_them() {
452 let dir = TempDir::new().unwrap();
453 write(dir.path(), "README.md", "# hi");
454 write(dir.path(), "a.rs", "fn a() {}");
455 let (files, ignored) = source_files(dir.path(), &filter()).unwrap();
456 assert_eq!(rels(&files, dir.path()), vec!["a.rs"]);
457 assert_eq!(ignored.files, 0, "a non-source file is not a skip");
458 }
459}