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/// One condition of a decision: where the decision is, which of its
177/// conditions this is, and whether its independent effect was shown.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct ConditionRecord {
180    pub line: usize,
181    pub column: usize,
182    pub index: usize,
183    pub covered: bool,
184    pub source: String,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
188#[serde(rename_all = "camelCase")]
189pub struct FileView {
190    pub file: String,
191    pub metrics: Vec<MetricView>,
192    /// Every line the language adapter decided was executable, in source
193    /// order. A changed-line check intersects a patch with this rather than
194    /// parsing syntax of its own: the adapter already knows which lines are
195    /// comments, blanks or declarations.
196    pub measured_lines: Vec<usize>,
197    /// Measured lines no selected test reached, in source order.
198    pub uncovered_lines: Vec<usize>,
199    pub missing_branches: Vec<Location>,
200    pub missing_conditions: Vec<Location>,
201    pub functions: Vec<FunctionRecord>,
202    pub branches: Vec<BranchRecord>,
203    /// Every condition of every decision, for an export that names them.
204    /// The JSON view gives `missing_conditions`, and a decision's own query
205    /// the rest.
206    #[serde(skip)]
207    pub conditions: Vec<ConditionRecord>,
208}
209
210impl FileView {
211    pub fn metric(&self, metric: Metric) -> Option<&MetricView> {
212        self.metrics.iter().find(|view| view.metric == metric)
213    }
214
215    /// Every measured line paired with whether a selected test reached it.
216    ///
217    /// Derived rather than stored, so a line can never appear covered here and
218    /// uncovered in the counts beside it.
219    pub fn line_hits(&self) -> impl Iterator<Item = (usize, bool)> + '_ {
220        self.measured_lines
221            .iter()
222            .map(|line| (*line, self.uncovered_lines.binary_search(line).is_err()))
223    }
224}
225
226/// Why a run cannot be gated at all, regardless of its numbers.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
228#[serde(rename_all = "camelCase")]
229pub struct Blocker {
230    pub reason: String,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
234#[serde(rename_all = "camelCase")]
235pub struct RunView {
236    pub schema_version: u32,
237    pub run: String,
238    pub generated_at: String,
239    /// The wrapped test command's own result. A report from a failed suite can
240    /// still be useful, but it must never turn a CI run green.
241    pub suite_passed: bool,
242    pub stale: bool,
243    pub stale_reasons: Vec<String>,
244    pub complete: bool,
245    pub limitations: Vec<String>,
246    pub totals: Vec<MetricView>,
247    pub files: Vec<FileView>,
248    /// Directories the run discovered product source in, paired with the
249    /// extensions it measured there.
250    ///
251    /// A changed file the run never measured is only worth reporting when it
252    /// is the kind of file this project measures. Without this, every changed
253    /// README, lockfile and test would be announced as an unmeasured gap, and
254    /// a list that is mostly noise stops being read.
255    pub source_neighbourhoods: BTreeSet<(String, String)>,
256}
257
258impl RunView {
259    pub fn metric(&self, metric: Metric) -> Option<&MetricView> {
260        self.totals.iter().find(|view| view.metric == metric)
261    }
262
263    pub fn file(&self, path: &str) -> Option<&FileView> {
264        self.files.iter().find(|file| file.file == path)
265    }
266
267    /// Whether the adapter treated this line as executable at all.
268    pub fn measured_line(&self, path: &str, line: usize) -> bool {
269        self.file(path)
270            .is_some_and(|file| file.measured_lines.binary_search(&line).is_ok())
271    }
272
273    /// Whether this path looks like product source for this project: a file
274    /// sitting where the run found measured source, with an extension it
275    /// measured there. True for a file added beside existing source, false for
276    /// a document, a lockfile or a test living somewhere nothing is measured.
277    pub fn looks_like_source(&self, path: &str) -> bool {
278        neighbourhood(path).is_some_and(|key| self.source_neighbourhoods.contains(&key))
279    }
280
281    /// Everything that makes this run unusable as evidence for a gate.
282    pub fn blockers(&self) -> Vec<Blocker> {
283        let mut blockers = Vec::new();
284        if !self.suite_passed {
285            blockers.push(Blocker {
286                reason: "the wrapped test command did not pass; a gate over a failed suite cannot report success".into(),
287            });
288        }
289        if self.stale {
290            let detail = if self.stale_reasons.is_empty() {
291                "the run no longer matches the current checkout".to_owned()
292            } else {
293                format!(
294                    "the run no longer matches the current checkout: {}",
295                    self.stale_reasons.join(", ")
296                )
297            };
298            blockers.push(Blocker { reason: detail });
299        }
300        blockers
301    }
302}
303
304/// A path's directory and extension, the pair that decides whether a file
305/// belongs to the same measured population as its neighbours.
306fn neighbourhood(path: &str) -> Option<(String, String)> {
307    let (directory, name) = path.rsplit_once('/').unwrap_or(("", path));
308    let (_, extension) = name.rsplit_once('.')?;
309    Some((directory.to_owned(), extension.to_owned()))
310}
311
312/// Build the shared view from a run's coverage view.
313pub fn build(
314    run: &str,
315    generated_at: &str,
316    view: &CoverageView,
317    suite_passed: bool,
318    stale: bool,
319    stale_reasons: Vec<String>,
320) -> Result<RunView, ReportError> {
321    let mut files = BTreeMap::<String, FileView>::new();
322    for line in &view.lines {
323        files
324            .entry(line.file.clone())
325            .or_insert_with(|| FileView {
326                file: line.file.clone(),
327                metrics: Vec::new(),
328                measured_lines: Vec::new(),
329                uncovered_lines: Vec::new(),
330                missing_branches: Vec::new(),
331                missing_conditions: Vec::new(),
332                functions: Vec::new(),
333                branches: Vec::new(),
334                conditions: Vec::new(),
335            })
336            .measured_lines
337            .extend(line.measured.then_some(line.line));
338        if line.measured
339            && !line.covered
340            && let Some(file) = files.get_mut(&line.file)
341        {
342            file.uncovered_lines.push(line.line);
343        }
344    }
345    for branch in &view.branches {
346        let entry = files
347            .entry(branch.meta.file.clone())
348            .or_insert_with(|| FileView {
349                file: branch.meta.file.clone(),
350                metrics: Vec::new(),
351                measured_lines: Vec::new(),
352                uncovered_lines: Vec::new(),
353                missing_branches: Vec::new(),
354                missing_conditions: Vec::new(),
355                functions: Vec::new(),
356                branches: Vec::new(),
357                conditions: Vec::new(),
358            });
359        for alternative in branch.alternatives.iter().filter(|a| !a.covered) {
360            entry.missing_branches.push(Location {
361                line: branch.meta.line,
362                column: branch.meta.column,
363            });
364            let _ = alternative;
365        }
366    }
367    for point in &view.points {
368        if point.meta.kind != crate::coverage_analysis::PointKind::Function {
369            continue;
370        }
371        if let Some(file) = files.get_mut(&point.meta.file) {
372            file.functions.push(FunctionRecord {
373                line: point.meta.line,
374                name: point
375                    .meta
376                    .label
377                    .clone()
378                    .unwrap_or_else(|| format!("{}:{}", point.meta.line, point.meta.column)),
379                covered: point.covered,
380            });
381        }
382    }
383    for (block, branch) in view.branches.iter().enumerate() {
384        if let Some(file) = files.get_mut(&branch.meta.file) {
385            for (index, alternative) in branch.alternatives.iter().enumerate() {
386                file.branches.push(BranchRecord {
387                    line: branch.meta.line,
388                    block,
389                    index,
390                    taken: alternative.covered,
391                });
392            }
393        }
394    }
395    for decision in &view.decisions {
396        let entry = files
397            .entry(decision.meta.file.clone())
398            .or_insert_with(|| FileView {
399                file: decision.meta.file.clone(),
400                metrics: Vec::new(),
401                measured_lines: Vec::new(),
402                uncovered_lines: Vec::new(),
403                missing_branches: Vec::new(),
404                missing_conditions: Vec::new(),
405                functions: Vec::new(),
406                branches: Vec::new(),
407                conditions: Vec::new(),
408            });
409        for _ in decision.conditions.iter().filter(|c| !c.covered) {
410            entry.missing_conditions.push(Location {
411                line: decision.meta.line,
412                column: decision.meta.column,
413            });
414        }
415        for condition in &decision.conditions {
416            entry.conditions.push(ConditionRecord {
417                line: decision.meta.line,
418                column: decision.meta.column,
419                index: condition.index,
420                covered: condition.covered,
421                source: condition.source.clone(),
422            });
423        }
424    }
425    let mut built = Vec::new();
426    for (path, mut file) in files {
427        file.metrics = metrics_of(&coverage_summary_for_file(view, &path)?);
428        file.measured_lines.sort_unstable();
429        file.measured_lines.dedup();
430        file.uncovered_lines.sort_unstable();
431        file.uncovered_lines.dedup();
432        file.missing_branches.sort_by_key(|at| (at.line, at.column));
433        file.missing_conditions
434            .sort_by_key(|at| (at.line, at.column));
435        file.functions
436            .sort_by(|a, b| (a.line, &a.name).cmp(&(b.line, &b.name)));
437        file.branches
438            .sort_by_key(|record| (record.line, record.block, record.index));
439        file.conditions
440            .sort_by_key(|record| (record.line, record.column, record.index));
441        built.push(file);
442    }
443    let source_neighbourhoods = built
444        .iter()
445        .filter_map(|file| neighbourhood(&file.file))
446        .collect::<BTreeSet<_>>();
447    Ok(RunView {
448        schema_version: RUN_VIEW_SCHEMA_VERSION,
449        run: run.to_owned(),
450        generated_at: generated_at.to_owned(),
451        suite_passed,
452        stale,
453        stale_reasons,
454        complete: view.summary.coverage_complete,
455        limitations: view
456            .limitations
457            .iter()
458            .map(|limitation| limitation.to_string())
459            .collect(),
460        totals: metrics_of(&view.summary),
461        files: built,
462        source_neighbourhoods,
463    })
464}
465
466/// A percentage floor, held in parts per million so a fractional target is
467/// compared exactly rather than through a float.
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
469#[serde(rename_all = "camelCase")]
470pub struct Floor {
471    pub metric: Metric,
472    pub ppm: u64,
473}
474
475/// Parse a percentage into parts per million, rejecting anything that is not a
476/// plain number between 0 and 100.
477pub fn parse_percentage(value: &str) -> Result<u64, String> {
478    let text = value.trim();
479    let (whole, fraction) = match text.split_once('.') {
480        Some((whole, fraction)) => (whole, fraction),
481        None => (text, ""),
482    };
483    if whole.is_empty() && fraction.is_empty() {
484        return Err(format!("{value:?} is not a percentage"));
485    }
486    if !whole.chars().all(|c| c.is_ascii_digit())
487        || !fraction.chars().all(|c| c.is_ascii_digit())
488        || fraction.len() > 4
489    {
490        return Err(format!(
491            "{value:?} is not a percentage between 0 and 100 with at most four decimal places"
492        ));
493    }
494    let whole: u64 = if whole.is_empty() {
495        0
496    } else {
497        whole
498            .parse()
499            .map_err(|_| format!("{value:?} is too large"))?
500    };
501    let scaled: u64 = if fraction.is_empty() {
502        0
503    } else {
504        format!("{fraction:0<4}")
505            .parse()
506            .map_err(|_| format!("{value:?} is not a percentage"))?
507    };
508    let ppm = whole
509        .checked_mul(10_000)
510        .and_then(|whole| whole.checked_add(scaled))
511        .ok_or_else(|| format!("{value:?} is too large"))?;
512    (ppm <= 1_000_000)
513        .then_some(ppm)
514        .ok_or_else(|| format!("{value:?} is above 100"))
515}
516
517#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
518#[serde(rename_all = "camelCase")]
519pub struct Violation {
520    /// Absent for a run-wide floor.
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub file: Option<String>,
523    pub metric: Metric,
524    pub covered: usize,
525    pub eligible: usize,
526    pub floor_ppm: u64,
527    pub uncovered_lines: Vec<usize>,
528}
529
530#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
531#[serde(rename_all = "camelCase", tag = "result")]
532pub enum Outcome {
533    Pass,
534    Fail {
535        violations: Vec<Violation>,
536    },
537    /// The request or the evidence cannot answer the question asked. Never a
538    /// pass, and never reported as a policy failure either.
539    Error {
540        reasons: Vec<String>,
541    },
542}
543
544impl Outcome {
545    pub fn exit_code(&self) -> u8 {
546        match self {
547            Outcome::Pass => 0,
548            Outcome::Fail { .. } => 1,
549            Outcome::Error { .. } => 2,
550        }
551    }
552}
553
554/// Judge a run against percentage floors.
555///
556/// Insufficient evidence is an error, never a pass: an unsupported metric, an
557/// empty scope, a partial measurement, a failed suite or a stale run all leave
558/// the question unanswered, and answering it anyway is how a gate goes green
559/// over a suite that never ran.
560pub fn check(view: &RunView, floors: &[Floor], per_file: bool) -> Outcome {
561    let mut reasons = view
562        .blockers()
563        .into_iter()
564        .map(|blocker| blocker.reason)
565        .collect::<Vec<_>>();
566    if floors.is_empty() {
567        reasons.push("no floor requested; give at least one --min-<metric>".into());
568    }
569    for floor in floors {
570        match view.metric(floor.metric) {
571            None => reasons.push(format!(
572                "{} is not measured by this run's language adapter; remove {}",
573                floor.metric.name(),
574                floor.metric.flag()
575            )),
576            Some(metric) => match &metric.applicability {
577                Applicability::NotApplicable => reasons.push(format!(
578                    "{} has nothing eligible in this run, which is not the same as complete; remove {} or widen the scope",
579                    floor.metric.name(),
580                    floor.metric.flag()
581                )),
582                Applicability::Incomplete { unmeasured } => reasons.push(format!(
583                    "{} left {unmeasured} obligation(s) unmeasured, so {} cannot be judged exactly",
584                    floor.metric.name(),
585                    floor.metric.flag()
586                )),
587                Applicability::Measured => {}
588            },
589        }
590    }
591    if !reasons.is_empty() {
592        return Outcome::Error { reasons };
593    }
594
595    let mut violations = Vec::new();
596    for floor in floors {
597        let Some(metric) = view.metric(floor.metric) else {
598            continue;
599        };
600        if !metric.meets(floor.ppm) {
601            violations.push(Violation {
602                file: None,
603                metric: floor.metric,
604                covered: metric.covered,
605                eligible: metric.eligible,
606                floor_ppm: floor.ppm,
607                uncovered_lines: Vec::new(),
608            });
609        }
610        if !per_file {
611            continue;
612        }
613        for file in &view.files {
614            let Some(counts) = file.metric(floor.metric) else {
615                continue;
616            };
617            // A file with nothing eligible for this metric is not a hundred
618            // percent and not a violation either; it simply has no obligation.
619            if counts.eligible == 0 || counts.meets(floor.ppm) {
620                continue;
621            }
622            violations.push(Violation {
623                file: Some(file.file.clone()),
624                metric: floor.metric,
625                covered: counts.covered,
626                eligible: counts.eligible,
627                floor_ppm: floor.ppm,
628                uncovered_lines: file.uncovered_lines.clone(),
629            });
630        }
631    }
632    if violations.is_empty() {
633        Outcome::Pass
634    } else {
635        Outcome::Fail { violations }
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642
643    fn metric(covered: usize, eligible: usize) -> MetricView {
644        MetricView {
645            metric: Metric::Lines,
646            covered,
647            eligible,
648            applicability: if eligible == 0 {
649                Applicability::NotApplicable
650            } else {
651                Applicability::Measured
652            },
653        }
654    }
655
656    fn view(totals: Vec<MetricView>, files: Vec<FileView>) -> RunView {
657        RunView {
658            schema_version: RUN_VIEW_SCHEMA_VERSION,
659            run: "run_1".into(),
660            generated_at: "now".into(),
661            suite_passed: true,
662            stale: false,
663            stale_reasons: Vec::new(),
664            complete: true,
665            limitations: Vec::new(),
666            totals,
667            files,
668            source_neighbourhoods: BTreeSet::new(),
669        }
670    }
671
672    #[test]
673    fn a_floor_is_compared_against_counts_and_never_a_rounded_percentage() {
674        // 9_999 of 10_000 displays as 100.00%. A gate that reads the display
675        // passes a run with an uncovered line, which is the whole reason this
676        // compares the counts instead.
677        let almost = metric(9_999, 10_000);
678        assert_eq!(format!("{:.2}", almost.percentage().unwrap()), "99.99");
679        assert!(!almost.meets(1_000_000));
680        assert!(almost.meets(999_000));
681        assert!(metric(10_000, 10_000).meets(1_000_000));
682
683        // And a fractional floor is exact in both directions.
684        assert!(metric(995, 1_000).meets(parse_percentage("99.5").unwrap()));
685        assert!(!metric(994, 1_000).meets(parse_percentage("99.5").unwrap()));
686    }
687
688    #[test]
689    fn nothing_eligible_is_not_complete_coverage() {
690        // Zero of zero satisfies every floor arithmetically, so applicability
691        // has to be decided before the comparison, not by it.
692        let empty = metric(0, 0);
693        assert!(empty.meets(1_000_000));
694        assert_eq!(empty.applicability, Applicability::NotApplicable);
695        let outcome = check(
696            &view(vec![empty], Vec::new()),
697            &[Floor {
698                metric: Metric::Lines,
699                ppm: 1_000_000,
700            }],
701            false,
702        );
703        assert!(matches!(outcome, Outcome::Error { .. }), "{outcome:?}");
704        assert_eq!(outcome.exit_code(), 2);
705    }
706
707    #[test]
708    fn evidence_that_cannot_answer_the_question_never_passes() {
709        let floors = [Floor {
710            metric: Metric::Lines,
711            ppm: 500_000,
712        }];
713        // Fully covered, but the suite failed: a gate over a failed suite must
714        // not report success however good the numbers look.
715        let mut failed = view(vec![metric(10, 10)], Vec::new());
716        failed.suite_passed = false;
717        assert_eq!(check(&failed, &floors, false).exit_code(), 2);
718
719        // Fully covered, but the run no longer matches the checkout.
720        let mut stale = view(vec![metric(10, 10)], Vec::new());
721        stale.stale = true;
722        stale.stale_reasons = vec!["instrumented source changed".into()];
723        let outcome = check(&stale, &floors, false);
724        assert_eq!(outcome.exit_code(), 2);
725        let Outcome::Error { reasons } = outcome else {
726            panic!("expected an error");
727        };
728        assert!(
729            reasons[0].contains("instrumented source changed"),
730            "{reasons:?}"
731        );
732
733        // A partial measurement cannot be judged exactly either.
734        let mut partial = view(vec![metric(10, 10)], Vec::new());
735        partial.totals[0].applicability = Applicability::Incomplete { unmeasured: 3 };
736        assert_eq!(check(&partial, &floors, false).exit_code(), 2);
737
738        // A metric this adapter never records is a request error, not a pass.
739        assert_eq!(
740            check(
741                &view(vec![metric(10, 10)], Vec::new()),
742                &[Floor {
743                    metric: Metric::Mcdc,
744                    ppm: 500_000
745                }],
746                false
747            )
748            .exit_code(),
749            2
750        );
751    }
752
753    #[test]
754    fn every_violation_is_reported_with_the_counts_behind_it() {
755        // One message per failing rule, each carrying its own numerator and
756        // denominator, so a reader can act without rerunning anything.
757        let files = vec![
758            FileView {
759                file: "src/a.ts".into(),
760                metrics: vec![MetricView {
761                    metric: Metric::Lines,
762                    covered: 1,
763                    eligible: 4,
764                    applicability: Applicability::Measured,
765                }],
766                measured_lines: vec![1, 2, 3, 4],
767                uncovered_lines: vec![2, 3, 4],
768                missing_branches: Vec::new(),
769                missing_conditions: Vec::new(),
770                functions: Vec::new(),
771                branches: Vec::new(),
772                conditions: Vec::new(),
773            },
774            FileView {
775                file: "src/b.ts".into(),
776                metrics: vec![MetricView {
777                    metric: Metric::Lines,
778                    covered: 6,
779                    eligible: 6,
780                    applicability: Applicability::Measured,
781                }],
782                measured_lines: vec![1, 2, 3, 4, 5, 6],
783                uncovered_lines: Vec::new(),
784                missing_branches: Vec::new(),
785                missing_conditions: Vec::new(),
786                functions: Vec::new(),
787                branches: Vec::new(),
788                conditions: Vec::new(),
789            },
790        ];
791        let outcome = check(
792            &view(vec![metric(7, 10)], files),
793            &[Floor {
794                metric: Metric::Lines,
795                ppm: 900_000,
796            }],
797            true,
798        );
799        let Outcome::Fail { violations } = outcome else {
800            panic!("expected a policy failure");
801        };
802        assert_eq!(violations.len(), 2, "{violations:?}");
803        assert_eq!(violations[0].file, None);
804        assert_eq!((violations[0].covered, violations[0].eligible), (7, 10));
805        assert_eq!(violations[1].file.as_deref(), Some("src/a.ts"));
806        assert_eq!(violations[1].uncovered_lines, [2, 3, 4]);
807    }
808
809    #[test]
810    fn a_percentage_is_read_exactly_or_refused() {
811        assert_eq!(parse_percentage("90").unwrap(), 900_000);
812        assert_eq!(parse_percentage("99.5").unwrap(), 995_000);
813        assert_eq!(parse_percentage("100").unwrap(), 1_000_000);
814        assert_eq!(parse_percentage("0").unwrap(), 0);
815        assert_eq!(parse_percentage(" 87.6543 ").unwrap(), 876_543);
816        for refused in ["101", "-1", "abc", "", "1e2", "50.123456", "100.0001"] {
817            assert!(parse_percentage(refused).is_err(), "{refused} was accepted");
818        }
819    }
820}