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
179 && !Self::is_path_pattern(&pattern.original)
180 && pattern.matches(name, is_dir)
181 {
182 return FilterResult::ExcludedByPattern(pattern.original.clone());
183 }
184 }
185 if !self.includes.is_empty() {
187 if !is_dir {
189 for pattern in &self.includes {
190 if !pattern.anchored
191 && !Self::is_path_pattern(&pattern.original)
192 && pattern.matches(name, false)
193 {
194 return FilterResult::Included;
195 }
196 }
197 return FilterResult::ExcludedByDefault;
199 }
200 return FilterResult::Included;
204 }
205 FilterResult::Included
207 }
208 fn is_path_pattern(original: &str) -> bool {
210 let trimmed = original.trim_start_matches('/').trim_end_matches('/');
211 trimmed.contains('/')
212 }
213 pub fn should_include(&self, relative_path: &Path, is_dir: bool) -> FilterResult {
225 for pattern in &self.excludes {
227 if pattern.matches(relative_path, is_dir) {
228 return FilterResult::ExcludedByPattern(pattern.original.clone());
229 }
230 }
231 if !self.includes.is_empty() {
233 for pattern in &self.includes {
235 if pattern.matches(relative_path, is_dir) {
236 return FilterResult::Included;
237 }
238 }
239 if is_dir {
241 for pattern in &self.includes {
242 if self.could_contain_matches(relative_path, pattern) {
243 return FilterResult::Included;
244 }
245 }
246 }
247 return FilterResult::ExcludedByDefault;
248 }
249 FilterResult::Included
251 }
252 pub fn directly_matches_include(&self, relative_path: &std::path::Path, is_dir: bool) -> bool {
262 if self.includes.is_empty() {
263 return true; }
265 for pattern in &self.includes {
266 if pattern.matches(relative_path, is_dir) {
267 return true;
268 }
269 }
270 false
271 }
272 pub fn could_contain_matches(&self, dir_path: &Path, pattern: &FilterPattern) -> bool {
274 if !pattern.anchored && !pattern.is_path_pattern() {
276 return true;
277 }
278 let pattern_path = pattern
281 .original
282 .trim_start_matches('/')
283 .trim_end_matches('/');
284 let prefix = Self::extract_literal_prefix(pattern_path);
285 let dir_str = dir_path.to_string_lossy();
286 if prefix.is_empty() {
289 return true;
290 }
291 if dir_str.is_empty() {
293 return true;
294 }
295 if prefix.starts_with(&*dir_str) {
301 let after_dir = &prefix[dir_str.len()..];
302 if after_dir.is_empty() || after_dir.starts_with('/') {
304 return true;
305 }
306 }
307 if let Some(after_prefix) = dir_str.strip_prefix(prefix)
309 && (after_prefix.is_empty() || after_prefix.starts_with('/'))
310 {
311 return true;
312 }
313 false
314 }
315 fn extract_literal_prefix(pattern: &str) -> &str {
325 let wildcard_pos = pattern.find(['*', '?', '[']).unwrap_or(pattern.len());
327 if wildcard_pos == pattern.len() {
329 return pattern;
330 }
331 if wildcard_pos == 0 {
333 return "";
334 }
335 let prefix = &pattern[..wildcard_pos];
337 match prefix.rfind('/') {
338 Some(pos) => &pattern[..pos],
339 None => {
340 ""
342 }
343 }
344 }
345 pub fn from_file(path: &Path) -> Result<Self, anyhow::Error> {
356 let content = std::fs::read_to_string(path)
357 .with_context(|| format!("failed to read filter file: {:?}", path))?;
358 Self::parse_content(&content)
359 }
360 pub fn from_args(
368 filter_file: Option<&std::path::Path>,
369 include: &[String],
370 exclude: &[String],
371 ) -> Result<Option<Self>, anyhow::Error> {
372 if filter_file.is_some() && (!include.is_empty() || !exclude.is_empty()) {
373 return Err(anyhow!(
374 "filter_file is mutually exclusive with include/exclude patterns"
375 ));
376 }
377 if let Some(path) = filter_file {
378 return Ok(Some(Self::from_file(path)?));
379 }
380 if include.is_empty() && exclude.is_empty() {
381 return Ok(None);
382 }
383 let mut settings = Self::new();
384 for p in include {
385 settings.add_include(p)?;
386 }
387 for p in exclude {
388 settings.add_exclude(p)?;
389 }
390 Ok(Some(settings))
391 }
392 pub fn parse_content(content: &str) -> Result<Self, anyhow::Error> {
394 let mut settings = Self::new();
395 for (line_num, line) in content.lines().enumerate() {
396 let line = line.trim();
397 if line.is_empty() || line.starts_with('#') {
399 continue;
400 }
401 let line_num = line_num + 1; if let Some(pattern) = line.strip_prefix("--include ") {
403 let pattern = pattern.trim();
404 settings
405 .add_include(pattern)
406 .with_context(|| format!("line {}: invalid include pattern", line_num))?;
407 } else if let Some(pattern) = line.strip_prefix("--exclude ") {
408 let pattern = pattern.trim();
409 settings
410 .add_exclude(pattern)
411 .with_context(|| format!("line {}: invalid exclude pattern", line_num))?;
412 } else {
413 return Err(anyhow!(
414 "line {}: invalid syntax '{}', expected '--include PATTERN' or '--exclude PATTERN'",
415 line_num,
416 line
417 ));
418 }
419 }
420 Ok(settings)
421 }
422}
423
424#[derive(Debug, Clone, Default)]
435pub struct TimeFilter {
436 pub modified_before: Option<std::time::Duration>,
438 pub created_before: Option<std::time::Duration>,
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
444pub enum TimeFilterResult {
445 Matched,
447 TooNewModified,
449 TooNewCreated,
451 TooNewBoth,
453}
454
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
458pub enum TimeSkipReason {
459 TooNewModified,
461 TooNewCreated,
463 TooNewBoth,
465}
466
467impl TimeFilterResult {
468 pub fn as_skip_reason(self) -> Option<TimeSkipReason> {
470 match self {
471 TimeFilterResult::Matched => None,
472 TimeFilterResult::TooNewModified => Some(TimeSkipReason::TooNewModified),
473 TimeFilterResult::TooNewCreated => Some(TimeSkipReason::TooNewCreated),
474 TimeFilterResult::TooNewBoth => Some(TimeSkipReason::TooNewBoth),
475 }
476 }
477}
478
479impl TimeFilter {
480 pub fn is_empty(&self) -> bool {
482 self.modified_before.is_none() && self.created_before.is_none()
483 }
484 pub fn matches(&self, metadata: &std::fs::Metadata) -> anyhow::Result<TimeFilterResult> {
492 let mtime = if self.modified_before.is_some() {
493 Some(
494 metadata
495 .modified()
496 .context("failed to read mtime from metadata")?,
497 )
498 } else {
499 None
500 };
501 let btime = if self.created_before.is_some() {
502 Some(
503 metadata
504 .created()
505 .context("failed to read birth time (created) from metadata")?,
506 )
507 } else {
508 None
509 };
510 Ok(self.evaluate(mtime, btime, std::time::SystemTime::now()))
511 }
512 fn evaluate(
520 &self,
521 mtime: Option<std::time::SystemTime>,
522 btime: Option<std::time::SystemTime>,
523 now: std::time::SystemTime,
524 ) -> TimeFilterResult {
525 let modified_too_new = self
526 .modified_before
527 .is_some_and(|threshold| mtime.is_none_or(|t| !is_at_least_age(now, t, threshold)));
528 let created_too_new = self
529 .created_before
530 .is_some_and(|threshold| btime.is_none_or(|t| !is_at_least_age(now, t, threshold)));
531 match (modified_too_new, created_too_new) {
532 (false, false) => TimeFilterResult::Matched,
533 (true, false) => TimeFilterResult::TooNewModified,
534 (false, true) => TimeFilterResult::TooNewCreated,
535 (true, true) => TimeFilterResult::TooNewBoth,
536 }
537 }
538}
539
540fn is_at_least_age(
543 now: std::time::SystemTime,
544 timestamp: std::time::SystemTime,
545 age: std::time::Duration,
546) -> bool {
547 match now.duration_since(timestamp) {
548 Ok(elapsed) => elapsed >= age,
549 Err(_) => false,
550 }
551}
552
553#[derive(Serialize, Deserialize)]
556struct FilterSettingsDto {
557 includes: Vec<String>,
558 excludes: Vec<String>,
559}
560
561impl Serialize for FilterSettings {
562 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
563 let dto = FilterSettingsDto {
564 includes: self.includes.iter().map(|p| p.original.clone()).collect(),
565 excludes: self.excludes.iter().map(|p| p.original.clone()).collect(),
566 };
567 dto.serialize(serializer)
568 }
569}
570
571impl<'de> Deserialize<'de> for FilterSettings {
572 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
573 let dto = FilterSettingsDto::deserialize(deserializer)?;
574 let mut settings = FilterSettings::new();
575 for pattern in dto.includes {
576 settings
577 .add_include(&pattern)
578 .map_err(serde::de::Error::custom)?;
579 }
580 for pattern in dto.excludes {
581 settings
582 .add_exclude(&pattern)
583 .map_err(serde::de::Error::custom)?;
584 }
585 Ok(settings)
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 #[test]
593 fn test_pattern_basic_glob() {
594 let pattern = FilterPattern::parse("*.rs").unwrap();
595 assert!(pattern.matches(Path::new("foo.rs"), false));
596 assert!(pattern.matches(Path::new("main.rs"), false));
597 assert!(!pattern.matches(Path::new("foo.txt"), false));
598 assert!(pattern.matches(Path::new("src/foo.rs"), false));
600 }
601 #[test]
602 fn test_pattern_double_star() {
603 let pattern = FilterPattern::parse("**/*.rs").unwrap();
604 assert!(pattern.matches(Path::new("src/foo.rs"), false));
605 assert!(pattern.matches(Path::new("a/b/c/d.rs"), false));
606 assert!(pattern.matches(Path::new("foo.rs"), false));
608 }
609 #[test]
610 fn test_pattern_question_mark() {
611 let pattern = FilterPattern::parse("file?.txt").unwrap();
612 assert!(pattern.matches(Path::new("file1.txt"), false));
613 assert!(pattern.matches(Path::new("fileA.txt"), false));
614 assert!(!pattern.matches(Path::new("file12.txt"), false));
615 assert!(!pattern.matches(Path::new("file.txt"), false));
616 }
617 #[test]
618 fn test_pattern_character_class() {
619 let pattern = FilterPattern::parse("[abc].txt").unwrap();
620 assert!(pattern.matches(Path::new("a.txt"), false));
621 assert!(pattern.matches(Path::new("b.txt"), false));
622 assert!(pattern.matches(Path::new("c.txt"), false));
623 assert!(!pattern.matches(Path::new("d.txt"), false));
624 }
625 #[test]
626 fn test_pattern_anchored() {
627 let pattern = FilterPattern::parse("/src").unwrap();
628 assert!(pattern.anchored);
629 assert!(pattern.matches(Path::new("src"), true));
631 assert!(!pattern.matches(Path::new("foo/src"), true));
632 }
633 #[test]
634 fn test_pattern_dir_only() {
635 let pattern = FilterPattern::parse("build/").unwrap();
636 assert!(pattern.dir_only);
637 assert!(pattern.matches(Path::new("build"), true));
639 assert!(!pattern.matches(Path::new("build"), false)); }
641 #[test]
642 fn test_include_only_mode() {
643 let mut settings = FilterSettings::new();
644 settings.add_include("*.rs").unwrap();
645 settings.add_include("Cargo.toml").unwrap();
646 assert!(matches!(
647 settings.should_include(Path::new("main.rs"), false),
648 FilterResult::Included
649 ));
650 assert!(matches!(
651 settings.should_include(Path::new("Cargo.toml"), false),
652 FilterResult::Included
653 ));
654 assert!(matches!(
655 settings.should_include(Path::new("README.md"), false),
656 FilterResult::ExcludedByDefault
657 ));
658 }
659 #[test]
660 fn test_exclude_only_mode() {
661 let mut settings = FilterSettings::new();
662 settings.add_exclude("*.log").unwrap();
663 settings.add_exclude("target/").unwrap();
664 assert!(matches!(
665 settings.should_include(Path::new("main.rs"), false),
666 FilterResult::Included
667 ));
668 match settings.should_include(Path::new("debug.log"), false) {
669 FilterResult::ExcludedByPattern(p) => assert_eq!(p, "*.log"),
670 other => panic!("expected ExcludedByPattern, got {:?}", other),
671 }
672 match settings.should_include(Path::new("target"), true) {
673 FilterResult::ExcludedByPattern(p) => assert_eq!(p, "target/"),
674 other => panic!("expected ExcludedByPattern, got {:?}", other),
675 }
676 }
677 #[test]
678 fn test_include_then_exclude() {
679 let mut settings = FilterSettings::new();
680 settings.add_include("*.rs").unwrap();
681 settings.add_exclude("test_*.rs").unwrap();
682 assert!(matches!(
684 settings.should_include(Path::new("main.rs"), false),
685 FilterResult::Included
686 ));
687 match settings.should_include(Path::new("test_foo.rs"), false) {
689 FilterResult::ExcludedByPattern(p) => assert_eq!(p, "test_*.rs"),
690 other => panic!("expected ExcludedByPattern, got {:?}", other),
691 }
692 assert!(matches!(
694 settings.should_include(Path::new("README.md"), false),
695 FilterResult::ExcludedByDefault
696 ));
697 }
698 #[test]
699 fn test_filter_file_basic() {
700 let content = r#"
701# this is a comment
702--include *.rs
703--include Cargo.toml
704
705--exclude target/
706--exclude *.log
707"#;
708 let settings = FilterSettings::parse_content(content).unwrap();
709 assert_eq!(settings.includes.len(), 2);
710 assert_eq!(settings.excludes.len(), 2);
711 }
712 #[test]
713 fn test_filter_file_comments() {
714 let content = "# only comments\n# and empty lines\n\n";
715 let settings = FilterSettings::parse_content(content).unwrap();
716 assert!(settings.is_empty());
717 }
718 #[test]
719 fn test_filter_file_syntax_error() {
720 let content = "invalid line without prefix";
721 let result = FilterSettings::parse_content(content);
722 assert!(result.is_err());
723 let err = result.unwrap_err().to_string();
724 assert!(err.contains("line 1"));
725 assert!(err.contains("invalid syntax"));
726 }
727 #[test]
728 fn test_empty_pattern_error() {
729 let result = FilterPattern::parse("");
730 assert!(result.is_err());
731 }
732 #[test]
733 fn test_is_empty() {
734 let empty = FilterSettings::new();
735 assert!(empty.is_empty());
736 let mut with_include = FilterSettings::new();
737 with_include.add_include("*.rs").unwrap();
738 assert!(!with_include.is_empty());
739 let mut with_exclude = FilterSettings::new();
740 with_exclude.add_exclude("*.log").unwrap();
741 assert!(!with_exclude.is_empty());
742 }
743 #[test]
744 fn test_has_includes() {
745 let empty = FilterSettings::new();
746 assert!(!empty.has_includes());
747 let mut with_include = FilterSettings::new();
748 with_include.add_include("*.rs").unwrap();
749 assert!(with_include.has_includes());
750 let mut with_exclude = FilterSettings::new();
751 with_exclude.add_exclude("*.log").unwrap();
752 assert!(!with_exclude.has_includes());
753 let mut with_both = FilterSettings::new();
754 with_both.add_include("*.rs").unwrap();
755 with_both.add_exclude("*.log").unwrap();
756 assert!(with_both.has_includes());
757 }
758 #[test]
759 fn test_filename_match_for_simple_patterns() {
760 let pattern = FilterPattern::parse("*.rs").unwrap();
762 assert!(pattern.matches(Path::new("foo.rs"), false));
763 assert!(pattern.matches(Path::new("src/foo.rs"), false)); assert!(pattern.matches(Path::new("a/b/c/foo.rs"), false));
766 }
767 #[test]
768 fn test_path_pattern_requires_full_match() {
769 let pattern = FilterPattern::parse("src/*.rs").unwrap();
771 assert!(pattern.matches(Path::new("src/foo.rs"), false));
772 assert!(!pattern.matches(Path::new("foo.rs"), false));
773 assert!(!pattern.matches(Path::new("other/src/foo.rs"), false));
774 }
775 #[test]
776 fn test_double_star_matches_nested_paths() {
777 let pattern = FilterPattern::parse("**/*.rs").unwrap();
779 assert!(pattern.matches(Path::new("foo.rs"), false));
780 assert!(pattern.matches(Path::new("src/foo.rs"), false));
781 assert!(pattern.matches(Path::new("src/lib/foo.rs"), false));
782 assert!(pattern.matches(Path::new("a/b/c/d/e.rs"), false));
783 }
784 #[test]
785 fn test_anchored_pattern_matches_only_at_root() {
786 let pattern = FilterPattern::parse("/src").unwrap();
788 assert!(pattern.matches(Path::new("src"), true));
789 assert!(!pattern.matches(Path::new("foo/src"), true));
790 assert!(!pattern.matches(Path::new("a/b/src"), true));
791 }
792 #[test]
793 fn test_nested_directory_pattern() {
794 let pattern = FilterPattern::parse("src/lib/").unwrap();
796 assert!(pattern.matches(Path::new("src/lib"), true));
797 assert!(!pattern.matches(Path::new("lib"), true));
798 assert!(!pattern.matches(Path::new("other/src/lib"), true));
799 }
800 #[test]
801 fn test_dir_only_simple_pattern_matches_at_any_level() {
802 let pattern = FilterPattern::parse("target/").unwrap();
805 assert!(pattern.dir_only);
806 assert!(!pattern.anchored);
807 assert!(pattern.matches(Path::new("target"), true));
809 assert!(pattern.matches(Path::new("foo/target"), true));
811 assert!(pattern.matches(Path::new("a/b/target"), true));
812 assert!(!pattern.matches(Path::new("target"), false));
814 assert!(!pattern.matches(Path::new("foo/target"), false));
815 }
816 #[test]
817 fn test_dir_only_pattern_could_contain_matches() {
818 let mut settings = FilterSettings::new();
820 settings.add_include("target/").unwrap();
821 let pattern = &settings.includes[0];
822 assert!(settings.could_contain_matches(Path::new("foo"), pattern));
824 assert!(settings.could_contain_matches(Path::new("a/b"), pattern));
825 assert!(settings.could_contain_matches(Path::new("src"), pattern));
826 }
827 #[test]
828 fn test_precedence_exclude_overrides_include() {
829 let mut settings = FilterSettings::new();
831 settings.add_include("*.rs").unwrap();
832 settings.add_exclude("test_*.rs").unwrap();
833 match settings.should_include(Path::new("test_main.rs"), false) {
835 FilterResult::ExcludedByPattern(p) => assert_eq!(p, "test_*.rs"),
836 other => panic!("expected ExcludedByPattern, got {:?}", other),
837 }
838 assert!(matches!(
840 settings.should_include(Path::new("main.rs"), false),
841 FilterResult::Included
842 ));
843 }
844 #[test]
845 fn test_should_include_root_item_non_anchored_exclude() {
846 let mut settings = FilterSettings::new();
848 settings.add_exclude("*.log").unwrap();
849 match settings.should_include_root_item(Path::new("debug.log"), false) {
851 FilterResult::ExcludedByPattern(p) => assert_eq!(p, "*.log"),
852 other => panic!("expected ExcludedByPattern, got {:?}", other),
853 }
854 assert!(matches!(
856 settings.should_include_root_item(Path::new("main.rs"), false),
857 FilterResult::Included
858 ));
859 }
860 #[test]
861 fn test_should_include_root_item_anchored_exclude_skipped() {
862 let mut settings = FilterSettings::new();
864 settings.add_exclude("/target/").unwrap();
865 assert!(matches!(
867 settings.should_include_root_item(Path::new("target"), true),
868 FilterResult::Included
869 ));
870 }
871 #[test]
872 fn test_should_include_root_item_non_anchored_include() {
873 let mut settings = FilterSettings::new();
875 settings.add_include("*.rs").unwrap();
876 assert!(matches!(
878 settings.should_include_root_item(Path::new("main.rs"), false),
879 FilterResult::Included
880 ));
881 assert!(matches!(
883 settings.should_include_root_item(Path::new("readme.md"), false),
884 FilterResult::ExcludedByDefault
885 ));
886 }
887 #[test]
888 fn test_should_include_root_item_anchored_include_skipped() {
889 let mut settings = FilterSettings::new();
892 settings.add_include("/bar").unwrap();
893 assert!(matches!(
895 settings.should_include_root_item(Path::new("foo"), true),
896 FilterResult::Included
897 ));
898 assert!(matches!(
900 settings.should_include_root_item(Path::new("baz"), false),
901 FilterResult::ExcludedByDefault
902 ));
903 }
904 #[test]
905 fn test_should_include_root_item_mixed_patterns() {
906 let mut settings = FilterSettings::new();
908 settings.add_include("*.rs").unwrap();
909 settings.add_include("/bar").unwrap();
910 settings.add_exclude("test_*.rs").unwrap();
911 assert!(matches!(
913 settings.should_include_root_item(Path::new("main.rs"), false),
914 FilterResult::Included
915 ));
916 match settings.should_include_root_item(Path::new("test_foo.rs"), false) {
918 FilterResult::ExcludedByPattern(p) => assert_eq!(p, "test_*.rs"),
919 other => panic!("expected ExcludedByPattern, got {:?}", other),
920 }
921 assert!(matches!(
923 settings.should_include_root_item(Path::new("foo"), true),
924 FilterResult::Included
925 ));
926 }
927 #[test]
928 fn test_could_contain_matches_anchored_double_star() {
929 let mut settings = FilterSettings::new();
931 settings.add_include("/src/**").unwrap();
932 let pattern = &settings.includes[0];
933 assert!(settings.could_contain_matches(Path::new(""), pattern));
935 assert!(settings.could_contain_matches(Path::new("src"), pattern));
937 assert!(settings.could_contain_matches(Path::new("src/foo"), pattern));
939 assert!(settings.could_contain_matches(Path::new("src/foo/bar"), pattern));
940 assert!(!settings.could_contain_matches(Path::new("build"), pattern));
942 assert!(!settings.could_contain_matches(Path::new("target"), pattern));
943 assert!(!settings.could_contain_matches(Path::new("build/src"), pattern));
944 }
945 #[test]
946 fn test_could_contain_matches_non_anchored_double_star() {
947 let mut settings = FilterSettings::new();
949 settings.add_include("**/*.rs").unwrap();
950 let pattern = &settings.includes[0];
951 assert!(settings.could_contain_matches(Path::new("src"), pattern));
953 assert!(settings.could_contain_matches(Path::new("build"), pattern));
954 assert!(settings.could_contain_matches(Path::new("any/path"), pattern));
955 }
956 #[test]
957 fn test_could_contain_matches_nested_prefix() {
958 let mut settings = FilterSettings::new();
960 settings.add_include("/src/foo/**").unwrap();
961 let pattern = &settings.includes[0];
962 assert!(settings.could_contain_matches(Path::new(""), pattern));
964 assert!(settings.could_contain_matches(Path::new("src"), pattern));
965 assert!(settings.could_contain_matches(Path::new("src/foo"), pattern));
967 assert!(settings.could_contain_matches(Path::new("src/foo/bar"), pattern));
969 assert!(!settings.could_contain_matches(Path::new("build"), pattern));
971 assert!(!settings.could_contain_matches(Path::new("src/bar"), pattern));
972 }
973 #[test]
974 fn test_extract_literal_prefix() {
975 assert_eq!(FilterSettings::extract_literal_prefix("src/**"), "src");
977 assert_eq!(
978 FilterSettings::extract_literal_prefix("src/foo/**"),
979 "src/foo"
980 );
981 assert_eq!(FilterSettings::extract_literal_prefix("**/*.rs"), "");
982 assert_eq!(FilterSettings::extract_literal_prefix("*.rs"), "");
983 assert_eq!(FilterSettings::extract_literal_prefix("src/*.rs"), "src");
984 assert_eq!(
986 FilterSettings::extract_literal_prefix("src/foo/bar"),
987 "src/foo/bar"
988 );
989 assert_eq!(FilterSettings::extract_literal_prefix("bar"), "bar");
990 assert_eq!(FilterSettings::extract_literal_prefix("src[0-9]/*.rs"), "");
991 }
992 #[test]
993 fn test_directly_matches_include_simple_pattern() {
994 let mut settings = FilterSettings::new();
996 settings.add_include("*.txt").unwrap();
997 assert!(settings.directly_matches_include(Path::new("foo.txt"), false));
999 assert!(settings.directly_matches_include(Path::new("bar/foo.txt"), false));
1000 assert!(!settings.directly_matches_include(Path::new("foo.rs"), false));
1002 assert!(!settings.directly_matches_include(Path::new("txt"), true));
1004 }
1005 #[test]
1006 fn test_directly_matches_include_anchored_pattern() {
1007 let mut settings = FilterSettings::new();
1009 settings.add_include("/foo").unwrap();
1010 assert!(settings.directly_matches_include(Path::new("foo"), true));
1012 assert!(settings.directly_matches_include(Path::new("foo"), false));
1013 assert!(!settings.directly_matches_include(Path::new("bar/foo"), true));
1015 }
1016 #[test]
1017 fn test_directly_matches_include_empty_includes() {
1018 let settings = FilterSettings::new();
1020 assert!(settings.directly_matches_include(Path::new("anything"), true));
1021 assert!(settings.directly_matches_include(Path::new("foo/bar"), false));
1022 }
1023 #[test]
1024 fn test_directly_matches_include_path_pattern() {
1025 let mut settings = FilterSettings::new();
1027 settings.add_include("src/*.rs").unwrap();
1028 assert!(settings.directly_matches_include(Path::new("src/foo.rs"), false));
1030 assert!(!settings.directly_matches_include(Path::new("foo.rs"), false));
1032 assert!(!settings.directly_matches_include(Path::new("other/foo.rs"), false));
1033 }
1034 #[test]
1035 fn test_directly_matches_include_dir_only_pattern() {
1036 let mut settings = FilterSettings::new();
1038 settings.add_include("target/").unwrap();
1039 assert!(settings.directly_matches_include(Path::new("target"), true));
1041 assert!(settings.directly_matches_include(Path::new("foo/target"), true));
1042 assert!(!settings.directly_matches_include(Path::new("target"), false));
1044 }
1045
1046 mod time_filter_tests {
1047 use super::*;
1048 use crate::testutils;
1049
1050 fn write_with_mtime(path: &std::path::Path, age: std::time::Duration) {
1051 std::fs::write(path, "x").unwrap();
1052 let past = filetime::FileTime::from_system_time(std::time::SystemTime::now() - age);
1053 filetime::set_file_mtime(path, past).unwrap();
1054 }
1055
1056 #[test]
1057 fn is_empty_when_no_thresholds_set() {
1058 assert!(TimeFilter::default().is_empty());
1059 let only_mtime = TimeFilter {
1060 modified_before: Some(std::time::Duration::from_secs(1)),
1061 created_before: None,
1062 };
1063 assert!(!only_mtime.is_empty());
1064 let only_btime = TimeFilter {
1065 modified_before: None,
1066 created_before: Some(std::time::Duration::from_secs(1)),
1067 };
1068 assert!(!only_btime.is_empty());
1069 }
1070
1071 #[tokio::test]
1072 async fn matches_returns_matched_when_no_thresholds() {
1073 let tmp = testutils::create_temp_dir().await.unwrap();
1074 let path = tmp.join("file");
1075 write_with_mtime(&path, std::time::Duration::from_secs(0));
1076 let metadata = std::fs::metadata(&path).unwrap();
1077 assert_eq!(
1078 TimeFilter::default().matches(&metadata).unwrap(),
1079 TimeFilterResult::Matched
1080 );
1081 }
1082
1083 #[tokio::test]
1084 async fn matches_when_mtime_older_than_threshold() {
1085 let tmp = testutils::create_temp_dir().await.unwrap();
1086 let path = tmp.join("file");
1087 write_with_mtime(&path, std::time::Duration::from_secs(7200));
1088 let metadata = std::fs::metadata(&path).unwrap();
1089 let filter = TimeFilter {
1090 modified_before: Some(std::time::Duration::from_secs(3600)),
1091 created_before: None,
1092 };
1093 assert_eq!(
1094 filter.matches(&metadata).unwrap(),
1095 TimeFilterResult::Matched
1096 );
1097 }
1098
1099 #[tokio::test]
1100 async fn reports_too_new_modified_when_mtime_recent() {
1101 let tmp = testutils::create_temp_dir().await.unwrap();
1102 let path = tmp.join("file");
1103 write_with_mtime(&path, std::time::Duration::from_secs(0));
1104 let metadata = std::fs::metadata(&path).unwrap();
1105 let filter = TimeFilter {
1106 modified_before: Some(std::time::Duration::from_secs(3600)),
1107 created_before: None,
1108 };
1109 assert_eq!(
1110 filter.matches(&metadata).unwrap(),
1111 TimeFilterResult::TooNewModified
1112 );
1113 }
1114
1115 #[tokio::test]
1122 async fn matches_exercises_btime_deterministically() {
1123 let tmp = testutils::create_temp_dir().await.unwrap();
1124 let path = tmp.join("file");
1125 std::fs::write(&path, "x").unwrap();
1126 let metadata = std::fs::metadata(&path).unwrap();
1127 let filter = TimeFilter {
1128 modified_before: None,
1129 created_before: Some(std::time::Duration::from_secs(3600)),
1130 };
1131 let result = filter.matches(&metadata);
1132 match metadata.created() {
1133 Ok(_) => assert_eq!(result.unwrap(), TimeFilterResult::TooNewCreated),
1134 Err(_) => assert!(
1135 result.is_err(),
1136 "matches() must return Err when created_before is set but btime is unavailable"
1137 ),
1138 }
1139 }
1140
1141 #[tokio::test]
1142 async fn matches_with_zero_threshold_is_always_satisfied() {
1143 let tmp = testutils::create_temp_dir().await.unwrap();
1145 let path = tmp.join("file");
1146 write_with_mtime(&path, std::time::Duration::from_secs(0));
1147 let metadata = std::fs::metadata(&path).unwrap();
1148 let filter = TimeFilter {
1149 modified_before: Some(std::time::Duration::from_secs(0)),
1150 created_before: None,
1151 };
1152 assert_eq!(
1153 filter.matches(&metadata).unwrap(),
1154 TimeFilterResult::Matched
1155 );
1156 }
1157
1158 mod evaluate_and_or_logic {
1162 use super::*;
1163
1164 fn now() -> std::time::SystemTime {
1165 std::time::UNIX_EPOCH + std::time::Duration::from_secs(10_000_000)
1167 }
1168
1169 fn age_before(
1170 t: std::time::SystemTime,
1171 age: std::time::Duration,
1172 ) -> std::time::SystemTime {
1173 t - age
1174 }
1175
1176 #[test]
1177 fn no_thresholds_always_matches_regardless_of_timestamps() {
1178 let filter = TimeFilter::default();
1179 assert_eq!(
1180 filter.evaluate(None, None, now()),
1181 TimeFilterResult::Matched
1182 );
1183 assert_eq!(
1184 filter.evaluate(
1185 Some(age_before(now(), std::time::Duration::from_secs(0))),
1186 None,
1187 now()
1188 ),
1189 TimeFilterResult::Matched
1190 );
1191 }
1192
1193 #[test]
1194 fn and_logic_both_pass_is_matched() {
1195 let filter = TimeFilter {
1196 modified_before: Some(std::time::Duration::from_secs(3600)),
1197 created_before: Some(std::time::Duration::from_secs(3600)),
1198 };
1199 let old = age_before(now(), std::time::Duration::from_secs(7200));
1200 assert_eq!(
1201 filter.evaluate(Some(old), Some(old), now()),
1202 TimeFilterResult::Matched
1203 );
1204 }
1205
1206 #[test]
1207 fn and_logic_only_mtime_passes_reports_created_too_new() {
1208 let filter = TimeFilter {
1209 modified_before: Some(std::time::Duration::from_secs(3600)),
1210 created_before: Some(std::time::Duration::from_secs(3600)),
1211 };
1212 let old = age_before(now(), std::time::Duration::from_secs(7200));
1213 let recent = age_before(now(), std::time::Duration::from_secs(60));
1214 assert_eq!(
1215 filter.evaluate(Some(old), Some(recent), now()),
1216 TimeFilterResult::TooNewCreated
1217 );
1218 }
1219
1220 #[test]
1221 fn and_logic_only_btime_passes_reports_modified_too_new() {
1222 let filter = TimeFilter {
1223 modified_before: Some(std::time::Duration::from_secs(3600)),
1224 created_before: Some(std::time::Duration::from_secs(3600)),
1225 };
1226 let old = age_before(now(), std::time::Duration::from_secs(7200));
1227 let recent = age_before(now(), std::time::Duration::from_secs(60));
1228 assert_eq!(
1229 filter.evaluate(Some(recent), Some(old), now()),
1230 TimeFilterResult::TooNewModified
1231 );
1232 }
1233
1234 #[test]
1235 fn and_logic_neither_passes_reports_too_new_both() {
1236 let filter = TimeFilter {
1237 modified_before: Some(std::time::Duration::from_secs(3600)),
1238 created_before: Some(std::time::Duration::from_secs(3600)),
1239 };
1240 let recent = age_before(now(), std::time::Duration::from_secs(60));
1241 assert_eq!(
1242 filter.evaluate(Some(recent), Some(recent), now()),
1243 TimeFilterResult::TooNewBoth
1244 );
1245 }
1246
1247 #[test]
1248 fn threshold_boundary_exactly_at_age_matches() {
1249 let filter = TimeFilter {
1251 modified_before: Some(std::time::Duration::from_secs(3600)),
1252 created_before: None,
1253 };
1254 let exact = age_before(now(), std::time::Duration::from_secs(3600));
1255 assert_eq!(
1256 filter.evaluate(Some(exact), None, now()),
1257 TimeFilterResult::Matched
1258 );
1259 }
1260
1261 #[test]
1262 fn future_timestamp_treated_as_too_new() {
1263 let filter = TimeFilter {
1265 modified_before: Some(std::time::Duration::from_secs(1)),
1266 created_before: None,
1267 };
1268 let future = now() + std::time::Duration::from_secs(3600);
1269 assert_eq!(
1270 filter.evaluate(Some(future), None, now()),
1271 TimeFilterResult::TooNewModified
1272 );
1273 }
1274
1275 #[test]
1276 fn missing_timestamp_when_threshold_configured_is_too_new() {
1277 let filter = TimeFilter {
1280 modified_before: Some(std::time::Duration::from_secs(3600)),
1281 created_before: Some(std::time::Duration::from_secs(3600)),
1282 };
1283 let old = age_before(now(), std::time::Duration::from_secs(7200));
1284 assert_eq!(
1285 filter.evaluate(Some(old), None, now()),
1286 TimeFilterResult::TooNewCreated
1287 );
1288 assert_eq!(
1289 filter.evaluate(None, Some(old), now()),
1290 TimeFilterResult::TooNewModified
1291 );
1292 assert_eq!(
1293 filter.evaluate(None, None, now()),
1294 TimeFilterResult::TooNewBoth
1295 );
1296 }
1297 }
1298
1299 mod skip_reason {
1301 use super::*;
1302
1303 #[test]
1304 fn matched_yields_none() {
1305 assert_eq!(TimeFilterResult::Matched.as_skip_reason(), None);
1306 }
1307
1308 #[test]
1309 fn too_new_variants_yield_matching_reasons() {
1310 assert_eq!(
1311 TimeFilterResult::TooNewModified.as_skip_reason(),
1312 Some(TimeSkipReason::TooNewModified)
1313 );
1314 assert_eq!(
1315 TimeFilterResult::TooNewCreated.as_skip_reason(),
1316 Some(TimeSkipReason::TooNewCreated)
1317 );
1318 assert_eq!(
1319 TimeFilterResult::TooNewBoth.as_skip_reason(),
1320 Some(TimeSkipReason::TooNewBoth)
1321 );
1322 }
1323 }
1324 }
1325 mod from_args_tests {
1326 use super::*;
1327 use std::sync::atomic::{AtomicU64, Ordering};
1328 static SEQ: AtomicU64 = AtomicU64::new(0);
1329 struct TempFilterFile {
1334 path: std::path::PathBuf,
1335 }
1336 impl TempFilterFile {
1337 fn new(content: &str) -> Self {
1338 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1339 let path = std::env::temp_dir()
1340 .join(format!("rcp-from-args-test-{}-{n}.txt", std::process::id()));
1341 std::fs::write(&path, content).unwrap();
1342 Self { path }
1343 }
1344 fn path(&self) -> &std::path::Path {
1345 &self.path
1346 }
1347 }
1348 impl Drop for TempFilterFile {
1349 fn drop(&mut self) {
1350 let _ = std::fs::remove_file(&self.path);
1351 }
1352 }
1353 #[test]
1354 fn returns_none_when_nothing_specified() {
1355 let result = FilterSettings::from_args(None, &[], &[]).unwrap();
1356 assert!(result.is_none());
1357 }
1358 #[test]
1359 fn builds_from_include_only() {
1360 let include = vec!["*.rs".to_string(), "Cargo.toml".to_string()];
1361 let settings = FilterSettings::from_args(None, &include, &[])
1362 .unwrap()
1363 .expect("should return Some when include is non-empty");
1364 assert_eq!(settings.includes.len(), 2);
1365 assert!(settings.excludes.is_empty());
1366 }
1367 #[test]
1368 fn builds_from_exclude_only() {
1369 let exclude = vec!["*.log".to_string(), "target/".to_string()];
1370 let settings = FilterSettings::from_args(None, &[], &exclude)
1371 .unwrap()
1372 .expect("should return Some when exclude is non-empty");
1373 assert!(settings.includes.is_empty());
1374 assert_eq!(settings.excludes.len(), 2);
1375 }
1376 #[test]
1377 fn builds_from_include_and_exclude() {
1378 let include = vec!["*.rs".to_string()];
1379 let exclude = vec!["target/".to_string()];
1380 let settings = FilterSettings::from_args(None, &include, &exclude)
1381 .unwrap()
1382 .expect("should return Some");
1383 assert_eq!(settings.includes.len(), 1);
1384 assert_eq!(settings.excludes.len(), 1);
1385 }
1386 #[test]
1387 fn loads_from_filter_file() {
1388 let file = TempFilterFile::new("--include *.rs\n--exclude target/\n");
1389 let settings = FilterSettings::from_args(Some(file.path()), &[], &[])
1390 .unwrap()
1391 .expect("should return Some when filter file is read");
1392 assert_eq!(settings.includes.len(), 1);
1393 assert_eq!(settings.excludes.len(), 1);
1394 }
1395 #[test]
1396 fn errors_when_filter_file_combined_with_include() {
1397 let file = TempFilterFile::new("--include *.rs\n");
1398 let include = vec!["*.txt".to_string()];
1399 let err = FilterSettings::from_args(Some(file.path()), &include, &[]).unwrap_err();
1400 assert!(err.to_string().contains("mutually exclusive"));
1401 }
1402 #[test]
1403 fn errors_when_filter_file_combined_with_exclude() {
1404 let file = TempFilterFile::new("--include *.rs\n");
1405 let exclude = vec!["*.log".to_string()];
1406 let err = FilterSettings::from_args(Some(file.path()), &[], &exclude).unwrap_err();
1407 assert!(err.to_string().contains("mutually exclusive"));
1408 }
1409 #[test]
1410 fn propagates_invalid_include_pattern() {
1411 let include = vec!["".to_string()];
1412 assert!(FilterSettings::from_args(None, &include, &[]).is_err());
1413 }
1414 #[test]
1415 fn propagates_missing_filter_file() {
1416 let path = std::path::PathBuf::from("/nonexistent/path/filters.txt");
1417 assert!(FilterSettings::from_args(Some(&path), &[], &[]).is_err());
1418 }
1419 }
1420}