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/// Trait for language-specific coverage providers.
213pub trait CoverageProvider {
214    /// Return extra CLI arguments to enable coverage for this adapter.
215    fn coverage_args(&self) -> Vec<String>;
216
217    /// Parse coverage data from the output directory.
218    fn parse_coverage(&self, output_dir: &Path) -> crate::error::Result<CoverageResult>;
219
220    /// Name of the coverage tool being used.
221    fn tool_name(&self) -> &str;
222}
223
224/// Adapter-specific coverage configurations.
225#[derive(Debug, Clone)]
226pub struct AdapterCoverageConfig {
227    /// Adapter name
228    pub adapter: String,
229    /// Coverage tool to use
230    pub tool: String,
231    /// Extra arguments for coverage collection
232    pub extra_args: Vec<String>,
233    /// Environment variables for coverage
234    pub env: HashMap<String, String>,
235}
236
237/// Known coverage tools per adapter.
238pub fn default_coverage_tool(adapter: &str) -> Option<AdapterCoverageConfig> {
239    let config = match adapter {
240        "rust" => AdapterCoverageConfig {
241            adapter: "rust".into(),
242            tool: "cargo-llvm-cov".into(),
243            extra_args: vec!["--lcov".into(), "--output-path".into()],
244            env: HashMap::new(),
245        },
246        "python" => AdapterCoverageConfig {
247            adapter: "python".into(),
248            tool: "coverage".into(),
249            extra_args: vec!["run".into(), "-m".into(), "pytest".into()],
250            env: HashMap::new(),
251        },
252        "javascript" => AdapterCoverageConfig {
253            adapter: "javascript".into(),
254            tool: "built-in".into(),
255            extra_args: vec!["--coverage".into()],
256            env: HashMap::new(),
257        },
258        "go" => AdapterCoverageConfig {
259            adapter: "go".into(),
260            tool: "go-cover".into(),
261            extra_args: vec!["-coverprofile=coverage.out".into()],
262            env: HashMap::new(),
263        },
264        "java" => AdapterCoverageConfig {
265            adapter: "java".into(),
266            tool: "jacoco".into(),
267            extra_args: Vec::new(),
268            env: HashMap::new(),
269        },
270        "cpp" => AdapterCoverageConfig {
271            adapter: "cpp".into(),
272            tool: "gcov".into(),
273            extra_args: vec!["--coverage".into()],
274            env: HashMap::new(),
275        },
276        "ruby" => AdapterCoverageConfig {
277            adapter: "ruby".into(),
278            tool: "simplecov".into(),
279            extra_args: Vec::new(),
280            env: HashMap::from([("COVERAGE".into(), "true".into())]),
281        },
282        "elixir" => AdapterCoverageConfig {
283            adapter: "elixir".into(),
284            tool: "mix-cover".into(),
285            extra_args: vec!["--cover".into()],
286            env: HashMap::new(),
287        },
288        "dotnet" => AdapterCoverageConfig {
289            adapter: "dotnet".into(),
290            tool: "xplat-coverage".into(),
291            extra_args: vec!["--collect:\"XPlat Code Coverage\"".into()],
292            env: HashMap::new(),
293        },
294        _ => return None,
295    };
296    Some(config)
297}
298
299/// Merge multiple coverage results (e.g. from parallel adapter runs).
300pub fn merge_coverage(results: &[CoverageResult]) -> CoverageResult {
301    let mut file_map: HashMap<PathBuf, FileCoverage> = HashMap::new();
302
303    for result in results {
304        for file in &result.files {
305            let entry = file_map
306                .entry(file.path.clone())
307                .or_insert_with(|| FileCoverage {
308                    path: file.path.clone(),
309                    total_lines: 0,
310                    covered_lines: 0,
311                    uncovered_ranges: Vec::new(),
312                    line_hits: HashMap::new(),
313                    total_branches: 0,
314                    covered_branches: 0,
315                });
316
317            // Merge line hits (take max)
318            for (&line, &hits) in &file.line_hits {
319                let existing = entry.line_hits.entry(line).or_insert(0);
320                *existing = (*existing).max(hits);
321            }
322
323            // Recalculate from merged line_hits
324            entry.total_lines = entry.total_lines.max(file.total_lines);
325            entry.covered_lines = entry.line_hits.values().filter(|&&h| h > 0).count();
326            entry.total_branches = entry.total_branches.max(file.total_branches);
327            entry.covered_branches = entry.covered_branches.max(file.covered_branches);
328        }
329    }
330
331    // Recompute uncovered ranges from merged line hits
332    let files: Vec<FileCoverage> = file_map
333        .into_values()
334        .map(|mut f| {
335            f.uncovered_ranges = compute_uncovered_ranges(&f.line_hits, f.total_lines);
336            f
337        })
338        .collect();
339
340    CoverageResult::from_files(files)
341}
342
343/// Compute contiguous uncovered line ranges from per-line hit data.
344fn compute_uncovered_ranges(
345    line_hits: &HashMap<usize, u64>,
346    total_lines: usize,
347) -> Vec<(usize, usize)> {
348    let mut ranges = Vec::new();
349    let mut start: Option<usize> = None;
350
351    for line in 1..=total_lines {
352        let is_covered = line_hits.get(&line).is_some_and(|&h| h > 0);
353        let is_executable = line_hits.contains_key(&line);
354
355        if is_executable && !is_covered {
356            if start.is_none() {
357                start = Some(line);
358            }
359        } else if let Some(s) = start {
360            ranges.push((s, line - 1));
361            start = None;
362        }
363    }
364
365    if let Some(s) = start {
366        ranges.push((s, total_lines));
367    }
368
369    ranges
370}
371
372/// Compute a coverage delta between two results.
373pub fn coverage_delta(old: &CoverageResult, new: &CoverageResult) -> CoverageDelta {
374    let line_delta = new.percentage - old.percentage;
375    let branch_delta = new.branch_percentage - old.branch_percentage;
376
377    let mut file_deltas = Vec::new();
378    let old_map: HashMap<&Path, &FileCoverage> =
379        old.files.iter().map(|f| (f.path.as_path(), f)).collect();
380
381    for file in &new.files {
382        if let Some(old_file) = old_map.get(file.path.as_path()) {
383            let delta = file.percentage() - old_file.percentage();
384            if delta.abs() > 0.01 {
385                file_deltas.push(FileCoverageDelta {
386                    path: file.path.clone(),
387                    old_percentage: old_file.percentage(),
388                    new_percentage: file.percentage(),
389                    delta,
390                });
391            }
392        } else {
393            file_deltas.push(FileCoverageDelta {
394                path: file.path.clone(),
395                old_percentage: 0.0,
396                new_percentage: file.percentage(),
397                delta: file.percentage(),
398            });
399        }
400    }
401
402    // Sort by absolute delta, largest first
403    file_deltas.sort_by(|a, b| {
404        b.delta
405            .abs()
406            .partial_cmp(&a.delta.abs())
407            .unwrap_or(std::cmp::Ordering::Equal)
408    });
409
410    CoverageDelta {
411        line_delta,
412        branch_delta,
413        file_deltas,
414    }
415}
416
417/// Overall coverage change between two runs.
418#[derive(Debug, Clone)]
419pub struct CoverageDelta {
420    /// Change in line coverage percentage
421    pub line_delta: f64,
422    /// Change in branch coverage percentage
423    pub branch_delta: f64,
424    /// Per-file coverage changes
425    pub file_deltas: Vec<FileCoverageDelta>,
426}
427
428impl CoverageDelta {
429    /// Whether coverage improved.
430    pub fn improved(&self) -> bool {
431        self.line_delta > 0.0
432    }
433
434    /// Whether coverage regressed.
435    pub fn regressed(&self) -> bool {
436        self.line_delta < -0.01
437    }
438
439    /// Format delta as a string with direction indicator.
440    pub fn format_delta(&self) -> String {
441        let arrow = if self.line_delta > 0.0 {
442            "↑"
443        } else if self.line_delta < -0.01 {
444            "↓"
445        } else {
446            "→"
447        };
448        format!("{arrow} {:.1}%", self.line_delta.abs())
449    }
450}
451
452/// Coverage change for a single file.
453#[derive(Debug, Clone)]
454pub struct FileCoverageDelta {
455    pub path: PathBuf,
456    pub old_percentage: f64,
457    pub new_percentage: f64,
458    pub delta: f64,
459}
460
461/// Check if a file should be included in coverage based on include/exclude patterns.
462pub fn should_include_file(path: &Path, include: &[String], exclude: &[String]) -> bool {
463    let path_str = path.to_string_lossy();
464
465    // If includes are specified, file must match at least one
466    if !include.is_empty() {
467        let matches_include = include.iter().any(|pattern| glob_match(pattern, &path_str));
468        if !matches_include {
469            return false;
470        }
471    }
472
473    // File must not match any exclude pattern
474    !exclude.iter().any(|pattern| glob_match(pattern, &path_str))
475}
476
477/// Simple glob matching for coverage include/exclude patterns.
478fn glob_match(pattern: &str, text: &str) -> bool {
479    let parts: Vec<&str> = pattern.split('*').collect();
480    if parts.len() == 1 {
481        return text == pattern;
482    }
483
484    let mut pos = 0;
485    for (i, part) in parts.iter().enumerate() {
486        if part.is_empty() {
487            continue;
488        }
489        if let Some(found) = text[pos..].find(part) {
490            if i == 0 && found != 0 {
491                return false; // Must start with first part
492            }
493            pos += found + part.len();
494        } else {
495            return false;
496        }
497    }
498
499    // If pattern doesn't end with *, text must end at pos
500    if !pattern.ends_with('*') && pos != text.len() {
501        return false;
502    }
503
504    true
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    fn make_file(path: &str, total: usize, covered: usize) -> FileCoverage {
512        let mut line_hits = HashMap::new();
513        for i in 1..=total {
514            line_hits.insert(i, if i <= covered { 1 } else { 0 });
515        }
516        FileCoverage {
517            path: PathBuf::from(path),
518            total_lines: total,
519            covered_lines: covered,
520            uncovered_ranges: Vec::new(),
521            line_hits,
522            total_branches: 0,
523            covered_branches: 0,
524        }
525    }
526
527    #[test]
528    fn coverage_from_files() {
529        let result =
530            CoverageResult::from_files(vec![make_file("a.rs", 100, 80), make_file("b.rs", 50, 50)]);
531        assert_eq!(result.total_lines, 150);
532        assert_eq!(result.covered_lines, 130);
533        assert!((result.percentage - 86.66).abs() < 0.1);
534    }
535
536    #[test]
537    fn coverage_empty() {
538        let result = CoverageResult::from_files(vec![]);
539        assert_eq!(result.total_lines, 0);
540        assert_eq!(result.percentage, 0.0);
541    }
542
543    #[test]
544    fn coverage_meets_threshold() {
545        let result = CoverageResult::from_files(vec![make_file("a.rs", 100, 80)]);
546        assert!(result.meets_threshold(80.0));
547        assert!(!result.meets_threshold(81.0));
548    }
549
550    #[test]
551    fn coverage_worst_files() {
552        let result = CoverageResult::from_files(vec![
553            make_file("good.rs", 100, 95),
554            make_file("bad.rs", 100, 20),
555            make_file("ok.rs", 100, 60),
556        ]);
557        let worst = result.worst_files(2);
558        assert_eq!(worst.len(), 2);
559        assert_eq!(worst[0].path, PathBuf::from("bad.rs"));
560        assert_eq!(worst[1].path, PathBuf::from("ok.rs"));
561    }
562
563    #[test]
564    fn coverage_uncovered_count() {
565        let result = CoverageResult::from_files(vec![
566            make_file("a.rs", 100, 0),
567            make_file("b.rs", 50, 50),
568            make_file("c.rs", 75, 0),
569        ]);
570        assert_eq!(result.uncovered_file_count(), 2);
571    }
572
573    #[test]
574    fn file_percentage() {
575        let file = make_file("a.rs", 100, 75);
576        assert_eq!(file.percentage(), 75.0);
577    }
578
579    #[test]
580    fn file_percentage_zero() {
581        let file = make_file("a.rs", 0, 0);
582        assert_eq!(file.percentage(), 0.0);
583    }
584
585    #[test]
586    fn file_fully_covered() {
587        let full = make_file("full.rs", 50, 50);
588        let partial = make_file("partial.rs", 50, 40);
589        let empty = make_file("empty.rs", 0, 0);
590        assert!(full.is_fully_covered());
591        assert!(!partial.is_fully_covered());
592        assert!(!empty.is_fully_covered());
593    }
594
595    #[test]
596    fn format_from_str() {
597        assert_eq!(CoverageFormat::from_str_lossy("lcov"), CoverageFormat::Lcov);
598        assert_eq!(
599            CoverageFormat::from_str_lossy("cobertura"),
600            CoverageFormat::Cobertura
601        );
602        assert_eq!(
603            CoverageFormat::from_str_lossy("XML"),
604            CoverageFormat::Cobertura
605        );
606        assert_eq!(CoverageFormat::from_str_lossy("html"), CoverageFormat::Html);
607        assert_eq!(CoverageFormat::from_str_lossy("json"), CoverageFormat::Json);
608        assert_eq!(
609            CoverageFormat::from_str_lossy("unknown"),
610            CoverageFormat::Summary
611        );
612    }
613
614    #[test]
615    fn format_extension() {
616        assert_eq!(CoverageFormat::Summary.extension(), "txt");
617        assert_eq!(CoverageFormat::Lcov.extension(), "lcov");
618        assert_eq!(CoverageFormat::Cobertura.extension(), "xml");
619    }
620
621    #[test]
622    fn default_coverage_tools() {
623        assert!(default_coverage_tool("rust").is_some());
624        assert!(default_coverage_tool("python").is_some());
625        assert!(default_coverage_tool("javascript").is_some());
626        assert!(default_coverage_tool("go").is_some());
627        assert!(default_coverage_tool("java").is_some());
628        assert!(default_coverage_tool("cpp").is_some());
629        assert!(default_coverage_tool("ruby").is_some());
630        assert!(default_coverage_tool("elixir").is_some());
631        assert!(default_coverage_tool("dotnet").is_some());
632        assert!(default_coverage_tool("unknown").is_none());
633    }
634
635    #[test]
636    fn coverage_delta_improved() {
637        let old = CoverageResult::from_files(vec![make_file("a.rs", 100, 70)]);
638        let new = CoverageResult::from_files(vec![make_file("a.rs", 100, 85)]);
639        let delta = coverage_delta(&old, &new);
640        assert!(delta.improved());
641        assert!(!delta.regressed());
642        assert!(delta.format_delta().contains("↑"));
643    }
644
645    #[test]
646    fn coverage_delta_regressed() {
647        let old = CoverageResult::from_files(vec![make_file("a.rs", 100, 85)]);
648        let new = CoverageResult::from_files(vec![make_file("a.rs", 100, 70)]);
649        let delta = coverage_delta(&old, &new);
650        assert!(delta.regressed());
651        assert!(!delta.improved());
652        assert!(delta.format_delta().contains("↓"));
653    }
654
655    #[test]
656    fn coverage_delta_stable() {
657        let old = CoverageResult::from_files(vec![make_file("a.rs", 100, 80)]);
658        let new = CoverageResult::from_files(vec![make_file("a.rs", 100, 80)]);
659        let delta = coverage_delta(&old, &new);
660        assert!(!delta.improved());
661        assert!(!delta.regressed());
662    }
663
664    #[test]
665    fn coverage_delta_new_file() {
666        let old = CoverageResult::from_files(vec![make_file("a.rs", 100, 80)]);
667        let new =
668            CoverageResult::from_files(vec![make_file("a.rs", 100, 80), make_file("b.rs", 50, 40)]);
669        let delta = coverage_delta(&old, &new);
670        let new_file = delta
671            .file_deltas
672            .iter()
673            .find(|d| d.path == Path::new("b.rs"));
674        assert!(new_file.is_some());
675        assert_eq!(new_file.unwrap().old_percentage, 0.0);
676    }
677
678    #[test]
679    fn merge_two_results() {
680        let r1 = CoverageResult::from_files(vec![make_file("a.rs", 100, 50)]);
681        let r2 = CoverageResult::from_files(vec![make_file("a.rs", 100, 80)]);
682        let merged = merge_coverage(&[r1, r2]);
683        assert_eq!(merged.files.len(), 1);
684        // Merged should take max hits, so covered >= 80
685        assert!(merged.covered_lines >= 80);
686    }
687
688    #[test]
689    fn merge_different_files() {
690        let r1 = CoverageResult::from_files(vec![make_file("a.rs", 100, 50)]);
691        let r2 = CoverageResult::from_files(vec![make_file("b.rs", 50, 40)]);
692        let merged = merge_coverage(&[r1, r2]);
693        assert_eq!(merged.files.len(), 2);
694    }
695
696    #[test]
697    fn uncovered_ranges() {
698        let mut hits = HashMap::new();
699        hits.insert(1, 5); // covered
700        hits.insert(2, 0); // uncovered
701        hits.insert(3, 0); // uncovered
702        hits.insert(4, 3); // covered
703        hits.insert(5, 0); // uncovered
704
705        let ranges = compute_uncovered_ranges(&hits, 5);
706        assert_eq!(ranges, vec![(2, 3), (5, 5)]);
707    }
708
709    #[test]
710    fn uncovered_ranges_all_covered() {
711        let mut hits = HashMap::new();
712        hits.insert(1, 1);
713        hits.insert(2, 1);
714        hits.insert(3, 1);
715        let ranges = compute_uncovered_ranges(&hits, 3);
716        assert!(ranges.is_empty());
717    }
718
719    #[test]
720    fn glob_match_simple() {
721        assert!(glob_match("*.rs", "foo.rs"));
722        assert!(glob_match("src/*.rs", "src/main.rs"));
723        assert!(!glob_match("*.rs", "foo.py"));
724    }
725
726    #[test]
727    fn glob_match_double_star() {
728        assert!(glob_match("src/*", "src/foo/bar.rs"));
729    }
730
731    #[test]
732    fn glob_match_exact() {
733        assert!(glob_match("main.rs", "main.rs"));
734        assert!(!glob_match("main.rs", "src/main.rs"));
735    }
736
737    #[test]
738    fn should_include_defaults() {
739        let path = Path::new("src/main.rs");
740        assert!(should_include_file(path, &[], &[]));
741    }
742
743    #[test]
744    fn should_include_with_include() {
745        let path = Path::new("src/main.rs");
746        assert!(should_include_file(path, &["src/*".into()], &[]));
747        assert!(!should_include_file(path, &["tests/*".into()], &[]));
748    }
749
750    #[test]
751    fn should_include_with_exclude() {
752        let path = Path::new("src/vendor/lib.rs");
753        assert!(!should_include_file(path, &[], &["*vendor*".into()]));
754        assert!(should_include_file(path, &[], &["*test*".into()]));
755    }
756
757    #[test]
758    fn filter_files_predicate() {
759        let result = CoverageResult::from_files(vec![
760            make_file("src/main.rs", 100, 80),
761            make_file("tests/test.rs", 50, 50),
762            make_file("src/lib.rs", 200, 150),
763        ]);
764        let filtered = result.filter_files(|f| f.path.starts_with("src"));
765        assert_eq!(filtered.files.len(), 2);
766        assert_eq!(filtered.total_lines, 300);
767    }
768
769    #[test]
770    fn config_default() {
771        let config = CoverageConfig::default();
772        assert!(!config.enabled);
773        assert_eq!(config.format, CoverageFormat::Summary);
774        assert!(config.threshold.is_none());
775    }
776
777    #[test]
778    fn branch_coverage() {
779        let file = FileCoverage {
780            path: PathBuf::from("a.rs"),
781            total_lines: 100,
782            covered_lines: 80,
783            uncovered_ranges: Vec::new(),
784            line_hits: HashMap::new(),
785            total_branches: 20,
786            covered_branches: 15,
787        };
788        assert_eq!(file.branch_percentage(), 75.0);
789    }
790}