1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9pub mod display;
10pub mod parsers;
11
12#[derive(Debug, Clone)]
14pub struct CoverageConfig {
15 pub enabled: bool,
17 pub format: CoverageFormat,
19 pub output_dir: PathBuf,
21 pub threshold: Option<f64>,
23 pub include: Vec<String>,
25 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#[derive(Debug, Clone, PartialEq)]
44pub enum CoverageFormat {
45 Summary,
47 Lcov,
49 Cobertura,
51 Html,
53 Json,
55}
56
57impl CoverageFormat {
58 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 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#[derive(Debug, Clone, serde::Serialize)]
83pub struct CoverageResult {
84 pub files: Vec<FileCoverage>,
86 pub total_lines: usize,
88 pub covered_lines: usize,
90 pub percentage: f64,
92 pub total_branches: usize,
94 pub covered_branches: usize,
96 pub branch_percentage: f64,
98}
99
100impl CoverageResult {
101 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 pub fn meets_threshold(&self, threshold: f64) -> bool {
133 self.percentage >= threshold
134 }
135
136 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 pub fn uncovered_file_count(&self) -> usize {
149 self.files.iter().filter(|f| f.covered_lines == 0).count()
150 }
151
152 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#[derive(Debug, Clone, serde::Serialize)]
169pub struct FileCoverage {
170 pub path: PathBuf,
172 pub total_lines: usize,
174 pub covered_lines: usize,
176 pub uncovered_ranges: Vec<(usize, usize)>,
178 #[serde(skip)]
180 pub line_hits: HashMap<usize, u64>,
181 pub total_branches: usize,
183 pub covered_branches: usize,
185}
186
187impl FileCoverage {
188 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 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 pub fn is_fully_covered(&self) -> bool {
208 self.covered_lines == self.total_lines && self.total_lines > 0
209 }
210}
211
212pub trait CoverageProvider {
214 fn coverage_args(&self) -> Vec<String>;
216
217 fn parse_coverage(&self, output_dir: &Path) -> crate::error::Result<CoverageResult>;
219
220 fn tool_name(&self) -> &str;
222}
223
224#[derive(Debug, Clone)]
226pub struct AdapterCoverageConfig {
227 pub adapter: String,
229 pub tool: String,
231 pub extra_args: Vec<String>,
233 pub env: HashMap<String, String>,
235}
236
237pub 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
299pub 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 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 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 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
343fn 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
372pub 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 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#[derive(Debug, Clone)]
419pub struct CoverageDelta {
420 pub line_delta: f64,
422 pub branch_delta: f64,
424 pub file_deltas: Vec<FileCoverageDelta>,
426}
427
428impl CoverageDelta {
429 pub fn improved(&self) -> bool {
431 self.line_delta > 0.0
432 }
433
434 pub fn regressed(&self) -> bool {
436 self.line_delta < -0.01
437 }
438
439 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#[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
461pub fn should_include_file(path: &Path, include: &[String], exclude: &[String]) -> bool {
463 let path_str = path.to_string_lossy();
464
465 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 !exclude.iter().any(|pattern| glob_match(pattern, &path_str))
475}
476
477fn 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; }
493 pos += found + part.len();
494 } else {
495 return false;
496 }
497 }
498
499 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 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); hits.insert(2, 0); hits.insert(3, 0); hits.insert(4, 3); hits.insert(5, 0); 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}