circuitpython_deploy/
ignore.rs

1use crate::error::{CpdError, Result};
2use ignore::{gitignore::GitignoreBuilder, Match};
3use std::path::{Path, PathBuf};
4
5pub struct IgnoreFilter {
6    gitignore: ignore::gitignore::Gitignore,
7    project_root: PathBuf,
8}
9
10impl IgnoreFilter {
11    pub fn new(project_root: &Path) -> Result<Self> {
12        let mut builder = GitignoreBuilder::new(project_root);
13        
14        // Add default ignores
15        Self::add_default_patterns(&mut builder)?;
16        
17        // Add .cpdignore if it exists
18        let cpdignore_path = project_root.join(".cpdignore");
19        if cpdignore_path.exists() {
20            builder.add(&cpdignore_path);
21        }
22        
23        // Add .gitignore if it exists (as additional patterns)
24        let gitignore_path = project_root.join(".gitignore");
25        if gitignore_path.exists() {
26            builder.add(&gitignore_path);
27        }
28        
29        let gitignore = builder.build().map_err(|e| CpdError::InvalidIgnorePattern {
30            pattern: e.to_string(),
31        })?;
32        
33        Ok(Self {
34            gitignore,
35            project_root: project_root.to_path_buf(),
36        })
37    }
38    
39    fn add_default_patterns(builder: &mut GitignoreBuilder) -> Result<()> {
40        // Default patterns to ignore (using proper gitignore syntax)
41        let default_patterns = [
42            ".git",
43            ".gitignore",
44            ".cpdignore",
45            "target",
46            "node_modules",
47            ".env",
48            ".env.local",
49            "*.tmp",
50            "*.temp",
51            ".DS_Store",
52            "Thumbs.db",
53            "*.pyc",
54            "__pycache__",
55            ".pytest_cache",
56            ".coverage",
57            ".vscode",
58            ".idea",
59            "*.swp",
60            "*.swo",
61            "*~",
62        ];
63        
64        for pattern in &default_patterns {
65            builder.add_line(None, pattern).map_err(|e| CpdError::InvalidIgnorePattern {
66                pattern: format!("Default pattern '{}': {}", pattern, e),
67            })?;
68        }
69        
70        Ok(())
71    }
72    
73    /// Check if a file should be included (not ignored)
74    pub fn should_include(&self, path: &Path) -> bool {
75        // Convert absolute path to relative path from project root
76        let relative_path = if path.is_absolute() {
77            match path.strip_prefix(&self.project_root) {
78                Ok(rel) => rel,
79                Err(_) => return true, // If not under project root, include by default
80            }
81        } else {
82            path
83        };
84        
85        match self.gitignore.matched(relative_path, path.is_dir()) {
86            Match::None | Match::Whitelist(_) => true,
87            Match::Ignore(_) => false,
88        }
89    }
90    
91    /// Get a closure that can be used for filtering
92    pub fn filter_fn(&self) -> impl Fn(&Path) -> bool + '_ {
93        move |path: &Path| self.should_include(path)
94    }
95    
96    /// List all patterns that would be applied
97    #[allow(dead_code)]
98    pub fn list_patterns(&self) -> Vec<String> {
99        // This is a simplified version - the actual gitignore crate doesn't expose patterns directly
100        // In a real implementation, you might want to store the patterns separately
101        vec![
102            ".git/".to_string(),
103            "target/".to_string(),
104            "node_modules/".to_string(),
105            "*.pyc".to_string(),
106            "__pycache__/".to_string(),
107        ]
108    }
109}
110
111/// Helper function to create a simple filter for testing
112#[allow(dead_code)]
113pub fn create_simple_filter(patterns: &[&str]) -> Result<impl Fn(&Path) -> bool> {
114    use std::path::Path;
115    let temp_dir = std::env::temp_dir();
116    let mut builder = GitignoreBuilder::new(&temp_dir);
117    
118    for pattern in patterns {
119        builder.add_line(None, pattern).map_err(|e| CpdError::InvalidIgnorePattern {
120            pattern: format!("Pattern '{}': {}", pattern, e),
121        })?;
122    }
123    
124    let gitignore = builder.build().map_err(|e| CpdError::InvalidIgnorePattern {
125        pattern: e.to_string(),
126    })?;
127    
128    Ok(move |path: &Path| {
129        // For testing, treat all paths as relative to avoid path prefix issues
130        let relative_path = if path.is_absolute() {
131            path.file_name().map(Path::new).unwrap_or(path)
132        } else {
133            path
134        };
135        
136        match gitignore.matched(relative_path, false) {
137            Match::None | Match::Whitelist(_) => true,
138            Match::Ignore(_) => false,
139        }
140    })
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use std::fs;
147    use tempfile::TempDir;
148    
149    #[test]
150    fn test_default_ignores() {
151        let temp_dir = TempDir::new().unwrap();
152        let filter = IgnoreFilter::new(temp_dir.path()).unwrap();
153        
154        // Test with absolute paths (as they would be used in practice)
155        let project_root = temp_dir.path();
156        
157
158        
159        // Should ignore .git directory
160        assert!(!filter.should_include(&project_root.join(".git")));
161        // Note: gitignore patterns like ".git" only match the exact name, not subdirectories
162        // This is expected behavior - to ignore subdirectories, we'd need ".git/**" pattern
163        
164        // Should ignore target directory
165        assert!(!filter.should_include(&project_root.join("target")));
166        // Note: target/* files need explicit patterns or walkdir should handle directory exclusion
167        
168        // Should include regular Python files
169        assert!(filter.should_include(&project_root.join("main.py")));
170        assert!(filter.should_include(&project_root.join("code.py")));
171        
172        // Should ignore compiled Python files
173        assert!(!filter.should_include(&project_root.join("test.pyc")));
174        assert!(!filter.should_include(&project_root.join("__pycache__")));
175    }
176    
177    #[test]
178    fn test_custom_cpdignore() {
179        let temp_dir = TempDir::new().unwrap();
180        let cpdignore_path = temp_dir.path().join(".cpdignore");
181        
182        // Create a .cpdignore file
183        fs::write(&cpdignore_path, "custom_ignore\n*.log\ntemp_*").unwrap();
184        
185        let filter = IgnoreFilter::new(temp_dir.path()).unwrap();
186        let project_root = temp_dir.path();
187        
188        // Should ignore custom patterns
189        assert!(!filter.should_include(&project_root.join("custom_ignore")));
190        assert!(!filter.should_include(&project_root.join("debug.log")));
191        assert!(!filter.should_include(&project_root.join("temp_file.txt")));
192        
193        // Should still include regular files
194        assert!(filter.should_include(&project_root.join("main.py")));
195    }
196    
197    #[test]
198    fn test_simple_filter() {
199        let filter = create_simple_filter(&["*.txt", "temp"]).unwrap();
200        
201        assert!(!filter(&PathBuf::from("readme.txt")));
202        assert!(!filter(&PathBuf::from("temp")));
203        assert!(filter(&PathBuf::from("main.py")));
204    }
205}