1use std::collections::{BTreeMap, BTreeSet};
16
17use crate::model::apply::PathApplyOutcome;
18use crate::model::batch::BatchResult;
19use crate::model::names::{ProjectName, RelativePath};
20use crate::model::resolver_coverage::ResolverClass;
21use crate::model::verdict::{BumpStatus, SeriesSummary, Verdict, exit, non_self_tracked};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum RoundClearance {
25 Clean(ClearanceFacts),
27 ZeroDomain(ClearanceFacts),
30 Findings(ClearanceFacts),
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ClearanceFacts {
38 pub candidates: usize,
40 pub series: usize,
42 pub rows: usize,
44 pub tracked: u32,
46 pub verdicts: SeriesSummary,
49 pub reasons: ReasonHistogram,
50 pub bumps: BumpSummary,
51 pub apply: ApplyRollup,
54 pub target: TargetFindingsRollup,
57 pub suites: Vec<String>,
59 pub exit_code: i32,
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct ReasonHistogram(BTreeMap<&'static str, u32>);
69
70impl ReasonHistogram {
71 fn record(&mut self, label: &'static str) {
72 *self.0.entry(label).or_insert(0) += 1;
73 }
74
75 #[must_use]
76 pub fn is_empty(&self) -> bool {
77 self.0.is_empty()
78 }
79
80 #[must_use]
81 pub fn total(&self) -> u32 {
82 self.0.values().copied().sum()
83 }
84
85 #[must_use]
86 pub fn count(&self, label: &str) -> u32 {
87 self.0.get(label).copied().unwrap_or(0)
88 }
89
90 #[must_use]
93 pub fn ranked(&self) -> Vec<(&'static str, u32)> {
94 let mut ranked: Vec<(&'static str, u32)> = self.0.iter().map(|(k, n)| (*k, *n)).collect();
95 ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
96 ranked
97 }
98}
99
100#[derive(Debug, Clone, Default, PartialEq, Eq)]
104pub struct BumpSummary {
105 pub total: u32,
106 pub snapshot_present: u32,
107 pub snapshot_missing: u32,
110 pub untracked: u32,
111 pub unassessed: u32,
113 pub introduced: u32,
115}
116
117impl BumpSummary {
118 #[must_use]
119 pub fn is_empty(&self) -> bool {
120 self.total == 0
121 }
122}
123
124#[derive(Debug, Clone, Default, PartialEq, Eq)]
126pub struct ApplyRollup {
127 pub rows_with_forecast: usize,
129 pub conflicted_rows: usize,
131 pub conflicted_paths: Vec<RelativePath>,
133 pub by_kind: BTreeMap<&'static str, u32>,
135 pub unassessed_paths: u32,
137}
138
139impl ApplyRollup {
140 #[must_use]
141 pub fn has_conflict(&self) -> bool {
142 self.conflicted_rows > 0
143 }
144}
145
146#[derive(Debug, Clone, Default, PartialEq, Eq)]
148pub struct TargetFindingsRollup {
149 pub rows_with_findings: usize,
151 pub finding_rows: usize,
153 pub by_class: Vec<(ResolverClass, usize)>,
155 pub unclassified: usize,
158}
159
160impl TargetFindingsRollup {
161 #[must_use]
162 pub fn has_findings(&self) -> bool {
163 self.finding_rows > 0
164 }
165}
166
167impl RoundClearance {
168 #[must_use]
169 pub fn from_results(results: &[BatchResult], self_projects: &BTreeSet<ProjectName>) -> Self {
170 let mut commits = BTreeSet::new();
171 let mut series = BTreeSet::new();
172 let mut tracked: u32 = 0;
173 let mut verdicts = SeriesSummary::default();
174 let mut reasons = ReasonHistogram::default();
175 let mut suites = BTreeSet::new();
176 let mut bumps = BumpSummary::default();
177 let mut seen_bumps = BTreeSet::new();
178 let mut snapshot_missing_bump = false;
179 let mut apply = ApplyRollup::default();
180 let mut conflicted_paths = BTreeSet::new();
181 let mut target = TargetFindingsRollup::default();
182 let mut target_by_class: BTreeMap<ResolverClass, usize> = BTreeMap::new();
183
184 for row in results {
185 commits.insert(&row.commit);
186 series.insert(&row.series);
187
188 verdicts.compatible += row.verdict.summary.compatible;
189 verdicts.requires_adaptation += row.verdict.summary.requires_adaptation;
190 verdicts.incompatible += row.verdict.summary.incompatible;
191 verdicts.inapplicable += row.verdict.summary.inapplicable;
192
193 tracked = tracked.saturating_add(non_self_tracked(&row.verdict.results, self_projects));
194 for pin in &row.verdict.results {
195 if let Verdict::Inapplicable { reason } = &pin.verdict {
196 reasons.record(reason.as_str());
197 }
198 }
199
200 for suite in &row.diagnostics.suggested_suites {
201 suites.insert(suite.clone());
202 }
203
204 if let Some(forecast) = &row.apply {
205 apply.rows_with_forecast += 1;
206 if forecast.has_conflict() {
207 apply.conflicted_rows += 1;
208 }
209 for (path, outcome) in &forecast.paths {
210 match outcome {
211 PathApplyOutcome::Conflict { kind } => {
212 conflicted_paths.insert(path.clone());
213 *apply.by_kind.entry(kind.as_str()).or_insert(0) += 1;
214 }
215 PathApplyOutcome::Unassessed { .. } => apply.unassessed_paths += 1,
216 PathApplyOutcome::CleanExact | PathApplyOutcome::CleanDrifted { .. } => {}
217 }
218 }
219 }
220
221 if let Some(findings) = &row.target_findings {
222 target.rows_with_findings += 1;
223 if !findings.is_empty() {
224 target.finding_rows += 1;
225 }
226 for (class, n) in findings.by_class() {
227 *target_by_class.entry(class).or_insert(0) += n;
228 }
229 target.unclassified += findings.unclassified();
230 }
231
232 for bump in &row.diagnostics.pin_bumps {
233 if !seen_bumps.insert((&row.commit, &bump.dep, bump.to.as_str())) {
234 continue;
235 }
236 bumps.total += 1;
237 if bump.from.is_none() {
238 bumps.introduced += 1;
239 }
240 match &bump.status {
241 Some(BumpStatus::SnapshotPresent) => bumps.snapshot_present += 1,
242 Some(BumpStatus::SnapshotMissing { .. }) => {
243 bumps.snapshot_missing += 1;
244 snapshot_missing_bump = true;
245 }
246 Some(BumpStatus::Untracked) => bumps.untracked += 1,
247 None => bumps.unassessed += 1,
248 }
249 }
250 }
251
252 apply.conflicted_paths = conflicted_paths.into_iter().collect();
253 target.by_class = target_by_class.into_iter().collect();
254
255 let blocking = verdicts.is_blocking();
256 let exit_code = if blocking || apply.has_conflict() || target.has_findings() {
258 exit::NEEDS_ATTENTION
259 } else {
260 exit::OK
261 };
262
263 let facts = ClearanceFacts {
264 candidates: commits.len(),
265 series: series.len(),
266 rows: results.len(),
267 tracked,
268 verdicts,
269 reasons,
270 bumps,
271 apply,
272 target,
273 suites: suites.into_iter().collect(),
274 exit_code,
275 };
276
277 let all_inapplicable = facts.verdicts.compatible == 0
278 && facts.verdicts.requires_adaptation == 0
279 && facts.verdicts.incompatible == 0
280 && facts.verdicts.inapplicable > 0;
281
282 if blocking
283 || tracked > 0
284 || snapshot_missing_bump
285 || facts.apply.has_conflict()
286 || facts.target.has_findings()
287 {
288 Self::Findings(facts)
289 } else if all_inapplicable {
290 Self::ZeroDomain(facts)
291 } else {
292 Self::Clean(facts)
293 }
294 }
295
296 #[must_use]
297 pub fn facts(&self) -> &ClearanceFacts {
298 match self {
299 Self::Clean(f) | Self::ZeroDomain(f) | Self::Findings(f) => f,
300 }
301 }
302
303 #[must_use]
304 pub fn is_clean(&self) -> bool {
305 matches!(self, Self::Clean(_) | Self::ZeroDomain(_))
306 }
307}