1use std::collections::{BTreeMap, BTreeSet};
6use std::mem;
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use crate::model::apply::ApplyForecast;
12use crate::model::findings::TargetFindings;
13use crate::model::names::{
14 Arity, CommitSha, DependencyName, FunctionName, ModuleName, ProjectName, RecordName,
15 RelativePath, TagName,
16};
17use crate::model::pin::Pin;
18use crate::model::pr_commit::PrCommit;
19use crate::model::symbol::{SymbolKind, SymbolRef};
20
21mod diagnostics;
22mod reason;
23mod touched;
24
25pub use diagnostics::*;
26pub use reason::*;
27pub use touched::*;
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
31pub struct PinVerdict {
32 pub pin: Pin,
33 pub verdict: Verdict,
34 #[serde(default)]
35 pub tracked_refs: usize,
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub tracked_ref_details: Vec<SymbolRef>,
38 #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 pub source_delta_details: Vec<SourceDelta>,
40 #[serde(default, skip_serializing_if = "TouchedKinds::is_empty")]
41 pub touched: TouchedKinds,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
46pub struct SourceDelta {
47 pub module: ModuleName,
48 pub function: FunctionName,
49 pub arity: Arity,
50 pub source_spec: String,
51 pub target_spec: String,
52}
53
54impl PinVerdict {
55 pub fn new(pin: Pin, verdict: Verdict) -> Self {
56 Self {
57 pin,
58 verdict,
59 tracked_refs: 0,
60 tracked_ref_details: Vec::new(),
61 source_delta_details: Vec::new(),
62 touched: TouchedKinds::default(),
63 }
64 }
65
66 #[must_use]
67 pub fn with_tracked_refs(mut self, n: usize) -> Self {
68 self.tracked_refs = n;
69 self
70 }
71
72 #[must_use]
73 pub fn with_tracked_ref_details(mut self, details: Vec<SymbolRef>) -> Self {
74 self.tracked_refs = details.len();
75 self.tracked_ref_details = details;
76 self
77 }
78
79 #[must_use]
80 pub fn with_source_delta_details(mut self, deltas: Vec<SourceDelta>) -> Self {
81 self.source_delta_details = deltas;
82 self
83 }
84
85 #[must_use]
86 pub fn with_touched(mut self, touched: TouchedKinds) -> Self {
87 self.touched = touched;
88 self
89 }
90}
91
92#[must_use]
94pub fn non_self_tracked(results: &[PinVerdict], self_projects: &BTreeSet<ProjectName>) -> u32 {
95 results
96 .iter()
97 .filter(|pin| !self_projects.contains(&pin.pin.project))
98 .fold(0u32, |acc, pin| {
99 acc.saturating_add(u32::try_from(pin.tracked_refs).unwrap_or(u32::MAX))
100 })
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
105pub struct SeriesVerdict {
106 pub results: Vec<PinVerdict>,
107 pub summary: SeriesSummary,
108}
109
110#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
111#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
112pub struct SeriesSummary {
113 pub compatible: u32,
114 pub requires_adaptation: u32,
115 pub incompatible: u32,
116 #[serde(default)]
117 pub inapplicable: u32,
118}
119
120impl SeriesSummary {
121 #[must_use]
124 pub fn is_blocking(&self) -> bool {
125 self.incompatible > 0 || self.requires_adaptation > 0
126 }
127}
128
129pub mod exit {
131 pub const OK: i32 = 0;
132 pub const NEEDS_ATTENTION: i32 = 3;
133}
134
135impl SeriesVerdict {
136 pub fn from_results(results: Vec<PinVerdict>) -> Self {
137 let summary = summary_of(&results);
138 Self { results, summary }
139 }
140
141 pub fn recompute_summary(&mut self) {
144 self.summary = summary_of(&self.results);
145 }
146
147 pub fn promote_inapplicable(self) -> Self {
151 let results: Vec<PinVerdict> = self
152 .results
153 .into_iter()
154 .map(|pv| {
155 if matches!(pv.verdict, Verdict::Compatible) && pv.tracked_refs == 0 {
156 if let Some(reason) = pv.touched.inapplicable_reason() {
157 return PinVerdict {
158 verdict: Verdict::Inapplicable { reason },
159 ..pv
160 };
161 }
162 }
163 pv
164 })
165 .collect();
166 Self::from_results(results)
167 }
168
169 pub fn worst_exit_code(&self) -> i32 {
171 if self.summary.is_blocking() {
172 exit::NEEDS_ATTENTION
173 } else {
174 exit::OK
175 }
176 }
177}
178
179impl ContentPresence {
180 pub fn fully_present(&self) -> bool {
182 self.hunks_considered > 0 && self.hunks_already_applied == self.hunks_considered
183 }
184}
185
186#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
190#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
191pub struct AlreadyPresent {
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub identical: Option<TargetMatch>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub content: Option<ContentPresence>,
198}
199
200impl AlreadyPresent {
201 pub fn is_empty(&self) -> bool {
202 self.identical.is_none() && self.content.is_none()
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
211pub struct DepPinDivergence {
212 pub dep: DependencyName,
213 pub source: String,
215 pub target: String,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
224pub struct PinBump {
225 pub dep: DependencyName,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub from: Option<String>,
230 pub to: String,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub status: Option<BumpStatus>,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
243#[serde(tag = "state", rename_all = "snake_case")]
244pub enum BumpStatus {
245 Untracked,
247 SnapshotMissing {
249 note: String,
250 },
251 SnapshotPresent,
252}
253
254#[non_exhaustive]
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
259#[serde(tag = "reason", rename_all = "snake_case")]
260pub enum AlreadyPresentSkipped {
261 CandidateMissingFromTargetOdb { sha: CommitSha },
264 NoMergeBase,
266 TargetWalkFailed { detail: String },
268}
269
270#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
273#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
274pub struct Unanalyzed {
275 #[serde(default)]
276 pub apply: usize,
277 #[serde(default)]
278 pub variable_dispatch: usize,
279}
280
281impl Unanalyzed {
282 pub fn is_empty(&self) -> bool {
283 self.apply == 0 && self.variable_dispatch == 0
284 }
285}
286
287fn summary_of(results: &[PinVerdict]) -> SeriesSummary {
288 let mut summary = SeriesSummary::default();
289 for r in results {
290 match r.verdict {
291 Verdict::Compatible => summary.compatible += 1,
292 Verdict::RequiresAdaptation { .. } => summary.requires_adaptation += 1,
293 Verdict::Incompatible { .. } => summary.incompatible += 1,
294 Verdict::Inapplicable { .. } => summary.inapplicable += 1,
295 }
296 }
297 summary
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
304pub enum AvailabilityQuery {
305 ExactArity {
306 module: ModuleName,
307 function: FunctionName,
308 arity: Arity,
309 },
310 AnyArity {
311 module: ModuleName,
312 function: FunctionName,
313 },
314 Record {
315 name: RecordName,
316 },
317}
318
319impl AvailabilityQuery {
320 fn for_symbol(symbol: &SymbolRef) -> Option<Self> {
321 match &symbol.kind {
322 SymbolKind::Function { mfa } => Some(Self::ExactArity {
323 module: mfa.module.clone(),
324 function: mfa.function.clone(),
325 arity: mfa.arity,
326 }),
327 SymbolKind::FunctionAnyArity { module, function } => Some(Self::AnyArity {
328 module: module.clone(),
329 function: function.clone(),
330 }),
331 SymbolKind::Record { name } => Some(Self::Record { name: name.clone() }),
332 SymbolKind::Behaviour { .. }
333 | SymbolKind::Callback { .. }
334 | SymbolKind::Macro { .. }
335 | SymbolKind::Type { .. } => None,
336 }
337 }
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
345pub struct SeriesEvaluation {
346 pub verdict: SeriesVerdict,
347 pub diagnostics: Diagnostics,
348 #[serde(default, skip_serializing_if = "PatchFacts::is_empty")]
349 pub patch_facts: PatchFacts,
350
351 #[serde(default)]
354 pub touched_paths: Vec<RelativePath>,
355
356 #[serde(default)]
362 pub pr_commits: Option<Vec<PrCommit>>,
363
364 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub apply: Option<ApplyForecast>,
370
371 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub target_findings: Option<TargetFindings>,
377}
378
379impl SeriesEvaluation {
380 pub fn worst_exit_code(&self) -> i32 {
385 if self.verdict.summary.is_blocking()
386 || self.apply.as_ref().is_some_and(ApplyForecast::has_conflict)
387 || self.target_findings.as_ref().is_some_and(|t| !t.is_empty())
388 {
389 exit::NEEDS_ATTENTION
390 } else {
391 exit::OK
392 }
393 }
394
395 pub fn apply_symbol_availability<F>(&mut self, mut lookup: F)
403 where
404 F: FnMut(&Pin, &AvailabilityQuery) -> Option<TagName>,
405 {
406 let mut any_changed = false;
407 for pv in &mut self.verdict.results {
408 let pin = pv.pin.clone();
409 let reasons = match &mut pv.verdict {
410 Verdict::RequiresAdaptation { reasons } | Verdict::Incompatible { reasons } => {
411 reasons
412 }
413 Verdict::Compatible | Verdict::Inapplicable { .. } => continue,
414 };
415 let mut changed = false;
416 for reason in reasons.iter_mut() {
417 match reason {
418 Reason::MissingSymbol {
419 symbol,
420 first_seen_at_tag: first_seen @ None,
421 needs_pin_at_least,
422 ..
423 } => {
424 let Some(query) = AvailabilityQuery::for_symbol(symbol) else {
425 continue;
426 };
427 if let Some(tag) = lookup(&pin, &query) {
428 *first_seen = Some(tag.clone());
429 *needs_pin_at_least = Some(NeedsPin {
430 project: pin.project.clone(),
431 tag,
432 });
433 changed = true;
434 }
435 }
436 Reason::ArityChanged {
437 module,
438 function,
439 expected,
440 expected_available_at: available @ None,
441 needs_pin_at_least,
442 ..
443 } => {
444 let query = AvailabilityQuery::ExactArity {
445 module: module.clone(),
446 function: function.clone(),
447 arity: *expected,
448 };
449 if let Some(tag) = lookup(&pin, &query) {
450 *available = Some(tag.clone());
451 *needs_pin_at_least = Some(NeedsPin {
452 project: pin.project.clone(),
453 tag,
454 });
455 changed = true;
456 }
457 }
458 _ => {}
459 }
460 }
461 if changed {
462 let taken = mem::take(reasons);
463 pv.verdict = Verdict::from_reasons(taken);
464 any_changed = true;
465 }
466 }
467 if any_changed {
468 self.verdict.recompute_summary();
469 }
470 }
471}
472
473#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
476pub struct PatchFacts {
477 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
478 pub logging_style: BTreeMap<PathBuf, FileLoggingStyle>,
479 #[serde(default, skip_serializing_if = "KhepriSignals::is_empty")]
480 pub khepri_signals: KhepriSignals,
481 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
482 pub introduces_versioned_record: BTreeSet<RecordName>,
483}
484
485impl PatchFacts {
486 pub fn is_empty(&self) -> bool {
487 self.logging_style.is_empty()
488 && self.khepri_signals.is_empty()
489 && self.introduces_versioned_record.is_empty()
490 }
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
495#[serde(rename_all = "snake_case")]
496pub enum LoggingStyle {
497 LoggerMacros,
498 RabbitLogModule,
499 Mixed,
500 None,
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
504#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
505pub struct FileLoggingStyle {
506 pub dominant: LoggingStyle,
507 pub logger_macro_sites: usize,
508 pub rabbit_log_sites: usize,
509}
510
511#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
512#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
513pub struct KhepriSignals {
514 #[serde(default)]
515 pub touches_khepri_module: bool,
516 #[serde(default)]
517 pub uses_khepri_macros: bool,
518 #[serde(default)]
519 pub touches_only_khepri_branch: bool,
520 #[serde(default)]
521 pub touches_dual_branch: bool,
522}
523
524impl KhepriSignals {
525 #[allow(clippy::trivially_copy_pass_by_ref)]
527 pub fn is_empty(&self) -> bool {
528 !self.touches_khepri_module
529 && !self.uses_khepri_macros
530 && !self.touches_only_khepri_branch
531 && !self.touches_dual_branch
532 }
533}