Skip to main content

aaai_core/diff/
ignore.rs

1//! `.aaaiignore` — gitignore-style path exclusion for the diff engine.
2//!
3//! Rules are plain glob patterns, one per line.  Lines beginning with `#`
4//! are comments.  Blank lines are ignored.  A leading `!` negates a rule.
5//!
6//! # Example `.aaaiignore`
7//!
8//! ```text
9//! # Generated files
10//! target/**
11//! *.lock
12//! .DS_Store
13//!
14//! # Never ignore these
15//! !Cargo.lock
16//! ```
17
18use std::path::Path;
19
20/// A compiled set of ignore rules loaded from an `.aaaiignore` file.
21#[derive(Debug, Clone, Default)]
22pub struct IgnoreRules {
23    /// Each entry is `(negated, compiled_pattern)`.
24    rules: Vec<(bool, glob::Pattern)>,
25}
26
27impl IgnoreRules {
28    /// Load rules from the given file path.
29    /// Returns an empty ruleset when the file doesn't exist.
30    pub fn load(path: &Path) -> anyhow::Result<Self> {
31        if !path.exists() {
32            return Ok(Self::default());
33        }
34        let text = std::fs::read_to_string(path)
35            .map_err(|e| anyhow::anyhow!("Cannot read {}: {e}", path.display()))?;
36        Self::from_str(&text)
37    }
38
39    /// Parse rules from a string (one pattern per line).
40    pub fn from_str(text: &str) -> anyhow::Result<Self> {
41        let mut rules = Vec::new();
42        for line in text.lines() {
43            let line = line.trim();
44            if line.is_empty() || line.starts_with('#') {
45                continue;
46            }
47            let (negated, pattern_str) = if let Some(rest) = line.strip_prefix('!') {
48                (true, rest.trim())
49            } else {
50                (false, line)
51            };
52            match glob::Pattern::new(pattern_str) {
53                Ok(pat) => rules.push((negated, pat)),
54                Err(e) => {
55                    log::warn!(".aaaiignore: invalid pattern {:?} — {e}", pattern_str);
56                }
57            }
58        }
59        Ok(Self { rules })
60    }
61
62    /// Return `true` when `path` should be excluded from the diff.
63    ///
64    /// Rules are evaluated in order; the last matching rule wins.
65    /// A negation rule (`!pattern`) un-ignores a previously ignored path.
66    pub fn is_ignored(&self, path: &str) -> bool {
67        let mut ignored = false;
68        for (negated, pat) in &self.rules {
69            if pat.matches(path) {
70                ignored = !negated;
71            }
72        }
73        ignored
74    }
75}
76
77// ── Tests ─────────────────────────────────────────────────────────────────
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    fn rules(text: &str) -> IgnoreRules {
84        IgnoreRules::from_str(text).unwrap()
85    }
86
87    #[test]
88    fn simple_glob_ignores() {
89        let r = rules("target/**\n*.lock");
90        assert!(r.is_ignored("target/debug/aaai"));
91        assert!(r.is_ignored("Cargo.lock"));
92        assert!(!r.is_ignored("src/main.rs"));
93    }
94
95    #[test]
96    fn negation_un_ignores() {
97        let r = rules("*.lock\n!Cargo.lock");
98        assert!(r.is_ignored("some.lock"));
99        assert!(!r.is_ignored("Cargo.lock"), "negation should un-ignore");
100    }
101
102    #[test]
103    fn comments_and_blanks_are_skipped() {
104        let r = rules("# comment\n\n*.tmp");
105        assert!(r.is_ignored("file.tmp"));
106        assert!(!r.is_ignored("file.rs"));
107    }
108
109    #[test]
110    fn empty_ruleset_ignores_nothing() {
111        let r = rules("");
112        assert!(!r.is_ignored("anything"));
113    }
114
115    #[test]
116    fn last_rule_wins() {
117        // Pattern says ignore all .yaml, then un-ignore audit.yaml, then re-ignore it.
118        let r = rules("*.yaml\n!audit.yaml\n*.yaml");
119        assert!(r.is_ignored("audit.yaml"), "last *.yaml should win");
120    }
121}