use anyhow::{Result, anyhow};
use glob::Pattern;
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Clone)]
pub struct VTCodeGitignore {
root_dir: PathBuf,
patterns: Vec<CompiledPattern>,
loaded: bool,
}
#[derive(Debug, Clone)]
struct CompiledPattern {
original: String,
pattern: Pattern,
negated: bool,
}
impl VTCodeGitignore {
pub async fn new() -> Result<Self> {
let current_dir = std::env::current_dir()
.map_err(|e| anyhow!("Failed to get current directory: {}", e))?;
Self::from_directory(¤t_dir).await
}
pub async fn from_directory(root_dir: &Path) -> Result<Self> {
let gitignore_path = root_dir.join(".vtcodegitignore");
let mut patterns = Vec::new();
let mut loaded = false;
if gitignore_path.exists() {
match Self::load_patterns(&gitignore_path).await {
Ok(loaded_patterns) => {
patterns = loaded_patterns;
loaded = true;
}
Err(e) => {
eprintln!("Warning: Failed to load .vtcodegitignore: {}", e);
}
}
}
Ok(Self {
root_dir: root_dir.to_path_buf(),
patterns,
loaded,
})
}
async fn load_patterns(file_path: &Path) -> Result<Vec<CompiledPattern>> {
let content = fs::read_to_string(file_path)
.await
.map_err(|e| anyhow!("Failed to read .vtcodegitignore: {}", e))?;
let mut patterns = Vec::new();
for (line_num, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (pattern_str, negated) = if let Some(stripped) = line.strip_prefix('!') {
(stripped.to_string(), true)
} else {
(line.to_string(), false)
};
let glob_pattern = Self::convert_gitignore_to_glob(&pattern_str);
match Pattern::new(&glob_pattern) {
Ok(pattern) => {
patterns.push(CompiledPattern {
original: pattern_str,
pattern,
negated,
});
}
Err(e) => {
return Err(anyhow!(
"Invalid pattern on line {}: '{}': {}",
line_num + 1,
pattern_str,
e
));
}
}
}
Ok(patterns)
}
fn convert_gitignore_to_glob(pattern: &str) -> String {
let mut result = pattern.to_string();
if result.ends_with('/') {
result = format!("{}/**", result.trim_end_matches('/'));
}
if !result.starts_with('/') && !result.starts_with("**/") && !result.contains('/') {
result = format!("**/{}", result);
}
result
}
pub fn should_exclude(&self, file_path: &Path) -> bool {
if !self.loaded || self.patterns.is_empty() {
return false;
}
let relative_path = match file_path.strip_prefix(&self.root_dir) {
Ok(rel) => rel,
Err(_) => {
file_path
}
};
let path_str = relative_path.to_string_lossy();
let mut excluded = false;
for pattern in &self.patterns {
if pattern.pattern.matches(&path_str) {
if pattern.original.ends_with('/') && file_path.is_file() {
continue;
}
if pattern.negated {
excluded = false;
} else {
excluded = true;
}
}
}
excluded
}
pub fn filter_paths(&self, paths: Vec<PathBuf>) -> Vec<PathBuf> {
if !self.loaded {
return paths;
}
paths
.into_iter()
.filter(|path| !self.should_exclude(path))
.collect()
}
pub fn is_loaded(&self) -> bool {
self.loaded
}
pub fn pattern_count(&self) -> usize {
self.patterns.len()
}
pub fn root_dir(&self) -> &Path {
&self.root_dir
}
}
impl Default for VTCodeGitignore {
fn default() -> Self {
Self {
root_dir: PathBuf::new(),
patterns: Vec::new(),
loaded: false,
}
}
}
static VTCODE_GITIGNORE: once_cell::sync::Lazy<tokio::sync::RwLock<VTCodeGitignore>> =
once_cell::sync::Lazy::new(|| tokio::sync::RwLock::new(VTCodeGitignore::default()));
pub async fn initialize_vtcode_gitignore() -> Result<()> {
let gitignore = VTCodeGitignore::new().await?;
let mut global_gitignore = VTCODE_GITIGNORE.write().await;
*global_gitignore = gitignore;
Ok(())
}
pub async fn get_global_vtcode_gitignore() -> tokio::sync::RwLockReadGuard<'static, VTCodeGitignore>
{
VTCODE_GITIGNORE.read().await
}
pub async fn should_exclude_file(file_path: &Path) -> bool {
let gitignore = get_global_vtcode_gitignore().await;
gitignore.should_exclude(file_path)
}
pub async fn filter_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let gitignore = get_global_vtcode_gitignore().await;
gitignore.filter_paths(paths)
}
pub async fn reload_vtcode_gitignore() -> Result<()> {
initialize_vtcode_gitignore().await
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
#[tokio::test]
async fn test_vtcodegitignore_integration() {
let temp_dir = TempDir::new().unwrap();
let gitignore_path = temp_dir.path().join(".vtcodegitignore");
let mut file = File::create(&gitignore_path).unwrap();
writeln!(file, "*.log").unwrap();
writeln!(file, "target/").unwrap();
writeln!(file, "!important.log").unwrap();
assert!(gitignore_path.exists());
let gitignore = VTCodeGitignore::from_directory(temp_dir.path())
.await
.unwrap();
assert!(gitignore.is_loaded());
assert_eq!(gitignore.pattern_count(), 3);
assert!(gitignore.should_exclude(&temp_dir.path().join("debug.log")));
assert!(gitignore.should_exclude(&temp_dir.path().join("target/binary")));
assert!(!gitignore.should_exclude(&temp_dir.path().join("important.log")));
assert!(!gitignore.should_exclude(&temp_dir.path().join("source.rs")));
println!("✓ VTCodeGitignore functionality works correctly!");
}
#[tokio::test]
async fn test_basic_pattern_matching() {
let temp_dir = TempDir::new().unwrap();
let gitignore_path = temp_dir.path().join(".vtcodegitignore");
let mut file = File::create(&gitignore_path).unwrap();
writeln!(file, "*.log").unwrap();
writeln!(file, "target/").unwrap();
writeln!(file, "!important.log").unwrap();
let gitignore = VTCodeGitignore::from_directory(temp_dir.path())
.await
.unwrap();
assert!(gitignore.is_loaded());
assert_eq!(gitignore.pattern_count(), 3);
assert!(gitignore.should_exclude(&temp_dir.path().join("debug.log")));
assert!(gitignore.should_exclude(&temp_dir.path().join("target/debug.exe")));
assert!(!gitignore.should_exclude(&temp_dir.path().join("important.log")));
assert!(!gitignore.should_exclude(&temp_dir.path().join("source.rs")));
}
#[tokio::test]
async fn test_no_gitignore_file() {
let temp_dir = TempDir::new().unwrap();
let gitignore = VTCodeGitignore::from_directory(temp_dir.path())
.await
.unwrap();
assert!(!gitignore.is_loaded());
assert_eq!(gitignore.pattern_count(), 0);
assert!(!gitignore.should_exclude(&temp_dir.path().join("anyfile.txt")));
}
#[tokio::test]
async fn test_empty_gitignore_file() {
let temp_dir = TempDir::new().unwrap();
let gitignore_path = temp_dir.path().join(".vtcodegitignore");
File::create(&gitignore_path).unwrap();
let gitignore = VTCodeGitignore::from_directory(temp_dir.path())
.await
.unwrap();
assert!(gitignore.is_loaded());
assert_eq!(gitignore.pattern_count(), 0);
}
#[tokio::test]
async fn test_comments_and_empty_lines() {
let temp_dir = TempDir::new().unwrap();
let gitignore_path = temp_dir.path().join(".vtcodegitignore");
let mut file = File::create(&gitignore_path).unwrap();
writeln!(file, "# This is a comment").unwrap();
writeln!(file, "").unwrap();
writeln!(file, "*.tmp").unwrap();
writeln!(file, "# Another comment").unwrap();
writeln!(file, "").unwrap();
let gitignore = VTCodeGitignore::from_directory(temp_dir.path())
.await
.unwrap();
assert!(gitignore.is_loaded());
assert_eq!(gitignore.pattern_count(), 1);
assert!(gitignore.should_exclude(&temp_dir.path().join("file.tmp")));
assert!(!gitignore.should_exclude(&temp_dir.path().join("file.txt")));
}
#[tokio::test]
async fn test_path_filtering() {
let temp_dir = TempDir::new().unwrap();
let gitignore_path = temp_dir.path().join(".vtcodegitignore");
let mut file = File::create(&gitignore_path).unwrap();
writeln!(file, "*.log").unwrap();
writeln!(file, "temp/").unwrap();
let gitignore = VTCodeGitignore::from_directory(temp_dir.path())
.await
.unwrap();
let paths = vec![
temp_dir.path().join("app.log"),
temp_dir.path().join("source.rs"),
temp_dir.path().join("temp/cache.dat"),
temp_dir.path().join("important.txt"),
];
let filtered = gitignore.filter_paths(paths);
assert_eq!(filtered.len(), 2);
assert!(filtered.contains(&temp_dir.path().join("source.rs")));
assert!(filtered.contains(&temp_dir.path().join("important.txt")));
assert!(!filtered.contains(&temp_dir.path().join("app.log")));
assert!(!filtered.contains(&temp_dir.path().join("temp/cache.dat")));
}
}