ccsync_core/config/
patterns.rs

1//! Gitignore-style pattern matching using the ignore crate
2
3use std::path::Path;
4
5use anyhow::Context;
6use ignore::gitignore::{Gitignore, GitignoreBuilder};
7
8use crate::error::Result;
9
10/// Pattern matcher for file inclusion/exclusion
11pub struct PatternMatcher {
12    gitignore: Option<Gitignore>,
13}
14
15impl PatternMatcher {
16    /// Create a new pattern matcher
17    #[must_use]
18    pub const fn new() -> Self {
19        Self { gitignore: None }
20    }
21
22    /// Build pattern matcher from ignore and include patterns
23    ///
24    /// # Errors
25    ///
26    /// Returns an error if patterns are invalid.
27    pub fn with_patterns(ignore_patterns: &[String], include_patterns: &[String]) -> Result<Self> {
28        let mut builder = GitignoreBuilder::new("");
29
30        // Add ignore patterns
31        for pattern in ignore_patterns {
32            builder
33                .add_line(None, pattern)
34                .with_context(|| format!("Invalid ignore pattern: '{pattern}'"))?;
35        }
36
37        // Add include patterns (negated ignores)
38        for pattern in include_patterns {
39            builder
40                .add_line(None, &format!("!{pattern}"))
41                .with_context(|| format!("Invalid include pattern: '{pattern}'"))?;
42        }
43
44        let gitignore = builder.build()?;
45
46        Ok(Self {
47            gitignore: Some(gitignore),
48        })
49    }
50
51    /// Check if a path should be included based on patterns
52    #[must_use]
53    pub fn should_include(&self, path: &Path, is_dir: bool) -> bool {
54        self.gitignore
55            .as_ref()
56            .is_none_or(|gi| !gi.matched(path, is_dir).is_ignore())
57    }
58}
59
60impl Default for PatternMatcher {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use std::path::PathBuf;
70
71    #[test]
72    fn test_no_patterns() {
73        let matcher = PatternMatcher::new();
74        assert!(matcher.should_include(&PathBuf::from("any/file.txt"), false));
75    }
76
77    #[test]
78    fn test_ignore_pattern() {
79        let matcher = PatternMatcher::with_patterns(&["*.tmp".to_string()], &[]).unwrap();
80
81        assert!(!matcher.should_include(&PathBuf::from("file.tmp"), false));
82        assert!(matcher.should_include(&PathBuf::from("file.txt"), false));
83    }
84
85    #[test]
86    fn test_include_overrides_ignore() {
87        let matcher =
88            PatternMatcher::with_patterns(&["*.tmp".to_string()], &["important.tmp".to_string()])
89                .unwrap();
90
91        assert!(!matcher.should_include(&PathBuf::from("file.tmp"), false));
92        assert!(matcher.should_include(&PathBuf::from("important.tmp"), false));
93    }
94
95    #[test]
96    fn test_directory_patterns() {
97        let matcher = PatternMatcher::with_patterns(&["node_modules/".to_string()], &[]).unwrap();
98
99        assert!(!matcher.should_include(&PathBuf::from("node_modules"), true));
100        assert!(matcher.should_include(&PathBuf::from("src"), true));
101    }
102
103    #[test]
104    fn test_relative_path_wildcards() {
105        let matcher = PatternMatcher::with_patterns(&["agents/git-*".to_string()], &[]).unwrap();
106
107        // Should match agents/git-* pattern
108        assert!(!matcher.should_include(&PathBuf::from("agents/git-commit.md"), false));
109        assert!(!matcher.should_include(&PathBuf::from("agents/git-helper.md"), false));
110
111        // Should NOT match agents/git-* pattern
112        assert!(matcher.should_include(&PathBuf::from("agents/other-agent.md"), false));
113        assert!(matcher.should_include(&PathBuf::from("skills/test.md"), false));
114    }
115}