use anyhow::{Context, anyhow};
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()
&& let Some(file_name) = relative_path.file_name()
&& 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)
&& (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 from_args(
filter_file: Option<&std::path::Path>,
include: &[String],
exclude: &[String],
) -> Result<Option<Self>, anyhow::Error> {
if filter_file.is_some() && (!include.is_empty() || !exclude.is_empty()) {
return Err(anyhow!(
"filter_file is mutually exclusive with include/exclude patterns"
));
}
if let Some(path) = filter_file {
return Ok(Some(Self::from_file(path)?));
}
if include.is_empty() && exclude.is_empty() {
return Ok(None);
}
let mut settings = Self::new();
for p in include {
settings.add_include(p)?;
}
for p in exclude {
settings.add_exclude(p)?;
}
Ok(Some(settings))
}
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(Debug, Clone, Default)]
pub struct TimeFilter {
pub modified_before: Option<std::time::Duration>,
pub created_before: Option<std::time::Duration>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimeFilterResult {
Matched,
TooNewModified,
TooNewCreated,
TooNewBoth,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimeSkipReason {
TooNewModified,
TooNewCreated,
TooNewBoth,
}
impl TimeFilterResult {
pub fn as_skip_reason(self) -> Option<TimeSkipReason> {
match self {
TimeFilterResult::Matched => None,
TimeFilterResult::TooNewModified => Some(TimeSkipReason::TooNewModified),
TimeFilterResult::TooNewCreated => Some(TimeSkipReason::TooNewCreated),
TimeFilterResult::TooNewBoth => Some(TimeSkipReason::TooNewBoth),
}
}
}
impl TimeFilter {
pub fn is_empty(&self) -> bool {
self.modified_before.is_none() && self.created_before.is_none()
}
pub fn matches(&self, metadata: &std::fs::Metadata) -> anyhow::Result<TimeFilterResult> {
let mtime = if self.modified_before.is_some() {
Some(
metadata
.modified()
.context("failed to read mtime from metadata")?,
)
} else {
None
};
let btime = if self.created_before.is_some() {
Some(
metadata
.created()
.context("failed to read birth time (created) from metadata")?,
)
} else {
None
};
Ok(self.evaluate(mtime, btime, std::time::SystemTime::now()))
}
fn evaluate(
&self,
mtime: Option<std::time::SystemTime>,
btime: Option<std::time::SystemTime>,
now: std::time::SystemTime,
) -> TimeFilterResult {
let modified_too_new = self
.modified_before
.is_some_and(|threshold| mtime.is_none_or(|t| !is_at_least_age(now, t, threshold)));
let created_too_new = self
.created_before
.is_some_and(|threshold| btime.is_none_or(|t| !is_at_least_age(now, t, threshold)));
match (modified_too_new, created_too_new) {
(false, false) => TimeFilterResult::Matched,
(true, false) => TimeFilterResult::TooNewModified,
(false, true) => TimeFilterResult::TooNewCreated,
(true, true) => TimeFilterResult::TooNewBoth,
}
}
}
fn is_at_least_age(
now: std::time::SystemTime,
timestamp: std::time::SystemTime,
age: std::time::Duration,
) -> bool {
match now.duration_since(timestamp) {
Ok(elapsed) => elapsed >= age,
Err(_) => false,
}
}
#[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));
}
mod time_filter_tests {
use super::*;
use crate::testutils;
fn write_with_mtime(path: &std::path::Path, age: std::time::Duration) {
std::fs::write(path, "x").unwrap();
let past = filetime::FileTime::from_system_time(std::time::SystemTime::now() - age);
filetime::set_file_mtime(path, past).unwrap();
}
#[test]
fn is_empty_when_no_thresholds_set() {
assert!(TimeFilter::default().is_empty());
let only_mtime = TimeFilter {
modified_before: Some(std::time::Duration::from_secs(1)),
created_before: None,
};
assert!(!only_mtime.is_empty());
let only_btime = TimeFilter {
modified_before: None,
created_before: Some(std::time::Duration::from_secs(1)),
};
assert!(!only_btime.is_empty());
}
#[tokio::test]
async fn matches_returns_matched_when_no_thresholds() {
let tmp = testutils::create_temp_dir().await.unwrap();
let path = tmp.join("file");
write_with_mtime(&path, std::time::Duration::from_secs(0));
let metadata = std::fs::metadata(&path).unwrap();
assert_eq!(
TimeFilter::default().matches(&metadata).unwrap(),
TimeFilterResult::Matched
);
}
#[tokio::test]
async fn matches_when_mtime_older_than_threshold() {
let tmp = testutils::create_temp_dir().await.unwrap();
let path = tmp.join("file");
write_with_mtime(&path, std::time::Duration::from_secs(7200));
let metadata = std::fs::metadata(&path).unwrap();
let filter = TimeFilter {
modified_before: Some(std::time::Duration::from_secs(3600)),
created_before: None,
};
assert_eq!(
filter.matches(&metadata).unwrap(),
TimeFilterResult::Matched
);
}
#[tokio::test]
async fn reports_too_new_modified_when_mtime_recent() {
let tmp = testutils::create_temp_dir().await.unwrap();
let path = tmp.join("file");
write_with_mtime(&path, std::time::Duration::from_secs(0));
let metadata = std::fs::metadata(&path).unwrap();
let filter = TimeFilter {
modified_before: Some(std::time::Duration::from_secs(3600)),
created_before: None,
};
assert_eq!(
filter.matches(&metadata).unwrap(),
TimeFilterResult::TooNewModified
);
}
#[tokio::test]
async fn matches_exercises_btime_deterministically() {
let tmp = testutils::create_temp_dir().await.unwrap();
let path = tmp.join("file");
std::fs::write(&path, "x").unwrap();
let metadata = std::fs::metadata(&path).unwrap();
let filter = TimeFilter {
modified_before: None,
created_before: Some(std::time::Duration::from_secs(3600)),
};
let result = filter.matches(&metadata);
match metadata.created() {
Ok(_) => assert_eq!(result.unwrap(), TimeFilterResult::TooNewCreated),
Err(_) => assert!(
result.is_err(),
"matches() must return Err when created_before is set but btime is unavailable"
),
}
}
#[tokio::test]
async fn matches_with_zero_threshold_is_always_satisfied() {
let tmp = testutils::create_temp_dir().await.unwrap();
let path = tmp.join("file");
write_with_mtime(&path, std::time::Duration::from_secs(0));
let metadata = std::fs::metadata(&path).unwrap();
let filter = TimeFilter {
modified_before: Some(std::time::Duration::from_secs(0)),
created_before: None,
};
assert_eq!(
filter.matches(&metadata).unwrap(),
TimeFilterResult::Matched
);
}
mod evaluate_and_or_logic {
use super::*;
fn now() -> std::time::SystemTime {
std::time::UNIX_EPOCH + std::time::Duration::from_secs(10_000_000)
}
fn age_before(
t: std::time::SystemTime,
age: std::time::Duration,
) -> std::time::SystemTime {
t - age
}
#[test]
fn no_thresholds_always_matches_regardless_of_timestamps() {
let filter = TimeFilter::default();
assert_eq!(
filter.evaluate(None, None, now()),
TimeFilterResult::Matched
);
assert_eq!(
filter.evaluate(
Some(age_before(now(), std::time::Duration::from_secs(0))),
None,
now()
),
TimeFilterResult::Matched
);
}
#[test]
fn and_logic_both_pass_is_matched() {
let filter = TimeFilter {
modified_before: Some(std::time::Duration::from_secs(3600)),
created_before: Some(std::time::Duration::from_secs(3600)),
};
let old = age_before(now(), std::time::Duration::from_secs(7200));
assert_eq!(
filter.evaluate(Some(old), Some(old), now()),
TimeFilterResult::Matched
);
}
#[test]
fn and_logic_only_mtime_passes_reports_created_too_new() {
let filter = TimeFilter {
modified_before: Some(std::time::Duration::from_secs(3600)),
created_before: Some(std::time::Duration::from_secs(3600)),
};
let old = age_before(now(), std::time::Duration::from_secs(7200));
let recent = age_before(now(), std::time::Duration::from_secs(60));
assert_eq!(
filter.evaluate(Some(old), Some(recent), now()),
TimeFilterResult::TooNewCreated
);
}
#[test]
fn and_logic_only_btime_passes_reports_modified_too_new() {
let filter = TimeFilter {
modified_before: Some(std::time::Duration::from_secs(3600)),
created_before: Some(std::time::Duration::from_secs(3600)),
};
let old = age_before(now(), std::time::Duration::from_secs(7200));
let recent = age_before(now(), std::time::Duration::from_secs(60));
assert_eq!(
filter.evaluate(Some(recent), Some(old), now()),
TimeFilterResult::TooNewModified
);
}
#[test]
fn and_logic_neither_passes_reports_too_new_both() {
let filter = TimeFilter {
modified_before: Some(std::time::Duration::from_secs(3600)),
created_before: Some(std::time::Duration::from_secs(3600)),
};
let recent = age_before(now(), std::time::Duration::from_secs(60));
assert_eq!(
filter.evaluate(Some(recent), Some(recent), now()),
TimeFilterResult::TooNewBoth
);
}
#[test]
fn threshold_boundary_exactly_at_age_matches() {
let filter = TimeFilter {
modified_before: Some(std::time::Duration::from_secs(3600)),
created_before: None,
};
let exact = age_before(now(), std::time::Duration::from_secs(3600));
assert_eq!(
filter.evaluate(Some(exact), None, now()),
TimeFilterResult::Matched
);
}
#[test]
fn future_timestamp_treated_as_too_new() {
let filter = TimeFilter {
modified_before: Some(std::time::Duration::from_secs(1)),
created_before: None,
};
let future = now() + std::time::Duration::from_secs(3600);
assert_eq!(
filter.evaluate(Some(future), None, now()),
TimeFilterResult::TooNewModified
);
}
#[test]
fn missing_timestamp_when_threshold_configured_is_too_new() {
let filter = TimeFilter {
modified_before: Some(std::time::Duration::from_secs(3600)),
created_before: Some(std::time::Duration::from_secs(3600)),
};
let old = age_before(now(), std::time::Duration::from_secs(7200));
assert_eq!(
filter.evaluate(Some(old), None, now()),
TimeFilterResult::TooNewCreated
);
assert_eq!(
filter.evaluate(None, Some(old), now()),
TimeFilterResult::TooNewModified
);
assert_eq!(
filter.evaluate(None, None, now()),
TimeFilterResult::TooNewBoth
);
}
}
mod skip_reason {
use super::*;
#[test]
fn matched_yields_none() {
assert_eq!(TimeFilterResult::Matched.as_skip_reason(), None);
}
#[test]
fn too_new_variants_yield_matching_reasons() {
assert_eq!(
TimeFilterResult::TooNewModified.as_skip_reason(),
Some(TimeSkipReason::TooNewModified)
);
assert_eq!(
TimeFilterResult::TooNewCreated.as_skip_reason(),
Some(TimeSkipReason::TooNewCreated)
);
assert_eq!(
TimeFilterResult::TooNewBoth.as_skip_reason(),
Some(TimeSkipReason::TooNewBoth)
);
}
}
}
mod from_args_tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
struct TempFilterFile {
path: std::path::PathBuf,
}
impl TempFilterFile {
fn new(content: &str) -> Self {
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir()
.join(format!("rcp-from-args-test-{}-{n}.txt", std::process::id()));
std::fs::write(&path, content).unwrap();
Self { path }
}
fn path(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for TempFilterFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
#[test]
fn returns_none_when_nothing_specified() {
let result = FilterSettings::from_args(None, &[], &[]).unwrap();
assert!(result.is_none());
}
#[test]
fn builds_from_include_only() {
let include = vec!["*.rs".to_string(), "Cargo.toml".to_string()];
let settings = FilterSettings::from_args(None, &include, &[])
.unwrap()
.expect("should return Some when include is non-empty");
assert_eq!(settings.includes.len(), 2);
assert!(settings.excludes.is_empty());
}
#[test]
fn builds_from_exclude_only() {
let exclude = vec!["*.log".to_string(), "target/".to_string()];
let settings = FilterSettings::from_args(None, &[], &exclude)
.unwrap()
.expect("should return Some when exclude is non-empty");
assert!(settings.includes.is_empty());
assert_eq!(settings.excludes.len(), 2);
}
#[test]
fn builds_from_include_and_exclude() {
let include = vec!["*.rs".to_string()];
let exclude = vec!["target/".to_string()];
let settings = FilterSettings::from_args(None, &include, &exclude)
.unwrap()
.expect("should return Some");
assert_eq!(settings.includes.len(), 1);
assert_eq!(settings.excludes.len(), 1);
}
#[test]
fn loads_from_filter_file() {
let file = TempFilterFile::new("--include *.rs\n--exclude target/\n");
let settings = FilterSettings::from_args(Some(file.path()), &[], &[])
.unwrap()
.expect("should return Some when filter file is read");
assert_eq!(settings.includes.len(), 1);
assert_eq!(settings.excludes.len(), 1);
}
#[test]
fn errors_when_filter_file_combined_with_include() {
let file = TempFilterFile::new("--include *.rs\n");
let include = vec!["*.txt".to_string()];
let err = FilterSettings::from_args(Some(file.path()), &include, &[]).unwrap_err();
assert!(err.to_string().contains("mutually exclusive"));
}
#[test]
fn errors_when_filter_file_combined_with_exclude() {
let file = TempFilterFile::new("--include *.rs\n");
let exclude = vec!["*.log".to_string()];
let err = FilterSettings::from_args(Some(file.path()), &[], &exclude).unwrap_err();
assert!(err.to_string().contains("mutually exclusive"));
}
#[test]
fn propagates_invalid_include_pattern() {
let include = vec!["".to_string()];
assert!(FilterSettings::from_args(None, &include, &[]).is_err());
}
#[test]
fn propagates_missing_filter_file() {
let path = std::path::PathBuf::from("/nonexistent/path/filters.txt");
assert!(FilterSettings::from_args(Some(&path), &[], &[]).is_err());
}
}
}