Skip to main content

common/
filter.rs

1//! Pattern-based file filtering for include/exclude operations
2//!
3//! This module provides glob pattern matching for filtering files during copy, link, and remove operations.
4//!
5//! # Pattern Syntax
6//!
7//! - `*` matches anything except `/`
8//! - `**` matches anything including `/` (crosses directories)
9//! - `?` matches a single character (except `/`)
10//! - `[...]` character classes
11//! - Leading `/` anchors to source root
12//! - Trailing `/` matches only directories
13//!
14//! # Examples
15//!
16//! ```
17//! use common::filter::{FilterSettings, FilterResult};
18//! use std::path::Path;
19//!
20//! let mut settings = FilterSettings::default();
21//! settings.add_exclude("*.log").unwrap();
22//! settings.add_exclude("target/").unwrap();
23//!
24//! // .log files are excluded
25//! assert!(matches!(
26//!     settings.should_include(Path::new("debug.log"), false),
27//!     FilterResult::ExcludedByPattern(_)
28//! ));
29//!
30//! // other files are included
31//! assert!(matches!(
32//!     settings.should_include(Path::new("main.rs"), false),
33//!     FilterResult::Included
34//! ));
35//! ```
36
37use anyhow::{Context, anyhow};
38use serde::{Deserialize, Deserializer, Serialize, Serializer};
39use std::path::Path;
40
41/// A compiled filter pattern with metadata about its original form
42#[derive(Debug, Clone)]
43pub struct FilterPattern {
44    /// original pattern string for dry-run explain output
45    pub original: String,
46    /// compiled glob matcher
47    matcher: globset::GlobMatcher,
48    /// pattern ends with / (matches only directories)
49    pub dir_only: bool,
50    /// pattern starts with / (anchored to root)
51    pub anchored: bool,
52}
53
54impl FilterPattern {
55    /// Parse a pattern string into a FilterPattern
56    pub fn parse(pattern: &str) -> Result<Self, anyhow::Error> {
57        if pattern.is_empty() {
58            return Err(anyhow!("empty pattern is not allowed"));
59        }
60        let original = pattern.to_string();
61        let dir_only = pattern.ends_with('/');
62        let anchored = pattern.starts_with('/');
63        // strip leading/trailing markers for glob compilation
64        let pattern_str = pattern.trim_start_matches('/').trim_end_matches('/');
65        if pattern_str.is_empty() {
66            return Err(anyhow!(
67                "pattern '{}' results in empty glob after stripping / markers",
68                pattern
69            ));
70        }
71        // build glob with appropriate settings
72        let glob = globset::GlobBuilder::new(pattern_str)
73            .literal_separator(true) // * doesn't match /
74            .build()
75            .with_context(|| format!("invalid glob pattern: {}", pattern))?;
76        let matcher = glob.compile_matcher();
77        Ok(Self {
78            original,
79            matcher,
80            dir_only,
81            anchored,
82        })
83    }
84    /// Check if this pattern contains path separators (excluding leading/trailing / markers).
85    /// Path patterns require full path matching, while simple patterns can match filenames.
86    fn is_path_pattern(&self) -> bool {
87        // strip leading / (anchored marker) and trailing / (dir-only marker)
88        let core = self.original.trim_start_matches('/').trim_end_matches('/');
89        core.contains('/')
90    }
91    /// Check if this pattern matches the given path
92    pub fn matches(&self, relative_path: &Path, is_dir: bool) -> bool {
93        // directory-only patterns only match directories
94        if self.dir_only && !is_dir {
95            return false;
96        }
97        if self.anchored {
98            // anchored patterns match from the root only
99            self.matcher.is_match(relative_path)
100        } else {
101            // non-anchored patterns can match any component or the full path
102            // first try full path match
103            if self.matcher.is_match(relative_path) {
104                return true;
105            }
106            // for non-anchored patterns, also try matching against just the filename
107            // unless it's a path pattern (in which case full path match is required)
108            if !self.is_path_pattern()
109                && let Some(file_name) = relative_path.file_name()
110                && self.matcher.is_match(Path::new(file_name))
111            {
112                return true;
113            }
114            false
115        }
116    }
117}
118
119/// Result of checking whether a path should be included
120#[derive(Debug, Clone)]
121pub enum FilterResult {
122    /// path should be processed
123    Included,
124    /// path was excluded because include patterns exist but none matched
125    ExcludedByDefault,
126    /// path was excluded by a specific pattern
127    ExcludedByPattern(String),
128}
129
130/// Settings for filtering files based on include/exclude patterns
131#[derive(Debug, Clone, Default)]
132pub struct FilterSettings {
133    /// patterns for files to include (if non-empty, only matching files are included)
134    pub includes: Vec<FilterPattern>,
135    /// patterns for files to exclude
136    pub excludes: Vec<FilterPattern>,
137}
138
139impl FilterSettings {
140    /// Create new empty filter settings
141    pub fn new() -> Self {
142        Self::default()
143    }
144    /// Add an include pattern
145    pub fn add_include(&mut self, pattern: &str) -> Result<(), anyhow::Error> {
146        self.includes.push(FilterPattern::parse(pattern)?);
147        Ok(())
148    }
149    /// Add an exclude pattern
150    pub fn add_exclude(&mut self, pattern: &str) -> Result<(), anyhow::Error> {
151        self.excludes.push(FilterPattern::parse(pattern)?);
152        Ok(())
153    }
154    /// Check if this filter has any patterns
155    pub fn is_empty(&self) -> bool {
156        self.includes.is_empty() && self.excludes.is_empty()
157    }
158    /// Check if this filter has any include patterns
159    pub fn has_includes(&self) -> bool {
160        !self.includes.is_empty()
161    }
162    /// Determine if a root item (the source itself) should be included based on filter patterns.
163    ///
164    /// This is a specialized version of `should_include` for root items (the source directory
165    /// or file being copied). Anchored patterns (starting with `/`) are skipped because they
166    /// are meant to match paths INSIDE the source root, not the source root itself.
167    ///
168    /// For root files, only non-anchored simple patterns (no `/`) can directly match the name.
169    /// For root directories, they're always traversed if include patterns exist (to find
170    /// matching content inside).
171    ///
172    /// For example, pattern `/bar` on source `foo/` should match `foo/bar`, not filter out `foo`.
173    pub fn should_include_root_item(&self, name: &Path, is_dir: bool) -> FilterResult {
174        // check excludes first - only non-anchored, simple patterns apply to root items
175        // (patterns with path separators like `bar/baz` match content inside, not the root)
176        // note: trailing `/` is a dir-only marker, not a path separator
177        for pattern in &self.excludes {
178            if !pattern.anchored
179                && !Self::is_path_pattern(&pattern.original)
180                && pattern.matches(name, is_dir)
181            {
182                return FilterResult::ExcludedByPattern(pattern.original.clone());
183            }
184        }
185        // if there are include patterns...
186        if !self.includes.is_empty() {
187            // for root files, check if any non-anchored simple pattern matches
188            if !is_dir {
189                for pattern in &self.includes {
190                    if !pattern.anchored
191                        && !Self::is_path_pattern(&pattern.original)
192                        && pattern.matches(name, false)
193                    {
194                        return FilterResult::Included;
195                    }
196                }
197                // no simple pattern matched the root file
198                return FilterResult::ExcludedByDefault;
199            }
200            // for root directories, always traverse to find matching content inside
201            // (both anchored patterns like /bar and path patterns like bar/*.txt
202            // need us to traverse the directory to find matches)
203            return FilterResult::Included;
204        }
205        // no includes and not excluded = included
206        FilterResult::Included
207    }
208    /// Check if a pattern is a path pattern (contains `/` other than leading/trailing markers)
209    fn is_path_pattern(original: &str) -> bool {
210        let trimmed = original.trim_start_matches('/').trim_end_matches('/');
211        trimmed.contains('/')
212    }
213    /// Determine if a path should be included based on filter patterns
214    ///
215    /// # Precedence
216    /// - If only excludes: include everything except matches
217    /// - If only includes: include only matches (exclude everything else by default)
218    /// - If both: excludes take priority (excludes checked first, then includes)
219    ///
220    /// # Directory handling
221    /// Directories are traversed when include patterns exist if they could potentially contain
222    /// matching files. For non-anchored patterns (like `*.txt`), all directories are traversed.
223    /// For anchored patterns (like `/bar`), only directories matching the pattern prefix are traversed.
224    pub fn should_include(&self, relative_path: &Path, is_dir: bool) -> FilterResult {
225        // check excludes first - if matched, path is excluded
226        for pattern in &self.excludes {
227            if pattern.matches(relative_path, is_dir) {
228                return FilterResult::ExcludedByPattern(pattern.original.clone());
229            }
230        }
231        // if there are include patterns, at least one must match
232        if !self.includes.is_empty() {
233            // first check if this path matches any include pattern
234            for pattern in &self.includes {
235                if pattern.matches(relative_path, is_dir) {
236                    return FilterResult::Included;
237                }
238            }
239            // for directories that don't directly match, check if they could contain matches
240            if is_dir {
241                for pattern in &self.includes {
242                    if self.could_contain_matches(relative_path, pattern) {
243                        return FilterResult::Included;
244                    }
245                }
246            }
247            return FilterResult::ExcludedByDefault;
248        }
249        // no includes specified and not excluded = included
250        FilterResult::Included
251    }
252    /// Check if a path directly matches any include pattern (not just could_contain_matches).
253    /// Used to determine if an empty directory should be kept.
254    ///
255    /// Returns true if:
256    /// - No include patterns exist (everything is directly included)
257    /// - At least one include pattern directly matches the path
258    ///
259    /// Returns false if:
260    /// - Include patterns exist but none match the path (directory was only traversed)
261    pub fn directly_matches_include(&self, relative_path: &std::path::Path, is_dir: bool) -> bool {
262        if self.includes.is_empty() {
263            return true; // no includes = everything directly included
264        }
265        for pattern in &self.includes {
266            if pattern.matches(relative_path, is_dir) {
267                return true;
268            }
269        }
270        false
271    }
272    /// Check if a directory could potentially contain files matching the pattern
273    pub fn could_contain_matches(&self, dir_path: &Path, pattern: &FilterPattern) -> bool {
274        // non-anchored simple patterns (no path separators) can match anywhere
275        if !pattern.anchored && !pattern.is_path_pattern() {
276            return true;
277        }
278        // extract the non-wildcard prefix from the pattern
279        // e.g., "/src/**" -> "src", "src/foo/**/*.rs" -> "src/foo", "**/*.rs" -> ""
280        let pattern_path = pattern
281            .original
282            .trim_start_matches('/')
283            .trim_end_matches('/');
284        let prefix = Self::extract_literal_prefix(pattern_path);
285        let dir_str = dir_path.to_string_lossy();
286        // if no literal prefix (pattern starts with wildcard like "**/*.rs"),
287        // it can match anywhere
288        if prefix.is_empty() {
289            return true;
290        }
291        // empty dir_path (root) is always an ancestor of any prefix
292        if dir_str.is_empty() {
293            return true;
294        }
295        // check if dir_path could lead to matches:
296        // 1. dir_path is an ancestor of prefix (e.g., "src" for prefix "src/foo")
297        // 2. dir_path equals the prefix
298        // 3. dir_path is a descendant of prefix (e.g., "src/foo/bar" for prefix "src")
299        // case 1 & 2: prefix starts with dir_path
300        if prefix.starts_with(&*dir_str) {
301            let after_dir = &prefix[dir_str.len()..];
302            // dir_path is ancestor if followed by '/' or is exact match
303            if after_dir.is_empty() || after_dir.starts_with('/') {
304                return true;
305            }
306        }
307        // case 3: dir_path is descendant of prefix
308        if let Some(after_prefix) = dir_str.strip_prefix(prefix)
309            && (after_prefix.is_empty() || after_prefix.starts_with('/'))
310        {
311            return true;
312        }
313        false
314    }
315    /// Extract the literal (non-wildcard) prefix from a pattern.
316    /// Returns the portion before any wildcard characters (*, ?, [), trimmed to complete path components.
317    /// Examples:
318    /// - "src/**" -> "src"
319    /// - "src/foo/**/*.rs" -> "src/foo"
320    /// - "**/*.rs" -> ""
321    /// - "bar" -> "bar" (no wildcards = entire pattern is literal)
322    /// - "bar/*.txt" -> "bar"
323    /// - "*.txt" -> ""
324    fn extract_literal_prefix(pattern: &str) -> &str {
325        // find first wildcard character
326        let wildcard_pos = pattern.find(['*', '?', '[']).unwrap_or(pattern.len());
327        // if no wildcards, entire pattern is literal
328        if wildcard_pos == pattern.len() {
329            return pattern;
330        }
331        // if wildcard at start, no prefix
332        if wildcard_pos == 0 {
333            return "";
334        }
335        // find the last '/' before the wildcard to get a complete path component
336        let prefix = &pattern[..wildcard_pos];
337        match prefix.rfind('/') {
338            Some(pos) => &pattern[..pos],
339            None => {
340                // no '/' before wildcard - pattern like "src*.txt" has no usable prefix
341                ""
342            }
343        }
344    }
345    /// Parse filter settings from a file
346    ///
347    /// # File Format
348    /// ```text
349    /// # comments supported
350    /// --include *.rs
351    /// --include Cargo.toml
352    /// --exclude target/
353    /// --exclude *.log
354    /// ```
355    pub fn from_file(path: &Path) -> Result<Self, anyhow::Error> {
356        let content = std::fs::read_to_string(path)
357            .with_context(|| format!("failed to read filter file: {:?}", path))?;
358        Self::parse_content(&content)
359    }
360    /// Build filter settings from CLI arguments. Either reads patterns from a
361    /// filter file, builds them from --include/--exclude lists, or returns
362    /// `None` when no filtering was requested.
363    ///
364    /// The file path and the include/exclude lists are mutually exclusive: the
365    /// CLI layer enforces this via clap's `conflicts_with_all`, and this helper
366    /// returns an error if a non-clap caller passes both.
367    pub fn from_args(
368        filter_file: Option<&std::path::Path>,
369        include: &[String],
370        exclude: &[String],
371    ) -> Result<Option<Self>, anyhow::Error> {
372        if filter_file.is_some() && (!include.is_empty() || !exclude.is_empty()) {
373            return Err(anyhow!(
374                "filter_file is mutually exclusive with include/exclude patterns"
375            ));
376        }
377        if let Some(path) = filter_file {
378            return Ok(Some(Self::from_file(path)?));
379        }
380        if include.is_empty() && exclude.is_empty() {
381            return Ok(None);
382        }
383        let mut settings = Self::new();
384        for p in include {
385            settings.add_include(p)?;
386        }
387        for p in exclude {
388            settings.add_exclude(p)?;
389        }
390        Ok(Some(settings))
391    }
392    /// Parse filter settings from a string (filter file format)
393    pub fn parse_content(content: &str) -> Result<Self, anyhow::Error> {
394        let mut settings = Self::new();
395        for (line_num, line) in content.lines().enumerate() {
396            let line = line.trim();
397            // skip empty lines and comments
398            if line.is_empty() || line.starts_with('#') {
399                continue;
400            }
401            let line_num = line_num + 1; // 1-based for error messages
402            if let Some(pattern) = line.strip_prefix("--include ") {
403                let pattern = pattern.trim();
404                settings
405                    .add_include(pattern)
406                    .with_context(|| format!("line {}: invalid include pattern", line_num))?;
407            } else if let Some(pattern) = line.strip_prefix("--exclude ") {
408                let pattern = pattern.trim();
409                settings
410                    .add_exclude(pattern)
411                    .with_context(|| format!("line {}: invalid exclude pattern", line_num))?;
412            } else {
413                return Err(anyhow!(
414                    "line {}: invalid syntax '{}', expected '--include PATTERN' or '--exclude PATTERN'",
415                    line_num,
416                    line
417                ));
418            }
419        }
420        Ok(settings)
421    }
422}
423
424/// Time-based filter for matching entries by age (mtime/btime).
425///
426/// Used in addition to glob filters to skip entries that are not yet old enough.
427/// Each threshold is interpreted as a minimum age — an entry matches when its
428/// timestamp is at least the threshold ago (i.e. the timestamp is "before"
429/// `now - threshold`). When both fields are set, both conditions must hold (AND).
430///
431/// `created_before` uses the file's birth time (`std::fs::Metadata::created()`).
432/// Some Linux filesystems do not expose btime; in that case [`Self::matches`]
433/// returns an error rather than silently treating the file as a match.
434#[derive(Debug, Clone, Default)]
435pub struct TimeFilter {
436    /// minimum age based on mtime; entry matches when mtime is at least this old
437    pub modified_before: Option<std::time::Duration>,
438    /// minimum age based on btime; entry matches when btime is at least this old
439    pub created_before: Option<std::time::Duration>,
440}
441
442/// Outcome of evaluating a [`TimeFilter`] against a metadata entry.
443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
444pub enum TimeFilterResult {
445    /// entry passes both configured time thresholds (or no thresholds are set)
446    Matched,
447    /// entry's mtime is too recent to satisfy `modified_before`
448    TooNewModified,
449    /// entry's btime is too recent to satisfy `created_before`
450    TooNewCreated,
451    /// entry fails both `modified_before` and `created_before`
452    TooNewBoth,
453}
454
455/// Why an entry was skipped by a [`TimeFilter`] — the subset of [`TimeFilterResult`]
456/// that does not include `Matched`. Constructed via `TimeFilterResult::as_skip_reason`.
457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
458pub enum TimeSkipReason {
459    /// entry's mtime is too recent
460    TooNewModified,
461    /// entry's btime is too recent
462    TooNewCreated,
463    /// both mtime and btime are too recent
464    TooNewBoth,
465}
466
467impl TimeFilterResult {
468    /// Returns the skip reason when this result represents a skip, or `None` when matched.
469    pub fn as_skip_reason(self) -> Option<TimeSkipReason> {
470        match self {
471            TimeFilterResult::Matched => None,
472            TimeFilterResult::TooNewModified => Some(TimeSkipReason::TooNewModified),
473            TimeFilterResult::TooNewCreated => Some(TimeSkipReason::TooNewCreated),
474            TimeFilterResult::TooNewBoth => Some(TimeSkipReason::TooNewBoth),
475        }
476    }
477}
478
479impl TimeFilter {
480    /// Returns true when no time thresholds are configured.
481    pub fn is_empty(&self) -> bool {
482        self.modified_before.is_none() && self.created_before.is_none()
483    }
484    /// Evaluate this filter against `metadata`.
485    ///
486    /// Returns:
487    /// - `Ok(TimeFilterResult::Matched)` when the entry passes all configured thresholds.
488    /// - `Ok(TimeFilterResult::TooNew*)` when one or both thresholds are not yet met.
489    /// - `Err(_)` when `created_before` is configured and the underlying `created()`
490    ///   call fails (e.g., a filesystem that does not expose birth time).
491    pub fn matches(&self, metadata: &std::fs::Metadata) -> anyhow::Result<TimeFilterResult> {
492        let mtime = if self.modified_before.is_some() {
493            Some(
494                metadata
495                    .modified()
496                    .context("failed to read mtime from metadata")?,
497            )
498        } else {
499            None
500        };
501        let btime = if self.created_before.is_some() {
502            Some(
503                metadata
504                    .created()
505                    .context("failed to read birth time (created) from metadata")?,
506            )
507        } else {
508            None
509        };
510        Ok(self.evaluate(mtime, btime, std::time::SystemTime::now()))
511    }
512    /// Pure-logic evaluation of this filter against raw timestamps.
513    ///
514    /// The timestamps are only inspected when the corresponding threshold is configured.
515    /// When a threshold is configured but the timestamp is `None`, the entry is treated as
516    /// "too new" for that axis (a `None` timestamp has no age signal, so it cannot satisfy
517    /// an age threshold). This helper is for deterministic unit testing of the AND logic;
518    /// prefer [`Self::matches`] for real callers.
519    fn evaluate(
520        &self,
521        mtime: Option<std::time::SystemTime>,
522        btime: Option<std::time::SystemTime>,
523        now: std::time::SystemTime,
524    ) -> TimeFilterResult {
525        let modified_too_new = self
526            .modified_before
527            .is_some_and(|threshold| mtime.is_none_or(|t| !is_at_least_age(now, t, threshold)));
528        let created_too_new = self
529            .created_before
530            .is_some_and(|threshold| btime.is_none_or(|t| !is_at_least_age(now, t, threshold)));
531        match (modified_too_new, created_too_new) {
532            (false, false) => TimeFilterResult::Matched,
533            (true, false) => TimeFilterResult::TooNewModified,
534            (false, true) => TimeFilterResult::TooNewCreated,
535            (true, true) => TimeFilterResult::TooNewBoth,
536        }
537    }
538}
539
540/// Returns true if `timestamp` is at least `age` old relative to `now`.
541/// Treats clock skew (timestamp in the future) as "not old enough".
542fn is_at_least_age(
543    now: std::time::SystemTime,
544    timestamp: std::time::SystemTime,
545    age: std::time::Duration,
546) -> bool {
547    match now.duration_since(timestamp) {
548        Ok(elapsed) => elapsed >= age,
549        Err(_) => false,
550    }
551}
552
553/// Data transfer object for FilterSettings serialization.
554/// Used for passing filter settings across process boundaries (e.g., to rcpd).
555#[derive(Serialize, Deserialize)]
556struct FilterSettingsDto {
557    includes: Vec<String>,
558    excludes: Vec<String>,
559}
560
561impl Serialize for FilterSettings {
562    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
563        let dto = FilterSettingsDto {
564            includes: self.includes.iter().map(|p| p.original.clone()).collect(),
565            excludes: self.excludes.iter().map(|p| p.original.clone()).collect(),
566        };
567        dto.serialize(serializer)
568    }
569}
570
571impl<'de> Deserialize<'de> for FilterSettings {
572    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
573        let dto = FilterSettingsDto::deserialize(deserializer)?;
574        let mut settings = FilterSettings::new();
575        for pattern in dto.includes {
576            settings
577                .add_include(&pattern)
578                .map_err(serde::de::Error::custom)?;
579        }
580        for pattern in dto.excludes {
581            settings
582                .add_exclude(&pattern)
583                .map_err(serde::de::Error::custom)?;
584        }
585        Ok(settings)
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    #[test]
593    fn test_pattern_basic_glob() {
594        let pattern = FilterPattern::parse("*.rs").unwrap();
595        assert!(pattern.matches(Path::new("foo.rs"), false));
596        assert!(pattern.matches(Path::new("main.rs"), false));
597        assert!(!pattern.matches(Path::new("foo.txt"), false));
598        // simple patterns match against filename, so src/foo.rs matches via its filename
599        assert!(pattern.matches(Path::new("src/foo.rs"), false));
600    }
601    #[test]
602    fn test_pattern_double_star() {
603        let pattern = FilterPattern::parse("**/*.rs").unwrap();
604        assert!(pattern.matches(Path::new("src/foo.rs"), false));
605        assert!(pattern.matches(Path::new("a/b/c/d.rs"), false));
606        // ** can match zero segments, so **/*.rs matches foo.rs
607        assert!(pattern.matches(Path::new("foo.rs"), false));
608    }
609    #[test]
610    fn test_pattern_question_mark() {
611        let pattern = FilterPattern::parse("file?.txt").unwrap();
612        assert!(pattern.matches(Path::new("file1.txt"), false));
613        assert!(pattern.matches(Path::new("fileA.txt"), false));
614        assert!(!pattern.matches(Path::new("file12.txt"), false));
615        assert!(!pattern.matches(Path::new("file.txt"), false));
616    }
617    #[test]
618    fn test_pattern_character_class() {
619        let pattern = FilterPattern::parse("[abc].txt").unwrap();
620        assert!(pattern.matches(Path::new("a.txt"), false));
621        assert!(pattern.matches(Path::new("b.txt"), false));
622        assert!(pattern.matches(Path::new("c.txt"), false));
623        assert!(!pattern.matches(Path::new("d.txt"), false));
624    }
625    #[test]
626    fn test_pattern_anchored() {
627        let pattern = FilterPattern::parse("/src").unwrap();
628        assert!(pattern.anchored);
629        // matches only at root level
630        assert!(pattern.matches(Path::new("src"), true));
631        assert!(!pattern.matches(Path::new("foo/src"), true));
632    }
633    #[test]
634    fn test_pattern_dir_only() {
635        let pattern = FilterPattern::parse("build/").unwrap();
636        assert!(pattern.dir_only);
637        // only matches directories
638        assert!(pattern.matches(Path::new("build"), true));
639        assert!(!pattern.matches(Path::new("build"), false)); // file named build
640    }
641    #[test]
642    fn test_include_only_mode() {
643        let mut settings = FilterSettings::new();
644        settings.add_include("*.rs").unwrap();
645        settings.add_include("Cargo.toml").unwrap();
646        assert!(matches!(
647            settings.should_include(Path::new("main.rs"), false),
648            FilterResult::Included
649        ));
650        assert!(matches!(
651            settings.should_include(Path::new("Cargo.toml"), false),
652            FilterResult::Included
653        ));
654        assert!(matches!(
655            settings.should_include(Path::new("README.md"), false),
656            FilterResult::ExcludedByDefault
657        ));
658    }
659    #[test]
660    fn test_exclude_only_mode() {
661        let mut settings = FilterSettings::new();
662        settings.add_exclude("*.log").unwrap();
663        settings.add_exclude("target/").unwrap();
664        assert!(matches!(
665            settings.should_include(Path::new("main.rs"), false),
666            FilterResult::Included
667        ));
668        match settings.should_include(Path::new("debug.log"), false) {
669            FilterResult::ExcludedByPattern(p) => assert_eq!(p, "*.log"),
670            other => panic!("expected ExcludedByPattern, got {:?}", other),
671        }
672        match settings.should_include(Path::new("target"), true) {
673            FilterResult::ExcludedByPattern(p) => assert_eq!(p, "target/"),
674            other => panic!("expected ExcludedByPattern, got {:?}", other),
675        }
676    }
677    #[test]
678    fn test_include_then_exclude() {
679        let mut settings = FilterSettings::new();
680        settings.add_include("*.rs").unwrap();
681        settings.add_exclude("test_*.rs").unwrap();
682        // regular .rs files are included
683        assert!(matches!(
684            settings.should_include(Path::new("main.rs"), false),
685            FilterResult::Included
686        ));
687        // test_*.rs files are excluded even though they match *.rs
688        match settings.should_include(Path::new("test_foo.rs"), false) {
689            FilterResult::ExcludedByPattern(p) => assert_eq!(p, "test_*.rs"),
690            other => panic!("expected ExcludedByPattern, got {:?}", other),
691        }
692        // non-.rs files are excluded by default
693        assert!(matches!(
694            settings.should_include(Path::new("README.md"), false),
695            FilterResult::ExcludedByDefault
696        ));
697    }
698    #[test]
699    fn test_filter_file_basic() {
700        let content = r#"
701# this is a comment
702--include *.rs
703--include Cargo.toml
704
705--exclude target/
706--exclude *.log
707"#;
708        let settings = FilterSettings::parse_content(content).unwrap();
709        assert_eq!(settings.includes.len(), 2);
710        assert_eq!(settings.excludes.len(), 2);
711    }
712    #[test]
713    fn test_filter_file_comments() {
714        let content = "# only comments\n# and empty lines\n\n";
715        let settings = FilterSettings::parse_content(content).unwrap();
716        assert!(settings.is_empty());
717    }
718    #[test]
719    fn test_filter_file_syntax_error() {
720        let content = "invalid line without prefix";
721        let result = FilterSettings::parse_content(content);
722        assert!(result.is_err());
723        let err = result.unwrap_err().to_string();
724        assert!(err.contains("line 1"));
725        assert!(err.contains("invalid syntax"));
726    }
727    #[test]
728    fn test_empty_pattern_error() {
729        let result = FilterPattern::parse("");
730        assert!(result.is_err());
731    }
732    #[test]
733    fn test_is_empty() {
734        let empty = FilterSettings::new();
735        assert!(empty.is_empty());
736        let mut with_include = FilterSettings::new();
737        with_include.add_include("*.rs").unwrap();
738        assert!(!with_include.is_empty());
739        let mut with_exclude = FilterSettings::new();
740        with_exclude.add_exclude("*.log").unwrap();
741        assert!(!with_exclude.is_empty());
742    }
743    #[test]
744    fn test_has_includes() {
745        let empty = FilterSettings::new();
746        assert!(!empty.has_includes());
747        let mut with_include = FilterSettings::new();
748        with_include.add_include("*.rs").unwrap();
749        assert!(with_include.has_includes());
750        let mut with_exclude = FilterSettings::new();
751        with_exclude.add_exclude("*.log").unwrap();
752        assert!(!with_exclude.has_includes());
753        let mut with_both = FilterSettings::new();
754        with_both.add_include("*.rs").unwrap();
755        with_both.add_exclude("*.log").unwrap();
756        assert!(with_both.has_includes());
757    }
758    #[test]
759    fn test_filename_match_for_simple_patterns() {
760        // simple patterns (no /) should match the filename anywhere in the path
761        let pattern = FilterPattern::parse("*.rs").unwrap();
762        assert!(pattern.matches(Path::new("foo.rs"), false));
763        assert!(pattern.matches(Path::new("src/foo.rs"), false)); // matches filename
764        // nested paths also match via filename
765        assert!(pattern.matches(Path::new("a/b/c/foo.rs"), false));
766    }
767    #[test]
768    fn test_path_pattern_requires_full_match() {
769        // patterns with / require the full path to match
770        let pattern = FilterPattern::parse("src/*.rs").unwrap();
771        assert!(pattern.matches(Path::new("src/foo.rs"), false));
772        assert!(!pattern.matches(Path::new("foo.rs"), false));
773        assert!(!pattern.matches(Path::new("other/src/foo.rs"), false));
774    }
775    #[test]
776    fn test_double_star_matches_nested_paths() {
777        // **/*.rs should match files at any depth
778        let pattern = FilterPattern::parse("**/*.rs").unwrap();
779        assert!(pattern.matches(Path::new("foo.rs"), false));
780        assert!(pattern.matches(Path::new("src/foo.rs"), false));
781        assert!(pattern.matches(Path::new("src/lib/foo.rs"), false));
782        assert!(pattern.matches(Path::new("a/b/c/d/e.rs"), false));
783    }
784    #[test]
785    fn test_anchored_pattern_matches_only_at_root() {
786        // /src should match only at root, not nested
787        let pattern = FilterPattern::parse("/src").unwrap();
788        assert!(pattern.matches(Path::new("src"), true));
789        assert!(!pattern.matches(Path::new("foo/src"), true));
790        assert!(!pattern.matches(Path::new("a/b/src"), true));
791    }
792    #[test]
793    fn test_nested_directory_pattern() {
794        // src/lib/ should match only that specific nested path
795        let pattern = FilterPattern::parse("src/lib/").unwrap();
796        assert!(pattern.matches(Path::new("src/lib"), true));
797        assert!(!pattern.matches(Path::new("lib"), true));
798        assert!(!pattern.matches(Path::new("other/src/lib"), true));
799    }
800    #[test]
801    fn test_dir_only_simple_pattern_matches_at_any_level() {
802        // target/ (dir-only) should match "target" at any level, like simple patterns
803        // the trailing / is just a dir-only marker, not a path separator
804        let pattern = FilterPattern::parse("target/").unwrap();
805        assert!(pattern.dir_only);
806        assert!(!pattern.anchored);
807        // should match at root
808        assert!(pattern.matches(Path::new("target"), true));
809        // should match nested (filename matching)
810        assert!(pattern.matches(Path::new("foo/target"), true));
811        assert!(pattern.matches(Path::new("a/b/target"), true));
812        // should NOT match files (dir-only)
813        assert!(!pattern.matches(Path::new("target"), false));
814        assert!(!pattern.matches(Path::new("foo/target"), false));
815    }
816    #[test]
817    fn test_dir_only_pattern_could_contain_matches() {
818        // target/ should allow traversal into any directory since it can match anywhere
819        let mut settings = FilterSettings::new();
820        settings.add_include("target/").unwrap();
821        let pattern = &settings.includes[0];
822        // should return true for any directory since target/ can match at any level
823        assert!(settings.could_contain_matches(Path::new("foo"), pattern));
824        assert!(settings.could_contain_matches(Path::new("a/b"), pattern));
825        assert!(settings.could_contain_matches(Path::new("src"), pattern));
826    }
827    #[test]
828    fn test_precedence_exclude_overrides_include() {
829        // when both include and exclude match, exclude wins (excludes checked first)
830        let mut settings = FilterSettings::new();
831        settings.add_include("*.rs").unwrap();
832        settings.add_exclude("test_*.rs").unwrap();
833        // file matching both patterns should be excluded
834        match settings.should_include(Path::new("test_main.rs"), false) {
835            FilterResult::ExcludedByPattern(p) => assert_eq!(p, "test_*.rs"),
836            other => panic!("expected ExcludedByPattern, got {:?}", other),
837        }
838        // file matching only include should be included
839        assert!(matches!(
840            settings.should_include(Path::new("main.rs"), false),
841            FilterResult::Included
842        ));
843    }
844    #[test]
845    fn test_should_include_root_item_non_anchored_exclude() {
846        // non-anchored exclude patterns should apply to root items
847        let mut settings = FilterSettings::new();
848        settings.add_exclude("*.log").unwrap();
849        // root file matching exclude pattern is excluded
850        match settings.should_include_root_item(Path::new("debug.log"), false) {
851            FilterResult::ExcludedByPattern(p) => assert_eq!(p, "*.log"),
852            other => panic!("expected ExcludedByPattern, got {:?}", other),
853        }
854        // root file not matching exclude pattern is included
855        assert!(matches!(
856            settings.should_include_root_item(Path::new("main.rs"), false),
857            FilterResult::Included
858        ));
859    }
860    #[test]
861    fn test_should_include_root_item_anchored_exclude_skipped() {
862        // anchored exclude patterns should NOT apply to root items
863        let mut settings = FilterSettings::new();
864        settings.add_exclude("/target/").unwrap();
865        // root directory "target" should NOT be excluded (pattern is anchored)
866        assert!(matches!(
867            settings.should_include_root_item(Path::new("target"), true),
868            FilterResult::Included
869        ));
870    }
871    #[test]
872    fn test_should_include_root_item_non_anchored_include() {
873        // non-anchored include patterns should apply to root items
874        let mut settings = FilterSettings::new();
875        settings.add_include("*.rs").unwrap();
876        // root file matching include pattern is included
877        assert!(matches!(
878            settings.should_include_root_item(Path::new("main.rs"), false),
879            FilterResult::Included
880        ));
881        // root file not matching include pattern is excluded by default
882        assert!(matches!(
883            settings.should_include_root_item(Path::new("readme.md"), false),
884            FilterResult::ExcludedByDefault
885        ));
886    }
887    #[test]
888    fn test_should_include_root_item_anchored_include_skipped() {
889        // anchored include patterns should NOT apply to root items directly
890        // but root directories should still be traversed if anchored patterns exist
891        let mut settings = FilterSettings::new();
892        settings.add_include("/bar").unwrap();
893        // root directory "foo" should be included (so we can traverse to find /bar inside)
894        assert!(matches!(
895            settings.should_include_root_item(Path::new("foo"), true),
896            FilterResult::Included
897        ));
898        // root file "baz" should be excluded by default (only anchored includes, none match)
899        assert!(matches!(
900            settings.should_include_root_item(Path::new("baz"), false),
901            FilterResult::ExcludedByDefault
902        ));
903    }
904    #[test]
905    fn test_should_include_root_item_mixed_patterns() {
906        // mix of anchored and non-anchored patterns
907        let mut settings = FilterSettings::new();
908        settings.add_include("*.rs").unwrap();
909        settings.add_include("/bar").unwrap();
910        settings.add_exclude("test_*.rs").unwrap();
911        // root .rs file is included (non-anchored include)
912        assert!(matches!(
913            settings.should_include_root_item(Path::new("main.rs"), false),
914            FilterResult::Included
915        ));
916        // root test_*.rs file is excluded (non-anchored exclude)
917        match settings.should_include_root_item(Path::new("test_foo.rs"), false) {
918            FilterResult::ExcludedByPattern(p) => assert_eq!(p, "test_*.rs"),
919            other => panic!("expected ExcludedByPattern, got {:?}", other),
920        }
921        // root directory "foo" is included (to traverse for /bar)
922        assert!(matches!(
923            settings.should_include_root_item(Path::new("foo"), true),
924            FilterResult::Included
925        ));
926    }
927    #[test]
928    fn test_could_contain_matches_anchored_double_star() {
929        // /src/** should only match directories under src, not unrelated directories
930        let mut settings = FilterSettings::new();
931        settings.add_include("/src/**").unwrap();
932        let pattern = &settings.includes[0];
933        // should return true for ancestors of "src" (we need to traverse to get there)
934        assert!(settings.could_contain_matches(Path::new(""), pattern));
935        // should return true for "src" itself (it's the prefix)
936        assert!(settings.could_contain_matches(Path::new("src"), pattern));
937        // should return true for descendants of "src" (matches can be inside)
938        assert!(settings.could_contain_matches(Path::new("src/foo"), pattern));
939        assert!(settings.could_contain_matches(Path::new("src/foo/bar"), pattern));
940        // should return FALSE for unrelated directories
941        assert!(!settings.could_contain_matches(Path::new("build"), pattern));
942        assert!(!settings.could_contain_matches(Path::new("target"), pattern));
943        assert!(!settings.could_contain_matches(Path::new("build/src"), pattern));
944    }
945    #[test]
946    fn test_could_contain_matches_non_anchored_double_star() {
947        // **/*.rs should match anywhere (no prefix)
948        let mut settings = FilterSettings::new();
949        settings.add_include("**/*.rs").unwrap();
950        let pattern = &settings.includes[0];
951        // should return true for any directory since ** can match anywhere
952        assert!(settings.could_contain_matches(Path::new("src"), pattern));
953        assert!(settings.could_contain_matches(Path::new("build"), pattern));
954        assert!(settings.could_contain_matches(Path::new("any/path"), pattern));
955    }
956    #[test]
957    fn test_could_contain_matches_nested_prefix() {
958        // /src/foo/** should have prefix "src/foo"
959        let mut settings = FilterSettings::new();
960        settings.add_include("/src/foo/**").unwrap();
961        let pattern = &settings.includes[0];
962        // ancestors of prefix
963        assert!(settings.could_contain_matches(Path::new(""), pattern));
964        assert!(settings.could_contain_matches(Path::new("src"), pattern));
965        // the prefix itself
966        assert!(settings.could_contain_matches(Path::new("src/foo"), pattern));
967        // descendants of prefix
968        assert!(settings.could_contain_matches(Path::new("src/foo/bar"), pattern));
969        // unrelated directories
970        assert!(!settings.could_contain_matches(Path::new("build"), pattern));
971        assert!(!settings.could_contain_matches(Path::new("src/bar"), pattern));
972    }
973    #[test]
974    fn test_extract_literal_prefix() {
975        // test the helper function
976        assert_eq!(FilterSettings::extract_literal_prefix("src/**"), "src");
977        assert_eq!(
978            FilterSettings::extract_literal_prefix("src/foo/**"),
979            "src/foo"
980        );
981        assert_eq!(FilterSettings::extract_literal_prefix("**/*.rs"), "");
982        assert_eq!(FilterSettings::extract_literal_prefix("*.rs"), "");
983        assert_eq!(FilterSettings::extract_literal_prefix("src/*.rs"), "src");
984        // no wildcards = entire pattern is literal
985        assert_eq!(
986            FilterSettings::extract_literal_prefix("src/foo/bar"),
987            "src/foo/bar"
988        );
989        assert_eq!(FilterSettings::extract_literal_prefix("bar"), "bar");
990        assert_eq!(FilterSettings::extract_literal_prefix("src[0-9]/*.rs"), "");
991    }
992    #[test]
993    fn test_directly_matches_include_simple_pattern() {
994        // simple pattern like *.txt matches file
995        let mut settings = FilterSettings::new();
996        settings.add_include("*.txt").unwrap();
997        // should match files with .txt extension
998        assert!(settings.directly_matches_include(Path::new("foo.txt"), false));
999        assert!(settings.directly_matches_include(Path::new("bar/foo.txt"), false));
1000        // should not match other files
1001        assert!(!settings.directly_matches_include(Path::new("foo.rs"), false));
1002        // directories don't match file patterns
1003        assert!(!settings.directly_matches_include(Path::new("txt"), true));
1004    }
1005    #[test]
1006    fn test_directly_matches_include_anchored_pattern() {
1007        // anchored /foo matches at root only
1008        let mut settings = FilterSettings::new();
1009        settings.add_include("/foo").unwrap();
1010        // should match at root
1011        assert!(settings.directly_matches_include(Path::new("foo"), true));
1012        assert!(settings.directly_matches_include(Path::new("foo"), false));
1013        // should not match nested
1014        assert!(!settings.directly_matches_include(Path::new("bar/foo"), true));
1015    }
1016    #[test]
1017    fn test_directly_matches_include_empty_includes() {
1018        // returns true when no includes
1019        let settings = FilterSettings::new();
1020        assert!(settings.directly_matches_include(Path::new("anything"), true));
1021        assert!(settings.directly_matches_include(Path::new("foo/bar"), false));
1022    }
1023    #[test]
1024    fn test_directly_matches_include_path_pattern() {
1025        // path pattern like src/*.rs
1026        let mut settings = FilterSettings::new();
1027        settings.add_include("src/*.rs").unwrap();
1028        // should match paths in src/
1029        assert!(settings.directly_matches_include(Path::new("src/foo.rs"), false));
1030        // should not match at root or other paths
1031        assert!(!settings.directly_matches_include(Path::new("foo.rs"), false));
1032        assert!(!settings.directly_matches_include(Path::new("other/foo.rs"), false));
1033    }
1034    #[test]
1035    fn test_directly_matches_include_dir_only_pattern() {
1036        // directory-only pattern target/
1037        let mut settings = FilterSettings::new();
1038        settings.add_include("target/").unwrap();
1039        // should match directories named target
1040        assert!(settings.directly_matches_include(Path::new("target"), true));
1041        assert!(settings.directly_matches_include(Path::new("foo/target"), true));
1042        // should not match files
1043        assert!(!settings.directly_matches_include(Path::new("target"), false));
1044    }
1045
1046    mod time_filter_tests {
1047        use super::*;
1048        use crate::testutils;
1049
1050        fn write_with_mtime(path: &std::path::Path, age: std::time::Duration) {
1051            std::fs::write(path, "x").unwrap();
1052            let past = filetime::FileTime::from_system_time(std::time::SystemTime::now() - age);
1053            filetime::set_file_mtime(path, past).unwrap();
1054        }
1055
1056        #[test]
1057        fn is_empty_when_no_thresholds_set() {
1058            assert!(TimeFilter::default().is_empty());
1059            let only_mtime = TimeFilter {
1060                modified_before: Some(std::time::Duration::from_secs(1)),
1061                created_before: None,
1062            };
1063            assert!(!only_mtime.is_empty());
1064            let only_btime = TimeFilter {
1065                modified_before: None,
1066                created_before: Some(std::time::Duration::from_secs(1)),
1067            };
1068            assert!(!only_btime.is_empty());
1069        }
1070
1071        #[tokio::test]
1072        async fn matches_returns_matched_when_no_thresholds() {
1073            let tmp = testutils::create_temp_dir().await.unwrap();
1074            let path = tmp.join("file");
1075            write_with_mtime(&path, std::time::Duration::from_secs(0));
1076            let metadata = std::fs::metadata(&path).unwrap();
1077            assert_eq!(
1078                TimeFilter::default().matches(&metadata).unwrap(),
1079                TimeFilterResult::Matched
1080            );
1081        }
1082
1083        #[tokio::test]
1084        async fn matches_when_mtime_older_than_threshold() {
1085            let tmp = testutils::create_temp_dir().await.unwrap();
1086            let path = tmp.join("file");
1087            write_with_mtime(&path, std::time::Duration::from_secs(7200));
1088            let metadata = std::fs::metadata(&path).unwrap();
1089            let filter = TimeFilter {
1090                modified_before: Some(std::time::Duration::from_secs(3600)),
1091                created_before: None,
1092            };
1093            assert_eq!(
1094                filter.matches(&metadata).unwrap(),
1095                TimeFilterResult::Matched
1096            );
1097        }
1098
1099        #[tokio::test]
1100        async fn reports_too_new_modified_when_mtime_recent() {
1101            let tmp = testutils::create_temp_dir().await.unwrap();
1102            let path = tmp.join("file");
1103            write_with_mtime(&path, std::time::Duration::from_secs(0));
1104            let metadata = std::fs::metadata(&path).unwrap();
1105            let filter = TimeFilter {
1106                modified_before: Some(std::time::Duration::from_secs(3600)),
1107                created_before: None,
1108            };
1109            assert_eq!(
1110                filter.matches(&metadata).unwrap(),
1111                TimeFilterResult::TooNewModified
1112            );
1113        }
1114
1115        /// Exercises `matches()` on the `created_before` axis with real metadata.
1116        /// The expected outcome depends on whether the filesystem exposes birth time:
1117        /// - Supported: a freshly-written file has a recent btime → `TooNewCreated`.
1118        /// - Unsupported: `matches()` must return `Err` (the contract is to surface
1119        ///   the failure rather than silently treat the file as a match).
1120        /// Deterministic on either platform — drift in either branch will fail loudly.
1121        #[tokio::test]
1122        async fn matches_exercises_btime_deterministically() {
1123            let tmp = testutils::create_temp_dir().await.unwrap();
1124            let path = tmp.join("file");
1125            std::fs::write(&path, "x").unwrap();
1126            let metadata = std::fs::metadata(&path).unwrap();
1127            let filter = TimeFilter {
1128                modified_before: None,
1129                created_before: Some(std::time::Duration::from_secs(3600)),
1130            };
1131            let result = filter.matches(&metadata);
1132            match metadata.created() {
1133                Ok(_) => assert_eq!(result.unwrap(), TimeFilterResult::TooNewCreated),
1134                Err(_) => assert!(
1135                    result.is_err(),
1136                    "matches() must return Err when created_before is set but btime is unavailable"
1137                ),
1138            }
1139        }
1140
1141        #[tokio::test]
1142        async fn matches_with_zero_threshold_is_always_satisfied() {
1143            // any file is at least zero seconds old
1144            let tmp = testutils::create_temp_dir().await.unwrap();
1145            let path = tmp.join("file");
1146            write_with_mtime(&path, std::time::Duration::from_secs(0));
1147            let metadata = std::fs::metadata(&path).unwrap();
1148            let filter = TimeFilter {
1149                modified_before: Some(std::time::Duration::from_secs(0)),
1150                created_before: None,
1151            };
1152            assert_eq!(
1153                filter.matches(&metadata).unwrap(),
1154                TimeFilterResult::Matched
1155            );
1156        }
1157
1158        /// Focused unit tests for the pure-logic [`TimeFilter::evaluate`] helper.
1159        /// Using raw timestamps makes AND/OR boundaries deterministic without depending
1160        /// on platform btime support or filesystem timestamp resolution.
1161        mod evaluate_and_or_logic {
1162            use super::*;
1163
1164            fn now() -> std::time::SystemTime {
1165                // pick a fixed anchor far in the past so threshold subtraction cannot underflow
1166                std::time::UNIX_EPOCH + std::time::Duration::from_secs(10_000_000)
1167            }
1168
1169            fn age_before(
1170                t: std::time::SystemTime,
1171                age: std::time::Duration,
1172            ) -> std::time::SystemTime {
1173                t - age
1174            }
1175
1176            #[test]
1177            fn no_thresholds_always_matches_regardless_of_timestamps() {
1178                let filter = TimeFilter::default();
1179                assert_eq!(
1180                    filter.evaluate(None, None, now()),
1181                    TimeFilterResult::Matched
1182                );
1183                assert_eq!(
1184                    filter.evaluate(
1185                        Some(age_before(now(), std::time::Duration::from_secs(0))),
1186                        None,
1187                        now()
1188                    ),
1189                    TimeFilterResult::Matched
1190                );
1191            }
1192
1193            #[test]
1194            fn and_logic_both_pass_is_matched() {
1195                let filter = TimeFilter {
1196                    modified_before: Some(std::time::Duration::from_secs(3600)),
1197                    created_before: Some(std::time::Duration::from_secs(3600)),
1198                };
1199                let old = age_before(now(), std::time::Duration::from_secs(7200));
1200                assert_eq!(
1201                    filter.evaluate(Some(old), Some(old), now()),
1202                    TimeFilterResult::Matched
1203                );
1204            }
1205
1206            #[test]
1207            fn and_logic_only_mtime_passes_reports_created_too_new() {
1208                let filter = TimeFilter {
1209                    modified_before: Some(std::time::Duration::from_secs(3600)),
1210                    created_before: Some(std::time::Duration::from_secs(3600)),
1211                };
1212                let old = age_before(now(), std::time::Duration::from_secs(7200));
1213                let recent = age_before(now(), std::time::Duration::from_secs(60));
1214                assert_eq!(
1215                    filter.evaluate(Some(old), Some(recent), now()),
1216                    TimeFilterResult::TooNewCreated
1217                );
1218            }
1219
1220            #[test]
1221            fn and_logic_only_btime_passes_reports_modified_too_new() {
1222                let filter = TimeFilter {
1223                    modified_before: Some(std::time::Duration::from_secs(3600)),
1224                    created_before: Some(std::time::Duration::from_secs(3600)),
1225                };
1226                let old = age_before(now(), std::time::Duration::from_secs(7200));
1227                let recent = age_before(now(), std::time::Duration::from_secs(60));
1228                assert_eq!(
1229                    filter.evaluate(Some(recent), Some(old), now()),
1230                    TimeFilterResult::TooNewModified
1231                );
1232            }
1233
1234            #[test]
1235            fn and_logic_neither_passes_reports_too_new_both() {
1236                let filter = TimeFilter {
1237                    modified_before: Some(std::time::Duration::from_secs(3600)),
1238                    created_before: Some(std::time::Duration::from_secs(3600)),
1239                };
1240                let recent = age_before(now(), std::time::Duration::from_secs(60));
1241                assert_eq!(
1242                    filter.evaluate(Some(recent), Some(recent), now()),
1243                    TimeFilterResult::TooNewBoth
1244                );
1245            }
1246
1247            #[test]
1248            fn threshold_boundary_exactly_at_age_matches() {
1249                // a timestamp exactly `threshold` ago is considered "at least threshold old"
1250                let filter = TimeFilter {
1251                    modified_before: Some(std::time::Duration::from_secs(3600)),
1252                    created_before: None,
1253                };
1254                let exact = age_before(now(), std::time::Duration::from_secs(3600));
1255                assert_eq!(
1256                    filter.evaluate(Some(exact), None, now()),
1257                    TimeFilterResult::Matched
1258                );
1259            }
1260
1261            #[test]
1262            fn future_timestamp_treated_as_too_new() {
1263                // clock skew: if mtime is AFTER now, treat as "not old enough"
1264                let filter = TimeFilter {
1265                    modified_before: Some(std::time::Duration::from_secs(1)),
1266                    created_before: None,
1267                };
1268                let future = now() + std::time::Duration::from_secs(3600);
1269                assert_eq!(
1270                    filter.evaluate(Some(future), None, now()),
1271                    TimeFilterResult::TooNewModified
1272                );
1273            }
1274
1275            #[test]
1276            fn missing_timestamp_when_threshold_configured_is_too_new() {
1277                // None with a configured threshold has no age signal; treat as too new
1278                // (used when the OS can't produce the timestamp — see evaluate doc comment)
1279                let filter = TimeFilter {
1280                    modified_before: Some(std::time::Duration::from_secs(3600)),
1281                    created_before: Some(std::time::Duration::from_secs(3600)),
1282                };
1283                let old = age_before(now(), std::time::Duration::from_secs(7200));
1284                assert_eq!(
1285                    filter.evaluate(Some(old), None, now()),
1286                    TimeFilterResult::TooNewCreated
1287                );
1288                assert_eq!(
1289                    filter.evaluate(None, Some(old), now()),
1290                    TimeFilterResult::TooNewModified
1291                );
1292                assert_eq!(
1293                    filter.evaluate(None, None, now()),
1294                    TimeFilterResult::TooNewBoth
1295                );
1296            }
1297        }
1298
1299        /// Tests for the skip-reason projection.
1300        mod skip_reason {
1301            use super::*;
1302
1303            #[test]
1304            fn matched_yields_none() {
1305                assert_eq!(TimeFilterResult::Matched.as_skip_reason(), None);
1306            }
1307
1308            #[test]
1309            fn too_new_variants_yield_matching_reasons() {
1310                assert_eq!(
1311                    TimeFilterResult::TooNewModified.as_skip_reason(),
1312                    Some(TimeSkipReason::TooNewModified)
1313                );
1314                assert_eq!(
1315                    TimeFilterResult::TooNewCreated.as_skip_reason(),
1316                    Some(TimeSkipReason::TooNewCreated)
1317                );
1318                assert_eq!(
1319                    TimeFilterResult::TooNewBoth.as_skip_reason(),
1320                    Some(TimeSkipReason::TooNewBoth)
1321                );
1322            }
1323        }
1324    }
1325    mod from_args_tests {
1326        use super::*;
1327        use std::sync::atomic::{AtomicU64, Ordering};
1328        static SEQ: AtomicU64 = AtomicU64::new(0);
1329        /// RAII guard that writes a uniquely-named filter file under the
1330        /// system temp dir and removes it when dropped. /tmp policies vary
1331        /// (systemd-tmpfiles often runs weekly, never on some hosts), so
1332        /// explicit cleanup keeps tests from leaving junk behind.
1333        struct TempFilterFile {
1334            path: std::path::PathBuf,
1335        }
1336        impl TempFilterFile {
1337            fn new(content: &str) -> Self {
1338                let n = SEQ.fetch_add(1, Ordering::Relaxed);
1339                let path = std::env::temp_dir()
1340                    .join(format!("rcp-from-args-test-{}-{n}.txt", std::process::id()));
1341                std::fs::write(&path, content).unwrap();
1342                Self { path }
1343            }
1344            fn path(&self) -> &std::path::Path {
1345                &self.path
1346            }
1347        }
1348        impl Drop for TempFilterFile {
1349            fn drop(&mut self) {
1350                let _ = std::fs::remove_file(&self.path);
1351            }
1352        }
1353        #[test]
1354        fn returns_none_when_nothing_specified() {
1355            let result = FilterSettings::from_args(None, &[], &[]).unwrap();
1356            assert!(result.is_none());
1357        }
1358        #[test]
1359        fn builds_from_include_only() {
1360            let include = vec!["*.rs".to_string(), "Cargo.toml".to_string()];
1361            let settings = FilterSettings::from_args(None, &include, &[])
1362                .unwrap()
1363                .expect("should return Some when include is non-empty");
1364            assert_eq!(settings.includes.len(), 2);
1365            assert!(settings.excludes.is_empty());
1366        }
1367        #[test]
1368        fn builds_from_exclude_only() {
1369            let exclude = vec!["*.log".to_string(), "target/".to_string()];
1370            let settings = FilterSettings::from_args(None, &[], &exclude)
1371                .unwrap()
1372                .expect("should return Some when exclude is non-empty");
1373            assert!(settings.includes.is_empty());
1374            assert_eq!(settings.excludes.len(), 2);
1375        }
1376        #[test]
1377        fn builds_from_include_and_exclude() {
1378            let include = vec!["*.rs".to_string()];
1379            let exclude = vec!["target/".to_string()];
1380            let settings = FilterSettings::from_args(None, &include, &exclude)
1381                .unwrap()
1382                .expect("should return Some");
1383            assert_eq!(settings.includes.len(), 1);
1384            assert_eq!(settings.excludes.len(), 1);
1385        }
1386        #[test]
1387        fn loads_from_filter_file() {
1388            let file = TempFilterFile::new("--include *.rs\n--exclude target/\n");
1389            let settings = FilterSettings::from_args(Some(file.path()), &[], &[])
1390                .unwrap()
1391                .expect("should return Some when filter file is read");
1392            assert_eq!(settings.includes.len(), 1);
1393            assert_eq!(settings.excludes.len(), 1);
1394        }
1395        #[test]
1396        fn errors_when_filter_file_combined_with_include() {
1397            let file = TempFilterFile::new("--include *.rs\n");
1398            let include = vec!["*.txt".to_string()];
1399            let err = FilterSettings::from_args(Some(file.path()), &include, &[]).unwrap_err();
1400            assert!(err.to_string().contains("mutually exclusive"));
1401        }
1402        #[test]
1403        fn errors_when_filter_file_combined_with_exclude() {
1404            let file = TempFilterFile::new("--include *.rs\n");
1405            let exclude = vec!["*.log".to_string()];
1406            let err = FilterSettings::from_args(Some(file.path()), &[], &exclude).unwrap_err();
1407            assert!(err.to_string().contains("mutually exclusive"));
1408        }
1409        #[test]
1410        fn propagates_invalid_include_pattern() {
1411            let include = vec!["".to_string()];
1412            assert!(FilterSettings::from_args(None, &include, &[]).is_err());
1413        }
1414        #[test]
1415        fn propagates_missing_filter_file() {
1416            let path = std::path::PathBuf::from("/nonexistent/path/filters.txt");
1417            assert!(FilterSettings::from_args(Some(&path), &[], &[]).is_err());
1418        }
1419    }
1420}