Skip to main content

backhopper_core/jigsaw/
report.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5use std::collections::{BTreeMap, BTreeSet};
6
7use serde::{Deserialize, Serialize};
8
9use crate::jigsaw::findings::{Finding, Stop, Warning, WithheldReason, WithheldSymbol};
10use crate::jigsaw::plan::{JigsawPlan, TagSourceKind};
11use crate::jigsaw::plugin_set::AppRole;
12use crate::jigsaw::references::{NotChecked, ReferenceSite, UncheckedSite};
13use crate::model::names::{ApplicationName, HostName, StoreDigest, vocabulary};
14use crate::model::non_empty::NonEmpty;
15use crate::model::release_version::{HostSeries, HostTag, OtpTag};
16
17vocabulary!(
18    /// The state of one app at one target.
19    #[derive(PartialOrd, Ord, Hash)]
20    pub enum JigsawCell: "cell state" {
21        Clean => "clean",
22        Broken => "broken",
23    }
24);
25
26mod private {
27    pub trait Sealed {}
28}
29
30/// An index into one axis of `TargetAxes`: the only two are `HostIdx`
31/// and `OtpIdx`.
32pub trait AxisIndex: private::Sealed + Copy + Ord {
33    fn position(self) -> usize;
34    fn adjacent_to(self, earlier: Self) -> bool {
35        self.position() == earlier.position() + 1
36    }
37}
38
39/// A host release, by position in the plan's ordered releases.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
41pub struct HostIdx(usize);
42
43/// An OTP tag, by position in the plan's ordered OTP tags.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
45pub struct OtpIdx(usize);
46
47impl private::Sealed for HostIdx {}
48impl private::Sealed for OtpIdx {}
49
50impl AxisIndex for HostIdx {
51    fn position(self) -> usize {
52        self.0
53    }
54}
55
56impl AxisIndex for OtpIdx {
57    fn position(self) -> usize {
58        self.0
59    }
60}
61
62/// A maximal stretch of adjacent members, both ends included.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct Run<I> {
65    pub first: I,
66    pub last: I,
67}
68
69/// The two ordered axes of a run, and the only source of `HostIdx` and
70/// `OtpIdx`.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct TargetAxes {
73    hosts: Vec<HostTag>,
74    otp: Vec<OtpTag>,
75    build: HostIdx,
76}
77
78impl TargetAxes {
79    #[must_use]
80    pub(crate) fn of(plan: &JigsawPlan) -> Self {
81        let hosts: Vec<HostTag> = plan
82            .releases()
83            .iter()
84            .map(|release| release.tag.clone())
85            .collect();
86        let otp = plan
87            .otp_lines()
88            .iter()
89            .flat_map(|line| line.tags().iter().cloned())
90            .collect();
91        let build = HostIdx(hosts.len() - 1);
92        Self { hosts, otp, build }
93    }
94
95    #[must_use]
96    pub fn hosts(&self) -> &[HostTag] {
97        &self.hosts
98    }
99
100    #[must_use]
101    pub fn otp_tags(&self) -> &[OtpTag] {
102        &self.otp
103    }
104
105    #[must_use]
106    pub fn build(&self) -> HostIdx {
107        self.build
108    }
109
110    #[must_use]
111    pub fn host_tag(&self, index: HostIdx) -> &HostTag {
112        &self.hosts[index.0]
113    }
114
115    #[must_use]
116    pub fn otp_tag(&self, index: OtpIdx) -> &OtpTag {
117        &self.otp[index.0]
118    }
119
120    pub fn host_indices(&self) -> impl Iterator<Item = HostIdx> + use<> {
121        (0..self.hosts.len()).map(HostIdx)
122    }
123
124    pub fn otp_indices(&self) -> impl Iterator<Item = OtpIdx> + use<> {
125        (0..self.otp.len()).map(OtpIdx)
126    }
127
128    /// The runs of adjacent members of `members`, ascending. Runs are
129    /// sorted, disjoint and never adjacent to each other, because they are
130    /// derived from a set.
131    #[must_use]
132    pub fn runs<I: AxisIndex>(members: &BTreeSet<I>) -> Vec<Run<I>> {
133        let mut runs: Vec<Run<I>> = Vec::new();
134        for &index in members {
135            match runs.last_mut() {
136                Some(run) if index.adjacent_to(run.last) => run.last = index,
137                _ => runs.push(Run {
138                    first: index,
139                    last: index,
140                }),
141            }
142        }
143        runs
144    }
145}
146
147/// Which targets a finding or warning covers. The axis is part of the
148/// type: a host finding covers every OTP tag, an OTP finding covers every
149/// host release, and an app nothing provides covers everything. A finding
150/// on some of both axes cannot be written down.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum FindingSpan {
153    Host(BTreeSet<HostIdx>),
154    Otp(BTreeSet<OtpIdx>),
155    Everywhere,
156}
157
158impl FindingSpan {
159    #[must_use]
160    pub fn covers(&self, host: HostIdx, otp: OtpIdx) -> bool {
161        match self {
162            Self::Host(releases) => releases.contains(&host),
163            Self::Otp(tags) => tags.contains(&otp),
164            Self::Everywhere => true,
165        }
166    }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct RangedFinding {
171    pub finding: Finding,
172    pub apps: NonEmpty<ApplicationName>,
173    pub sites: NonEmpty<ReferenceSite>,
174    pub span: FindingSpan,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct RangedWithheld {
179    pub symbol: WithheldSymbol,
180    pub sites: NonEmpty<ReferenceSite>,
181    pub span: FindingSpan,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct RangedWarning {
186    pub warning: Warning,
187    pub span: FindingSpan,
188}
189
190/// What the run covered: every distinct reference compared at every
191/// target, and what it did not compare, by reason. Every label of both
192/// vocabularies is present, with zero where nothing was counted. The
193/// count of a listed reason is the number of unchecked or withheld sites
194/// with it. The other reasons count anchors.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct Tallies {
197    checked: usize,
198    not_checked: BTreeMap<NotChecked, usize>,
199    withheld: BTreeMap<WithheldReason, usize>,
200}
201
202impl Tallies {
203    #[must_use]
204    pub(crate) fn new(
205        checked: usize,
206        not_checked: impl IntoIterator<Item = (NotChecked, usize)>,
207        unchecked: &[UncheckedSite],
208        stopped: impl IntoIterator<Item = (Stop, usize)>,
209        withheld: &[RangedWithheld],
210    ) -> Self {
211        let mut tallies = Self {
212            checked,
213            not_checked: NotChecked::ALL.iter().map(|&r| (r, 0)).collect(),
214            withheld: WithheldReason::ALL.iter().map(|&r| (r, 0)).collect(),
215        };
216        for (reason, n) in not_checked {
217            *tallies.not_checked.entry(reason).or_default() += n;
218        }
219        for site in unchecked {
220            *tallies.not_checked.entry(site.reason()).or_default() += 1;
221        }
222        for (stop, n) in stopped {
223            *tallies.withheld.entry(stop.reason()).or_default() += n;
224        }
225        for ranged in withheld {
226            *tallies.withheld.entry(ranged.symbol.reason()).or_default() +=
227                ranged.sites.as_slice().len();
228        }
229        tallies
230    }
231
232    #[must_use]
233    pub fn checked(&self) -> usize {
234        self.checked
235    }
236
237    #[must_use]
238    pub fn not_checked(&self) -> &BTreeMap<NotChecked, usize> {
239        &self.not_checked
240    }
241
242    #[must_use]
243    pub fn withheld(&self) -> &BTreeMap<WithheldReason, usize> {
244        &self.withheld
245    }
246}
247
248pub(crate) struct ReportParts {
249    pub host: HostName,
250    pub series: HostSeries,
251    pub tag_source: TagSourceKind,
252    pub targets: TargetAxes,
253    pub apps: BTreeMap<ApplicationName, AppRole>,
254    pub findings: Vec<RangedFinding>,
255    pub warnings: Vec<RangedWarning>,
256    pub withheld: Vec<RangedWithheld>,
257    pub tallies: Tallies,
258    pub unchecked: Vec<UncheckedSite>,
259    pub host_provided: BTreeSet<ApplicationName>,
260    pub store_digest: StoreDigest,
261}
262
263/// The answer of a run. The matrix is not stored: `cell` and `is_clean`
264/// read the findings, so there is no second copy to disagree with them.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct JigsawReport {
267    host: HostName,
268    series: HostSeries,
269    tag_source: TagSourceKind,
270    targets: TargetAxes,
271    apps: BTreeMap<ApplicationName, AppRole>,
272    findings: Vec<RangedFinding>,
273    warnings: Vec<RangedWarning>,
274    withheld: Vec<RangedWithheld>,
275    tallies: Tallies,
276    unchecked: Vec<UncheckedSite>,
277    host_provided: BTreeSet<ApplicationName>,
278    store_digest: StoreDigest,
279}
280
281impl JigsawReport {
282    pub(crate) fn from_parts(parts: ReportParts) -> Self {
283        Self {
284            host: parts.host,
285            series: parts.series,
286            tag_source: parts.tag_source,
287            targets: parts.targets,
288            apps: parts.apps,
289            findings: parts.findings,
290            warnings: parts.warnings,
291            withheld: parts.withheld,
292            tallies: parts.tallies,
293            unchecked: parts.unchecked,
294            host_provided: parts.host_provided,
295            store_digest: parts.store_digest,
296        }
297    }
298
299    #[must_use]
300    pub fn host(&self) -> &HostName {
301        &self.host
302    }
303
304    #[must_use]
305    pub fn series(&self) -> HostSeries {
306        self.series
307    }
308
309    #[must_use]
310    pub fn tag_source(&self) -> TagSourceKind {
311        self.tag_source
312    }
313
314    #[must_use]
315    pub fn targets(&self) -> &TargetAxes {
316        &self.targets
317    }
318
319    #[must_use]
320    pub fn build_release(&self) -> &HostTag {
321        self.targets.host_tag(self.targets.build())
322    }
323
324    #[must_use]
325    pub fn apps(&self) -> &BTreeMap<ApplicationName, AppRole> {
326        &self.apps
327    }
328
329    #[must_use]
330    pub fn findings(&self) -> &[RangedFinding] {
331        &self.findings
332    }
333
334    #[must_use]
335    pub fn warnings(&self) -> &[RangedWarning] {
336        &self.warnings
337    }
338
339    /// The comparisons that were not made, ordered by axis and symbol.
340    #[must_use]
341    pub fn withheld(&self) -> &[RangedWithheld] {
342        &self.withheld
343    }
344
345    #[must_use]
346    pub fn tallies(&self) -> &Tallies {
347        &self.tallies
348    }
349
350    /// The places behind the listed reasons of the tallies, ordered by
351    /// reason, file, line and symbol.
352    #[must_use]
353    pub fn unchecked(&self) -> &[UncheckedSite] {
354        &self.unchecked
355    }
356
357    #[must_use]
358    pub fn host_provided(&self) -> &BTreeSet<ApplicationName> {
359        &self.host_provided
360    }
361
362    #[must_use]
363    pub fn store_digest(&self) -> &StoreDigest {
364        &self.store_digest
365    }
366
367    /// An app is broken at a target when a finding that names it covers
368    /// the target.
369    #[must_use]
370    pub fn cell(&self, app: &ApplicationName, host: HostIdx, otp: OtpIdx) -> JigsawCell {
371        let broken = self.findings.iter().any(|ranged| {
372            ranged.apps.iter().any(|named| named == app) && ranged.span.covers(host, otp)
373        });
374        if broken {
375            JigsawCell::Broken
376        } else {
377            JigsawCell::Clean
378        }
379    }
380
381    /// Warnings never count.
382    #[must_use]
383    pub fn is_clean(&self) -> bool {
384        self.findings.is_empty()
385    }
386}