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)]
343pub struct TargetAxis {
344 pub apply: ApplyForecast,
345 pub findings: TargetFindings,
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351pub struct TargetAxisRef<'a> {
352 pub apply: &'a ApplyForecast,
353 pub findings: &'a TargetFindings,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
363#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
364pub struct TargetAxisSlot {
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 apply: Option<ApplyForecast>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
368 target_findings: Option<TargetFindings>,
369}
370
371impl TargetAxisSlot {
372 #[must_use]
373 pub fn absent() -> Self {
374 Self::default()
375 }
376
377 #[must_use]
378 pub fn present(axis: TargetAxis) -> Self {
379 Self {
380 apply: Some(axis.apply),
381 target_findings: Some(axis.findings),
382 }
383 }
384
385 #[must_use]
386 pub fn get(&self) -> Option<TargetAxisRef<'_>> {
387 match (&self.apply, &self.target_findings) {
388 (Some(apply), Some(findings)) => Some(TargetAxisRef { apply, findings }),
389 _ => None,
390 }
391 }
392
393 #[must_use]
396 pub fn into_axis(self) -> Option<TargetAxis> {
397 match (self.apply, self.target_findings) {
398 (Some(apply), Some(findings)) => Some(TargetAxis { apply, findings }),
399 _ => None,
400 }
401 }
402
403 pub(crate) fn from_wire_pair(
408 apply: Option<ApplyForecast>,
409 target_findings: Option<TargetFindings>,
410 ) -> Self {
411 match (apply, target_findings) {
412 (Some(apply), Some(target_findings)) => Self {
413 apply: Some(apply),
414 target_findings: Some(target_findings),
415 },
416 _ => Self::absent(),
417 }
418 }
419}
420
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
426pub struct SeriesEvaluation {
427 pub verdict: SeriesVerdict,
428 pub diagnostics: Diagnostics,
429 #[serde(default, skip_serializing_if = "PatchFacts::is_empty")]
430 pub patch_facts: PatchFacts,
431
432 #[serde(default)]
435 pub touched_paths: Vec<RelativePath>,
436
437 #[serde(default)]
443 pub pr_commits: Option<Vec<PrCommit>>,
444
445 #[serde(flatten)]
451 pub target: TargetAxisSlot,
452}
453
454impl SeriesEvaluation {
455 pub fn worst_exit_code(&self) -> i32 {
460 let target = self.target.get();
461 if self.verdict.summary.is_blocking()
462 || target.is_some_and(|t| t.apply.has_conflict())
463 || target.is_some_and(|t| !t.findings.is_empty())
464 {
465 exit::NEEDS_ATTENTION
466 } else {
467 exit::OK
468 }
469 }
470
471 pub fn apply_symbol_availability<F>(&mut self, mut lookup: F)
479 where
480 F: FnMut(&Pin, &AvailabilityQuery) -> Option<TagName>,
481 {
482 let mut any_changed = false;
483 for pv in &mut self.verdict.results {
484 let pin = pv.pin.clone();
485 let reasons = match &mut pv.verdict {
486 Verdict::RequiresAdaptation { reasons } | Verdict::Incompatible { reasons } => {
487 reasons
488 }
489 Verdict::Compatible | Verdict::Inapplicable { .. } => continue,
490 };
491 let mut changed = false;
492 for reason in reasons.iter_mut() {
493 match reason {
494 Reason::MissingSymbol {
495 symbol,
496 first_seen_at_tag: first_seen @ None,
497 needs_pin_at_least,
498 ..
499 } => {
500 let Some(query) = AvailabilityQuery::for_symbol(symbol) else {
501 continue;
502 };
503 if let Some(tag) = lookup(&pin, &query) {
504 *first_seen = Some(tag.clone());
505 *needs_pin_at_least = Some(NeedsPin {
506 project: pin.project.clone(),
507 tag,
508 });
509 changed = true;
510 }
511 }
512 Reason::ArityChanged {
513 module,
514 function,
515 expected,
516 expected_available_at: available @ None,
517 needs_pin_at_least,
518 ..
519 } => {
520 let query = AvailabilityQuery::ExactArity {
521 module: module.clone(),
522 function: function.clone(),
523 arity: *expected,
524 };
525 if let Some(tag) = lookup(&pin, &query) {
526 *available = Some(tag.clone());
527 *needs_pin_at_least = Some(NeedsPin {
528 project: pin.project.clone(),
529 tag,
530 });
531 changed = true;
532 }
533 }
534 _ => {}
535 }
536 }
537 if changed {
538 let taken = mem::take(reasons);
539 pv.verdict = Verdict::from_reasons(taken);
540 any_changed = true;
541 }
542 }
543 if any_changed {
544 self.verdict.recompute_summary();
545 }
546 }
547}
548
549#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
551#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
552pub struct PatchFacts {
553 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
554 pub logging_style: BTreeMap<PathBuf, FileLoggingStyle>,
555 #[serde(default, skip_serializing_if = "KhepriSignals::is_empty")]
556 pub khepri_signals: KhepriSignals,
557 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
558 pub introduces_versioned_record: BTreeSet<RecordName>,
559}
560
561impl PatchFacts {
562 pub fn is_empty(&self) -> bool {
563 self.logging_style.is_empty()
564 && self.khepri_signals.is_empty()
565 && self.introduces_versioned_record.is_empty()
566 }
567}
568
569#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
570#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
571#[serde(rename_all = "snake_case")]
572pub enum LoggingStyle {
573 LoggerMacros,
574 RabbitLogModule,
575 Mixed,
576 None,
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
580#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
581pub struct FileLoggingStyle {
582 pub dominant: LoggingStyle,
583 pub logger_macro_sites: usize,
584 pub rabbit_log_sites: usize,
585}
586
587#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
588#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
589pub struct KhepriSignals {
590 #[serde(default)]
591 pub touches_khepri_module: bool,
592 #[serde(default)]
593 pub uses_khepri_macros: bool,
594 #[serde(default)]
595 pub touches_only_khepri_branch: bool,
596 #[serde(default)]
597 pub touches_dual_branch: bool,
598}
599
600impl KhepriSignals {
601 #[allow(clippy::trivially_copy_pass_by_ref)]
603 pub fn is_empty(&self) -> bool {
604 !self.touches_khepri_module
605 && !self.uses_khepri_macros
606 && !self.touches_only_khepri_branch
607 && !self.touches_dual_branch
608 }
609}