Skip to main content

testx/coverage/
mod.rs

1//! Coverage integration module.
2//!
3//! Provides a unified interface for collecting and displaying
4//! code coverage across all supported languages/adapters.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9pub mod display;
10pub mod parsers;
11
12/// Coverage configuration.
13#[derive(Debug, Clone)]
14pub struct CoverageConfig {
15    /// Whether coverage collection is enabled
16    pub enabled: bool,
17    /// Output format for coverage data
18    pub format: CoverageFormat,
19    /// Directory for coverage output files
20    pub output_dir: PathBuf,
21    /// Minimum coverage threshold (fail if below)
22    pub threshold: Option<f64>,
23    /// Paths to include in coverage (glob patterns)
24    pub include: Vec<String>,
25    /// Paths to exclude from coverage (glob patterns)
26    pub exclude: Vec<String>,
27}
28
29impl Default for CoverageConfig {
30    fn default() -> Self {
31        Self {
32            enabled: false,
33            format: CoverageFormat::Summary,
34            output_dir: PathBuf::from("coverage"),
35            threshold: None,
36            include: Vec::new(),
37            exclude: Vec::new(),
38        }
39    }
40}
41
42/// Output format for coverage reports.
43#[derive(Debug, Clone, PartialEq)]
44pub enum CoverageFormat {
45    /// Text summary only
46    Summary,
47    /// LCOV format
48    Lcov,
49    /// Cobertura XML
50    Cobertura,
51    /// HTML report
52    Html,
53    /// JSON data
54    Json,
55}
56
57impl CoverageFormat {
58    /// Parse a format string (case-insensitive).
59    pub fn from_str_lossy(s: &str) -> Self {
60        match s.to_lowercase().as_str() {
61            "lcov" => CoverageFormat::Lcov,
62            "cobertura" | "xml" => CoverageFormat::Cobertura,
63            "html" => CoverageFormat::Html,
64            "json" => CoverageFormat::Json,
65            _ => CoverageFormat::Summary,
66        }
67    }
68
69    /// File extension for this format.
70    pub fn extension(&self) -> &str {
71        match self {
72            CoverageFormat::Summary => "txt",
73            CoverageFormat::Lcov => "lcov",
74            CoverageFormat::Cobertura => "xml",
75            CoverageFormat::Html => "html",
76            CoverageFormat::Json => "json",
77        }
78    }
79}
80
81/// Complete coverage result for a project.
82#[derive(Debug, Clone, serde::Serialize)]
83pub struct CoverageResult {
84    /// Per-file coverage data
85    pub files: Vec<FileCoverage>,
86    /// Total lines in all files
87    pub total_lines: usize,
88    /// Total covered lines
89    pub covered_lines: usize,
90    /// Overall coverage percentage
91    pub percentage: f64,
92    /// Total branches (if available)
93    pub total_branches: usize,
94    /// Covered branches (if available)
95    pub covered_branches: usize,
96    /// Branch coverage percentage
97    pub branch_percentage: f64,
98}
99
100impl CoverageResult {
101    /// Create a CoverageResult from a vector of file coverage data.
102    pub fn from_files(files: Vec<FileCoverage>) -> Self {
103        let total_lines: usize = files.iter().map(|f| f.total_lines).sum();
104        let covered_lines: usize = files.iter().map(|f| f.covered_lines).sum();
105        let total_branches: usize = files.iter().map(|f| f.total_branches).sum();
106        let covered_branches: usize = files.iter().map(|f| f.covered_branches).sum();
107
108        let percentage = if total_lines > 0 {
109            covered_lines as f64 / total_lines as f64 * 100.0
110        } else {
111            0.0
112        };
113
114        let branch_percentage = if total_branches > 0 {
115            covered_branches as f64 / total_branches as f64 * 100.0
116        } else {
117            0.0
118        };
119
120        Self {
121            files,
122            total_lines,
123            covered_lines,
124            percentage,
125            total_branches,
126            covered_branches,
127            branch_percentage,
128        }
129    }
130
131    /// Check if coverage meets a minimum threshold.
132    pub fn meets_threshold(&self, threshold: f64) -> bool {
133        self.percentage >= threshold
134    }
135
136    /// Get files sorted by coverage percentage (lowest first).
137    pub fn worst_files(&self, n: usize) -> Vec<&FileCoverage> {
138        let mut sorted: Vec<&FileCoverage> = self.files.iter().collect();
139        sorted.sort_by(|a, b| {
140            a.percentage()
141                .partial_cmp(&b.percentage())
142                .unwrap_or(std::cmp::Ordering::Equal)
143        });
144        sorted.into_iter().take(n).collect()
145    }
146
147    /// Get the number of uncovered files.
148    pub fn uncovered_file_count(&self) -> usize {
149        self.files.iter().filter(|f| f.covered_lines == 0).count()
150    }
151
152    /// Filter files by a predicate.
153    pub fn filter_files<F>(&self, predicate: F) -> Self
154    where
155        F: Fn(&FileCoverage) -> bool,
156    {
157        let files: Vec<FileCoverage> = self
158            .files
159            .iter()
160            .filter(|f| predicate(f))
161            .cloned()
162            .collect();
163        Self::from_files(files)
164    }
165}
166
167/// Coverage data for a single file.
168#[derive(Debug, Clone, serde::Serialize)]
169pub struct FileCoverage {
170    /// Relative path to the file
171    pub path: PathBuf,
172    /// Total executable lines
173    pub total_lines: usize,
174    /// Number of lines with coverage
175    pub covered_lines: usize,
176    /// Uncovered line ranges: [(start, end), ...]
177    pub uncovered_ranges: Vec<(usize, usize)>,
178    /// Per-line hit counts: line_number -> hit_count
179    #[serde(skip)]
180    pub line_hits: HashMap<usize, u64>,
181    /// Total branches in the file
182    pub total_branches: usize,
183    /// Covered branches
184    pub covered_branches: usize,
185}
186
187impl FileCoverage {
188    /// Coverage percentage for this file.
189    pub fn percentage(&self) -> f64 {
190        if self.total_lines == 0 {
191            0.0
192        } else {
193            self.covered_lines as f64 / self.total_lines as f64 * 100.0
194        }
195    }
196
197    /// Branch coverage percentage for this file.
198    pub fn branch_percentage(&self) -> f64 {
199        if self.total_branches == 0 {
200            0.0
201        } else {
202            self.covered_branches as f64 / self.total_branches as f64 * 100.0
203        }
204    }
205
206    /// Whether this file has full line coverage.
207    pub fn is_fully_covered(&self) -> bool {
208        self.covered_lines == self.total_lines && self.total_lines > 0
209    }
210}
211
212/// Adapter-specific coverage configurations.
213#[derive(Debug, Clone)]
214pub struct AdapterCoverageConfig {
215    /// Adapter name
216    pub adapter: String,
217    /// Coverage tool to use
218    pub tool: String,
219    /// Extra arguments for coverage collection
220    pub extra_args: Vec<String>,
221    /// Environment variables for coverage
222    pub env: HashMap<String, String>,
223}
224
225/// Known coverage tools per adapter.
226pub fn default_coverage_tool(adapter: &str) -> Option<AdapterCoverageConfig> {
227    let config = match adapter {
228        "rust" => AdapterCoverageConfig {
229            adapter: "rust".into(),
230            tool: "cargo-llvm-cov".into(),
231            extra_args: vec!["--lcov".into(), "--output-path".into()],
232            env: HashMap::new(),
233        },
234        "python" => AdapterCoverageConfig {
235            adapter: "python".into(),
236            tool: "coverage".into(),
237            extra_args: vec!["run".into(), "-m".into(), "pytest".into()],
238            env: HashMap::new(),
239        },
240        "javascript" => AdapterCoverageConfig {
241            adapter: "javascript".into(),
242            tool: "built-in".into(),
243            extra_args: vec!["--coverage".into()],
244            env: HashMap::new(),
245        },
246        "go" => AdapterCoverageConfig {
247            adapter: "go".into(),
248            tool: "go-cover".into(),
249            extra_args: vec!["-coverprofile=coverage.out".into()],
250            env: HashMap::new(),
251        },
252        "java" => AdapterCoverageConfig {
253            adapter: "java".into(),
254            tool: "jacoco".into(),
255            extra_args: Vec::new(),
256            env: HashMap::new(),
257        },
258        "cpp" => AdapterCoverageConfig {
259            adapter: "cpp".into(),
260            tool: "gcov".into(),
261            extra_args: vec!["--coverage".into()],
262            env: HashMap::new(),
263        },
264        "ruby" => AdapterCoverageConfig {
265            adapter: "ruby".into(),
266            tool: "simplecov".into(),
267            extra_args: Vec::new(),
268            env: HashMap::from([("COVERAGE".into(), "true".into())]),
269        },
270        "elixir" => AdapterCoverageConfig {
271            adapter: "elixir".into(),
272            tool: "mix-cover".into(),
273            extra_args: vec!["--cover".into()],
274            env: HashMap::new(),
275        },
276        "dotnet" => AdapterCoverageConfig {
277            adapter: "dotnet".into(),
278            tool: "xplat-coverage".into(),
279            extra_args: vec!["--collect:\"XPlat Code Coverage\"".into()],
280            env: HashMap::new(),
281        },
282        _ => return None,
283    };
284    Some(config)
285}
286
287/// Merge multiple coverage results (e.g. from parallel adapter runs).
288pub fn merge_coverage(results: &[CoverageResult]) -> CoverageResult {
289    let mut file_map: HashMap<PathBuf, FileCoverage> = HashMap::new();
290
291    for result in results {
292        for file in &result.files {
293            let entry = file_map
294                .entry(file.path.clone())
295                .or_insert_with(|| FileCoverage {
296                    path: file.path.clone(),
297                    total_lines: 0,
298                    covered_lines: 0,
299                    uncovered_ranges: Vec::new(),
300                    line_hits: HashMap::new(),
301                    total_branches: 0,
302                    covered_branches: 0,
303                });
304
305            // Merge line hits (take max)
306            for (&line, &hits) in &file.line_hits {
307                let existing = entry.line_hits.entry(line).or_insert(0);
308                *existing = (*existing).max(hits);
309            }
310
311            // Recalculate from merged line_hits
312            entry.total_lines = entry.line_hits.len().max(file.total_lines);
313            entry.covered_lines = entry.line_hits.values().filter(|&&h| h > 0).count();
314            entry.total_branches = entry.total_branches.max(file.total_branches);
315            entry.covered_branches = entry.covered_branches.max(file.covered_branches);
316        }
317    }
318
319    // Recompute uncovered ranges from merged line hits
320    let files: Vec<FileCoverage> = file_map
321        .into_values()
322        .map(|mut f| {
323            f.uncovered_ranges = compute_uncovered_ranges(&f.line_hits, f.total_lines);
324            f
325        })
326        .collect();
327
328    CoverageResult::from_files(files)
329}
330
331/// Compute contiguous uncovered line ranges from per-line hit data.
332fn compute_uncovered_ranges(
333    line_hits: &HashMap<usize, u64>,
334    total_lines: usize,
335) -> Vec<(usize, usize)> {
336    let mut ranges = Vec::new();
337    let mut start: Option<usize> = None;
338
339    for line in 1..=total_lines {
340        let is_covered = line_hits.get(&line).is_some_and(|&h| h > 0);
341        let is_executable = line_hits.contains_key(&line);
342
343        if is_executable && !is_covered {
344            if start.is_none() {
345                start = Some(line);
346            }
347        } else if let Some(s) = start {
348            ranges.push((s, line - 1));
349            start = None;
350        }
351    }
352
353    if let Some(s) = start {
354        ranges.push((s, total_lines));
355    }
356
357    ranges
358}
359
360/// Compute a coverage delta between two results.
361pub fn coverage_delta(old: &CoverageResult, new: &CoverageResult) -> CoverageDelta {
362    let line_delta = new.percentage - old.percentage;
363    let branch_delta = new.branch_percentage - old.branch_percentage;
364
365    let mut file_deltas = Vec::new();
366    let old_map: HashMap<&Path, &FileCoverage> =
367        old.files.iter().map(|f| (f.path.as_path(), f)).collect();
368
369    for file in &new.files {
370        if let Some(old_file) = old_map.get(file.path.as_path()) {
371            let delta = file.percentage() - old_file.percentage();
372            if delta.abs() > 0.01 {
373                file_deltas.push(FileCoverageDelta {
374                    path: file.path.clone(),
375                    old_percentage: old_file.percentage(),
376                    new_percentage: file.percentage(),
377                    delta,
378                });
379            }
380        } else {
381            file_deltas.push(FileCoverageDelta {
382                path: file.path.clone(),
383                old_percentage: 0.0,
384                new_percentage: file.percentage(),
385                delta: file.percentage(),
386            });
387        }
388    }
389
390    // Sort by absolute delta, largest first
391    file_deltas.sort_by(|a, b| {
392        b.delta
393            .abs()
394            .partial_cmp(&a.delta.abs())
395            .unwrap_or(std::cmp::Ordering::Equal)
396    });
397
398    CoverageDelta {
399        line_delta,
400        branch_delta,
401        file_deltas,
402    }
403}
404
405/// Overall coverage change between two runs.
406#[derive(Debug, Clone)]
407pub struct CoverageDelta {
408    /// Change in line coverage percentage
409    pub line_delta: f64,
410    /// Change in branch coverage percentage
411    pub branch_delta: f64,
412    /// Per-file coverage changes
413    pub file_deltas: Vec<FileCoverageDelta>,
414}
415
416impl CoverageDelta {
417    /// Whether coverage improved.
418    pub fn improved(&self) -> bool {
419        self.line_delta > 0.0
420    }
421
422    /// Whether coverage regressed.
423    pub fn regressed(&self) -> bool {
424        self.line_delta < -0.01
425    }
426
427    /// Format delta as a string with direction indicator.
428    pub fn format_delta(&self) -> String {
429        let arrow = if self.line_delta > 0.0 {
430            "↑"
431        } else if self.line_delta < -0.01 {
432            "↓"
433        } else {
434            "→"
435        };
436        format!("{arrow} {:.1}%", self.line_delta.abs())
437    }
438}
439
440/// Coverage change for a single file.
441#[derive(Debug, Clone)]
442pub struct FileCoverageDelta {
443    pub path: PathBuf,
444    pub old_percentage: f64,
445    pub new_percentage: f64,
446    pub delta: f64,
447}
448
449/// Check if a file should be included in coverage based on include/exclude patterns.
450pub fn should_include_file(path: &Path, include: &[String], exclude: &[String]) -> bool {
451    let path_str = path.to_string_lossy();
452
453    // If includes are specified, file must match at least one
454    if !include.is_empty() {
455        let matches_include = include.iter().any(|pattern| glob_match(pattern, &path_str));
456        if !matches_include {
457            return false;
458        }
459    }
460
461    // File must not match any exclude pattern
462    !exclude.iter().any(|pattern| glob_match(pattern, &path_str))
463}
464
465/// Simple glob matching for coverage include/exclude patterns.
466fn glob_match(pattern: &str, text: &str) -> bool {
467    let parts: Vec<&str> = pattern.split('*').collect();
468    if parts.len() == 1 {
469        return text == pattern;
470    }
471
472    let mut pos = 0;
473    for (i, part) in parts.iter().enumerate() {
474        if part.is_empty() {
475            continue;
476        }
477        if let Some(found) = text[pos..].find(part) {
478            if i == 0 && found != 0 {
479                return false; // Must start with first part
480            }
481            pos += found + part.len();
482        } else {
483            return false;
484        }
485    }
486
487    // If pattern doesn't end with *, text must end at pos
488    if !pattern.ends_with('*') && pos != text.len() {
489        return false;
490    }
491
492    true
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    fn make_file(path: &str, total: usize, covered: usize) -> FileCoverage {
500        let mut line_hits = HashMap::new();
501        for i in 1..=total {
502            line_hits.insert(i, if i <= covered { 1 } else { 0 });
503        }
504        FileCoverage {
505            path: PathBuf::from(path),
506            total_lines: total,
507            covered_lines: covered,
508            uncovered_ranges: Vec::new(),
509            line_hits,
510            total_branches: 0,
511            covered_branches: 0,
512        }
513    }
514
515    #[test]
516    fn coverage_from_files() {
517        let result =
518            CoverageResult::from_files(vec![make_file("a.rs", 100, 80), make_file("b.rs", 50, 50)]);
519        assert_eq!(result.total_lines, 150);
520        assert_eq!(result.covered_lines, 130);
521        assert!((result.percentage - 86.66).abs() < 0.1);
522    }
523
524    #[test]
525    fn coverage_empty() {
526        let result = CoverageResult::from_files(vec![]);
527        assert_eq!(result.total_lines, 0);
528        assert_eq!(result.percentage, 0.0);
529    }
530
531    #[test]
532    fn coverage_meets_threshold() {
533        let result = CoverageResult::from_files(vec![make_file("a.rs", 100, 80)]);
534        assert!(result.meets_threshold(80.0));
535        assert!(!result.meets_threshold(81.0));
536    }
537
538    #[test]
539    fn coverage_worst_files() {
540        let result = CoverageResult::from_files(vec![
541            make_file("good.rs", 100, 95),
542            make_file("bad.rs", 100, 20),
543            make_file("ok.rs", 100, 60),
544        ]);
545        let worst = result.worst_files(2);
546        assert_eq!(worst.len(), 2);
547        assert_eq!(worst[0].path, PathBuf::from("bad.rs"));
548        assert_eq!(worst[1].path, PathBuf::from("ok.rs"));
549    }
550
551    #[test]
552    fn coverage_uncovered_count() {
553        let result = CoverageResult::from_files(vec![
554            make_file("a.rs", 100, 0),
555            make_file("b.rs", 50, 50),
556            make_file("c.rs", 75, 0),
557        ]);
558        assert_eq!(result.uncovered_file_count(), 2);
559    }
560
561    #[test]
562    fn file_percentage() {
563        let file = make_file("a.rs", 100, 75);
564        assert_eq!(file.percentage(), 75.0);
565    }
566
567    #[test]
568    fn file_percentage_zero() {
569        let file = make_file("a.rs", 0, 0);
570        assert_eq!(file.percentage(), 0.0);
571    }
572
573    #[test]
574    fn file_fully_covered() {
575        let full = make_file("full.rs", 50, 50);
576        let partial = make_file("partial.rs", 50, 40);
577        let empty = make_file("empty.rs", 0, 0);
578        assert!(full.is_fully_covered());
579        assert!(!partial.is_fully_covered());
580        assert!(!empty.is_fully_covered());
581    }
582
583    #[test]
584    fn format_from_str() {
585        assert_eq!(CoverageFormat::from_str_lossy("lcov"), CoverageFormat::Lcov);
586        assert_eq!(
587            CoverageFormat::from_str_lossy("cobertura"),
588            CoverageFormat::Cobertura
589        );
590        assert_eq!(
591            CoverageFormat::from_str_lossy("XML"),
592            CoverageFormat::Cobertura
593        );
594        assert_eq!(CoverageFormat::from_str_lossy("html"), CoverageFormat::Html);
595        assert_eq!(CoverageFormat::from_str_lossy("json"), CoverageFormat::Json);
596        assert_eq!(
597            CoverageFormat::from_str_lossy("unknown"),
598            CoverageFormat::Summary
599        );
600    }
601
602    #[test]
603    fn format_extension() {
604        assert_eq!(CoverageFormat::Summary.extension(), "txt");
605        assert_eq!(CoverageFormat::Lcov.extension(), "lcov");
606        assert_eq!(CoverageFormat::Cobertura.extension(), "xml");
607    }
608
609    #[test]
610    fn default_coverage_tools() {
611        assert!(default_coverage_tool("rust").is_some());
612        assert!(default_coverage_tool("python").is_some());
613        assert!(default_coverage_tool("javascript").is_some());
614        assert!(default_coverage_tool("go").is_some());
615        assert!(default_coverage_tool("java").is_some());
616        assert!(default_coverage_tool("cpp").is_some());
617        assert!(default_coverage_tool("ruby").is_some());
618        assert!(default_coverage_tool("elixir").is_some());
619        assert!(default_coverage_tool("dotnet").is_some());
620        assert!(default_coverage_tool("unknown").is_none());
621    }
622
623    #[test]
624    fn coverage_delta_improved() {
625        let old = CoverageResult::from_files(vec![make_file("a.rs", 100, 70)]);
626        let new = CoverageResult::from_files(vec![make_file("a.rs", 100, 85)]);
627        let delta = coverage_delta(&old, &new);
628        assert!(delta.improved());
629        assert!(!delta.regressed());
630        assert!(delta.format_delta().contains("↑"));
631    }
632
633    #[test]
634    fn coverage_delta_regressed() {
635        let old = CoverageResult::from_files(vec![make_file("a.rs", 100, 85)]);
636        let new = CoverageResult::from_files(vec![make_file("a.rs", 100, 70)]);
637        let delta = coverage_delta(&old, &new);
638        assert!(delta.regressed());
639        assert!(!delta.improved());
640        assert!(delta.format_delta().contains("↓"));
641    }
642
643    #[test]
644    fn coverage_delta_stable() {
645        let old = CoverageResult::from_files(vec![make_file("a.rs", 100, 80)]);
646        let new = CoverageResult::from_files(vec![make_file("a.rs", 100, 80)]);
647        let delta = coverage_delta(&old, &new);
648        assert!(!delta.improved());
649        assert!(!delta.regressed());
650    }
651
652    #[test]
653    fn coverage_delta_new_file() {
654        let old = CoverageResult::from_files(vec![make_file("a.rs", 100, 80)]);
655        let new =
656            CoverageResult::from_files(vec![make_file("a.rs", 100, 80), make_file("b.rs", 50, 40)]);
657        let delta = coverage_delta(&old, &new);
658        let new_file = delta
659            .file_deltas
660            .iter()
661            .find(|d| d.path == Path::new("b.rs"));
662        assert!(new_file.is_some());
663        assert_eq!(new_file.unwrap().old_percentage, 0.0);
664    }
665
666    #[test]
667    fn merge_two_results() {
668        let r1 = CoverageResult::from_files(vec![make_file("a.rs", 100, 50)]);
669        let r2 = CoverageResult::from_files(vec![make_file("a.rs", 100, 80)]);
670        let merged = merge_coverage(&[r1, r2]);
671        assert_eq!(merged.files.len(), 1);
672        // Merged should take max hits, so covered >= 80
673        assert!(merged.covered_lines >= 80);
674    }
675
676    #[test]
677    fn merge_different_files() {
678        let r1 = CoverageResult::from_files(vec![make_file("a.rs", 100, 50)]);
679        let r2 = CoverageResult::from_files(vec![make_file("b.rs", 50, 40)]);
680        let merged = merge_coverage(&[r1, r2]);
681        assert_eq!(merged.files.len(), 2);
682    }
683
684    #[test]
685    fn uncovered_ranges() {
686        let mut hits = HashMap::new();
687        hits.insert(1, 5); // covered
688        hits.insert(2, 0); // uncovered
689        hits.insert(3, 0); // uncovered
690        hits.insert(4, 3); // covered
691        hits.insert(5, 0); // uncovered
692
693        let ranges = compute_uncovered_ranges(&hits, 5);
694        assert_eq!(ranges, vec![(2, 3), (5, 5)]);
695    }
696
697    #[test]
698    fn uncovered_ranges_all_covered() {
699        let mut hits = HashMap::new();
700        hits.insert(1, 1);
701        hits.insert(2, 1);
702        hits.insert(3, 1);
703        let ranges = compute_uncovered_ranges(&hits, 3);
704        assert!(ranges.is_empty());
705    }
706
707    #[test]
708    fn glob_match_simple() {
709        assert!(glob_match("*.rs", "foo.rs"));
710        assert!(glob_match("src/*.rs", "src/main.rs"));
711        assert!(!glob_match("*.rs", "foo.py"));
712    }
713
714    #[test]
715    fn glob_match_double_star() {
716        assert!(glob_match("src/*", "src/foo/bar.rs"));
717    }
718
719    #[test]
720    fn glob_match_exact() {
721        assert!(glob_match("main.rs", "main.rs"));
722        assert!(!glob_match("main.rs", "src/main.rs"));
723    }
724
725    #[test]
726    fn should_include_defaults() {
727        let path = Path::new("src/main.rs");
728        assert!(should_include_file(path, &[], &[]));
729    }
730
731    #[test]
732    fn should_include_with_include() {
733        let path = Path::new("src/main.rs");
734        assert!(should_include_file(path, &["src/*".into()], &[]));
735        assert!(!should_include_file(path, &["tests/*".into()], &[]));
736    }
737
738    #[test]
739    fn should_include_with_exclude() {
740        let path = Path::new("src/vendor/lib.rs");
741        assert!(!should_include_file(path, &[], &["*vendor*".into()]));
742        assert!(should_include_file(path, &[], &["*test*".into()]));
743    }
744
745    #[test]
746    fn filter_files_predicate() {
747        let result = CoverageResult::from_files(vec![
748            make_file("src/main.rs", 100, 80),
749            make_file("tests/test.rs", 50, 50),
750            make_file("src/lib.rs", 200, 150),
751        ]);
752        let filtered = result.filter_files(|f| f.path.starts_with("src"));
753        assert_eq!(filtered.files.len(), 2);
754        assert_eq!(filtered.total_lines, 300);
755    }
756
757    #[test]
758    fn config_default() {
759        let config = CoverageConfig::default();
760        assert!(!config.enabled);
761        assert_eq!(config.format, CoverageFormat::Summary);
762        assert!(config.threshold.is_none());
763    }
764
765    #[test]
766    fn branch_coverage() {
767        let file = FileCoverage {
768            path: PathBuf::from("a.rs"),
769            total_lines: 100,
770            covered_lines: 80,
771            uncovered_ranges: Vec::new(),
772            line_hits: HashMap::new(),
773            total_branches: 20,
774            covered_branches: 15,
775        };
776        assert_eq!(file.branch_percentage(), 75.0);
777    }
778}