1use anyhow::{Context, anyhow};
38use serde::{Deserialize, Deserializer, Serialize, Serializer};
39use std::path::Path;
40
41#[derive(Debug, Clone)]
43pub struct FilterPattern {
44 pub original: String,
46 matcher: globset::GlobMatcher,
48 pub dir_only: bool,
50 pub anchored: bool,
52}
53
54impl FilterPattern {
55 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 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 let glob = globset::GlobBuilder::new(pattern_str)
73 .literal_separator(true) .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 fn is_path_pattern(&self) -> bool {
87 let core = self.original.trim_start_matches('/').trim_end_matches('/');
89 core.contains('/')
90 }
91 pub fn matches(&self, relative_path: &Path, is_dir: bool) -> bool {
93 if self.dir_only && !is_dir {
95 return false;
96 }
97 if self.anchored {
98 self.matcher.is_match(relative_path)
100 } else {
101 if self.matcher.is_match(relative_path) {
104 return true;
105 }
106 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#[derive(Debug, Clone)]
121pub enum FilterResult {
122 Included,
124 ExcludedByDefault,
126 ExcludedByPattern(String),
128}
129
130#[derive(Debug, Clone, Default)]
132pub struct FilterSettings {
133 pub includes: Vec<FilterPattern>,
135 pub excludes: Vec<FilterPattern>,
137}
138
139impl FilterSettings {
140 pub fn new() -> Self {
142 Self::default()
143 }
144 pub fn add_include(&mut self, pattern: &str) -> Result<(), anyhow::Error> {
146 self.includes.push(FilterPattern::parse(pattern)?);
147 Ok(())
148 }
149 pub fn add_exclude(&mut self, pattern: &str) -> Result<(), anyhow::Error> {
151 self.excludes.push(FilterPattern::parse(pattern)?);
152 Ok(())
153 }
154 pub fn is_empty(&self) -> bool {
156 self.includes.is_empty() && self.excludes.is_empty()
157 }
158 pub fn has_includes(&self) -> bool {
160 !self.includes.is_empty()
161 }
162 pub fn should_include_root_item(&self, name: &Path, is_dir: bool) -> FilterResult {
174 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 !self.includes.is_empty() {
184 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 return FilterResult::ExcludedByDefault;
196 }
197 return FilterResult::Included;
201 }
202 FilterResult::Included
204 }
205 pub fn should_include(&self, relative_path: &Path, is_dir: bool) -> FilterResult {
217 for pattern in &self.excludes {
219 if pattern.matches(relative_path, is_dir) {
220 return FilterResult::ExcludedByPattern(pattern.original.clone());
221 }
222 }
223 if !self.includes.is_empty() {
225 for pattern in &self.includes {
227 if pattern.matches(relative_path, is_dir) {
228 return FilterResult::Included;
229 }
230 }
231 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 FilterResult::Included
243 }
244 pub fn directly_matches_include(&self, relative_path: &std::path::Path, is_dir: bool) -> bool {
254 if self.includes.is_empty() {
255 return true; }
257 for pattern in &self.includes {
258 if pattern.matches(relative_path, is_dir) {
259 return true;
260 }
261 }
262 false
263 }
264 pub fn could_contain_matches(&self, dir_path: &Path, pattern: &FilterPattern) -> bool {
266 if !pattern.anchored && !pattern.is_path_pattern() {
268 return true;
269 }
270 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 prefix.is_empty() {
281 return true;
282 }
283 if dir_str.is_empty() {
285 return true;
286 }
287 if prefix.starts_with(&*dir_str) {
293 let after_dir = &prefix[dir_str.len()..];
294 if after_dir.is_empty() || after_dir.starts_with('/') {
296 return true;
297 }
298 }
299 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 fn extract_literal_prefix(pattern: &str) -> &str {
317 let wildcard_pos = pattern.find(['*', '?', '[']).unwrap_or(pattern.len());
319 if wildcard_pos == pattern.len() {
321 return pattern;
322 }
323 if wildcard_pos == 0 {
325 return "";
326 }
327 let prefix = &pattern[..wildcard_pos];
329 match prefix.rfind('/') {
330 Some(pos) => &pattern[..pos],
331 None => {
332 ""
334 }
335 }
336 }
337 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 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 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 if line.is_empty() || line.starts_with('#') {
391 continue;
392 }
393 let line_num = line_num + 1; 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#[derive(Debug, Clone, Default)]
427pub struct TimeFilter {
428 pub modified_before: Option<std::time::Duration>,
430 pub created_before: Option<std::time::Duration>,
432}
433
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436pub enum TimeFilterResult {
437 Matched,
439 TooNewModified,
441 TooNewCreated,
443 TooNewBoth,
445}
446
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum TimeSkipReason {
451 TooNewModified,
453 TooNewCreated,
455 TooNewBoth,
457}
458
459impl TimeFilterResult {
460 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
484pub 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 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 pub fn is_empty(&self) -> bool {
529 self.modified_before.is_none() && self.created_before.is_none()
530 }
531 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 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
587fn 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#[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 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 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 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 assert!(pattern.matches(Path::new("build"), true));
686 assert!(!pattern.matches(Path::new("build"), false)); }
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 assert!(matches!(
731 settings.should_include(Path::new("main.rs"), false),
732 FilterResult::Included
733 ));
734 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 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 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)); assert!(pattern.matches(Path::new("a/b/c/foo.rs"), false));
813 }
814 #[test]
815 fn test_path_pattern_requires_full_match() {
816 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 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 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 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 let pattern = FilterPattern::parse("target/").unwrap();
852 assert!(pattern.dir_only);
853 assert!(!pattern.anchored);
854 assert!(pattern.matches(Path::new("target"), true));
856 assert!(pattern.matches(Path::new("foo/target"), true));
858 assert!(pattern.matches(Path::new("a/b/target"), true));
859 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 let mut settings = FilterSettings::new();
867 settings.add_include("target/").unwrap();
868 let pattern = &settings.includes[0];
869 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 let mut settings = FilterSettings::new();
878 settings.add_include("*.rs").unwrap();
879 settings.add_exclude("test_*.rs").unwrap();
880 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 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 let mut settings = FilterSettings::new();
895 settings.add_exclude("*.log").unwrap();
896 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 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 let mut settings = FilterSettings::new();
911 settings.add_exclude("/target/").unwrap();
912 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 let mut settings = FilterSettings::new();
922 settings.add_include("*.rs").unwrap();
923 assert!(matches!(
925 settings.should_include_root_item(Path::new("main.rs"), false),
926 FilterResult::Included
927 ));
928 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 let mut settings = FilterSettings::new();
939 settings.add_include("/bar").unwrap();
940 assert!(matches!(
942 settings.should_include_root_item(Path::new("foo"), true),
943 FilterResult::Included
944 ));
945 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 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 assert!(matches!(
960 settings.should_include_root_item(Path::new("main.rs"), false),
961 FilterResult::Included
962 ));
963 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 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 let mut settings = FilterSettings::new();
978 settings.add_include("/src/**").unwrap();
979 let pattern = &settings.includes[0];
980 assert!(settings.could_contain_matches(Path::new(""), pattern));
982 assert!(settings.could_contain_matches(Path::new("src"), pattern));
984 assert!(settings.could_contain_matches(Path::new("src/foo"), pattern));
986 assert!(settings.could_contain_matches(Path::new("src/foo/bar"), pattern));
987 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 let mut settings = FilterSettings::new();
996 settings.add_include("**/*.rs").unwrap();
997 let pattern = &settings.includes[0];
998 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 let mut settings = FilterSettings::new();
1007 settings.add_include("/src/foo/**").unwrap();
1008 let pattern = &settings.includes[0];
1009 assert!(settings.could_contain_matches(Path::new(""), pattern));
1011 assert!(settings.could_contain_matches(Path::new("src"), pattern));
1012 assert!(settings.could_contain_matches(Path::new("src/foo"), pattern));
1014 assert!(settings.could_contain_matches(Path::new("src/foo/bar"), pattern));
1016 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 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 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 let mut settings = FilterSettings::new();
1043 settings.add_include("*.txt").unwrap();
1044 assert!(settings.directly_matches_include(Path::new("foo.txt"), false));
1046 assert!(settings.directly_matches_include(Path::new("bar/foo.txt"), false));
1047 assert!(!settings.directly_matches_include(Path::new("foo.rs"), false));
1049 assert!(!settings.directly_matches_include(Path::new("txt"), true));
1051 }
1052 #[test]
1053 fn test_directly_matches_include_anchored_pattern() {
1054 let mut settings = FilterSettings::new();
1056 settings.add_include("/foo").unwrap();
1057 assert!(settings.directly_matches_include(Path::new("foo"), true));
1059 assert!(settings.directly_matches_include(Path::new("foo"), false));
1060 assert!(!settings.directly_matches_include(Path::new("bar/foo"), true));
1062 }
1063 #[test]
1064 fn test_directly_matches_include_empty_includes() {
1065 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 let mut settings = FilterSettings::new();
1074 settings.add_include("src/*.rs").unwrap();
1075 assert!(settings.directly_matches_include(Path::new("src/foo.rs"), false));
1077 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 let mut settings = FilterSettings::new();
1085 settings.add_include("target/").unwrap();
1086 assert!(settings.directly_matches_include(Path::new("target"), true));
1088 assert!(settings.directly_matches_include(Path::new("foo/target"), true));
1089 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 #[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 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 mod evaluate_and_or_logic {
1209 use super::*;
1210
1211 fn now() -> std::time::SystemTime {
1212 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 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 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 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 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 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}