Skip to main content

supercov_engine/
run_view.rs

1//! One versioned view of a run, shared by every gate, export and report.
2//!
3//! A threshold check, a changed-line check, an LCOV export and an HTML report
4//! all have to agree about what a run measured. Deriving that four times is how
5//! a gate and a report come to disagree about the same run, so it is derived
6//! once, here, from the same core that produced the run's own summary.
7//!
8//! The view keeps applicability separate from the numbers. A percentage cannot
9//! distinguish "nothing was uncovered" from "nothing was measured", and a gate
10//! that treats those alike reports success for a run that proved nothing.
11
12use std::collections::{BTreeMap, BTreeSet};
13
14use serde::Serialize;
15
16use crate::coverage_analysis::CoverageSummary;
17use crate::coverage_report::{CoverageView, ReportError, coverage_summary_for_file};
18
19pub const RUN_VIEW_SCHEMA_VERSION: u32 = 1;
20
21/// The structural metrics a floor can be set on.
22///
23/// Assertion assessment is deliberately absent. It answers a different
24/// question -- whether a test checks what it executes -- and its own check
25/// already carries the freshness and acknowledgement rules that answer costs.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub enum Metric {
29    Lines,
30    Statements,
31    Functions,
32    Branches,
33    Mcdc,
34}
35
36impl Metric {
37    pub const ALL: [Metric; 5] = [
38        Metric::Lines,
39        Metric::Statements,
40        Metric::Functions,
41        Metric::Branches,
42        Metric::Mcdc,
43    ];
44
45    pub fn name(self) -> &'static str {
46        match self {
47            Metric::Lines => "lines",
48            Metric::Statements => "statements",
49            Metric::Functions => "functions",
50            Metric::Branches => "branches",
51            Metric::Mcdc => "mcdc",
52        }
53    }
54
55    pub fn parse(value: &str) -> Option<Self> {
56        Metric::ALL
57            .into_iter()
58            .find(|metric| metric.name() == value.to_ascii_lowercase())
59    }
60
61    /// The flag that sets this metric's floor, for error messages that tell the
62    /// reader what to change.
63    pub fn flag(self) -> &'static str {
64        match self {
65            Metric::Lines => "--min-lines",
66            Metric::Statements => "--min-statements",
67            Metric::Functions => "--min-functions",
68            Metric::Branches => "--min-branches",
69            Metric::Mcdc => "--min-mcdc",
70        }
71    }
72}
73
74/// Whether a metric can be judged at all, before any number is compared.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "camelCase", tag = "state")]
77pub enum Applicability {
78    /// Measured exactly. Only this state can pass or fail a floor.
79    Measured,
80    /// Nothing eligible. Zero of zero is not a hundred percent, and a floor on
81    /// it is a policy decision the author has to make rather than one this
82    /// tool should make quietly.
83    NotApplicable,
84    /// The run declined some obligations, so a floor cannot be judged without
85    /// deciding what the unmeasured ones would have been. A measurement gap is
86    /// not a coverage gap, and reporting one as the other is a wrong number.
87    Incomplete { unmeasured: usize },
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
91#[serde(rename_all = "camelCase")]
92pub struct MetricView {
93    pub metric: Metric,
94    pub covered: usize,
95    pub eligible: usize,
96    #[serde(flatten)]
97    pub applicability: Applicability,
98}
99
100impl MetricView {
101    /// Exact comparison against a floor in parts per million of a percentage.
102    ///
103    /// The counts are compared directly, never a formatted percentage: a run
104    /// that displays `100.00%` with one line of ten thousand uncovered must
105    /// fail a 100% requirement, and a float percentage cannot promise that.
106    pub fn meets(&self, floor_ppm: u64) -> bool {
107        u128::from(self.covered as u64) * 1_000_000
108            >= u128::from(floor_ppm) * u128::from(self.eligible as u64)
109    }
110
111    /// For display only. Never compare this.
112    pub fn percentage(&self) -> Option<f64> {
113        (self.eligible > 0).then(|| self.covered as f64 * 100.0 / self.eligible as f64)
114    }
115}
116
117fn metric_counts(summary: &CoverageSummary, metric: Metric) -> (usize, usize) {
118    match metric {
119        Metric::Lines => (summary.lines.covered, summary.lines.total),
120        Metric::Statements => (summary.statements.covered, summary.statements.total),
121        Metric::Functions => (summary.functions.covered, summary.functions.total),
122        Metric::Branches => (summary.branches.covered, summary.branches.total),
123        Metric::Mcdc => (summary.covered_conditions, summary.conditions),
124    }
125}
126
127fn metrics_of(summary: &CoverageSummary) -> Vec<MetricView> {
128    let unmeasured = summary.unmeasured_obligations.unwrap_or(0);
129    Metric::ALL
130        .into_iter()
131        .map(|metric| {
132            let (covered, eligible) = metric_counts(summary, metric);
133            let applicability = if eligible == 0 {
134                Applicability::NotApplicable
135            } else if unmeasured > 0 {
136                Applicability::Incomplete { unmeasured }
137            } else {
138                Applicability::Measured
139            };
140            MetricView {
141                metric,
142                covered,
143                eligible,
144                applicability,
145            }
146        })
147        .collect()
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
151#[serde(rename_all = "camelCase")]
152pub struct Location {
153    pub line: usize,
154    pub column: usize,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
158#[serde(rename_all = "camelCase")]
159pub struct FunctionRecord {
160    pub line: usize,
161    pub name: String,
162    pub covered: bool,
163}
164
165/// One alternative of one branch, in the shape every export format wants:
166/// which decision it belongs to and whether it was taken.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
168#[serde(rename_all = "camelCase")]
169pub struct BranchRecord {
170    pub line: usize,
171    pub block: usize,
172    pub index: usize,
173    pub taken: bool,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
177#[serde(rename_all = "camelCase")]
178pub struct FileView {
179    pub file: String,
180    pub metrics: Vec<MetricView>,
181    /// Every line the language adapter decided was executable, in source
182    /// order. A changed-line check intersects a patch with this rather than
183    /// parsing syntax of its own: the adapter already knows which lines are
184    /// comments, blanks or declarations.
185    pub measured_lines: Vec<usize>,
186    /// Measured lines no selected test reached, in source order.
187    pub uncovered_lines: Vec<usize>,
188    pub missing_branches: Vec<Location>,
189    pub missing_conditions: Vec<Location>,
190    pub functions: Vec<FunctionRecord>,
191    pub branches: Vec<BranchRecord>,
192}
193
194impl FileView {
195    pub fn metric(&self, metric: Metric) -> Option<&MetricView> {
196        self.metrics.iter().find(|view| view.metric == metric)
197    }
198
199    /// Every measured line paired with whether a selected test reached it.
200    ///
201    /// Derived rather than stored, so a line can never appear covered here and
202    /// uncovered in the counts beside it.
203    pub fn line_hits(&self) -> impl Iterator<Item = (usize, bool)> + '_ {
204        self.measured_lines
205            .iter()
206            .map(|line| (*line, self.uncovered_lines.binary_search(line).is_err()))
207    }
208}
209
210/// Why a run cannot be gated at all, regardless of its numbers.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
212#[serde(rename_all = "camelCase")]
213pub struct Blocker {
214    pub reason: String,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
218#[serde(rename_all = "camelCase")]
219pub struct RunView {
220    pub schema_version: u32,
221    pub run: String,
222    pub generated_at: String,
223    /// The wrapped test command's own result. A report from a failed suite can
224    /// still be useful, but it must never turn a CI run green.
225    pub suite_passed: bool,
226    pub stale: bool,
227    pub stale_reasons: Vec<String>,
228    pub complete: bool,
229    pub limitations: Vec<String>,
230    pub totals: Vec<MetricView>,
231    pub files: Vec<FileView>,
232    /// Directories the run discovered product source in, paired with the
233    /// extensions it measured there.
234    ///
235    /// A changed file the run never measured is only worth reporting when it
236    /// is the kind of file this project measures. Without this, every changed
237    /// README, lockfile and test would be announced as an unmeasured gap, and
238    /// a list that is mostly noise stops being read.
239    pub source_neighbourhoods: BTreeSet<(String, String)>,
240}
241
242impl RunView {
243    pub fn metric(&self, metric: Metric) -> Option<&MetricView> {
244        self.totals.iter().find(|view| view.metric == metric)
245    }
246
247    pub fn file(&self, path: &str) -> Option<&FileView> {
248        self.files.iter().find(|file| file.file == path)
249    }
250
251    /// Whether the adapter treated this line as executable at all.
252    pub fn measured_line(&self, path: &str, line: usize) -> bool {
253        self.file(path)
254            .is_some_and(|file| file.measured_lines.binary_search(&line).is_ok())
255    }
256
257    /// Whether this path looks like product source for this project: a file
258    /// sitting where the run found measured source, with an extension it
259    /// measured there. True for a file added beside existing source, false for
260    /// a document, a lockfile or a test living somewhere nothing is measured.
261    pub fn looks_like_source(&self, path: &str) -> bool {
262        neighbourhood(path).is_some_and(|key| self.source_neighbourhoods.contains(&key))
263    }
264
265    /// Everything that makes this run unusable as evidence for a gate.
266    pub fn blockers(&self) -> Vec<Blocker> {
267        let mut blockers = Vec::new();
268        if !self.suite_passed {
269            blockers.push(Blocker {
270                reason: "the wrapped test command did not pass; a gate over a failed suite cannot report success".into(),
271            });
272        }
273        if self.stale {
274            let detail = if self.stale_reasons.is_empty() {
275                "the run no longer matches the current checkout".to_owned()
276            } else {
277                format!(
278                    "the run no longer matches the current checkout: {}",
279                    self.stale_reasons.join(", ")
280                )
281            };
282            blockers.push(Blocker { reason: detail });
283        }
284        blockers
285    }
286}
287
288/// A path's directory and extension, the pair that decides whether a file
289/// belongs to the same measured population as its neighbours.
290fn neighbourhood(path: &str) -> Option<(String, String)> {
291    let (directory, name) = path.rsplit_once('/').unwrap_or(("", path));
292    let (_, extension) = name.rsplit_once('.')?;
293    Some((directory.to_owned(), extension.to_owned()))
294}
295
296/// Build the shared view from a run's coverage view.
297pub fn build(
298    run: &str,
299    generated_at: &str,
300    view: &CoverageView,
301    suite_passed: bool,
302    stale: bool,
303    stale_reasons: Vec<String>,
304) -> Result<RunView, ReportError> {
305    let mut files = BTreeMap::<String, FileView>::new();
306    for line in &view.lines {
307        files
308            .entry(line.file.clone())
309            .or_insert_with(|| FileView {
310                file: line.file.clone(),
311                metrics: Vec::new(),
312                measured_lines: Vec::new(),
313                uncovered_lines: Vec::new(),
314                missing_branches: Vec::new(),
315                missing_conditions: Vec::new(),
316                functions: Vec::new(),
317                branches: Vec::new(),
318            })
319            .measured_lines
320            .extend(line.measured.then_some(line.line));
321        if line.measured
322            && !line.covered
323            && let Some(file) = files.get_mut(&line.file)
324        {
325            file.uncovered_lines.push(line.line);
326        }
327    }
328    for branch in &view.branches {
329        let entry = files
330            .entry(branch.meta.file.clone())
331            .or_insert_with(|| FileView {
332                file: branch.meta.file.clone(),
333                metrics: Vec::new(),
334                measured_lines: Vec::new(),
335                uncovered_lines: Vec::new(),
336                missing_branches: Vec::new(),
337                missing_conditions: Vec::new(),
338                functions: Vec::new(),
339                branches: Vec::new(),
340            });
341        for alternative in branch.alternatives.iter().filter(|a| !a.covered) {
342            entry.missing_branches.push(Location {
343                line: branch.meta.line,
344                column: branch.meta.column,
345            });
346            let _ = alternative;
347        }
348    }
349    for point in &view.points {
350        if point.meta.kind != crate::coverage_analysis::PointKind::Function {
351            continue;
352        }
353        if let Some(file) = files.get_mut(&point.meta.file) {
354            file.functions.push(FunctionRecord {
355                line: point.meta.line,
356                name: point
357                    .meta
358                    .label
359                    .clone()
360                    .unwrap_or_else(|| format!("{}:{}", point.meta.line, point.meta.column)),
361                covered: point.covered,
362            });
363        }
364    }
365    for (block, branch) in view.branches.iter().enumerate() {
366        if let Some(file) = files.get_mut(&branch.meta.file) {
367            for (index, alternative) in branch.alternatives.iter().enumerate() {
368                file.branches.push(BranchRecord {
369                    line: branch.meta.line,
370                    block,
371                    index,
372                    taken: alternative.covered,
373                });
374            }
375        }
376    }
377    for decision in &view.decisions {
378        let entry = files
379            .entry(decision.meta.file.clone())
380            .or_insert_with(|| FileView {
381                file: decision.meta.file.clone(),
382                metrics: Vec::new(),
383                measured_lines: Vec::new(),
384                uncovered_lines: Vec::new(),
385                missing_branches: Vec::new(),
386                missing_conditions: Vec::new(),
387                functions: Vec::new(),
388                branches: Vec::new(),
389            });
390        for _ in decision.conditions.iter().filter(|c| !c.covered) {
391            entry.missing_conditions.push(Location {
392                line: decision.meta.line,
393                column: decision.meta.column,
394            });
395        }
396    }
397    let mut built = Vec::new();
398    for (path, mut file) in files {
399        file.metrics = metrics_of(&coverage_summary_for_file(view, &path)?);
400        file.measured_lines.sort_unstable();
401        file.measured_lines.dedup();
402        file.uncovered_lines.sort_unstable();
403        file.uncovered_lines.dedup();
404        file.missing_branches.sort_by_key(|at| (at.line, at.column));
405        file.missing_conditions
406            .sort_by_key(|at| (at.line, at.column));
407        file.functions
408            .sort_by(|a, b| (a.line, &a.name).cmp(&(b.line, &b.name)));
409        file.branches
410            .sort_by_key(|record| (record.line, record.block, record.index));
411        built.push(file);
412    }
413    let source_neighbourhoods = built
414        .iter()
415        .filter_map(|file| neighbourhood(&file.file))
416        .collect::<BTreeSet<_>>();
417    Ok(RunView {
418        schema_version: RUN_VIEW_SCHEMA_VERSION,
419        run: run.to_owned(),
420        generated_at: generated_at.to_owned(),
421        suite_passed,
422        stale,
423        stale_reasons,
424        complete: view.summary.coverage_complete,
425        limitations: view
426            .limitations
427            .iter()
428            .map(|limitation| limitation.to_string())
429            .collect(),
430        totals: metrics_of(&view.summary),
431        files: built,
432        source_neighbourhoods,
433    })
434}
435
436/// A percentage floor, held in parts per million so a fractional target is
437/// compared exactly rather than through a float.
438#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
439#[serde(rename_all = "camelCase")]
440pub struct Floor {
441    pub metric: Metric,
442    pub ppm: u64,
443}
444
445/// Parse a percentage into parts per million, rejecting anything that is not a
446/// plain number between 0 and 100.
447pub fn parse_percentage(value: &str) -> Result<u64, String> {
448    let text = value.trim();
449    let (whole, fraction) = match text.split_once('.') {
450        Some((whole, fraction)) => (whole, fraction),
451        None => (text, ""),
452    };
453    if whole.is_empty() && fraction.is_empty() {
454        return Err(format!("{value:?} is not a percentage"));
455    }
456    if !whole.chars().all(|c| c.is_ascii_digit())
457        || !fraction.chars().all(|c| c.is_ascii_digit())
458        || fraction.len() > 4
459    {
460        return Err(format!(
461            "{value:?} is not a percentage between 0 and 100 with at most four decimal places"
462        ));
463    }
464    let whole: u64 = if whole.is_empty() {
465        0
466    } else {
467        whole
468            .parse()
469            .map_err(|_| format!("{value:?} is too large"))?
470    };
471    let scaled: u64 = if fraction.is_empty() {
472        0
473    } else {
474        format!("{fraction:0<4}")
475            .parse()
476            .map_err(|_| format!("{value:?} is not a percentage"))?
477    };
478    let ppm = whole
479        .checked_mul(10_000)
480        .and_then(|whole| whole.checked_add(scaled))
481        .ok_or_else(|| format!("{value:?} is too large"))?;
482    (ppm <= 1_000_000)
483        .then_some(ppm)
484        .ok_or_else(|| format!("{value:?} is above 100"))
485}
486
487#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
488#[serde(rename_all = "camelCase")]
489pub struct Violation {
490    /// Absent for a run-wide floor.
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub file: Option<String>,
493    pub metric: Metric,
494    pub covered: usize,
495    pub eligible: usize,
496    pub floor_ppm: u64,
497    pub uncovered_lines: Vec<usize>,
498}
499
500#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
501#[serde(rename_all = "camelCase", tag = "result")]
502pub enum Outcome {
503    Pass,
504    Fail {
505        violations: Vec<Violation>,
506    },
507    /// The request or the evidence cannot answer the question asked. Never a
508    /// pass, and never reported as a policy failure either.
509    Error {
510        reasons: Vec<String>,
511    },
512}
513
514impl Outcome {
515    pub fn exit_code(&self) -> u8 {
516        match self {
517            Outcome::Pass => 0,
518            Outcome::Fail { .. } => 1,
519            Outcome::Error { .. } => 2,
520        }
521    }
522}
523
524/// Judge a run against percentage floors.
525///
526/// Insufficient evidence is an error, never a pass: an unsupported metric, an
527/// empty scope, a partial measurement, a failed suite or a stale run all leave
528/// the question unanswered, and answering it anyway is how a gate goes green
529/// over a suite that never ran.
530pub fn check(view: &RunView, floors: &[Floor], per_file: bool) -> Outcome {
531    let mut reasons = view
532        .blockers()
533        .into_iter()
534        .map(|blocker| blocker.reason)
535        .collect::<Vec<_>>();
536    if floors.is_empty() {
537        reasons.push("no floor requested; give at least one --min-<metric>".into());
538    }
539    for floor in floors {
540        match view.metric(floor.metric) {
541            None => reasons.push(format!(
542                "{} is not measured by this run's language adapter; remove {}",
543                floor.metric.name(),
544                floor.metric.flag()
545            )),
546            Some(metric) => match &metric.applicability {
547                Applicability::NotApplicable => reasons.push(format!(
548                    "{} has nothing eligible in this run, which is not the same as complete; remove {} or widen the scope",
549                    floor.metric.name(),
550                    floor.metric.flag()
551                )),
552                Applicability::Incomplete { unmeasured } => reasons.push(format!(
553                    "{} left {unmeasured} obligation(s) unmeasured, so {} cannot be judged exactly",
554                    floor.metric.name(),
555                    floor.metric.flag()
556                )),
557                Applicability::Measured => {}
558            },
559        }
560    }
561    if !reasons.is_empty() {
562        return Outcome::Error { reasons };
563    }
564
565    let mut violations = Vec::new();
566    for floor in floors {
567        let Some(metric) = view.metric(floor.metric) else {
568            continue;
569        };
570        if !metric.meets(floor.ppm) {
571            violations.push(Violation {
572                file: None,
573                metric: floor.metric,
574                covered: metric.covered,
575                eligible: metric.eligible,
576                floor_ppm: floor.ppm,
577                uncovered_lines: Vec::new(),
578            });
579        }
580        if !per_file {
581            continue;
582        }
583        for file in &view.files {
584            let Some(counts) = file.metric(floor.metric) else {
585                continue;
586            };
587            // A file with nothing eligible for this metric is not a hundred
588            // percent and not a violation either; it simply has no obligation.
589            if counts.eligible == 0 || counts.meets(floor.ppm) {
590                continue;
591            }
592            violations.push(Violation {
593                file: Some(file.file.clone()),
594                metric: floor.metric,
595                covered: counts.covered,
596                eligible: counts.eligible,
597                floor_ppm: floor.ppm,
598                uncovered_lines: file.uncovered_lines.clone(),
599            });
600        }
601    }
602    if violations.is_empty() {
603        Outcome::Pass
604    } else {
605        Outcome::Fail { violations }
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    fn metric(covered: usize, eligible: usize) -> MetricView {
614        MetricView {
615            metric: Metric::Lines,
616            covered,
617            eligible,
618            applicability: if eligible == 0 {
619                Applicability::NotApplicable
620            } else {
621                Applicability::Measured
622            },
623        }
624    }
625
626    fn view(totals: Vec<MetricView>, files: Vec<FileView>) -> RunView {
627        RunView {
628            schema_version: RUN_VIEW_SCHEMA_VERSION,
629            run: "run_1".into(),
630            generated_at: "now".into(),
631            suite_passed: true,
632            stale: false,
633            stale_reasons: Vec::new(),
634            complete: true,
635            limitations: Vec::new(),
636            totals,
637            files,
638            source_neighbourhoods: BTreeSet::new(),
639        }
640    }
641
642    #[test]
643    fn a_floor_is_compared_against_counts_and_never_a_rounded_percentage() {
644        // 9_999 of 10_000 displays as 100.00%. A gate that reads the display
645        // passes a run with an uncovered line, which is the whole reason this
646        // compares the counts instead.
647        let almost = metric(9_999, 10_000);
648        assert_eq!(format!("{:.2}", almost.percentage().unwrap()), "99.99");
649        assert!(!almost.meets(1_000_000));
650        assert!(almost.meets(999_000));
651        assert!(metric(10_000, 10_000).meets(1_000_000));
652
653        // And a fractional floor is exact in both directions.
654        assert!(metric(995, 1_000).meets(parse_percentage("99.5").unwrap()));
655        assert!(!metric(994, 1_000).meets(parse_percentage("99.5").unwrap()));
656    }
657
658    #[test]
659    fn nothing_eligible_is_not_complete_coverage() {
660        // Zero of zero satisfies every floor arithmetically, so applicability
661        // has to be decided before the comparison, not by it.
662        let empty = metric(0, 0);
663        assert!(empty.meets(1_000_000));
664        assert_eq!(empty.applicability, Applicability::NotApplicable);
665        let outcome = check(
666            &view(vec![empty], Vec::new()),
667            &[Floor {
668                metric: Metric::Lines,
669                ppm: 1_000_000,
670            }],
671            false,
672        );
673        assert!(matches!(outcome, Outcome::Error { .. }), "{outcome:?}");
674        assert_eq!(outcome.exit_code(), 2);
675    }
676
677    #[test]
678    fn evidence_that_cannot_answer_the_question_never_passes() {
679        let floors = [Floor {
680            metric: Metric::Lines,
681            ppm: 500_000,
682        }];
683        // Fully covered, but the suite failed: a gate over a failed suite must
684        // not report success however good the numbers look.
685        let mut failed = view(vec![metric(10, 10)], Vec::new());
686        failed.suite_passed = false;
687        assert_eq!(check(&failed, &floors, false).exit_code(), 2);
688
689        // Fully covered, but the run no longer matches the checkout.
690        let mut stale = view(vec![metric(10, 10)], Vec::new());
691        stale.stale = true;
692        stale.stale_reasons = vec!["instrumented source changed".into()];
693        let outcome = check(&stale, &floors, false);
694        assert_eq!(outcome.exit_code(), 2);
695        let Outcome::Error { reasons } = outcome else {
696            panic!("expected an error");
697        };
698        assert!(
699            reasons[0].contains("instrumented source changed"),
700            "{reasons:?}"
701        );
702
703        // A partial measurement cannot be judged exactly either.
704        let mut partial = view(vec![metric(10, 10)], Vec::new());
705        partial.totals[0].applicability = Applicability::Incomplete { unmeasured: 3 };
706        assert_eq!(check(&partial, &floors, false).exit_code(), 2);
707
708        // A metric this adapter never records is a request error, not a pass.
709        assert_eq!(
710            check(
711                &view(vec![metric(10, 10)], Vec::new()),
712                &[Floor {
713                    metric: Metric::Mcdc,
714                    ppm: 500_000
715                }],
716                false
717            )
718            .exit_code(),
719            2
720        );
721    }
722
723    #[test]
724    fn every_violation_is_reported_with_the_counts_behind_it() {
725        // One message per failing rule, each carrying its own numerator and
726        // denominator, so a reader can act without rerunning anything.
727        let files = vec![
728            FileView {
729                file: "src/a.ts".into(),
730                metrics: vec![MetricView {
731                    metric: Metric::Lines,
732                    covered: 1,
733                    eligible: 4,
734                    applicability: Applicability::Measured,
735                }],
736                measured_lines: vec![1, 2, 3, 4],
737                uncovered_lines: vec![2, 3, 4],
738                missing_branches: Vec::new(),
739                missing_conditions: Vec::new(),
740                functions: Vec::new(),
741                branches: Vec::new(),
742            },
743            FileView {
744                file: "src/b.ts".into(),
745                metrics: vec![MetricView {
746                    metric: Metric::Lines,
747                    covered: 6,
748                    eligible: 6,
749                    applicability: Applicability::Measured,
750                }],
751                measured_lines: vec![1, 2, 3, 4, 5, 6],
752                uncovered_lines: Vec::new(),
753                missing_branches: Vec::new(),
754                missing_conditions: Vec::new(),
755                functions: Vec::new(),
756                branches: Vec::new(),
757            },
758        ];
759        let outcome = check(
760            &view(vec![metric(7, 10)], files),
761            &[Floor {
762                metric: Metric::Lines,
763                ppm: 900_000,
764            }],
765            true,
766        );
767        let Outcome::Fail { violations } = outcome else {
768            panic!("expected a policy failure");
769        };
770        assert_eq!(violations.len(), 2, "{violations:?}");
771        assert_eq!(violations[0].file, None);
772        assert_eq!((violations[0].covered, violations[0].eligible), (7, 10));
773        assert_eq!(violations[1].file.as_deref(), Some("src/a.ts"));
774        assert_eq!(violations[1].uncovered_lines, [2, 3, 4]);
775    }
776
777    #[test]
778    fn a_percentage_is_read_exactly_or_refused() {
779        assert_eq!(parse_percentage("90").unwrap(), 900_000);
780        assert_eq!(parse_percentage("99.5").unwrap(), 995_000);
781        assert_eq!(parse_percentage("100").unwrap(), 1_000_000);
782        assert_eq!(parse_percentage("0").unwrap(), 0);
783        assert_eq!(parse_percentage(" 87.6543 ").unwrap(), 876_543);
784        for refused in ["101", "-1", "abc", "", "1e2", "50.123456", "100.0001"] {
785            assert!(parse_percentage(refused).is_err(), "{refused} was accepted");
786        }
787    }
788}