Skip to main content

devclean/
policy.rs

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