1use anyhow::Result;
2use ignore::WalkBuilder;
3use serde::{Deserialize, Serialize};
4use std::{
5 collections::HashSet,
6 path::{Path, PathBuf},
7};
8
9#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize)]
10#[serde(default, deny_unknown_fields)]
11pub struct ScanOptions {
12 pub include_ignored: bool,
14}
15
16pub(crate) struct ScanFilter {
17 paths: HashSet<PathBuf>,
18 pub diagnostics: Vec<String>,
19}
20impl ScanFilter {
21 pub fn new(root: &Path, options: ScanOptions) -> Result<Self> {
22 let mut paths = HashSet::new();
23 let mut diagnostics = Vec::new();
24 let mut builder = WalkBuilder::new(root);
25 builder
26 .hidden(false)
27 .ignore(false)
28 .follow_links(false)
29 .git_ignore(!options.include_ignored)
30 .git_exclude(!options.include_ignored)
31 .git_global(!options.include_ignored)
32 .require_git(false)
33 .filter_entry(|e| {
34 e.depth() == 0
35 || !e.file_type().is_some_and(|t| t.is_dir())
36 || !hard_excluded(e.file_name().to_str().unwrap_or(""))
37 });
38 for entry in builder.build() {
39 match entry {
40 Ok(entry) => {
41 paths.insert(entry.into_path());
42 }
43 Err(error) => diagnostics.push(error.to_string()),
44 }
45 }
46 if !options.include_ignored {
47 add_tracked_paths(root, &mut paths);
48 }
49 Ok(Self { paths, diagnostics })
50 }
51 pub fn allows(&self, path: &Path) -> bool {
52 self.paths.contains(path)
53 }
54 pub fn paths(&self) -> impl Iterator<Item = &PathBuf> {
55 self.paths.iter()
56 }
57}
58
59pub(crate) fn hard_excluded(name: &str) -> bool {
60 (crate::catalog::excluded(name) && !matches!(name, "Pods" | "Carthage" | "node_modules"))
61 || matches!(
62 name,
63 ".github" | ".codex" | ".claude" | ".agents" | ".idea" | ".vscode"
64 )
65}
66
67fn add_tracked_paths(root: &Path, paths: &mut HashSet<PathBuf>) {
70 let run = |args: &[&str]| {
71 std::process::Command::new("git")
72 .arg("-c")
73 .arg("core.fsmonitor=false")
74 .arg("-C")
75 .arg(root)
76 .args(args)
77 .env("GIT_OPTIONAL_LOCKS", "0")
78 .output()
79 };
80 let Ok(prefix) = run(&["rev-parse", "--show-prefix"]) else {
81 return;
82 };
83 if !prefix.status.success() {
84 return;
85 }
86 let Ok(prefix) = String::from_utf8(prefix.stdout) else {
87 return;
88 };
89 let prefix = prefix.trim_end_matches(['\r', '\n']);
90 let Ok(files) = run(&[
91 "ls-files",
92 "--cached",
93 "--recurse-submodules",
94 "--full-name",
95 "-z",
96 "--",
97 ".",
98 ]) else {
99 return;
100 };
101 if !files.status.success() {
102 return;
103 }
104 for entry in files.stdout.split(|b| *b == 0).filter(|p| !p.is_empty()) {
105 let Ok(entry) = std::str::from_utf8(entry) else {
106 continue;
107 };
108 let Some(relative) = entry.strip_prefix(prefix) else {
109 continue;
110 };
111 let relative = Path::new(relative);
112 if relative
113 .components()
114 .any(|p| !matches!(p, std::path::Component::Normal(_)))
115 || relative
116 .parent()
117 .into_iter()
118 .flat_map(Path::components)
119 .any(|p| hard_excluded(&p.as_os_str().to_string_lossy()))
120 {
121 continue;
122 }
123 let mut path = root.join(relative);
124 while path.starts_with(root) {
125 paths.insert(path.clone());
126 if path == root || !path.pop() {
127 break;
128 }
129 }
130 }
131}