vtcode_core/utils/
vtcodegitignore.rs

1use anyhow::{Result, anyhow};
2use glob::Pattern;
3use std::path::{Path, PathBuf};
4use tokio::fs;
5
6/// Represents a .vtcodegitignore file with pattern matching capabilities
7#[derive(Debug, Clone)]
8pub struct VTCodeGitignore {
9    /// Root directory where .vtcodegitignore was found
10    root_dir: PathBuf,
11    /// Compiled glob patterns for matching
12    patterns: Vec<CompiledPattern>,
13    /// Whether the .vtcodegitignore file exists and was loaded
14    loaded: bool,
15}
16
17/// A compiled pattern with its original string and compiled glob
18#[derive(Debug, Clone)]
19struct CompiledPattern {
20    /// Original pattern string from the file
21    original: String,
22    /// Compiled glob pattern
23    pattern: Pattern,
24    /// Whether this is a negation pattern (starts with !)
25    negated: bool,
26}
27
28impl VTCodeGitignore {
29    /// Create a new VTCodeGitignore instance by looking for .vtcodegitignore in the current directory
30    pub async fn new() -> Result<Self> {
31        let current_dir = std::env::current_dir()
32            .map_err(|e| anyhow!("Failed to get current directory: {}", e))?;
33
34        Self::from_directory(&current_dir).await
35    }
36
37    /// Create a VTCodeGitignore instance from a specific directory
38    pub async fn from_directory(root_dir: &Path) -> Result<Self> {
39        let gitignore_path = root_dir.join(".vtcodegitignore");
40
41        let mut patterns = Vec::new();
42        let mut loaded = false;
43
44        if gitignore_path.exists() {
45            match Self::load_patterns(&gitignore_path).await {
46                Ok(loaded_patterns) => {
47                    patterns = loaded_patterns;
48                    loaded = true;
49                }
50                Err(e) => {
51                    // Log warning but don't fail - just treat as no patterns
52                    eprintln!("Warning: Failed to load .vtcodegitignore: {}", e);
53                }
54            }
55        }
56
57        Ok(Self {
58            root_dir: root_dir.to_path_buf(),
59            patterns,
60            loaded,
61        })
62    }
63
64    /// Load patterns from the .vtcodegitignore file
65    async fn load_patterns(file_path: &Path) -> Result<Vec<CompiledPattern>> {
66        let content = fs::read_to_string(file_path)
67            .await
68            .map_err(|e| anyhow!("Failed to read .vtcodegitignore: {}", e))?;
69
70        let mut patterns = Vec::new();
71
72        for (line_num, line) in content.lines().enumerate() {
73            let line = line.trim();
74
75            // Skip empty lines and comments
76            if line.is_empty() || line.starts_with('#') {
77                continue;
78            }
79
80            // Parse the pattern
81            let (pattern_str, negated) = if let Some(stripped) = line.strip_prefix('!') {
82                (stripped.to_string(), true)
83            } else {
84                (line.to_string(), false)
85            };
86
87            // Convert gitignore patterns to glob patterns
88            let glob_pattern = Self::convert_gitignore_to_glob(&pattern_str);
89
90            match Pattern::new(&glob_pattern) {
91                Ok(pattern) => {
92                    patterns.push(CompiledPattern {
93                        original: pattern_str,
94                        pattern,
95                        negated,
96                    });
97                }
98                Err(e) => {
99                    return Err(anyhow!(
100                        "Invalid pattern on line {}: '{}': {}",
101                        line_num + 1,
102                        pattern_str,
103                        e
104                    ));
105                }
106            }
107        }
108
109        Ok(patterns)
110    }
111
112    /// Convert gitignore pattern syntax to glob pattern syntax
113    fn convert_gitignore_to_glob(pattern: &str) -> String {
114        let mut result = pattern.to_string();
115
116        // Handle directory-only patterns (ending with /)
117        if result.ends_with('/') {
118            result = format!("{}/**", result.trim_end_matches('/'));
119        }
120
121        // Handle patterns that don't start with / or **/
122        if !result.starts_with('/') && !result.starts_with("**/") && !result.contains('/') {
123            // Simple filename pattern - make it match anywhere
124            result = format!("**/{}", result);
125        }
126
127        result
128    }
129
130    /// Check if a file path should be excluded based on the .vtcodegitignore patterns
131    pub fn should_exclude(&self, file_path: &Path) -> bool {
132        if !self.loaded || self.patterns.is_empty() {
133            return false;
134        }
135
136        // Convert to relative path from the root directory
137        let relative_path = match file_path.strip_prefix(&self.root_dir) {
138            Ok(rel) => rel,
139            Err(_) => {
140                // If we can't make it relative, use the full path
141                file_path
142            }
143        };
144
145        let path_str = relative_path.to_string_lossy();
146
147        // Default to not excluded
148        let mut excluded = false;
149
150        for pattern in &self.patterns {
151            if pattern.pattern.matches(&path_str) {
152                if pattern.original.ends_with('/') && file_path.is_file() {
153                    // Directory-only rules should not exclude individual files.
154                    continue;
155                }
156                if pattern.negated {
157                    // Negation pattern - include this file
158                    excluded = false;
159                } else {
160                    // Normal pattern - exclude this file
161                    excluded = true;
162                }
163            }
164        }
165
166        excluded
167    }
168
169    /// Filter a list of file paths based on .vtcodegitignore patterns
170    pub fn filter_paths(&self, paths: Vec<PathBuf>) -> Vec<PathBuf> {
171        if !self.loaded {
172            return paths;
173        }
174
175        paths
176            .into_iter()
177            .filter(|path| !self.should_exclude(path))
178            .collect()
179    }
180
181    /// Check if the .vtcodegitignore file was loaded successfully
182    pub fn is_loaded(&self) -> bool {
183        self.loaded
184    }
185
186    /// Get the number of patterns loaded
187    pub fn pattern_count(&self) -> usize {
188        self.patterns.len()
189    }
190
191    /// Get the root directory
192    pub fn root_dir(&self) -> &Path {
193        &self.root_dir
194    }
195}
196
197impl Default for VTCodeGitignore {
198    fn default() -> Self {
199        Self {
200            root_dir: PathBuf::new(),
201            patterns: Vec::new(),
202            loaded: false,
203        }
204    }
205}
206
207/// Global .vtcodegitignore instance for easy access
208static VTCODE_GITIGNORE: once_cell::sync::Lazy<tokio::sync::RwLock<VTCodeGitignore>> =
209    once_cell::sync::Lazy::new(|| tokio::sync::RwLock::new(VTCodeGitignore::default()));
210
211/// Initialize the global .vtcodegitignore instance
212pub async fn initialize_vtcode_gitignore() -> Result<()> {
213    let gitignore = VTCodeGitignore::new().await?;
214    let mut global_gitignore = VTCODE_GITIGNORE.write().await;
215    *global_gitignore = gitignore;
216    Ok(())
217}
218
219/// Get the global .vtcodegitignore instance
220pub async fn get_global_vtcode_gitignore() -> tokio::sync::RwLockReadGuard<'static, VTCodeGitignore>
221{
222    VTCODE_GITIGNORE.read().await
223}
224
225/// Check if a file should be excluded by the global .vtcodegitignore
226pub async fn should_exclude_file(file_path: &Path) -> bool {
227    let gitignore = get_global_vtcode_gitignore().await;
228    gitignore.should_exclude(file_path)
229}
230
231/// Filter paths using the global .vtcodegitignore
232pub async fn filter_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
233    let gitignore = get_global_vtcode_gitignore().await;
234    gitignore.filter_paths(paths)
235}
236
237/// Reload the global .vtcodegitignore from disk
238pub async fn reload_vtcode_gitignore() -> Result<()> {
239    initialize_vtcode_gitignore().await
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use std::fs::File;
246    use std::io::Write;
247    use tempfile::TempDir;
248
249    /// Test the vtcodegitignore functionality in isolation
250    #[tokio::test]
251    async fn test_vtcodegitignore_integration() {
252        // Create a temporary directory for testing
253        let temp_dir = TempDir::new().unwrap();
254        let gitignore_path = temp_dir.path().join(".vtcodegitignore");
255
256        // Create a .vtcodegitignore file
257        let mut file = File::create(&gitignore_path).unwrap();
258        writeln!(file, "*.log").unwrap();
259        writeln!(file, "target/").unwrap();
260        writeln!(file, "!important.log").unwrap();
261
262        // Test that the file was created
263        assert!(gitignore_path.exists());
264
265        // Test pattern matching logic
266        let gitignore = VTCodeGitignore::from_directory(temp_dir.path())
267            .await
268            .unwrap();
269        assert!(gitignore.is_loaded());
270        assert_eq!(gitignore.pattern_count(), 3);
271
272        // Test file exclusion
273        assert!(gitignore.should_exclude(&temp_dir.path().join("debug.log")));
274        assert!(gitignore.should_exclude(&temp_dir.path().join("target/binary")));
275        assert!(!gitignore.should_exclude(&temp_dir.path().join("important.log")));
276        assert!(!gitignore.should_exclude(&temp_dir.path().join("source.rs")));
277
278        println!("✓ VTCodeGitignore functionality works correctly!");
279    }
280
281    #[tokio::test]
282    async fn test_basic_pattern_matching() {
283        let temp_dir = TempDir::new().unwrap();
284        let gitignore_path = temp_dir.path().join(".vtcodegitignore");
285
286        // Create a simple .vtcodegitignore
287        let mut file = File::create(&gitignore_path).unwrap();
288        writeln!(file, "*.log").unwrap();
289        writeln!(file, "target/").unwrap();
290        writeln!(file, "!important.log").unwrap();
291
292        let gitignore = VTCodeGitignore::from_directory(temp_dir.path())
293            .await
294            .unwrap();
295        assert!(gitignore.is_loaded());
296        assert_eq!(gitignore.pattern_count(), 3);
297
298        // Test pattern matching
299        assert!(gitignore.should_exclude(&temp_dir.path().join("debug.log")));
300        assert!(gitignore.should_exclude(&temp_dir.path().join("target/debug.exe")));
301        assert!(!gitignore.should_exclude(&temp_dir.path().join("important.log")));
302        assert!(!gitignore.should_exclude(&temp_dir.path().join("source.rs")));
303    }
304
305    #[tokio::test]
306    async fn test_no_gitignore_file() {
307        let temp_dir = TempDir::new().unwrap();
308        let gitignore = VTCodeGitignore::from_directory(temp_dir.path())
309            .await
310            .unwrap();
311        assert!(!gitignore.is_loaded());
312        assert_eq!(gitignore.pattern_count(), 0);
313        assert!(!gitignore.should_exclude(&temp_dir.path().join("anyfile.txt")));
314    }
315
316    #[tokio::test]
317    async fn test_empty_gitignore_file() {
318        let temp_dir = TempDir::new().unwrap();
319        let gitignore_path = temp_dir.path().join(".vtcodegitignore");
320
321        // Create an empty .vtcodegitignore
322        File::create(&gitignore_path).unwrap();
323
324        let gitignore = VTCodeGitignore::from_directory(temp_dir.path())
325            .await
326            .unwrap();
327        assert!(gitignore.is_loaded());
328        assert_eq!(gitignore.pattern_count(), 0);
329    }
330
331    #[tokio::test]
332    async fn test_comments_and_empty_lines() {
333        let temp_dir = TempDir::new().unwrap();
334        let gitignore_path = temp_dir.path().join(".vtcodegitignore");
335
336        // Create .vtcodegitignore with comments and empty lines
337        let mut file = File::create(&gitignore_path).unwrap();
338        writeln!(file, "# This is a comment").unwrap();
339        writeln!(file, "").unwrap();
340        writeln!(file, "*.tmp").unwrap();
341        writeln!(file, "# Another comment").unwrap();
342        writeln!(file, "").unwrap();
343
344        let gitignore = VTCodeGitignore::from_directory(temp_dir.path())
345            .await
346            .unwrap();
347        assert!(gitignore.is_loaded());
348        assert_eq!(gitignore.pattern_count(), 1); // Only the *.tmp pattern should be loaded
349
350        assert!(gitignore.should_exclude(&temp_dir.path().join("file.tmp")));
351        assert!(!gitignore.should_exclude(&temp_dir.path().join("file.txt")));
352    }
353
354    #[tokio::test]
355    async fn test_path_filtering() {
356        let temp_dir = TempDir::new().unwrap();
357        let gitignore_path = temp_dir.path().join(".vtcodegitignore");
358
359        // Create .vtcodegitignore
360        let mut file = File::create(&gitignore_path).unwrap();
361        writeln!(file, "*.log").unwrap();
362        writeln!(file, "temp/").unwrap();
363
364        let gitignore = VTCodeGitignore::from_directory(temp_dir.path())
365            .await
366            .unwrap();
367
368        let paths = vec![
369            temp_dir.path().join("app.log"),
370            temp_dir.path().join("source.rs"),
371            temp_dir.path().join("temp/cache.dat"),
372            temp_dir.path().join("important.txt"),
373        ];
374
375        let filtered = gitignore.filter_paths(paths);
376
377        assert_eq!(filtered.len(), 2);
378        assert!(filtered.contains(&temp_dir.path().join("source.rs")));
379        assert!(filtered.contains(&temp_dir.path().join("important.txt")));
380        assert!(!filtered.contains(&temp_dir.path().join("app.log")));
381        assert!(!filtered.contains(&temp_dir.path().join("temp/cache.dat")));
382    }
383}