Skip to main content

testx/
filter.rs

1use crate::adapters::{TestCase, TestRunResult, TestStatus, TestSuite};
2
3/// A compiled test filter that can match test cases by name pattern.
4#[derive(Debug, Clone)]
5pub struct TestFilter {
6    /// Include patterns — tests must match at least one (if non-empty)
7    include: Vec<FilterPattern>,
8    /// Exclude patterns — tests matching any of these are removed
9    exclude: Vec<FilterPattern>,
10    /// Only include tests with these statuses (empty = all)
11    status_filter: Vec<TestStatus>,
12    /// Filter by suite name
13    suite_filter: Option<String>,
14}
15
16/// A single filter pattern that can match test names.
17#[derive(Debug, Clone)]
18pub enum FilterPattern {
19    /// Exact string match
20    Exact(String),
21    /// Prefix match (pattern ends with *)
22    Prefix(String),
23    /// Suffix match (pattern starts with *)
24    Suffix(String),
25    /// Contains match (pattern starts and ends with *)
26    Contains(String),
27    /// Simple glob with * wildcards
28    Glob(Vec<GlobSegment>),
29}
30
31/// A segment in a glob pattern.
32#[derive(Debug, Clone)]
33pub enum GlobSegment {
34    /// A literal string to match
35    Literal(String),
36    /// A wildcard matching any characters
37    Wildcard,
38}
39
40impl TestFilter {
41    /// Create a new empty filter (matches everything).
42    pub fn new() -> Self {
43        Self {
44            include: Vec::new(),
45            exclude: Vec::new(),
46            status_filter: Vec::new(),
47            suite_filter: None,
48        }
49    }
50
51    /// Add an include pattern.
52    pub fn include(mut self, pattern: &str) -> Self {
53        self.include.push(FilterPattern::parse(pattern));
54        self
55    }
56
57    /// Add multiple include patterns from a comma-separated string.
58    pub fn include_csv(mut self, patterns: &str) -> Self {
59        for pattern in patterns.split(',') {
60            let pattern = pattern.trim();
61            if !pattern.is_empty() {
62                self.include.push(FilterPattern::parse(pattern));
63            }
64        }
65        self
66    }
67
68    /// Add an exclude pattern.
69    pub fn exclude(mut self, pattern: &str) -> Self {
70        self.exclude.push(FilterPattern::parse(pattern));
71        self
72    }
73
74    /// Add multiple exclude patterns from a comma-separated string.
75    pub fn exclude_csv(mut self, patterns: &str) -> Self {
76        for pattern in patterns.split(',') {
77            let pattern = pattern.trim();
78            if !pattern.is_empty() {
79                self.exclude.push(FilterPattern::parse(pattern));
80            }
81        }
82        self
83    }
84
85    /// Only show tests with the given status.
86    pub fn status(mut self, status: TestStatus) -> Self {
87        self.status_filter.push(status);
88        self
89    }
90
91    /// Only show tests from suites matching this name.
92    pub fn suite(mut self, name: &str) -> Self {
93        self.suite_filter = Some(name.to_string());
94        self
95    }
96
97    /// Check if this filter has any active constraints.
98    pub fn is_active(&self) -> bool {
99        !self.include.is_empty()
100            || !self.exclude.is_empty()
101            || !self.status_filter.is_empty()
102            || self.suite_filter.is_some()
103    }
104
105    /// Check if a test case matches this filter.
106    pub fn matches(&self, test: &TestCase, suite_name: &str) -> bool {
107        // Check suite filter
108        if let Some(ref sf) = self.suite_filter
109            && !suite_name.contains(sf)
110        {
111            return false;
112        }
113
114        // Check status filter
115        if !self.status_filter.is_empty() && !self.status_filter.contains(&test.status) {
116            return false;
117        }
118
119        // Check exclude patterns first (any match excludes)
120        if self.exclude.iter().any(|p| p.matches(&test.name)) {
121            return false;
122        }
123
124        // Check include patterns (must match at least one, if any exist)
125        if !self.include.is_empty() && !self.include.iter().any(|p| p.matches(&test.name)) {
126            return false;
127        }
128
129        true
130    }
131
132    /// Apply this filter to a test run result, returning a filtered copy.
133    pub fn apply(&self, result: &TestRunResult) -> TestRunResult {
134        if !self.is_active() {
135            return result.clone();
136        }
137
138        let suites = result
139            .suites
140            .iter()
141            .filter_map(|suite| {
142                // Apply suite filter
143                if let Some(ref sf) = self.suite_filter
144                    && !suite.name.contains(sf)
145                {
146                    return None;
147                }
148
149                let tests: Vec<TestCase> = suite
150                    .tests
151                    .iter()
152                    .filter(|t| self.matches(t, &suite.name))
153                    .cloned()
154                    .collect();
155
156                if tests.is_empty() {
157                    None
158                } else {
159                    Some(TestSuite {
160                        name: suite.name.clone(),
161                        tests,
162                    })
163                }
164            })
165            .collect();
166
167        TestRunResult {
168            suites,
169            duration: result.duration,
170            raw_exit_code: result.raw_exit_code,
171        }
172    }
173}
174
175impl Default for TestFilter {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181impl FilterPattern {
182    /// Parse a string pattern into a FilterPattern.
183    pub fn parse(pattern: &str) -> Self {
184        if !pattern.contains('*') {
185            return FilterPattern::Exact(pattern.to_string());
186        }
187
188        // "*foo" -> Suffix
189        if pattern.starts_with('*') && !pattern[1..].contains('*') {
190            return FilterPattern::Suffix(pattern[1..].to_string());
191        }
192
193        // "foo*" -> Prefix
194        if pattern.ends_with('*') && !pattern[..pattern.len() - 1].contains('*') {
195            return FilterPattern::Prefix(pattern[..pattern.len() - 1].to_string());
196        }
197
198        // "*foo*" -> Contains
199        if pattern.starts_with('*')
200            && pattern.ends_with('*')
201            && !pattern[1..pattern.len() - 1].contains('*')
202        {
203            return FilterPattern::Contains(pattern[1..pattern.len() - 1].to_string());
204        }
205
206        // General glob pattern
207        let segments = parse_glob_segments(pattern);
208        FilterPattern::Glob(segments)
209    }
210
211    /// Check if a test name matches this pattern.
212    pub fn matches(&self, name: &str) -> bool {
213        match self {
214            FilterPattern::Exact(s) => name == s,
215            FilterPattern::Prefix(p) => name.starts_with(p),
216            FilterPattern::Suffix(s) => name.ends_with(s),
217            FilterPattern::Contains(s) => name.contains(s),
218            FilterPattern::Glob(segments) => glob_match(segments, name),
219        }
220    }
221}
222
223/// Parse a glob pattern into segments.
224fn parse_glob_segments(pattern: &str) -> Vec<GlobSegment> {
225    let mut segments = Vec::new();
226    let mut current = String::new();
227
228    for ch in pattern.chars() {
229        if ch == '*' {
230            if !current.is_empty() {
231                segments.push(GlobSegment::Literal(std::mem::take(&mut current)));
232            }
233            // Collapse multiple wildcards
234            if !matches!(segments.last(), Some(GlobSegment::Wildcard)) {
235                segments.push(GlobSegment::Wildcard);
236            }
237        } else {
238            current.push(ch);
239        }
240    }
241    if !current.is_empty() {
242        segments.push(GlobSegment::Literal(current));
243    }
244
245    segments
246}
247
248/// Match a glob pattern against a string.
249fn glob_match(segments: &[GlobSegment], s: &str) -> bool {
250    glob_match_recursive(segments, s, 0, 0, 0)
251}
252
253fn glob_match_recursive(
254    segments: &[GlobSegment],
255    s: &str,
256    seg_idx: usize,
257    str_idx: usize,
258    depth: usize,
259) -> bool {
260    // Guard against pathological patterns (e.g. *a*b*c*d*... on non-matching strings)
261    const MAX_DEPTH: usize = 1024;
262    if depth > MAX_DEPTH {
263        return false;
264    }
265
266    if seg_idx == segments.len() {
267        return str_idx == s.len();
268    }
269
270    match &segments[seg_idx] {
271        GlobSegment::Literal(lit) => {
272            if s[str_idx..].starts_with(lit) {
273                glob_match_recursive(segments, s, seg_idx + 1, str_idx + lit.len(), depth + 1)
274            } else {
275                false
276            }
277        }
278        GlobSegment::Wildcard => {
279            // Try matching 0 or more characters.
280            // Optimization: if this is the last segment, any remaining string matches.
281            if seg_idx + 1 == segments.len() {
282                return true;
283            }
284            // Skip ahead to the next literal to avoid exponential backtracking:
285            // find all positions where the next literal could start, only recurse there.
286            if let Some(GlobSegment::Literal(next_lit)) = segments.get(seg_idx + 1) {
287                let remaining = &s[str_idx..];
288                let mut search_from = 0;
289                while let Some(pos) = remaining[search_from..].find(next_lit.as_str()) {
290                    let abs_pos = str_idx + search_from + pos;
291                    if glob_match_recursive(segments, s, seg_idx + 1, abs_pos, depth + 1) {
292                        return true;
293                    }
294                    search_from += pos + 1;
295                }
296                false
297            } else {
298                // Next segment is also a wildcard — try each position
299                for i in str_idx..=s.len() {
300                    if glob_match_recursive(segments, s, seg_idx + 1, i, depth + 1) {
301                        return true;
302                    }
303                }
304                false
305            }
306        }
307    }
308}
309
310/// Build a TestFilter from filter configuration strings.
311pub fn build_filter(include: Option<&str>, exclude: Option<&str>, failed_only: bool) -> TestFilter {
312    let mut filter = TestFilter::new();
313
314    if let Some(inc) = include {
315        filter = filter.include_csv(inc);
316    }
317    if let Some(exc) = exclude {
318        filter = filter.exclude_csv(exc);
319    }
320    if failed_only {
321        filter = filter.status(TestStatus::Failed);
322    }
323
324    filter
325}
326
327/// Filter test results and return summary statistics.
328pub struct FilterSummary {
329    /// Total tests before filtering
330    pub total_before: usize,
331    /// Total tests after filtering
332    pub total_after: usize,
333    /// Number of tests removed
334    pub filtered_out: usize,
335    /// Number of suites removed entirely
336    pub suites_removed: usize,
337}
338
339/// Apply filter and compute summary statistics.
340pub fn filter_with_summary(
341    filter: &TestFilter,
342    result: &TestRunResult,
343) -> (TestRunResult, FilterSummary) {
344    let total_before = result.total_tests();
345    let suites_before = result.suites.len();
346
347    let filtered = filter.apply(result);
348
349    let total_after = filtered.total_tests();
350    let suites_after = filtered.suites.len();
351
352    let summary = FilterSummary {
353        total_before,
354        total_after,
355        filtered_out: total_before.saturating_sub(total_after),
356        suites_removed: suites_before.saturating_sub(suites_after),
357    };
358
359    (filtered, summary)
360}
361
362/// Extract a list of failed test names from a result for re-running.
363pub fn failed_test_names(result: &TestRunResult) -> Vec<String> {
364    let mut names = Vec::new();
365    for suite in &result.suites {
366        for test in &suite.tests {
367            if test.status == TestStatus::Failed {
368                names.push(test.name.clone());
369            }
370        }
371    }
372    names
373}
374
375/// Extract test names matching a filter.
376pub fn matching_test_names(filter: &TestFilter, result: &TestRunResult) -> Vec<String> {
377    let mut names = Vec::new();
378    for suite in &result.suites {
379        for test in &suite.tests {
380            if filter.matches(test, &suite.name) {
381                names.push(test.name.clone());
382            }
383        }
384    }
385    names
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use std::time::Duration;
392
393    fn make_test(name: &str, status: TestStatus) -> TestCase {
394        TestCase {
395            name: name.into(),
396            status,
397            duration: Duration::from_millis(1),
398            error: None,
399        }
400    }
401
402    fn make_suite(name: &str, tests: Vec<TestCase>) -> TestSuite {
403        TestSuite {
404            name: name.into(),
405            tests,
406        }
407    }
408
409    fn make_result(suites: Vec<TestSuite>) -> TestRunResult {
410        TestRunResult {
411            suites,
412            duration: Duration::from_millis(100),
413            raw_exit_code: 0,
414        }
415    }
416
417    // ─── FilterPattern Tests ────────────────────────────────────────────
418
419    #[test]
420    fn pattern_exact() {
421        let p = FilterPattern::parse("test_add");
422        assert!(p.matches("test_add"));
423        assert!(!p.matches("test_add_extra"));
424        assert!(!p.matches("test"));
425    }
426
427    #[test]
428    fn pattern_prefix() {
429        let p = FilterPattern::parse("test_*");
430        assert!(p.matches("test_add"));
431        assert!(p.matches("test_"));
432        assert!(!p.matches("other_add"));
433    }
434
435    #[test]
436    fn pattern_suffix() {
437        let p = FilterPattern::parse("*_add");
438        assert!(p.matches("test_add"));
439        assert!(p.matches("_add"));
440        assert!(!p.matches("test_sub"));
441    }
442
443    #[test]
444    fn pattern_contains() {
445        let p = FilterPattern::parse("*divide*");
446        assert!(p.matches("test_divide_by_zero"));
447        assert!(p.matches("divide"));
448        assert!(!p.matches("test_add"));
449    }
450
451    #[test]
452    fn pattern_glob() {
453        let p = FilterPattern::parse("test_*_basic_*");
454        assert!(p.matches("test_math_basic_add"));
455        assert!(p.matches("test_str_basic_concat"));
456        assert!(!p.matches("test_math_advanced_add"));
457    }
458
459    #[test]
460    fn pattern_exact_no_wildcard() {
461        let p = FilterPattern::parse("hello");
462        assert!(matches!(p, FilterPattern::Exact(_)));
463    }
464
465    // ─── TestFilter Tests ───────────────────────────────────────────────
466
467    #[test]
468    fn filter_include_single() {
469        let filter = TestFilter::new().include("test_add");
470        let test = make_test("test_add", TestStatus::Passed);
471        assert!(filter.matches(&test, "suite"));
472
473        let test2 = make_test("test_sub", TestStatus::Passed);
474        assert!(!filter.matches(&test2, "suite"));
475    }
476
477    #[test]
478    fn filter_include_csv() {
479        let filter = TestFilter::new().include_csv("test_add, test_sub");
480        assert!(filter.matches(&make_test("test_add", TestStatus::Passed), "s"));
481        assert!(filter.matches(&make_test("test_sub", TestStatus::Passed), "s"));
482        assert!(!filter.matches(&make_test("test_mul", TestStatus::Passed), "s"));
483    }
484
485    #[test]
486    fn filter_exclude() {
487        let filter = TestFilter::new().exclude("*slow*");
488        assert!(filter.matches(&make_test("test_fast", TestStatus::Passed), "s"));
489        assert!(!filter.matches(&make_test("test_slow_add", TestStatus::Passed), "s"));
490    }
491
492    #[test]
493    fn filter_status() {
494        let filter = TestFilter::new().status(TestStatus::Failed);
495        assert!(!filter.matches(&make_test("test", TestStatus::Passed), "s"));
496        assert!(filter.matches(&make_test("test", TestStatus::Failed), "s"));
497    }
498
499    #[test]
500    fn filter_suite() {
501        let filter = TestFilter::new().suite("MathTest");
502        assert!(filter.matches(&make_test("test", TestStatus::Passed), "MathTest"));
503        assert!(!filter.matches(&make_test("test", TestStatus::Passed), "StringTest"));
504    }
505
506    #[test]
507    fn filter_combined() {
508        let filter = TestFilter::new()
509            .include("test_*")
510            .exclude("*slow*")
511            .status(TestStatus::Failed);
512
513        assert!(!filter.matches(&make_test("test_add", TestStatus::Passed), "s")); // wrong status
514        assert!(filter.matches(&make_test("test_add", TestStatus::Failed), "s")); // matches all
515        assert!(!filter.matches(&make_test("test_slow", TestStatus::Failed), "s")); // excluded
516        assert!(!filter.matches(&make_test("other", TestStatus::Failed), "s")); // no include match
517    }
518
519    #[test]
520    fn filter_empty_matches_all() {
521        let filter = TestFilter::new();
522        assert!(!filter.is_active());
523        assert!(filter.matches(&make_test("anything", TestStatus::Passed), "any"));
524    }
525
526    // ─── Apply Tests ────────────────────────────────────────────────────
527
528    #[test]
529    fn apply_filter_to_result() {
530        let result = make_result(vec![make_suite(
531            "tests",
532            vec![
533                make_test("test_add", TestStatus::Passed),
534                make_test("test_sub", TestStatus::Passed),
535                make_test("test_div", TestStatus::Failed),
536            ],
537        )]);
538
539        let filter = TestFilter::new().status(TestStatus::Failed);
540        let filtered = filter.apply(&result);
541
542        assert_eq!(filtered.total_tests(), 1);
543        assert_eq!(filtered.suites[0].tests[0].name, "test_div");
544    }
545
546    #[test]
547    fn apply_filter_removes_empty_suites() {
548        let result = make_result(vec![
549            make_suite("MathTest", vec![make_test("test_add", TestStatus::Passed)]),
550            make_suite(
551                "StringTest",
552                vec![make_test("test_upper", TestStatus::Passed)],
553            ),
554        ]);
555
556        let filter = TestFilter::new().suite("MathTest");
557        let filtered = filter.apply(&result);
558
559        assert_eq!(filtered.suites.len(), 1);
560        assert_eq!(filtered.suites[0].name, "MathTest");
561    }
562
563    #[test]
564    fn apply_no_filter_returns_clone() {
565        let result = make_result(vec![make_suite(
566            "tests",
567            vec![make_test("test_add", TestStatus::Passed)],
568        )]);
569
570        let filter = TestFilter::new();
571        let filtered = filter.apply(&result);
572
573        assert_eq!(filtered.total_tests(), result.total_tests());
574    }
575
576    // ─── Glob Matching Tests ────────────────────────────────────────────
577
578    #[test]
579    fn glob_segments_parsing() {
580        let segs = parse_glob_segments("test_*_basic_*");
581        assert_eq!(segs.len(), 4);
582        assert!(matches!(&segs[0], GlobSegment::Literal(s) if s == "test_"));
583        assert!(matches!(&segs[1], GlobSegment::Wildcard));
584        assert!(matches!(&segs[2], GlobSegment::Literal(s) if s == "_basic_"));
585        assert!(matches!(&segs[3], GlobSegment::Wildcard));
586    }
587
588    #[test]
589    fn glob_match_basic() {
590        let segs = parse_glob_segments("hello");
591        assert!(glob_match(&segs, "hello"));
592        assert!(!glob_match(&segs, "hell"));
593    }
594
595    #[test]
596    fn glob_match_wildcard() {
597        let segs = parse_glob_segments("*");
598        assert!(glob_match(&segs, "anything"));
599        assert!(glob_match(&segs, ""));
600    }
601
602    #[test]
603    fn glob_match_complex() {
604        let segs = parse_glob_segments("test_*_*_end");
605        assert!(glob_match(&segs, "test_a_b_end"));
606        assert!(glob_match(&segs, "test_foo_bar_end"));
607        assert!(!glob_match(&segs, "test_end"));
608    }
609
610    // ─── Helper Function Tests ──────────────────────────────────────────
611
612    #[test]
613    fn build_filter_basic() {
614        let filter = build_filter(Some("test_*"), Some("*slow*"), false);
615        assert!(filter.is_active());
616        assert!(filter.matches(&make_test("test_fast", TestStatus::Passed), "s"));
617        assert!(!filter.matches(&make_test("test_slow", TestStatus::Passed), "s"));
618    }
619
620    #[test]
621    fn build_filter_failed_only() {
622        let filter = build_filter(None, None, true);
623        assert!(filter.is_active());
624        assert!(!filter.matches(&make_test("test", TestStatus::Passed), "s"));
625        assert!(filter.matches(&make_test("test", TestStatus::Failed), "s"));
626    }
627
628    #[test]
629    fn build_filter_none() {
630        let filter = build_filter(None, None, false);
631        assert!(!filter.is_active());
632    }
633
634    #[test]
635    fn filter_with_summary_test() {
636        let result = make_result(vec![make_suite(
637            "tests",
638            vec![
639                make_test("test_a", TestStatus::Passed),
640                make_test("test_b", TestStatus::Failed),
641                make_test("test_c", TestStatus::Passed),
642            ],
643        )]);
644
645        let filter = TestFilter::new().status(TestStatus::Failed);
646        let (filtered, summary) = filter_with_summary(&filter, &result);
647
648        assert_eq!(summary.total_before, 3);
649        assert_eq!(summary.total_after, 1);
650        assert_eq!(summary.filtered_out, 2);
651        assert_eq!(filtered.total_failed(), 1);
652    }
653
654    #[test]
655    fn failed_test_names_test() {
656        let result = make_result(vec![make_suite(
657            "tests",
658            vec![
659                make_test("test_a", TestStatus::Passed),
660                make_test("test_b", TestStatus::Failed),
661                make_test("test_c", TestStatus::Failed),
662            ],
663        )]);
664
665        let names = failed_test_names(&result);
666        assert_eq!(names, vec!["test_b", "test_c"]);
667    }
668
669    #[test]
670    fn matching_test_names_test() {
671        let result = make_result(vec![make_suite(
672            "tests",
673            vec![
674                make_test("test_add", TestStatus::Passed),
675                make_test("test_sub", TestStatus::Passed),
676                make_test("other", TestStatus::Passed),
677            ],
678        )]);
679
680        let filter = TestFilter::new().include("test_*");
681        let names = matching_test_names(&filter, &result);
682        assert_eq!(names, vec!["test_add", "test_sub"]);
683    }
684
685    #[test]
686    fn exclude_csv_multiple() {
687        let filter = TestFilter::new().exclude_csv("*slow*, *flaky*, *skip*");
688        assert!(filter.matches(&make_test("test_fast", TestStatus::Passed), "s"));
689        assert!(!filter.matches(&make_test("test_slow", TestStatus::Passed), "s"));
690        assert!(!filter.matches(&make_test("test_flaky", TestStatus::Passed), "s"));
691        assert!(!filter.matches(&make_test("test_skip_me", TestStatus::Passed), "s"));
692    }
693}