use anyhow::{anyhow, Context};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct FilterPattern {
pub original: String,
matcher: globset::GlobMatcher,
pub dir_only: bool,
pub anchored: bool,
}
impl FilterPattern {
pub fn parse(pattern: &str) -> Result<Self, anyhow::Error> {
if pattern.is_empty() {
return Err(anyhow!("empty pattern is not allowed"));
}
let original = pattern.to_string();
let dir_only = pattern.ends_with('/');
let anchored = pattern.starts_with('/');
let pattern_str = pattern.trim_start_matches('/').trim_end_matches('/');
if pattern_str.is_empty() {
return Err(anyhow!(
"pattern '{}' results in empty glob after stripping / markers",
pattern
));
}
let glob = globset::GlobBuilder::new(pattern_str)
.literal_separator(true) .build()
.with_context(|| format!("invalid glob pattern: {}", pattern))?;
let matcher = glob.compile_matcher();
Ok(Self {
original,
matcher,
dir_only,
anchored,
})
}
fn is_path_pattern(&self) -> bool {
let core = self.original.trim_start_matches('/').trim_end_matches('/');
core.contains('/')
}
pub fn matches(&self, relative_path: &Path, is_dir: bool) -> bool {
if self.dir_only && !is_dir {
return false;
}
if self.anchored {
self.matcher.is_match(relative_path)
} else {
if self.matcher.is_match(relative_path) {
return true;
}
if !self.is_path_pattern() {
if let Some(file_name) = relative_path.file_name() {
if self.matcher.is_match(Path::new(file_name)) {
return true;
}
}
}
false
}
}
}
#[derive(Debug, Clone)]
pub enum FilterResult {
Included,
ExcludedByDefault,
ExcludedByPattern(String),
}
#[derive(Debug, Clone, Default)]
pub struct FilterSettings {
pub includes: Vec<FilterPattern>,
pub excludes: Vec<FilterPattern>,
}
impl FilterSettings {
pub fn new() -> Self {
Self::default()
}
pub fn add_include(&mut self, pattern: &str) -> Result<(), anyhow::Error> {
self.includes.push(FilterPattern::parse(pattern)?);
Ok(())
}
pub fn add_exclude(&mut self, pattern: &str) -> Result<(), anyhow::Error> {
self.excludes.push(FilterPattern::parse(pattern)?);
Ok(())
}
pub fn is_empty(&self) -> bool {
self.includes.is_empty() && self.excludes.is_empty()
}
pub fn has_includes(&self) -> bool {
!self.includes.is_empty()
}
pub fn should_include_root_item(&self, name: &Path, is_dir: bool) -> FilterResult {
for pattern in &self.excludes {
if !pattern.anchored
&& !Self::is_path_pattern(&pattern.original)
&& pattern.matches(name, is_dir)
{
return FilterResult::ExcludedByPattern(pattern.original.clone());
}
}
if !self.includes.is_empty() {
if !is_dir {
for pattern in &self.includes {
if !pattern.anchored
&& !Self::is_path_pattern(&pattern.original)
&& pattern.matches(name, false)
{
return FilterResult::Included;
}
}
return FilterResult::ExcludedByDefault;
}
return FilterResult::Included;
}
FilterResult::Included
}
fn is_path_pattern(original: &str) -> bool {
let trimmed = original.trim_start_matches('/').trim_end_matches('/');
trimmed.contains('/')
}
pub fn should_include(&self, relative_path: &Path, is_dir: bool) -> FilterResult {
for pattern in &self.excludes {
if pattern.matches(relative_path, is_dir) {
return FilterResult::ExcludedByPattern(pattern.original.clone());
}
}
if !self.includes.is_empty() {
for pattern in &self.includes {
if pattern.matches(relative_path, is_dir) {
return FilterResult::Included;
}
}
if is_dir {
for pattern in &self.includes {
if self.could_contain_matches(relative_path, pattern) {
return FilterResult::Included;
}
}
}
return FilterResult::ExcludedByDefault;
}
FilterResult::Included
}
pub fn directly_matches_include(&self, relative_path: &std::path::Path, is_dir: bool) -> bool {
if self.includes.is_empty() {
return true; }
for pattern in &self.includes {
if pattern.matches(relative_path, is_dir) {
return true;
}
}
false
}
pub fn could_contain_matches(&self, dir_path: &Path, pattern: &FilterPattern) -> bool {
if !pattern.anchored && !pattern.is_path_pattern() {
return true;
}
let pattern_path = pattern
.original
.trim_start_matches('/')
.trim_end_matches('/');
let prefix = Self::extract_literal_prefix(pattern_path);
let dir_str = dir_path.to_string_lossy();
if prefix.is_empty() {
return true;
}
if dir_str.is_empty() {
return true;
}
if prefix.starts_with(&*dir_str) {
let after_dir = &prefix[dir_str.len()..];
if after_dir.is_empty() || after_dir.starts_with('/') {
return true;
}
}
if let Some(after_prefix) = dir_str.strip_prefix(prefix) {
if after_prefix.is_empty() || after_prefix.starts_with('/') {
return true;
}
}
false
}
fn extract_literal_prefix(pattern: &str) -> &str {
let wildcard_pos = pattern.find(['*', '?', '[']).unwrap_or(pattern.len());
if wildcard_pos == pattern.len() {
return pattern;
}
if wildcard_pos == 0 {
return "";
}
let prefix = &pattern[..wildcard_pos];
match prefix.rfind('/') {
Some(pos) => &pattern[..pos],
None => {
""
}
}
}
pub fn from_file(path: &Path) -> Result<Self, anyhow::Error> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read filter file: {:?}", path))?;
Self::parse_content(&content)
}
pub fn parse_content(content: &str) -> Result<Self, anyhow::Error> {
let mut settings = Self::new();
for (line_num, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line_num = line_num + 1; if let Some(pattern) = line.strip_prefix("--include ") {
let pattern = pattern.trim();
settings
.add_include(pattern)
.with_context(|| format!("line {}: invalid include pattern", line_num))?;
} else if let Some(pattern) = line.strip_prefix("--exclude ") {
let pattern = pattern.trim();
settings
.add_exclude(pattern)
.with_context(|| format!("line {}: invalid exclude pattern", line_num))?;
} else {
return Err(anyhow!(
"line {}: invalid syntax '{}', expected '--include PATTERN' or '--exclude PATTERN'",
line_num, line
));
}
}
Ok(settings)
}
}
#[derive(Serialize, Deserialize)]
struct FilterSettingsDto {
includes: Vec<String>,
excludes: Vec<String>,
}
impl Serialize for FilterSettings {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let dto = FilterSettingsDto {
includes: self.includes.iter().map(|p| p.original.clone()).collect(),
excludes: self.excludes.iter().map(|p| p.original.clone()).collect(),
};
dto.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for FilterSettings {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let dto = FilterSettingsDto::deserialize(deserializer)?;
let mut settings = FilterSettings::new();
for pattern in dto.includes {
settings
.add_include(&pattern)
.map_err(serde::de::Error::custom)?;
}
for pattern in dto.excludes {
settings
.add_exclude(&pattern)
.map_err(serde::de::Error::custom)?;
}
Ok(settings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pattern_basic_glob() {
let pattern = FilterPattern::parse("*.rs").unwrap();
assert!(pattern.matches(Path::new("foo.rs"), false));
assert!(pattern.matches(Path::new("main.rs"), false));
assert!(!pattern.matches(Path::new("foo.txt"), false));
assert!(pattern.matches(Path::new("src/foo.rs"), false));
}
#[test]
fn test_pattern_double_star() {
let pattern = FilterPattern::parse("**/*.rs").unwrap();
assert!(pattern.matches(Path::new("src/foo.rs"), false));
assert!(pattern.matches(Path::new("a/b/c/d.rs"), false));
assert!(pattern.matches(Path::new("foo.rs"), false));
}
#[test]
fn test_pattern_question_mark() {
let pattern = FilterPattern::parse("file?.txt").unwrap();
assert!(pattern.matches(Path::new("file1.txt"), false));
assert!(pattern.matches(Path::new("fileA.txt"), false));
assert!(!pattern.matches(Path::new("file12.txt"), false));
assert!(!pattern.matches(Path::new("file.txt"), false));
}
#[test]
fn test_pattern_character_class() {
let pattern = FilterPattern::parse("[abc].txt").unwrap();
assert!(pattern.matches(Path::new("a.txt"), false));
assert!(pattern.matches(Path::new("b.txt"), false));
assert!(pattern.matches(Path::new("c.txt"), false));
assert!(!pattern.matches(Path::new("d.txt"), false));
}
#[test]
fn test_pattern_anchored() {
let pattern = FilterPattern::parse("/src").unwrap();
assert!(pattern.anchored);
assert!(pattern.matches(Path::new("src"), true));
assert!(!pattern.matches(Path::new("foo/src"), true));
}
#[test]
fn test_pattern_dir_only() {
let pattern = FilterPattern::parse("build/").unwrap();
assert!(pattern.dir_only);
assert!(pattern.matches(Path::new("build"), true));
assert!(!pattern.matches(Path::new("build"), false)); }
#[test]
fn test_include_only_mode() {
let mut settings = FilterSettings::new();
settings.add_include("*.rs").unwrap();
settings.add_include("Cargo.toml").unwrap();
assert!(matches!(
settings.should_include(Path::new("main.rs"), false),
FilterResult::Included
));
assert!(matches!(
settings.should_include(Path::new("Cargo.toml"), false),
FilterResult::Included
));
assert!(matches!(
settings.should_include(Path::new("README.md"), false),
FilterResult::ExcludedByDefault
));
}
#[test]
fn test_exclude_only_mode() {
let mut settings = FilterSettings::new();
settings.add_exclude("*.log").unwrap();
settings.add_exclude("target/").unwrap();
assert!(matches!(
settings.should_include(Path::new("main.rs"), false),
FilterResult::Included
));
match settings.should_include(Path::new("debug.log"), false) {
FilterResult::ExcludedByPattern(p) => assert_eq!(p, "*.log"),
other => panic!("expected ExcludedByPattern, got {:?}", other),
}
match settings.should_include(Path::new("target"), true) {
FilterResult::ExcludedByPattern(p) => assert_eq!(p, "target/"),
other => panic!("expected ExcludedByPattern, got {:?}", other),
}
}
#[test]
fn test_include_then_exclude() {
let mut settings = FilterSettings::new();
settings.add_include("*.rs").unwrap();
settings.add_exclude("test_*.rs").unwrap();
assert!(matches!(
settings.should_include(Path::new("main.rs"), false),
FilterResult::Included
));
match settings.should_include(Path::new("test_foo.rs"), false) {
FilterResult::ExcludedByPattern(p) => assert_eq!(p, "test_*.rs"),
other => panic!("expected ExcludedByPattern, got {:?}", other),
}
assert!(matches!(
settings.should_include(Path::new("README.md"), false),
FilterResult::ExcludedByDefault
));
}
#[test]
fn test_filter_file_basic() {
let content = r#"
# this is a comment
--include *.rs
--include Cargo.toml
--exclude target/
--exclude *.log
"#;
let settings = FilterSettings::parse_content(content).unwrap();
assert_eq!(settings.includes.len(), 2);
assert_eq!(settings.excludes.len(), 2);
}
#[test]
fn test_filter_file_comments() {
let content = "# only comments\n# and empty lines\n\n";
let settings = FilterSettings::parse_content(content).unwrap();
assert!(settings.is_empty());
}
#[test]
fn test_filter_file_syntax_error() {
let content = "invalid line without prefix";
let result = FilterSettings::parse_content(content);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("line 1"));
assert!(err.contains("invalid syntax"));
}
#[test]
fn test_empty_pattern_error() {
let result = FilterPattern::parse("");
assert!(result.is_err());
}
#[test]
fn test_is_empty() {
let empty = FilterSettings::new();
assert!(empty.is_empty());
let mut with_include = FilterSettings::new();
with_include.add_include("*.rs").unwrap();
assert!(!with_include.is_empty());
let mut with_exclude = FilterSettings::new();
with_exclude.add_exclude("*.log").unwrap();
assert!(!with_exclude.is_empty());
}
#[test]
fn test_has_includes() {
let empty = FilterSettings::new();
assert!(!empty.has_includes());
let mut with_include = FilterSettings::new();
with_include.add_include("*.rs").unwrap();
assert!(with_include.has_includes());
let mut with_exclude = FilterSettings::new();
with_exclude.add_exclude("*.log").unwrap();
assert!(!with_exclude.has_includes());
let mut with_both = FilterSettings::new();
with_both.add_include("*.rs").unwrap();
with_both.add_exclude("*.log").unwrap();
assert!(with_both.has_includes());
}
#[test]
fn test_filename_match_for_simple_patterns() {
let pattern = FilterPattern::parse("*.rs").unwrap();
assert!(pattern.matches(Path::new("foo.rs"), false));
assert!(pattern.matches(Path::new("src/foo.rs"), false)); assert!(pattern.matches(Path::new("a/b/c/foo.rs"), false));
}
#[test]
fn test_path_pattern_requires_full_match() {
let pattern = FilterPattern::parse("src/*.rs").unwrap();
assert!(pattern.matches(Path::new("src/foo.rs"), false));
assert!(!pattern.matches(Path::new("foo.rs"), false));
assert!(!pattern.matches(Path::new("other/src/foo.rs"), false));
}
#[test]
fn test_double_star_matches_nested_paths() {
let pattern = FilterPattern::parse("**/*.rs").unwrap();
assert!(pattern.matches(Path::new("foo.rs"), false));
assert!(pattern.matches(Path::new("src/foo.rs"), false));
assert!(pattern.matches(Path::new("src/lib/foo.rs"), false));
assert!(pattern.matches(Path::new("a/b/c/d/e.rs"), false));
}
#[test]
fn test_anchored_pattern_matches_only_at_root() {
let pattern = FilterPattern::parse("/src").unwrap();
assert!(pattern.matches(Path::new("src"), true));
assert!(!pattern.matches(Path::new("foo/src"), true));
assert!(!pattern.matches(Path::new("a/b/src"), true));
}
#[test]
fn test_nested_directory_pattern() {
let pattern = FilterPattern::parse("src/lib/").unwrap();
assert!(pattern.matches(Path::new("src/lib"), true));
assert!(!pattern.matches(Path::new("lib"), true));
assert!(!pattern.matches(Path::new("other/src/lib"), true));
}
#[test]
fn test_dir_only_simple_pattern_matches_at_any_level() {
let pattern = FilterPattern::parse("target/").unwrap();
assert!(pattern.dir_only);
assert!(!pattern.anchored);
assert!(pattern.matches(Path::new("target"), true));
assert!(pattern.matches(Path::new("foo/target"), true));
assert!(pattern.matches(Path::new("a/b/target"), true));
assert!(!pattern.matches(Path::new("target"), false));
assert!(!pattern.matches(Path::new("foo/target"), false));
}
#[test]
fn test_dir_only_pattern_could_contain_matches() {
let mut settings = FilterSettings::new();
settings.add_include("target/").unwrap();
let pattern = &settings.includes[0];
assert!(settings.could_contain_matches(Path::new("foo"), pattern));
assert!(settings.could_contain_matches(Path::new("a/b"), pattern));
assert!(settings.could_contain_matches(Path::new("src"), pattern));
}
#[test]
fn test_precedence_exclude_overrides_include() {
let mut settings = FilterSettings::new();
settings.add_include("*.rs").unwrap();
settings.add_exclude("test_*.rs").unwrap();
match settings.should_include(Path::new("test_main.rs"), false) {
FilterResult::ExcludedByPattern(p) => assert_eq!(p, "test_*.rs"),
other => panic!("expected ExcludedByPattern, got {:?}", other),
}
assert!(matches!(
settings.should_include(Path::new("main.rs"), false),
FilterResult::Included
));
}
#[test]
fn test_should_include_root_item_non_anchored_exclude() {
let mut settings = FilterSettings::new();
settings.add_exclude("*.log").unwrap();
match settings.should_include_root_item(Path::new("debug.log"), false) {
FilterResult::ExcludedByPattern(p) => assert_eq!(p, "*.log"),
other => panic!("expected ExcludedByPattern, got {:?}", other),
}
assert!(matches!(
settings.should_include_root_item(Path::new("main.rs"), false),
FilterResult::Included
));
}
#[test]
fn test_should_include_root_item_anchored_exclude_skipped() {
let mut settings = FilterSettings::new();
settings.add_exclude("/target/").unwrap();
assert!(matches!(
settings.should_include_root_item(Path::new("target"), true),
FilterResult::Included
));
}
#[test]
fn test_should_include_root_item_non_anchored_include() {
let mut settings = FilterSettings::new();
settings.add_include("*.rs").unwrap();
assert!(matches!(
settings.should_include_root_item(Path::new("main.rs"), false),
FilterResult::Included
));
assert!(matches!(
settings.should_include_root_item(Path::new("readme.md"), false),
FilterResult::ExcludedByDefault
));
}
#[test]
fn test_should_include_root_item_anchored_include_skipped() {
let mut settings = FilterSettings::new();
settings.add_include("/bar").unwrap();
assert!(matches!(
settings.should_include_root_item(Path::new("foo"), true),
FilterResult::Included
));
assert!(matches!(
settings.should_include_root_item(Path::new("baz"), false),
FilterResult::ExcludedByDefault
));
}
#[test]
fn test_should_include_root_item_mixed_patterns() {
let mut settings = FilterSettings::new();
settings.add_include("*.rs").unwrap();
settings.add_include("/bar").unwrap();
settings.add_exclude("test_*.rs").unwrap();
assert!(matches!(
settings.should_include_root_item(Path::new("main.rs"), false),
FilterResult::Included
));
match settings.should_include_root_item(Path::new("test_foo.rs"), false) {
FilterResult::ExcludedByPattern(p) => assert_eq!(p, "test_*.rs"),
other => panic!("expected ExcludedByPattern, got {:?}", other),
}
assert!(matches!(
settings.should_include_root_item(Path::new("foo"), true),
FilterResult::Included
));
}
#[test]
fn test_could_contain_matches_anchored_double_star() {
let mut settings = FilterSettings::new();
settings.add_include("/src/**").unwrap();
let pattern = &settings.includes[0];
assert!(settings.could_contain_matches(Path::new(""), pattern));
assert!(settings.could_contain_matches(Path::new("src"), pattern));
assert!(settings.could_contain_matches(Path::new("src/foo"), pattern));
assert!(settings.could_contain_matches(Path::new("src/foo/bar"), pattern));
assert!(!settings.could_contain_matches(Path::new("build"), pattern));
assert!(!settings.could_contain_matches(Path::new("target"), pattern));
assert!(!settings.could_contain_matches(Path::new("build/src"), pattern));
}
#[test]
fn test_could_contain_matches_non_anchored_double_star() {
let mut settings = FilterSettings::new();
settings.add_include("**/*.rs").unwrap();
let pattern = &settings.includes[0];
assert!(settings.could_contain_matches(Path::new("src"), pattern));
assert!(settings.could_contain_matches(Path::new("build"), pattern));
assert!(settings.could_contain_matches(Path::new("any/path"), pattern));
}
#[test]
fn test_could_contain_matches_nested_prefix() {
let mut settings = FilterSettings::new();
settings.add_include("/src/foo/**").unwrap();
let pattern = &settings.includes[0];
assert!(settings.could_contain_matches(Path::new(""), pattern));
assert!(settings.could_contain_matches(Path::new("src"), pattern));
assert!(settings.could_contain_matches(Path::new("src/foo"), pattern));
assert!(settings.could_contain_matches(Path::new("src/foo/bar"), pattern));
assert!(!settings.could_contain_matches(Path::new("build"), pattern));
assert!(!settings.could_contain_matches(Path::new("src/bar"), pattern));
}
#[test]
fn test_extract_literal_prefix() {
assert_eq!(FilterSettings::extract_literal_prefix("src/**"), "src");
assert_eq!(
FilterSettings::extract_literal_prefix("src/foo/**"),
"src/foo"
);
assert_eq!(FilterSettings::extract_literal_prefix("**/*.rs"), "");
assert_eq!(FilterSettings::extract_literal_prefix("*.rs"), "");
assert_eq!(FilterSettings::extract_literal_prefix("src/*.rs"), "src");
assert_eq!(
FilterSettings::extract_literal_prefix("src/foo/bar"),
"src/foo/bar"
);
assert_eq!(FilterSettings::extract_literal_prefix("bar"), "bar");
assert_eq!(FilterSettings::extract_literal_prefix("src[0-9]/*.rs"), "");
}
#[test]
fn test_directly_matches_include_simple_pattern() {
let mut settings = FilterSettings::new();
settings.add_include("*.txt").unwrap();
assert!(settings.directly_matches_include(Path::new("foo.txt"), false));
assert!(settings.directly_matches_include(Path::new("bar/foo.txt"), false));
assert!(!settings.directly_matches_include(Path::new("foo.rs"), false));
assert!(!settings.directly_matches_include(Path::new("txt"), true));
}
#[test]
fn test_directly_matches_include_anchored_pattern() {
let mut settings = FilterSettings::new();
settings.add_include("/foo").unwrap();
assert!(settings.directly_matches_include(Path::new("foo"), true));
assert!(settings.directly_matches_include(Path::new("foo"), false));
assert!(!settings.directly_matches_include(Path::new("bar/foo"), true));
}
#[test]
fn test_directly_matches_include_empty_includes() {
let settings = FilterSettings::new();
assert!(settings.directly_matches_include(Path::new("anything"), true));
assert!(settings.directly_matches_include(Path::new("foo/bar"), false));
}
#[test]
fn test_directly_matches_include_path_pattern() {
let mut settings = FilterSettings::new();
settings.add_include("src/*.rs").unwrap();
assert!(settings.directly_matches_include(Path::new("src/foo.rs"), false));
assert!(!settings.directly_matches_include(Path::new("foo.rs"), false));
assert!(!settings.directly_matches_include(Path::new("other/foo.rs"), false));
}
#[test]
fn test_directly_matches_include_dir_only_pattern() {
let mut settings = FilterSettings::new();
settings.add_include("target/").unwrap();
assert!(settings.directly_matches_include(Path::new("target"), true));
assert!(settings.directly_matches_include(Path::new("foo/target"), true));
assert!(!settings.directly_matches_include(Path::new("target"), false));
}
}