Skip to main content

devclean/
policy.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use anyhow::{Context, Result, bail};
6use globset::{Glob, GlobSet, GlobSetBuilder};
7
8/// Compiled path exclusions used during scan and cleanup revalidation.
9#[derive(Debug)]
10pub struct ExcludePolicy {
11    patterns: Vec<String>,
12    matcher: GlobSet,
13}
14
15/// Reuses repository-root discovery while validating multiple cleanup candidates.
16#[derive(Debug, Default)]
17pub struct GitTrackedGuard {
18    roots_by_directory: HashMap<PathBuf, Option<PathBuf>>,
19}
20
21impl GitTrackedGuard {
22    /// Returns true when Git tracks the candidate itself or content below it.
23    ///
24    /// # Errors
25    ///
26    /// Returns an error when Git cannot inspect an applicable repository.
27    pub fn contains_tracked_files(&mut self, path: &Path) -> Result<bool> {
28        let probe = path.parent().unwrap_or(path);
29        let Some(root) = self.repository_root(probe)? else {
30            return Ok(false);
31        };
32        let relative = path
33            .strip_prefix(&root)
34            .with_context(|| format!("{} escaped Git root {}", path.display(), root.display()))?;
35        let output = Command::new("git")
36            .args(["-C"])
37            .arg(&root)
38            .args(["ls-files", "--"])
39            .arg(relative)
40            .output()
41            .context("failed to list Git-tracked files")?;
42        if !output.status.success() {
43            bail!("git ls-files failed for {}", path.display());
44        }
45        Ok(!output.stdout.is_empty())
46    }
47
48    fn repository_root(&mut self, probe: &Path) -> Result<Option<PathBuf>> {
49        let mut visited = Vec::new();
50        for ancestor in probe.ancestors() {
51            if let Some(cached) = self.roots_by_directory.get(ancestor).cloned() {
52                for directory in visited {
53                    self.roots_by_directory.insert(directory, cached.clone());
54                }
55                return Ok(cached);
56            }
57            visited.push(ancestor.to_path_buf());
58            if ancestor.join(".git").exists() {
59                let root = ancestor
60                    .canonicalize()
61                    .context("failed to normalize Git root")?;
62                for directory in visited {
63                    self.roots_by_directory
64                        .insert(directory, Some(root.clone()));
65                }
66                return Ok(Some(root));
67            }
68        }
69        for directory in visited {
70            self.roots_by_directory.insert(directory, None);
71        }
72        Ok(None)
73    }
74}
75
76impl ExcludePolicy {
77    /// Compiles user-provided glob patterns.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error when a pattern is invalid.
82    pub fn new(patterns: &[String]) -> Result<Self> {
83        let mut builder = GlobSetBuilder::new();
84        for pattern in patterns {
85            builder.add(
86                Glob::new(pattern)
87                    .with_context(|| format!("invalid exclude pattern `{pattern}`"))?,
88            );
89        }
90        Ok(Self {
91            patterns: patterns.to_vec(),
92            matcher: builder.build()?,
93        })
94    }
95
96    /// Returns true when an absolute, root-relative, or basename form matches.
97    #[must_use]
98    pub fn matches(&self, path: &Path, roots: &[PathBuf]) -> bool {
99        if self.patterns.is_empty() {
100            return false;
101        }
102        if self.matcher.is_match(normalize(path)) {
103            return true;
104        }
105        if path
106            .file_name()
107            .is_some_and(|name| self.matcher.is_match(name))
108        {
109            return true;
110        }
111        roots.iter().any(|root| {
112            path.strip_prefix(root)
113                .is_ok_and(|relative| self.matcher.is_match(normalize(relative)))
114        })
115    }
116}
117
118/// Returns true when Git tracks the candidate itself or any content below it.
119///
120/// # Errors
121///
122/// Returns an error when Git is installed but cannot inspect an applicable repository.
123pub fn contains_git_tracked_files(path: &Path) -> Result<bool> {
124    GitTrackedGuard::default().contains_tracked_files(path)
125}
126
127fn normalize(path: &Path) -> String {
128    path.to_string_lossy().replace('\\', "/")
129}
130
131#[cfg(test)]
132mod tests {
133    use tempfile::tempdir;
134
135    use super::*;
136
137    #[test]
138    fn exclude_policy_should_match_root_relative_glob() -> Result<()> {
139        let temporary = tempdir()?;
140        let path = temporary.path().join("vendor/node_modules");
141        let policy = ExcludePolicy::new(&["vendor/**".to_owned()])?;
142
143        assert!(policy.matches(&path, &[temporary.path().to_path_buf()]));
144        Ok(())
145    }
146}