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)
251}
252
253fn glob_match_recursive(segments: &[GlobSegment], s: &str, seg_idx: usize, str_idx: usize) -> bool {
254    if seg_idx == segments.len() {
255        return str_idx == s.len();
256    }
257
258    match &segments[seg_idx] {
259        GlobSegment::Literal(lit) => {
260            if s[str_idx..].starts_with(lit) {
261                glob_match_recursive(segments, s, seg_idx + 1, str_idx + lit.len())
262            } else {
263                false
264            }
265        }
266        GlobSegment::Wildcard => {
267            // Try matching 0 or more characters
268            for i in str_idx..=s.len() {
269                if glob_match_recursive(segments, s, seg_idx + 1, i) {
270                    return true;
271                }
272            }
273            false
274        }
275    }
276}
277
278/// Build a TestFilter from filter configuration strings.
279pub fn build_filter(include: Option<&str>, exclude: Option<&str>, failed_only: bool) -> TestFilter {
280    let mut filter = TestFilter::new();
281
282    if let Some(inc) = include {
283        filter = filter.include_csv(inc);
284    }
285    if let Some(exc) = exclude {
286        filter = filter.exclude_csv(exc);
287    }
288    if failed_only {
289        filter = filter.status(TestStatus::Failed);
290    }
291
292    filter
293}
294
295/// Filter test results and return summary statistics.
296pub struct FilterSummary {
297    /// Total tests before filtering
298    pub total_before: usize,
299    /// Total tests after filtering
300    pub total_after: usize,
301    /// Number of tests removed
302    pub filtered_out: usize,
303    /// Number of suites removed entirely
304    pub suites_removed: usize,
305}
306
307/// Apply filter and compute summary statistics.
308pub fn filter_with_summary(
309    filter: &TestFilter,
310    result: &TestRunResult,
311) -> (TestRunResult, FilterSummary) {
312    let total_before = result.total_tests();
313    let suites_before = result.suites.len();
314
315    let filtered = filter.apply(result);
316
317    let total_after = filtered.total_tests();
318    let suites_after = filtered.suites.len();
319
320    let summary = FilterSummary {
321        total_before,
322        total_after,
323        filtered_out: total_before.saturating_sub(total_after),
324        suites_removed: suites_before.saturating_sub(suites_after),
325    };
326
327    (filtered, summary)
328}
329
330/// Extract a list of failed test names from a result for re-running.
331pub fn failed_test_names(result: &TestRunResult) -> Vec<String> {
332    let mut names = Vec::new();
333    for suite in &result.suites {
334        for test in &suite.tests {
335            if test.status == TestStatus::Failed {
336                names.push(test.name.clone());
337            }
338        }
339    }
340    names
341}
342
343/// Extract test names matching a filter.
344pub fn matching_test_names(filter: &TestFilter, result: &TestRunResult) -> Vec<String> {
345    let mut names = Vec::new();
346    for suite in &result.suites {
347        for test in &suite.tests {
348            if filter.matches(test, &suite.name) {
349                names.push(test.name.clone());
350            }
351        }
352    }
353    names
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use std::time::Duration;
360
361    fn make_test(name: &str, status: TestStatus) -> TestCase {
362        TestCase {
363            name: name.into(),
364            status,
365            duration: Duration::from_millis(1),
366            error: None,
367        }
368    }
369
370    fn make_suite(name: &str, tests: Vec<TestCase>) -> TestSuite {
371        TestSuite {
372            name: name.into(),
373            tests,
374        }
375    }
376
377    fn make_result(suites: Vec<TestSuite>) -> TestRunResult {
378        TestRunResult {
379            suites,
380            duration: Duration::from_millis(100),
381            raw_exit_code: 0,
382        }
383    }
384
385    // ─── FilterPattern Tests ────────────────────────────────────────────
386
387    #[test]
388    fn pattern_exact() {
389        let p = FilterPattern::parse("test_add");
390        assert!(p.matches("test_add"));
391        assert!(!p.matches("test_add_extra"));
392        assert!(!p.matches("test"));
393    }
394
395    #[test]
396    fn pattern_prefix() {
397        let p = FilterPattern::parse("test_*");
398        assert!(p.matches("test_add"));
399        assert!(p.matches("test_"));
400        assert!(!p.matches("other_add"));
401    }
402
403    #[test]
404    fn pattern_suffix() {
405        let p = FilterPattern::parse("*_add");
406        assert!(p.matches("test_add"));
407        assert!(p.matches("_add"));
408        assert!(!p.matches("test_sub"));
409    }
410
411    #[test]
412    fn pattern_contains() {
413        let p = FilterPattern::parse("*divide*");
414        assert!(p.matches("test_divide_by_zero"));
415        assert!(p.matches("divide"));
416        assert!(!p.matches("test_add"));
417    }
418
419    #[test]
420    fn pattern_glob() {
421        let p = FilterPattern::parse("test_*_basic_*");
422        assert!(p.matches("test_math_basic_add"));
423        assert!(p.matches("test_str_basic_concat"));
424        assert!(!p.matches("test_math_advanced_add"));
425    }
426
427    #[test]
428    fn pattern_exact_no_wildcard() {
429        let p = FilterPattern::parse("hello");
430        assert!(matches!(p, FilterPattern::Exact(_)));
431    }
432
433    // ─── TestFilter Tests ───────────────────────────────────────────────
434
435    #[test]
436    fn filter_include_single() {
437        let filter = TestFilter::new().include("test_add");
438        let test = make_test("test_add", TestStatus::Passed);
439        assert!(filter.matches(&test, "suite"));
440
441        let test2 = make_test("test_sub", TestStatus::Passed);
442        assert!(!filter.matches(&test2, "suite"));
443    }
444
445    #[test]
446    fn filter_include_csv() {
447        let filter = TestFilter::new().include_csv("test_add, test_sub");
448        assert!(filter.matches(&make_test("test_add", TestStatus::Passed), "s"));
449        assert!(filter.matches(&make_test("test_sub", TestStatus::Passed), "s"));
450        assert!(!filter.matches(&make_test("test_mul", TestStatus::Passed), "s"));
451    }
452
453    #[test]
454    fn filter_exclude() {
455        let filter = TestFilter::new().exclude("*slow*");
456        assert!(filter.matches(&make_test("test_fast", TestStatus::Passed), "s"));
457        assert!(!filter.matches(&make_test("test_slow_add", TestStatus::Passed), "s"));
458    }
459
460    #[test]
461    fn filter_status() {
462        let filter = TestFilter::new().status(TestStatus::Failed);
463        assert!(!filter.matches(&make_test("test", TestStatus::Passed), "s"));
464        assert!(filter.matches(&make_test("test", TestStatus::Failed), "s"));
465    }
466
467    #[test]
468    fn filter_suite() {
469        let filter = TestFilter::new().suite("MathTest");
470        assert!(filter.matches(&make_test("test", TestStatus::Passed), "MathTest"));
471        assert!(!filter.matches(&make_test("test", TestStatus::Passed), "StringTest"));
472    }
473
474    #[test]
475    fn filter_combined() {
476        let filter = TestFilter::new()
477            .include("test_*")
478            .exclude("*slow*")
479            .status(TestStatus::Failed);
480
481        assert!(!filter.matches(&make_test("test_add", TestStatus::Passed), "s")); // wrong status
482        assert!(filter.matches(&make_test("test_add", TestStatus::Failed), "s")); // matches all
483        assert!(!filter.matches(&make_test("test_slow", TestStatus::Failed), "s")); // excluded
484        assert!(!filter.matches(&make_test("other", TestStatus::Failed), "s")); // no include match
485    }
486
487    #[test]
488    fn filter_empty_matches_all() {
489        let filter = TestFilter::new();
490        assert!(!filter.is_active());
491        assert!(filter.matches(&make_test("anything", TestStatus::Passed), "any"));
492    }
493
494    // ─── Apply Tests ────────────────────────────────────────────────────
495
496    #[test]
497    fn apply_filter_to_result() {
498        let result = make_result(vec![make_suite(
499            "tests",
500            vec![
501                make_test("test_add", TestStatus::Passed),
502                make_test("test_sub", TestStatus::Passed),
503                make_test("test_div", TestStatus::Failed),
504            ],
505        )]);
506
507        let filter = TestFilter::new().status(TestStatus::Failed);
508        let filtered = filter.apply(&result);
509
510        assert_eq!(filtered.total_tests(), 1);
511        assert_eq!(filtered.suites[0].tests[0].name, "test_div");
512    }
513
514    #[test]
515    fn apply_filter_removes_empty_suites() {
516        let result = make_result(vec![
517            make_suite("MathTest", vec![make_test("test_add", TestStatus::Passed)]),
518            make_suite(
519                "StringTest",
520                vec![make_test("test_upper", TestStatus::Passed)],
521            ),
522        ]);
523
524        let filter = TestFilter::new().suite("MathTest");
525        let filtered = filter.apply(&result);
526
527        assert_eq!(filtered.suites.len(), 1);
528        assert_eq!(filtered.suites[0].name, "MathTest");
529    }
530
531    #[test]
532    fn apply_no_filter_returns_clone() {
533        let result = make_result(vec![make_suite(
534            "tests",
535            vec![make_test("test_add", TestStatus::Passed)],
536        )]);
537
538        let filter = TestFilter::new();
539        let filtered = filter.apply(&result);
540
541        assert_eq!(filtered.total_tests(), result.total_tests());
542    }
543
544    // ─── Glob Matching Tests ────────────────────────────────────────────
545
546    #[test]
547    fn glob_segments_parsing() {
548        let segs = parse_glob_segments("test_*_basic_*");
549        assert_eq!(segs.len(), 4);
550        assert!(matches!(&segs[0], GlobSegment::Literal(s) if s == "test_"));
551        assert!(matches!(&segs[1], GlobSegment::Wildcard));
552        assert!(matches!(&segs[2], GlobSegment::Literal(s) if s == "_basic_"));
553        assert!(matches!(&segs[3], GlobSegment::Wildcard));
554    }
555
556    #[test]
557    fn glob_match_basic() {
558        let segs = parse_glob_segments("hello");
559        assert!(glob_match(&segs, "hello"));
560        assert!(!glob_match(&segs, "hell"));
561    }
562
563    #[test]
564    fn glob_match_wildcard() {
565        let segs = parse_glob_segments("*");
566        assert!(glob_match(&segs, "anything"));
567        assert!(glob_match(&segs, ""));
568    }
569
570    #[test]
571    fn glob_match_complex() {
572        let segs = parse_glob_segments("test_*_*_end");
573        assert!(glob_match(&segs, "test_a_b_end"));
574        assert!(glob_match(&segs, "test_foo_bar_end"));
575        assert!(!glob_match(&segs, "test_end"));
576    }
577
578    // ─── Helper Function Tests ──────────────────────────────────────────
579
580    #[test]
581    fn build_filter_basic() {
582        let filter = build_filter(Some("test_*"), Some("*slow*"), false);
583        assert!(filter.is_active());
584        assert!(filter.matches(&make_test("test_fast", TestStatus::Passed), "s"));
585        assert!(!filter.matches(&make_test("test_slow", TestStatus::Passed), "s"));
586    }
587
588    #[test]
589    fn build_filter_failed_only() {
590        let filter = build_filter(None, None, true);
591        assert!(filter.is_active());
592        assert!(!filter.matches(&make_test("test", TestStatus::Passed), "s"));
593        assert!(filter.matches(&make_test("test", TestStatus::Failed), "s"));
594    }
595
596    #[test]
597    fn build_filter_none() {
598        let filter = build_filter(None, None, false);
599        assert!(!filter.is_active());
600    }
601
602    #[test]
603    fn filter_with_summary_test() {
604        let result = make_result(vec![make_suite(
605            "tests",
606            vec![
607                make_test("test_a", TestStatus::Passed),
608                make_test("test_b", TestStatus::Failed),
609                make_test("test_c", TestStatus::Passed),
610            ],
611        )]);
612
613        let filter = TestFilter::new().status(TestStatus::Failed);
614        let (filtered, summary) = filter_with_summary(&filter, &result);
615
616        assert_eq!(summary.total_before, 3);
617        assert_eq!(summary.total_after, 1);
618        assert_eq!(summary.filtered_out, 2);
619        assert_eq!(filtered.total_failed(), 1);
620    }
621
622    #[test]
623    fn failed_test_names_test() {
624        let result = make_result(vec![make_suite(
625            "tests",
626            vec![
627                make_test("test_a", TestStatus::Passed),
628                make_test("test_b", TestStatus::Failed),
629                make_test("test_c", TestStatus::Failed),
630            ],
631        )]);
632
633        let names = failed_test_names(&result);
634        assert_eq!(names, vec!["test_b", "test_c"]);
635    }
636
637    #[test]
638    fn matching_test_names_test() {
639        let result = make_result(vec![make_suite(
640            "tests",
641            vec![
642                make_test("test_add", TestStatus::Passed),
643                make_test("test_sub", TestStatus::Passed),
644                make_test("other", TestStatus::Passed),
645            ],
646        )]);
647
648        let filter = TestFilter::new().include("test_*");
649        let names = matching_test_names(&filter, &result);
650        assert_eq!(names, vec!["test_add", "test_sub"]);
651    }
652
653    #[test]
654    fn exclude_csv_multiple() {
655        let filter = TestFilter::new().exclude_csv("*slow*, *flaky*, *skip*");
656        assert!(filter.matches(&make_test("test_fast", TestStatus::Passed), "s"));
657        assert!(!filter.matches(&make_test("test_slow", TestStatus::Passed), "s"));
658        assert!(!filter.matches(&make_test("test_flaky", TestStatus::Passed), "s"));
659        assert!(!filter.matches(&make_test("test_skip_me", TestStatus::Passed), "s"));
660    }
661}