use crate::error::{NonoError, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct ExclusionConfig {
pub use_gitignore: bool,
pub exclude_patterns: Vec<String>,
pub exclude_globs: Vec<String>,
pub force_include: Vec<String>,
}
impl Default for ExclusionConfig {
fn default() -> Self {
Self {
use_gitignore: true,
exclude_patterns: Vec::new(),
exclude_globs: Vec::new(),
force_include: Vec::new(),
}
}
}
pub struct ExclusionFilter {
gitignore: Option<Gitignore>,
exclude_patterns: Vec<String>,
exclude_globs: Option<GlobSet>,
force_include: Vec<String>,
}
impl ExclusionFilter {
pub fn new(config: ExclusionConfig, root: &Path) -> Result<Self> {
let gitignore = if config.use_gitignore {
let gitignore_path = root.join(".gitignore");
if gitignore_path.exists() {
let mut builder = GitignoreBuilder::new(root);
builder.add(&gitignore_path);
match builder.build() {
Ok(gi) => Some(gi),
Err(e) => {
tracing::warn!("Failed to parse .gitignore: {}", e);
None
}
}
} else {
None
}
} else {
None
};
let exclude_globs =
if config.exclude_globs.is_empty() {
None
} else {
let mut builder = GlobSetBuilder::new();
for pattern in &config.exclude_globs {
let glob = Glob::new(pattern).map_err(|e| {
NonoError::ConfigParse(format!("Invalid glob pattern '{}': {}", pattern, e))
})?;
builder.add(glob);
}
Some(builder.build().map_err(|e| {
NonoError::ConfigParse(format!("Failed to build glob set: {}", e))
})?)
};
Ok(Self {
gitignore,
exclude_patterns: config.exclude_patterns,
exclude_globs,
force_include: config.force_include,
})
}
#[must_use]
pub fn is_excluded(&self, path: &Path) -> bool {
if self.matches_force_include(path) {
return false;
}
if self.matches_exclude_patterns(path) {
return true;
}
if self.matches_exclude_globs(path) {
return true;
}
if let Some(ref gi) = self.gitignore {
let is_dir = path.is_dir();
if gi.matched(path, is_dir).is_ignore() {
return true;
}
}
false
}
fn matches_exclude_patterns(&self, path: &Path) -> bool {
for pattern in &self.exclude_patterns {
if pattern.contains('/') {
let path_str = path.to_string_lossy();
if path_str.contains(pattern.as_str()) {
return true;
}
} else {
for component in path.components() {
if let std::path::Component::Normal(name) = component {
if name.to_string_lossy() == *pattern {
return true;
}
}
}
}
}
false
}
fn matches_exclude_globs(&self, path: &Path) -> bool {
if let Some(ref globs) = self.exclude_globs {
if let Some(filename) = path.file_name() {
return globs.is_match(filename);
}
}
false
}
fn matches_force_include(&self, path: &Path) -> bool {
for pattern in &self.force_include {
if pattern.contains('/') {
let path_str = path.to_string_lossy();
if path_str.contains(pattern.as_str()) {
return true;
}
} else {
for component in path.components() {
if let std::path::Component::Normal(name) = component {
if name.to_string_lossy() == *pattern {
return true;
}
}
}
}
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
fn make_filter(patterns: Vec<&str>) -> ExclusionFilter {
let dir = TempDir::new().expect("tempdir");
let config = ExclusionConfig {
use_gitignore: false,
exclude_patterns: patterns.into_iter().map(String::from).collect(),
exclude_globs: Vec::new(),
force_include: Vec::new(),
};
ExclusionFilter::new(config, dir.path()).expect("filter")
}
#[test]
fn component_pattern_matches() {
let filter = make_filter(vec!["node_modules", ".DS_Store"]);
assert!(filter.is_excluded(&PathBuf::from("/project/node_modules/pkg/index.js")));
assert!(filter.is_excluded(&PathBuf::from("/project/.DS_Store")));
}
#[test]
fn slash_pattern_matches_as_substring() {
let filter = make_filter(vec![".git/objects"]);
assert!(filter.is_excluded(&PathBuf::from("/project/.git/objects/ab/cdef")));
assert!(!filter.is_excluded(&PathBuf::from("/project/.git/config")));
}
#[test]
fn normal_files_not_excluded() {
let filter = make_filter(vec!["node_modules", "target"]);
assert!(!filter.is_excluded(&PathBuf::from("/project/src/main.rs")));
assert!(!filter.is_excluded(&PathBuf::from("/project/README.md")));
}
#[test]
fn empty_patterns_excludes_nothing() {
let filter = make_filter(vec![]);
assert!(!filter.is_excluded(&PathBuf::from("/project/node_modules/pkg")));
assert!(!filter.is_excluded(&PathBuf::from("/project/.DS_Store")));
}
#[test]
fn force_include_overrides_patterns() {
let dir = TempDir::new().expect("tempdir");
let config = ExclusionConfig {
use_gitignore: false,
exclude_patterns: vec!["node_modules".to_string()],
exclude_globs: Vec::new(),
force_include: vec!["important_modules".to_string()],
};
let filter = ExclusionFilter::new(config, dir.path()).expect("filter");
assert!(!filter.is_excluded(&PathBuf::from(
"/project/important_modules/node_modules/pkg"
)));
}
#[test]
fn glob_pattern_matches_filename() {
let dir = TempDir::new().expect("tempdir");
let config = ExclusionConfig {
use_gitignore: false,
exclude_patterns: Vec::new(),
exclude_globs: vec!["*.tmp.[0-9]*.[0-9]*".to_string()],
force_include: Vec::new(),
};
let filter = ExclusionFilter::new(config, dir.path()).expect("filter");
assert!(filter.is_excluded(&PathBuf::from("/project/README.md.tmp.48846.1771084621260")));
assert!(filter.is_excluded(&PathBuf::from("/project/src/main.rs.tmp.12345.9876543210")));
assert!(!filter.is_excluded(&PathBuf::from("/project/README.md")));
assert!(!filter.is_excluded(&PathBuf::from("/project/file.tmp")));
assert!(!filter.is_excluded(&PathBuf::from("/project/file.tmp.backup.old")));
}
#[test]
fn force_include_matches_path_component() {
let dir = TempDir::new().expect("tempdir");
let config = ExclusionConfig {
use_gitignore: false,
exclude_patterns: vec!["node_modules".to_string()],
exclude_globs: Vec::new(),
force_include: vec!["node_modules".to_string()],
};
let filter = ExclusionFilter::new(config, dir.path()).expect("filter");
assert!(!filter.is_excluded(&PathBuf::from("/project/node_modules/pkg/index.js")));
}
#[test]
fn force_include_rejects_substring_match() {
let dir = TempDir::new().expect("tempdir");
let config = ExclusionConfig {
use_gitignore: false,
exclude_patterns: vec!["myapp".to_string()],
exclude_globs: Vec::new(),
force_include: vec!["app".to_string()],
};
let filter = ExclusionFilter::new(config, dir.path()).expect("filter");
assert!(filter.is_excluded(&PathBuf::from("/project/myapp/src/main.rs")));
}
#[test]
fn gitignore_integration() {
let dir = TempDir::new().expect("tempdir");
std::fs::write(dir.path().join(".gitignore"), "*.log\nbuild/\n").expect("write gitignore");
std::fs::create_dir(dir.path().join("build")).expect("create build dir");
let config = ExclusionConfig {
use_gitignore: true,
exclude_patterns: Vec::new(),
exclude_globs: Vec::new(),
force_include: Vec::new(),
};
let filter = ExclusionFilter::new(config, dir.path()).expect("filter");
assert!(filter.is_excluded(&dir.path().join("app.log")));
assert!(filter.is_excluded(&dir.path().join("build")));
}
}