use crate::{PatternIssue, PatternSeverity, PatternValidationLevel};
use anyhow::{bail, Context};
use std::{
collections::HashSet,
fs::{File, OpenOptions},
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
};
fn sanitize_pattern(pattern: &str) -> String {
pattern.replace(['\n', '\r'], "").trim().to_string()
}
fn validate_file_path(file_path: &Path, base_dir: Option<&Path>) -> anyhow::Result<()> {
let resolved = if file_path.exists() {
file_path.canonicalize()
} else if let Some(parent) = file_path.parent() {
parent.canonicalize().and_then(|p| {
file_path
.file_name()
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid file path")
})
.map(|name| p.join(name))
})
} else {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Invalid file path",
))
}
.with_context(|| format!("Invalid file path: {}", file_path.display()))?;
if let Some(base) = base_dir {
let base_resolved = base
.canonicalize()
.with_context(|| format!("Invalid base directory: {}", base.display()))?;
if !resolved.starts_with(base_resolved) {
bail!(
"File path {} is outside allowed directory",
file_path.display()
);
}
}
Ok(())
}
pub fn read_ignore_patterns(file_path: &Path) -> anyhow::Result<HashSet<String>> {
if !file_path.exists() {
return Ok(HashSet::new());
}
let file = File::open(file_path)
.with_context(|| format!("Failed to open ignore file: {}", file_path.display()))?;
let reader = BufReader::new(file);
let mut patterns = HashSet::new();
for line in reader.lines() {
let line =
line.with_context(|| format!("Failed to read line from: {}", file_path.display()))?;
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') {
patterns.insert(trimmed.to_string());
}
}
Ok(patterns)
}
pub fn write_ignore_patterns(
file_path: &Path,
patterns: &[String],
append: bool,
) -> anyhow::Result<()> {
if patterns.is_empty() {
return Ok(());
}
validate_file_path(file_path, None)?;
let sanitized_patterns: Vec<String> = patterns
.iter()
.map(|p| sanitize_pattern(p))
.filter(|p| !p.is_empty())
.collect();
if sanitized_patterns.is_empty() {
return Ok(());
}
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
let mut file = if append {
OpenOptions::new()
.create(true)
.append(true)
.read(true)
.open(file_path)
} else {
OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(file_path)
}
.with_context(|| format!("Failed to write to: {}", file_path.display()))?;
if append && file.metadata().map(|m| m.len()).unwrap_or(0) > 0 {
writeln!(file)
.with_context(|| format!("Failed to write newline to: {}", file_path.display()))?;
}
let mut writer = BufWriter::new(&mut file);
for pattern in sanitized_patterns {
writeln!(writer, "{pattern}")
.with_context(|| format!("Failed to write pattern to: {}", file_path.display()))?;
}
writer
.flush()
.with_context(|| format!("Failed to flush writes to: {}", file_path.display()))?;
Ok(())
}
pub fn add_patterns_to_ignore_file(
file_path: &Path,
new_patterns: &[String],
avoid_duplicates: bool,
_validation_level: PatternValidationLevel,
) -> anyhow::Result<Vec<String>> {
if new_patterns.is_empty() {
return Ok(Vec::new());
}
let existing_patterns = if avoid_duplicates {
read_ignore_patterns(file_path)?
} else {
HashSet::new()
};
let patterns_to_add: Vec<String> = new_patterns
.iter()
.map(|p| sanitize_pattern(p))
.filter(|p| !p.is_empty() && (!avoid_duplicates || !existing_patterns.contains(p)))
.collect();
if !patterns_to_add.is_empty() {
write_ignore_patterns(file_path, &patterns_to_add, true)?;
}
Ok(patterns_to_add)
}
pub fn validate_ignore_patterns(patterns: &[String]) -> Vec<PatternIssue> {
let mut issues = Vec::new();
for original_pattern in patterns {
let pattern = sanitize_pattern(original_pattern);
if pattern.is_empty() {
continue;
}
if original_pattern.contains(['\n', '\r']) {
issues.push(PatternIssue {
pattern: original_pattern.clone(),
severity: PatternSeverity::Error,
message: "Pattern contains newline characters which will corrupt the ignore file"
.to_string(),
});
}
if pattern.starts_with('/') && pattern.ends_with('/') && pattern.len() > 2 {
issues.push(PatternIssue {
pattern: pattern.clone(),
severity: PatternSeverity::Info,
message: "Pattern has leading and trailing slashes - might be too restrictive"
.to_string(),
});
}
if pattern.starts_with("./") {
issues.push(PatternIssue {
pattern: pattern.clone(),
severity: PatternSeverity::Info,
message: "Pattern starts with './' which is redundant".to_string(),
});
}
if pattern.matches("**").count() > 1 {
issues.push(PatternIssue {
pattern: pattern.clone(),
severity: PatternSeverity::Warning,
message: "Pattern contains multiple '**' which may not work as expected"
.to_string(),
});
}
if matches!(pattern.as_str(), "*" | "**" | "/") {
issues.push(PatternIssue {
pattern: pattern.clone(),
severity: PatternSeverity::Warning,
message: "Pattern is very broad and may ignore more than intended".to_string(),
});
}
if matches!(
pattern.as_str(),
".git" | ".gitignore" | "README*" | "LICENSE*"
) {
issues.push(PatternIssue {
pattern: pattern.clone(),
severity: PatternSeverity::Warning,
message: "Pattern might ignore important project files".to_string(),
});
}
}
issues
}
pub fn ensure_info_exclude_exists(exclude_file_path: &Path) -> anyhow::Result<()> {
if exclude_file_path.exists() {
return Ok(());
}
if let Some(parent) = exclude_file_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
let template = r#"# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~
"#;
std::fs::write(exclude_file_path, template).with_context(|| {
format!(
"Failed to initialize exclude file: {}",
exclude_file_path.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::{io::Write, path::PathBuf};
use tempfile::{NamedTempFile, TempDir};
#[test]
fn test_sanitize_pattern() {
assert_eq!(sanitize_pattern("*.pyc"), "*.pyc");
assert_eq!(sanitize_pattern(" *.pyc "), "*.pyc");
assert_eq!(sanitize_pattern("*.pyc\n"), "*.pyc");
assert_eq!(sanitize_pattern("*.pyc\r\n"), "*.pyc");
assert_eq!(sanitize_pattern(""), "");
}
#[test]
fn test_read_ignore_patterns_nonexistent() {
let result = read_ignore_patterns(&PathBuf::from("/nonexistent/file"));
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 0);
}
#[test]
fn test_read_ignore_patterns() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "*.pyc").unwrap();
writeln!(temp_file, "# comment").unwrap();
writeln!(temp_file).unwrap();
writeln!(temp_file, "__pycache__/").unwrap();
let patterns = read_ignore_patterns(temp_file.path()).unwrap();
assert_eq!(patterns.len(), 2);
assert!(patterns.contains("*.pyc"));
assert!(patterns.contains("__pycache__/"));
}
#[test]
fn test_validate_ignore_patterns() {
let patterns = vec!["*.pyc".to_string(), "build".to_string()];
let issues = validate_ignore_patterns(&patterns);
assert_eq!(issues.len(), 0);
let patterns = vec!["*.pyc\n".to_string()];
let issues = validate_ignore_patterns(&patterns);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, PatternSeverity::Error);
}
#[test]
fn test_write_ignore_patterns() {
let temp_dir = TempDir::new().unwrap();
let temp_file = temp_dir.path().join("test_ignore");
let patterns = vec!["*.pyc".to_string(), "__pycache__/".to_string()];
write_ignore_patterns(&temp_file, &patterns, false).unwrap();
let content = std::fs::read_to_string(&temp_file).unwrap();
assert!(content.contains("*.pyc\n"));
assert!(content.contains("__pycache__/\n"));
}
}