Skip to main content

backhopper_core/jigsaw/
evaluate.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 crate::jigsaw::anchor::{Anchored, anchor};
8use crate::jigsaw::check::{check_host, check_otp};
9use crate::jigsaw::findings::{Checked, Finding, Stop, Warning, Withheld, WithheldSymbol};
10use crate::jigsaw::loaded::LoadedPlan;
11use crate::jigsaw::plugin_set::PluginSet;
12use crate::jigsaw::references::{ReferenceSet, ReferenceSite, UncheckedSite};
13use crate::jigsaw::report::{
14    FindingSpan, HostIdx, JigsawReport, OtpIdx, RangedFinding, RangedWarning, RangedWithheld,
15    ReportParts, Tallies, TargetAxes,
16};
17use crate::jigsaw::surface::{AppPresence, Blocker, ReleaseSurface};
18use crate::model::names::ApplicationName;
19use crate::model::non_empty::NonEmpty;
20
21#[derive(Debug, Clone, Copy)]
22enum Target {
23    Host(HostIdx),
24    Otp(OtpIdx),
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
28enum Axis {
29    Host,
30    Otp,
31    Everywhere,
32}
33
34impl Target {
35    fn axis(self) -> Axis {
36        match self {
37            Self::Host(_) => Axis::Host,
38            Self::Otp(_) => Axis::Otp,
39        }
40    }
41}
42
43#[derive(Default)]
44struct Hit {
45    hosts: BTreeSet<HostIdx>,
46    otp: BTreeSet<OtpIdx>,
47    sites: BTreeSet<ReferenceSite>,
48}
49
50impl Hit {
51    fn add(&mut self, target: Target, sites: &BTreeSet<ReferenceSite>) {
52        match target {
53            Target::Host(host) => self.hosts.insert(host),
54            Target::Otp(otp) => self.otp.insert(otp),
55        };
56        self.sites.extend(sites.iter().cloned());
57    }
58
59    fn span(self, axis: Axis) -> (FindingSpan, BTreeSet<ReferenceSite>) {
60        let span = match axis {
61            Axis::Host => FindingSpan::Host(self.hosts),
62            Axis::Otp => FindingSpan::Otp(self.otp),
63            Axis::Everywhere => FindingSpan::Everywhere,
64        };
65        (span, self.sites)
66    }
67}
68
69/// The results of the per-surface passes, before they become a report.
70#[derive(Default)]
71struct Accumulator {
72    findings: BTreeMap<(Axis, Finding), Hit>,
73    warnings: BTreeMap<Warning, BTreeSet<HostIdx>>,
74    withheld: BTreeMap<(Axis, WithheldSymbol), Hit>,
75    stopped: BTreeMap<Stop, BTreeSet<(Axis, usize)>>,
76}
77
78impl Accumulator {
79    fn record(
80        &mut self,
81        target: Target,
82        anchor_index: usize,
83        sites: &BTreeSet<ReferenceSite>,
84        checked: Checked,
85    ) {
86        for finding in checked.findings {
87            self.findings
88                .entry((target.axis(), finding))
89                .or_default()
90                .add(target, sites);
91        }
92        if let Target::Host(host) = target {
93            for warning in checked.warnings {
94                self.warnings.entry(warning).or_default().insert(host);
95            }
96        }
97        for withheld in checked.withheld {
98            match withheld {
99                Withheld::Symbol(symbol) => self
100                    .withheld
101                    .entry((target.axis(), symbol))
102                    .or_default()
103                    .add(target, sites),
104                Withheld::Stopped(stop) => {
105                    self.stopped
106                        .entry(stop)
107                        .or_default()
108                        .insert((target.axis(), anchor_index));
109                }
110            }
111        }
112    }
113}
114
115/// Runs the plugin set against every target of a loaded plan.
116///
117/// The surfaces are borrowed views built here from the loaded snapshots.
118/// `collect` reads the plugin set against the build patch's surface and
119/// returns its references, so a reference set cannot come from a different
120/// build patch. Host anchors are checked once per host release and OTP
121/// anchors once per OTP tag: `N + M` passes give the `N x M` cells.
122pub fn evaluate<E>(
123    loaded: &LoadedPlan,
124    plugins: &PluginSet,
125    collect: impl FnOnce(&PluginSet, &ReleaseSurface<'_>) -> Result<ReferenceSet, E>,
126) -> Result<JigsawReport, E> {
127    let surfaces = loaded.surfaces();
128    let references = collect(plugins, surfaces.build())?;
129    let anchored = anchor(plugins, &references, surfaces.build(), surfaces.otp());
130    let axes = TargetAxes::of(loaded.plan());
131    let mut acc = Accumulator::default();
132
133    for (host, release) in axes.host_indices().zip(surfaces.releases()) {
134        for (index, anchor) in anchored.host.iter().enumerate() {
135            let checked = check_host(&anchor.kind, release);
136            acc.record(Target::Host(host), index, &anchor.sites, checked);
137        }
138        for (app, blocker) in release.shipped().blockers() {
139            let warning = match blocker {
140                Blocker::Manifest(reason) => Warning::ManifestUnreadable {
141                    app: app.clone(),
142                    reason: reason.clone(),
143                },
144                Blocker::Untracked => Warning::AppNotTracked { app: app.clone() },
145            };
146            acc.warnings.entry(warning).or_default().insert(host);
147        }
148        for app in plugins.apps().keys() {
149            if release.shipped().lookup(app) == AppPresence::Provided {
150                acc.warnings
151                    .entry(Warning::BundledAppShippedByHost { app: app.clone() })
152                    .or_default()
153                    .insert(host);
154            }
155        }
156    }
157    for (otp, surface) in axes.otp_indices().zip(surfaces.otp()) {
158        for (index, anchor) in anchored.otp.iter().enumerate() {
159            let checked = check_otp(&anchor.kind, surface);
160            acc.record(Target::Otp(otp), index, &anchor.sites, checked);
161        }
162    }
163    for unprovided in &anchored.unprovided {
164        let finding = Finding::AppMissing {
165            app: unprovided.kind.app.clone(),
166            required_by: unprovided.kind.required_by.clone(),
167        };
168        acc.findings
169            .entry((Axis::Everywhere, finding))
170            .or_default()
171            .sites
172            .extend(unprovided.sites.iter().cloned());
173    }
174
175    Ok(assemble(loaded, plugins, &references, &anchored, axes, acc))
176}
177
178fn assemble(
179    loaded: &LoadedPlan,
180    plugins: &PluginSet,
181    references: &ReferenceSet,
182    anchored: &Anchored,
183    targets: TargetAxes,
184    acc: Accumulator,
185) -> JigsawReport {
186    let findings = acc
187        .findings
188        .into_iter()
189        .filter_map(|((axis, finding), hit)| {
190            let (span, sites) = hit.span(axis);
191            let apps: BTreeSet<ApplicationName> =
192                sites.iter().map(|site| site.file.app.clone()).collect();
193            Some(RangedFinding {
194                finding,
195                apps: NonEmpty::try_from(apps.into_iter().collect::<Vec<_>>()).ok()?,
196                sites: NonEmpty::try_from(sites.into_iter().collect::<Vec<_>>()).ok()?,
197                span,
198            })
199        })
200        .collect();
201    let warnings = acc
202        .warnings
203        .into_iter()
204        .map(|(warning, hosts)| RangedWarning {
205            warning,
206            span: FindingSpan::Host(hosts),
207        })
208        .collect();
209    let not_checked = references
210        .not_checked()
211        .iter()
212        .chain(&anchored.not_checked)
213        .map(|(&reason, &n)| (reason, n));
214    let unchecked: Vec<UncheckedSite> = references
215        .unchecked()
216        .iter()
217        .chain(&anchored.unchecked)
218        .cloned()
219        .collect::<BTreeSet<_>>()
220        .into_iter()
221        .collect();
222    let stopped = acc
223        .stopped
224        .into_iter()
225        .map(|(stop, anchors)| (stop, anchors.len()));
226    let withheld: Vec<RangedWithheld> = acc
227        .withheld
228        .into_iter()
229        .filter_map(|((axis, symbol), hit)| {
230            let (span, sites) = hit.span(axis);
231            Some(RangedWithheld {
232                symbol,
233                sites: NonEmpty::try_from(sites.into_iter().collect::<Vec<_>>()).ok()?,
234                span,
235            })
236        })
237        .collect();
238    let plan = loaded.plan();
239    JigsawReport::from_parts(ReportParts {
240        host: plan.host().clone(),
241        series: plan.series(),
242        tag_source: plan.tag_source(),
243        targets,
244        apps: plugins
245            .apps()
246            .iter()
247            .map(|(name, app)| (name.clone(), app.role()))
248            .collect(),
249        findings,
250        warnings,
251        tallies: Tallies::new(
252            anchored.checked(),
253            not_checked,
254            &unchecked,
255            stopped,
256            &withheld,
257        ),
258        withheld,
259        unchecked,
260        host_provided: plugins.host_provided().clone(),
261        store_digest: loaded.store_digest().clone(),
262    })
263}