1#![cfg_attr(docsrs, feature(doc_cfg))]
47#![warn(missing_docs)]
48#![warn(rust_2018_idioms)]
49
50use std::io;
51use std::path::PathBuf;
52use std::process::Command;
53
54use dev_report::{CheckResult, Evidence, Severity};
55use serde::{Deserialize, Serialize};
56
57pub mod baseline;
58pub use baseline::{Baseline, BaselineStore, JsonFileBaselineStore};
59
60mod producer;
61pub use producer::CoverageProducer;
62
63#[derive(Debug, Clone)]
87pub struct CoverageRun {
88 name: String,
89 version: String,
90 workdir: Option<PathBuf>,
91 workspace: bool,
92 excludes: Vec<String>,
93 features: Vec<String>,
94 all_features: bool,
95 no_default_features: bool,
96 per_file: bool,
97}
98
99impl CoverageRun {
100 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
107 Self {
108 name: name.into(),
109 version: version.into(),
110 workdir: None,
111 workspace: false,
112 excludes: Vec::new(),
113 features: Vec::new(),
114 all_features: false,
115 no_default_features: false,
116 per_file: false,
117 }
118 }
119
120 pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
122 self.workdir = Some(dir.into());
123 self
124 }
125
126 pub fn subject(&self) -> &str {
128 &self.name
129 }
130
131 pub fn subject_version(&self) -> &str {
133 &self.version
134 }
135
136 pub fn workspace(mut self) -> Self {
138 self.workspace = true;
139 self
140 }
141
142 pub fn exclude(mut self, pattern: impl Into<String>) -> Self {
144 self.excludes.push(pattern.into());
145 self
146 }
147
148 pub fn feature(mut self, name: impl Into<String>) -> Self {
150 self.features.push(name.into());
151 self
152 }
153
154 pub fn all_features(mut self) -> Self {
156 self.all_features = true;
157 self
158 }
159
160 pub fn no_default_features(mut self) -> Self {
162 self.no_default_features = true;
163 self
164 }
165
166 pub fn per_file(mut self) -> Self {
171 self.per_file = true;
172 self
173 }
174
175 pub fn execute(&self) -> Result<CoverageResult, CoverageError> {
181 detect_tool()?;
182 let stdout = self.run_llvm_cov()?;
183 parse_llvm_cov_json(&stdout, self.name.clone(), self.version.clone())
184 }
185
186 fn run_llvm_cov(&self) -> Result<String, CoverageError> {
187 let mut cmd = Command::new("cargo");
188 cmd.arg("llvm-cov");
189 if !self.per_file {
190 cmd.arg("--summary-only");
191 }
192 cmd.arg("--json");
193 if self.workspace {
194 cmd.arg("--workspace");
195 }
196 for pat in &self.excludes {
197 cmd.args(["--exclude", pat]);
198 }
199 if self.all_features {
200 cmd.arg("--all-features");
201 }
202 if self.no_default_features {
203 cmd.arg("--no-default-features");
204 }
205 for feat in &self.features {
206 cmd.args(["--features", feat]);
207 }
208 if let Some(dir) = self.workdir.as_ref() {
209 cmd.current_dir(dir);
210 }
211 let output = cmd
212 .output()
213 .map_err(|e| CoverageError::SubprocessFailed(e.to_string()))?;
214 if !output.status.success() {
215 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
216 return Err(CoverageError::SubprocessFailed(stderr));
217 }
218 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
219 }
220}
221
222fn detect_tool() -> Result<(), CoverageError> {
223 let probe = Command::new("cargo")
224 .args(["llvm-cov", "--version"])
225 .output();
226 match probe {
227 Ok(out) if out.status.success() => Ok(()),
228 Ok(_) => Err(CoverageError::ToolNotInstalled),
229 Err(_) => Err(CoverageError::ToolNotInstalled),
230 }
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct CoverageResult {
267 pub name: String,
269 pub version: String,
271 pub line_pct: f64,
273 pub function_pct: f64,
275 pub region_pct: f64,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub branch_pct: Option<f64>,
281 pub total_lines: u64,
283 pub covered_lines: u64,
285 pub total_functions: u64,
287 pub covered_functions: u64,
289 pub total_regions: u64,
291 pub covered_regions: u64,
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
296 pub files: Vec<FileCoverage>,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct FileCoverage {
305 pub filename: String,
307 pub line_pct: f64,
309 pub function_pct: f64,
311 pub region_pct: f64,
313 pub total_lines: u64,
315 pub covered_lines: u64,
317}
318
319impl CoverageResult {
320 pub fn into_check_result(self, threshold: CoverageThreshold) -> CheckResult {
345 let (actual, target, label) = threshold.applied_to(&self);
346 let name = format!("coverage::{}", self.name);
347 let detail = format!("{label} coverage {actual:.2}% (threshold {target:.2}%)");
348 let mut check = if actual < target {
349 CheckResult::fail(name, Severity::Warning).with_detail(detail)
350 } else {
351 CheckResult::pass(name).with_detail(detail)
352 };
353 check = check
354 .with_tag("coverage")
355 .with_evidence(Evidence::numeric(format!("{label}_pct"), actual))
356 .with_evidence(Evidence::numeric(format!("{label}_pct_threshold"), target))
357 .with_evidence(Evidence::numeric_int(
358 "total_lines",
359 self.total_lines as i64,
360 ))
361 .with_evidence(Evidence::numeric_int(
362 "covered_lines",
363 self.covered_lines as i64,
364 ));
365 check
366 }
367
368 pub fn diff(&self, baseline: &Baseline, tolerance_pct: f64) -> CoverageDiff {
398 let line = self.line_pct - baseline.line_pct;
399 let func = self.function_pct - baseline.function_pct;
400 let region = self.region_pct - baseline.region_pct;
401 let worst = line.min(func).min(region);
402 CoverageDiff {
403 line_pct_delta: line,
404 function_pct_delta: func,
405 region_pct_delta: region,
406 regressed: worst < -tolerance_pct,
407 }
408 }
409
410 pub fn to_baseline(&self) -> Baseline {
412 Baseline {
413 name: self.name.clone(),
414 line_pct: self.line_pct,
415 function_pct: self.function_pct,
416 region_pct: self.region_pct,
417 }
418 }
419
420 pub fn least_covered_files(&self, n: usize) -> Vec<&FileCoverage> {
425 let mut refs: Vec<&FileCoverage> = self.files.iter().collect();
426 refs.sort_by(|a, b| {
427 a.line_pct
428 .partial_cmp(&b.line_pct)
429 .unwrap_or(std::cmp::Ordering::Equal)
430 });
431 refs.into_iter().take(n).collect()
432 }
433}
434
435#[derive(Debug, Clone, Copy)]
441pub enum CoverageThreshold {
442 MinLinePct(f64),
444 MinFunctionPct(f64),
446 MinRegionPct(f64),
448}
449
450impl CoverageThreshold {
451 pub fn min_line_pct(pct: f64) -> Self {
453 Self::MinLinePct(pct)
454 }
455
456 pub fn min_function_pct(pct: f64) -> Self {
458 Self::MinFunctionPct(pct)
459 }
460
461 pub fn min_region_pct(pct: f64) -> Self {
463 Self::MinRegionPct(pct)
464 }
465
466 fn applied_to(self, r: &CoverageResult) -> (f64, f64, &'static str) {
467 match self {
468 Self::MinLinePct(p) => (r.line_pct, p, "line"),
469 Self::MinFunctionPct(p) => (r.function_pct, p, "function"),
470 Self::MinRegionPct(p) => (r.region_pct, p, "region"),
471 }
472 }
473}
474
475#[derive(Debug, Clone, Copy)]
486pub struct CoverageDiff {
487 pub line_pct_delta: f64,
489 pub function_pct_delta: f64,
491 pub region_pct_delta: f64,
493 pub regressed: bool,
495}
496
497#[derive(Debug)]
503pub enum CoverageError {
504 ToolNotInstalled,
506 SubprocessFailed(String),
508 ParseError(String),
510 Io(io::Error),
512}
513
514impl std::fmt::Display for CoverageError {
515 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516 match self {
517 Self::ToolNotInstalled => {
518 write!(
519 f,
520 "cargo-llvm-cov is not installed; run `cargo install cargo-llvm-cov`"
521 )
522 }
523 Self::SubprocessFailed(s) => write!(f, "cargo llvm-cov failed: {s}"),
524 Self::ParseError(s) => write!(f, "could not parse cargo llvm-cov output: {s}"),
525 Self::Io(e) => write!(f, "io error: {e}"),
526 }
527 }
528}
529
530impl std::error::Error for CoverageError {
531 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
532 match self {
533 Self::Io(e) => Some(e),
534 _ => None,
535 }
536 }
537}
538
539impl From<io::Error> for CoverageError {
540 fn from(e: io::Error) -> Self {
541 Self::Io(e)
542 }
543}
544
545#[derive(Deserialize)]
550struct LlvmCovExport {
551 #[serde(default)]
552 data: Vec<LlvmCovData>,
553}
554
555#[derive(Deserialize)]
556struct LlvmCovData {
557 #[serde(default)]
558 files: Vec<LlvmCovFile>,
559 totals: LlvmCovTotals,
560}
561
562#[derive(Deserialize)]
563struct LlvmCovFile {
564 filename: String,
565 summary: LlvmCovTotals,
566}
567
568#[derive(Deserialize)]
569struct LlvmCovTotals {
570 lines: LlvmCovMetric,
571 functions: LlvmCovMetric,
572 regions: LlvmCovMetric,
573 #[serde(default)]
574 branches: Option<LlvmCovMetric>,
575}
576
577#[derive(Deserialize, Default, Clone, Copy)]
578struct LlvmCovMetric {
579 #[serde(default)]
580 count: u64,
581 #[serde(default)]
582 covered: u64,
583 #[serde(default)]
584 percent: f64,
585}
586
587fn parse_llvm_cov_json(
588 json: &str,
589 name: String,
590 version: String,
591) -> Result<CoverageResult, CoverageError> {
592 let export: LlvmCovExport =
593 serde_json::from_str(json).map_err(|e| CoverageError::ParseError(e.to_string()))?;
594 let data = export
595 .data
596 .into_iter()
597 .next()
598 .ok_or_else(|| CoverageError::ParseError("export.data was empty".into()))?;
599 let totals = data.totals;
600 let files = data
601 .files
602 .into_iter()
603 .map(|f| FileCoverage {
604 filename: f.filename,
605 line_pct: f.summary.lines.percent,
606 function_pct: f.summary.functions.percent,
607 region_pct: f.summary.regions.percent,
608 total_lines: f.summary.lines.count,
609 covered_lines: f.summary.lines.covered,
610 })
611 .collect();
612 Ok(CoverageResult {
613 name,
614 version,
615 line_pct: totals.lines.percent,
616 function_pct: totals.functions.percent,
617 region_pct: totals.regions.percent,
618 branch_pct: totals.branches.map(|b| b.percent),
619 total_lines: totals.lines.count,
620 covered_lines: totals.lines.covered,
621 total_functions: totals.functions.count,
622 covered_functions: totals.functions.covered,
623 total_regions: totals.regions.count,
624 covered_regions: totals.regions.covered,
625 files,
626 })
627}
628
629#[cfg(test)]
634mod tests {
635 use super::*;
636 use dev_report::Verdict;
637
638 fn sample_result(line: f64, func: f64, region: f64) -> CoverageResult {
639 CoverageResult {
640 name: "x".into(),
641 version: "0.1.0".into(),
642 line_pct: line,
643 function_pct: func,
644 region_pct: region,
645 branch_pct: None,
646 total_lines: 100,
647 covered_lines: (line as u64),
648 total_functions: 20,
649 covered_functions: 16,
650 total_regions: 50,
651 covered_regions: 40,
652 files: Vec::new(),
653 }
654 }
655
656 #[test]
657 fn threshold_pass_when_above() {
658 let c = sample_result(90.0, 85.0, 80.0)
659 .into_check_result(CoverageThreshold::min_line_pct(80.0));
660 assert_eq!(c.verdict, Verdict::Pass);
661 assert!(c.has_tag("coverage"));
662 assert!(c.evidence.iter().any(|e| e.label == "line_pct"));
663 }
664
665 #[test]
666 fn threshold_fail_when_below() {
667 let c = sample_result(50.0, 60.0, 40.0)
668 .into_check_result(CoverageThreshold::min_line_pct(80.0));
669 assert_eq!(c.verdict, Verdict::Fail);
670 assert_eq!(c.severity, Some(Severity::Warning));
671 }
672
673 #[test]
674 fn threshold_function_and_region_paths() {
675 let r = sample_result(90.0, 50.0, 90.0);
676 let c = r
677 .clone()
678 .into_check_result(CoverageThreshold::min_function_pct(80.0));
679 assert_eq!(c.verdict, Verdict::Fail);
680 let c2 = sample_result(90.0, 85.0, 50.0)
681 .into_check_result(CoverageThreshold::min_region_pct(80.0));
682 assert_eq!(c2.verdict, Verdict::Fail);
683 }
684
685 #[test]
686 fn diff_signs_deltas_correctly() {
687 let r = sample_result(75.0, 80.0, 70.0);
688 let b = Baseline {
689 name: "x".into(),
690 line_pct: 80.0,
691 function_pct: 85.0,
692 region_pct: 75.0,
693 };
694 let d = r.diff(&b, 0.0);
695 assert!(d.line_pct_delta < 0.0);
696 assert!(d.function_pct_delta < 0.0);
697 assert!(d.region_pct_delta < 0.0);
698 assert!(d.regressed);
699 }
700
701 #[test]
702 fn diff_tolerance_accepts_small_drops() {
703 let r = sample_result(79.5, 84.5, 74.5);
704 let b = Baseline {
705 name: "x".into(),
706 line_pct: 80.0,
707 function_pct: 85.0,
708 region_pct: 75.0,
709 };
710 let d = r.diff(&b, 1.0);
712 assert!(!d.regressed);
713 }
714
715 #[test]
716 fn diff_improvement_is_not_regression() {
717 let r = sample_result(95.0, 95.0, 95.0);
718 let b = Baseline {
719 name: "x".into(),
720 line_pct: 80.0,
721 function_pct: 85.0,
722 region_pct: 75.0,
723 };
724 let d = r.diff(&b, 0.0);
725 assert!(d.line_pct_delta > 0.0);
726 assert!(!d.regressed);
727 }
728
729 #[test]
730 fn least_covered_files_returns_sorted_subset() {
731 let mut r = sample_result(80.0, 80.0, 80.0);
732 r.files = vec![
733 FileCoverage {
734 filename: "a.rs".into(),
735 line_pct: 90.0,
736 function_pct: 90.0,
737 region_pct: 90.0,
738 total_lines: 10,
739 covered_lines: 9,
740 },
741 FileCoverage {
742 filename: "b.rs".into(),
743 line_pct: 50.0,
744 function_pct: 50.0,
745 region_pct: 50.0,
746 total_lines: 10,
747 covered_lines: 5,
748 },
749 FileCoverage {
750 filename: "c.rs".into(),
751 line_pct: 70.0,
752 function_pct: 70.0,
753 region_pct: 70.0,
754 total_lines: 10,
755 covered_lines: 7,
756 },
757 ];
758 let least = r.least_covered_files(2);
759 assert_eq!(least.len(), 2);
760 assert_eq!(least[0].filename, "b.rs");
761 assert_eq!(least[1].filename, "c.rs");
762 }
763
764 #[test]
765 fn parse_llvm_cov_summary_only() {
766 let json = r#"{
767 "type": "llvm.coverage.json.export",
768 "version": "2.0.1",
769 "data": [{
770 "files": [],
771 "totals": {
772 "lines": { "count": 200, "covered": 170, "percent": 85.0 },
773 "functions": { "count": 50, "covered": 45, "percent": 90.0 },
774 "regions": { "count": 100, "covered": 80, "percent": 80.0 },
775 "branches": { "count": 30, "covered": 24, "percent": 80.0 }
776 }
777 }]
778 }"#;
779 let r = parse_llvm_cov_json(json, "x".into(), "0.1.0".into()).unwrap();
780 assert_eq!(r.line_pct, 85.0);
781 assert_eq!(r.function_pct, 90.0);
782 assert_eq!(r.region_pct, 80.0);
783 assert_eq!(r.branch_pct, Some(80.0));
784 assert_eq!(r.total_lines, 200);
785 assert_eq!(r.covered_lines, 170);
786 assert!(r.files.is_empty());
787 }
788
789 #[test]
790 fn parse_llvm_cov_with_files() {
791 let json = r#"{
792 "type": "llvm.coverage.json.export",
793 "version": "2.0.1",
794 "data": [{
795 "files": [
796 {
797 "filename": "/abs/path/src/lib.rs",
798 "summary": {
799 "lines": { "count": 100, "covered": 90, "percent": 90.0 },
800 "functions": { "count": 20, "covered": 18, "percent": 90.0 },
801 "regions": { "count": 50, "covered": 42, "percent": 84.0 }
802 }
803 }
804 ],
805 "totals": {
806 "lines": { "count": 100, "covered": 90, "percent": 90.0 },
807 "functions": { "count": 20, "covered": 18, "percent": 90.0 },
808 "regions": { "count": 50, "covered": 42, "percent": 84.0 }
809 }
810 }]
811 }"#;
812 let r = parse_llvm_cov_json(json, "x".into(), "0.1.0".into()).unwrap();
813 assert_eq!(r.files.len(), 1);
814 assert_eq!(r.files[0].filename, "/abs/path/src/lib.rs");
815 assert_eq!(r.files[0].line_pct, 90.0);
816 assert!(r.branch_pct.is_none());
818 }
819
820 #[test]
821 fn parse_llvm_cov_rejects_empty_data() {
822 let json = r#"{ "type": "llvm.coverage.json.export", "version": "2", "data": [] }"#;
823 let r = parse_llvm_cov_json(json, "x".into(), "0.1.0".into());
824 assert!(matches!(r, Err(CoverageError::ParseError(_))));
825 }
826
827 #[test]
828 fn parse_llvm_cov_rejects_garbage() {
829 let r = parse_llvm_cov_json("not json", "x".into(), "0.1.0".into());
830 assert!(matches!(r, Err(CoverageError::ParseError(_))));
831 }
832
833 #[test]
834 fn coverage_result_round_trips_through_json() {
835 let r = sample_result(85.0, 88.0, 80.0);
836 let s = serde_json::to_string(&r).unwrap();
837 let back: CoverageResult = serde_json::from_str(&s).unwrap();
838 assert_eq!(back.name, r.name);
839 assert_eq!(back.line_pct, r.line_pct);
840 }
841
842 #[test]
843 fn to_baseline_strips_per_file_detail() {
844 let mut r = sample_result(85.0, 88.0, 80.0);
845 r.files.push(FileCoverage {
846 filename: "a.rs".into(),
847 line_pct: 50.0,
848 function_pct: 50.0,
849 region_pct: 50.0,
850 total_lines: 10,
851 covered_lines: 5,
852 });
853 let b = r.to_baseline();
854 assert_eq!(b.name, "x");
855 assert_eq!(b.line_pct, 85.0);
856 let s = serde_json::to_string(&b).unwrap();
858 assert!(!s.contains("a.rs"));
859 }
860}