backhopper_core/model/findings.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
5//! Row-level symbol-axis findings resolved against the target tree.
6//! The same reasons also merge into applicable pin verdicts; this
7//! record is the copy that survives inapplicable ones, so a suite-only
8//! pick cannot silently drop what the target checks found.
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14use crate::model::resolver_coverage::ResolverClass;
15use crate::model::verdict::Reason;
16
17/// Symbol-axis findings for one row, resolved against the target tree.
18/// Present exactly when a target context was supplied; independent of
19/// every pin verdict, so an inapplicable row still carries its
20/// findings.
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
23pub struct TargetFindings {
24 /// Every reason the live-axis target checks produced for this row,
25 /// in production order.
26 pub reasons: Vec<Reason>,
27}
28
29impl TargetFindings {
30 #[must_use]
31 pub fn is_empty(&self) -> bool {
32 self.reasons.is_empty()
33 }
34
35 /// Finding counts by resolver class, for ranking.
36 #[must_use]
37 pub fn by_class(&self) -> Vec<(ResolverClass, usize)> {
38 let mut counts: BTreeMap<ResolverClass, usize> = BTreeMap::new();
39 for reason in &self.reasons {
40 if let Some(class) = reason.resolver_class() {
41 *counts.entry(class).or_insert(0) += 1;
42 }
43 }
44 counts.into_iter().collect()
45 }
46
47 /// Findings with no resolver class, so the class view never hides
48 /// a reason.
49 #[must_use]
50 pub fn unclassified(&self) -> usize {
51 self.reasons
52 .iter()
53 .filter(|r| r.resolver_class().is_none())
54 .count()
55 }
56
57 #[must_use]
58 pub fn blocking(&self) -> bool {
59 self.reasons.iter().any(Reason::is_blocking)
60 }
61}