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