Skip to main content

dev_coverage/
lib.rs

1//! # dev-coverage
2//!
3//! Test coverage measurement and regression detection for Rust. Part of
4//! the `dev-*` verification suite.
5//!
6//! Wraps `cargo-llvm-cov` — the modern Rust coverage standard — and emits
7//! results as a [`dev_report::Report`]. Compares against a stored
8//! baseline to flag regressions so AI agents and CI gates can decide
9//! whether a PR drops coverage too far.
10//!
11//! ## Quick example
12//!
13//! ```no_run
14//! use dev_coverage::{CoverageRun, CoverageThreshold};
15//!
16//! let run = CoverageRun::new("my-crate", "0.1.0");
17//! let result = run.execute().unwrap();
18//!
19//! let threshold = CoverageThreshold::min_line_pct(80.0);
20//! let check = result.into_check_result(threshold);
21//! ```
22//!
23//! ## What dev-coverage provides
24//!
25//! - [`CoverageRun`] — builder around `cargo llvm-cov`.
26//! - [`CoverageResult`] — line / function / region percentages plus
27//!   per-file breakdown.
28//! - [`CoverageThreshold`] — fail when coverage drops below an absolute
29//!   floor.
30//! - [`Baseline`] + [`BaselineStore`] — persist per-commit coverage so
31//!   the next run can flag regressions.
32//! - [`CoverageProducer`] — `dev_report::Producer` integration for
33//!   pipelines that compose multiple producers via `dev-tools`.
34//!
35//! ## Requirements
36//!
37//! `cargo-llvm-cov` must be installed on the system:
38//!
39//! ```text
40//! cargo install cargo-llvm-cov
41//! ```
42//!
43//! The crate detects its absence and emits
44//! [`CoverageError::ToolNotInstalled`] without panicking.
45
46#![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// ---------------------------------------------------------------------------
64// CoverageRun
65// ---------------------------------------------------------------------------
66
67/// Configuration for a coverage run.
68///
69/// Wraps `cargo llvm-cov --json --summary-only`. Use the builder methods
70/// to scope the run (working directory, workspace toggle, excludes,
71/// features), then call [`execute`](Self::execute) to invoke the
72/// subprocess and parse the result.
73///
74/// # Example
75///
76/// ```no_run
77/// use dev_coverage::CoverageRun;
78///
79/// let run = CoverageRun::new("my-crate", "0.1.0")
80///     .workspace()
81///     .exclude("tests/*")
82///     .all_features();
83///
84/// let _result = run.execute().unwrap();
85/// ```
86#[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    /// Begin a coverage run for the given crate name and version.
101    ///
102    /// `name` and `version` are descriptive — they identify the subject
103    /// in the produced `dev-report::Report`. They do NOT need to match
104    /// the package being measured (that is determined by `cargo`'s own
105    /// resolution from the working directory).
106    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    /// Run `cargo llvm-cov` from `dir` instead of the current directory.
121    pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
122        self.workdir = Some(dir.into());
123        self
124    }
125
126    /// Descriptive subject name passed in via [`new`](Self::new).
127    pub fn subject(&self) -> &str {
128        &self.name
129    }
130
131    /// Descriptive subject version passed in via [`new`](Self::new).
132    pub fn subject_version(&self) -> &str {
133        &self.version
134    }
135
136    /// Pass `--workspace` so every workspace member is measured.
137    pub fn workspace(mut self) -> Self {
138        self.workspace = true;
139        self
140    }
141
142    /// Pass `--exclude <pattern>`. May be called multiple times.
143    pub fn exclude(mut self, pattern: impl Into<String>) -> Self {
144        self.excludes.push(pattern.into());
145        self
146    }
147
148    /// Add a specific feature to enable. May be called multiple times.
149    pub fn feature(mut self, name: impl Into<String>) -> Self {
150        self.features.push(name.into());
151        self
152    }
153
154    /// Pass `--all-features`.
155    pub fn all_features(mut self) -> Self {
156        self.all_features = true;
157        self
158    }
159
160    /// Pass `--no-default-features`.
161    pub fn no_default_features(mut self) -> Self {
162        self.no_default_features = true;
163        self
164    }
165
166    /// Request a per-file breakdown (drops `--summary-only`).
167    ///
168    /// Default is summary-only — much smaller JSON, faster parse. Enable
169    /// this when you need [`CoverageResult::files`] populated.
170    pub fn per_file(mut self) -> Self {
171        self.per_file = true;
172        self
173    }
174
175    /// Execute the run.
176    ///
177    /// Returns a [`CoverageResult`] on success, or a [`CoverageError`]
178    /// describing what went wrong (missing tool, subprocess failure,
179    /// parse failure).
180    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// ---------------------------------------------------------------------------
234// CoverageResult + FileCoverage
235// ---------------------------------------------------------------------------
236
237/// Result of a coverage run.
238///
239/// Top-level percentages and counts are always populated. The
240/// `files` vector is populated only when the run was configured with
241/// [`CoverageRun::per_file`].
242///
243/// # Example
244///
245/// ```
246/// use dev_coverage::CoverageResult;
247///
248/// let r = CoverageResult {
249///     name: "my-crate".into(),
250///     version: "0.1.0".into(),
251///     line_pct: 87.5,
252///     function_pct: 90.0,
253///     region_pct: 82.0,
254///     branch_pct: None,
255///     total_lines: 200,
256///     covered_lines: 175,
257///     total_functions: 50,
258///     covered_functions: 45,
259///     total_regions: 100,
260///     covered_regions: 82,
261///     files: Vec::new(),
262/// };
263/// assert!(r.line_pct > 80.0);
264/// ```
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct CoverageResult {
267    /// Crate or subject name (descriptive; matches the `Report` subject).
268    pub name: String,
269    /// Subject version (descriptive; matches the `Report` subject_version).
270    pub version: String,
271    /// Percentage of executable lines exercised by tests. `0.0..=100.0`.
272    pub line_pct: f64,
273    /// Percentage of functions called by tests. `0.0..=100.0`.
274    pub function_pct: f64,
275    /// Percentage of regions (branch points) exercised. `0.0..=100.0`.
276    pub region_pct: f64,
277    /// Percentage of branches exercised. Not all builds emit branch
278    /// counts; `None` when absent.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub branch_pct: Option<f64>,
281    /// Total executable lines.
282    pub total_lines: u64,
283    /// Lines exercised at least once.
284    pub covered_lines: u64,
285    /// Total functions.
286    pub total_functions: u64,
287    /// Functions called by at least one test.
288    pub covered_functions: u64,
289    /// Total regions.
290    pub total_regions: u64,
291    /// Regions exercised at least once.
292    pub covered_regions: u64,
293    /// Per-file breakdown. Empty unless the run was configured with
294    /// [`CoverageRun::per_file`].
295    #[serde(default, skip_serializing_if = "Vec::is_empty")]
296    pub files: Vec<FileCoverage>,
297}
298
299/// Coverage measurements for a single source file.
300///
301/// Populated only when the parent run was configured with
302/// [`CoverageRun::per_file`].
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct FileCoverage {
305    /// Absolute path emitted by `cargo llvm-cov`.
306    pub filename: String,
307    /// Line coverage percentage. `0.0..=100.0`.
308    pub line_pct: f64,
309    /// Function coverage percentage. `0.0..=100.0`.
310    pub function_pct: f64,
311    /// Region coverage percentage. `0.0..=100.0`.
312    pub region_pct: f64,
313    /// Total executable lines in this file.
314    pub total_lines: u64,
315    /// Lines in this file exercised at least once.
316    pub covered_lines: u64,
317}
318
319impl CoverageResult {
320    /// Convert this result into a [`CheckResult`] against the given threshold.
321    ///
322    /// Pass when the measured percentage meets or exceeds the threshold,
323    /// otherwise fail with [`Severity::Warning`]. The verdict carries
324    /// numeric evidence for both the actual and target percentages.
325    ///
326    /// # Example
327    ///
328    /// ```
329    /// use dev_coverage::{CoverageResult, CoverageThreshold};
330    /// use dev_report::Verdict;
331    ///
332    /// let r = CoverageResult {
333    ///     name: "x".into(), version: "0.1.0".into(),
334    ///     line_pct: 90.0, function_pct: 85.0, region_pct: 80.0,
335    ///     branch_pct: None,
336    ///     total_lines: 100, covered_lines: 90,
337    ///     total_functions: 20, covered_functions: 17,
338    ///     total_regions: 50, covered_regions: 40,
339    ///     files: Vec::new(),
340    /// };
341    /// let c = r.into_check_result(CoverageThreshold::min_line_pct(80.0));
342    /// assert_eq!(c.verdict, Verdict::Pass);
343    /// ```
344    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    /// Compare this result against a stored baseline.
369    ///
370    /// Returns a [`CoverageDiff`] carrying signed deltas for each metric.
371    /// `tolerance_pct` is the maximum negative delta tolerated before
372    /// the diff is flagged as a regression — e.g. `tolerance_pct = 1.0`
373    /// allows up to a 1-percentage-point drop without regressing.
374    ///
375    /// # Example
376    ///
377    /// ```
378    /// use dev_coverage::{Baseline, CoverageResult};
379    ///
380    /// let r = CoverageResult {
381    ///     name: "x".into(), version: "0.1.0".into(),
382    ///     line_pct: 75.0, function_pct: 80.0, region_pct: 70.0,
383    ///     branch_pct: None,
384    ///     total_lines: 100, covered_lines: 75,
385    ///     total_functions: 20, covered_functions: 16,
386    ///     total_regions: 50, covered_regions: 35,
387    ///     files: Vec::new(),
388    /// };
389    /// let baseline = Baseline {
390    ///     name: "x".into(),
391    ///     line_pct: 80.0, function_pct: 85.0, region_pct: 75.0,
392    /// };
393    /// let diff = r.diff(&baseline, 1.0);
394    /// assert!(diff.regressed);
395    /// assert_eq!(diff.line_pct_delta, -5.0);
396    /// ```
397    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    /// Convert this result into a [`Baseline`] suitable for persisting.
411    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    /// Return the `n` files with the lowest line coverage, sorted ascending.
421    ///
422    /// Useful for emitting evidence about which files most need attention.
423    /// Returns an empty vector when `files` was not populated.
424    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// ---------------------------------------------------------------------------
436// CoverageThreshold
437// ---------------------------------------------------------------------------
438
439/// Threshold defining the minimum acceptable coverage.
440#[derive(Debug, Clone, Copy)]
441pub enum CoverageThreshold {
442    /// Fail when `line_pct` is below the given percentage.
443    MinLinePct(f64),
444    /// Fail when `function_pct` is below the given percentage.
445    MinFunctionPct(f64),
446    /// Fail when `region_pct` is below the given percentage.
447    MinRegionPct(f64),
448}
449
450impl CoverageThreshold {
451    /// Build a line-coverage threshold.
452    pub fn min_line_pct(pct: f64) -> Self {
453        Self::MinLinePct(pct)
454    }
455
456    /// Build a function-coverage threshold.
457    pub fn min_function_pct(pct: f64) -> Self {
458        Self::MinFunctionPct(pct)
459    }
460
461    /// Build a region-coverage threshold.
462    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// ---------------------------------------------------------------------------
476// CoverageDiff
477// ---------------------------------------------------------------------------
478
479/// Signed deltas between a current [`CoverageResult`] and a stored
480/// [`Baseline`].
481///
482/// Negative deltas indicate coverage dropped; `regressed` is `true`
483/// when at least one delta exceeds the tolerance passed to
484/// [`CoverageResult::diff`].
485#[derive(Debug, Clone, Copy)]
486pub struct CoverageDiff {
487    /// Current `line_pct` minus baseline `line_pct`.
488    pub line_pct_delta: f64,
489    /// Current `function_pct` minus baseline `function_pct`.
490    pub function_pct_delta: f64,
491    /// Current `region_pct` minus baseline `region_pct`.
492    pub region_pct_delta: f64,
493    /// `true` when at least one delta is worse than the tolerance.
494    pub regressed: bool,
495}
496
497// ---------------------------------------------------------------------------
498// CoverageError
499// ---------------------------------------------------------------------------
500
501/// Errors that can arise during a coverage run.
502#[derive(Debug)]
503pub enum CoverageError {
504    /// `cargo-llvm-cov` is not installed.
505    ToolNotInstalled,
506    /// The coverage subprocess returned a non-zero exit code.
507    SubprocessFailed(String),
508    /// The coverage output could not be parsed as JSON of the expected shape.
509    ParseError(String),
510    /// An I/O error occurred while reading or writing baseline files.
511    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// ---------------------------------------------------------------------------
546// LLVM-cov JSON parser
547// ---------------------------------------------------------------------------
548
549#[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// ---------------------------------------------------------------------------
630// Tests
631// ---------------------------------------------------------------------------
632
633#[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        // 0.5pp drops everywhere; tolerance of 1.0 accepts.
711        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        // No branches section in this fixture.
817        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        // Baseline doesn't carry per-file detail.
857        let s = serde_json::to_string(&b).unwrap();
858        assert!(!s.contains("a.rs"));
859    }
860}