Skip to main content

resopt/
scan_options.rs

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    /// Include paths matched by Git ignore rules. Fixed build/VCS exclusions remain.
13    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
67// Git ignore rules apply to untracked paths. Preserve force-added assets when
68// an actual Git index is available; plain directories still use ignore files.
69fn 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}