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
212#[derive(Debug, Clone)]
214pub struct AdapterCoverageConfig {
215 pub adapter: String,
217 pub tool: String,
219 pub extra_args: Vec<String>,
221 pub env: HashMap<String, String>,
223}
224
225pub 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
287pub 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 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 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 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
331fn 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
360pub 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 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#[derive(Debug, Clone)]
407pub struct CoverageDelta {
408 pub line_delta: f64,
410 pub branch_delta: f64,
412 pub file_deltas: Vec<FileCoverageDelta>,
414}
415
416impl CoverageDelta {
417 pub fn improved(&self) -> bool {
419 self.line_delta > 0.0
420 }
421
422 pub fn regressed(&self) -> bool {
424 self.line_delta < -0.01
425 }
426
427 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#[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
449pub fn should_include_file(path: &Path, include: &[String], exclude: &[String]) -> bool {
451 let path_str = path.to_string_lossy();
452
453 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 !exclude.iter().any(|pattern| glob_match(pattern, &path_str))
463}
464
465fn 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; }
481 pos += found + part.len();
482 } else {
483 return false;
484 }
485 }
486
487 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 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); hits.insert(2, 0); hits.insert(3, 0); hits.insert(4, 3); hits.insert(5, 0); 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}