backhopper_core/model/batch.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//! Public wire-format types for `check batch`. Promoted from the
6//! previously-private types in `backhopper-cli::commands::check`.
7
8use std::collections::BTreeSet;
9use std::num::NonZeroU32;
10
11use serde::{Deserialize, Serialize};
12
13use crate::model::apply::ApplyForecast;
14use crate::model::clearance::RoundClearance;
15use crate::model::findings::TargetFindings;
16use crate::model::fingerprint::VerdictFingerprint;
17use crate::model::names::{CommitSha, ProjectName, RelativePath, SeriesName, TagName};
18use crate::model::pin::Pin;
19use crate::model::pr_commit::PrCommit;
20use crate::model::producer::Producer;
21use crate::model::resolver_coverage::ResolverCoverage;
22use crate::model::verdict::{Diagnostics, PatchFacts, SeriesVerdict};
23
24/// Top-level payload of a `check batch` envelope.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
27pub struct BatchPayload {
28 pub queried_against: Vec<BatchQuery>,
29 pub results: Vec<BatchResult>,
30
31 /// Projects evaluated as the backport subject itself, excluded from
32 /// the tracked-dependency tally. `None` means the producing binary
33 /// predates the field, not "no self project": a current producer
34 /// always emits `Some`, even when empty, so it is never skipped.
35 #[serde(default)]
36 pub self_projects: Option<BTreeSet<ProjectName>>,
37
38 /// Symbol classes this producer checks. A corpus row keeps it so the
39 /// fold routes bug-vs-gap against the producing binary, not the one
40 /// running the fold. `None` for a producer that predates the field.
41 #[serde(default)]
42 pub resolver_coverage: Option<ResolverCoverage>,
43
44 /// The fingerprint generation this producer stamped. Lets a corpus
45 /// spanning a bump segregate generations instead of reading an
46 /// unexplained zero-match. `None` for a producer that predates it.
47 #[serde(default)]
48 pub fingerprint_version: Option<u32>,
49}
50
51impl BatchPayload {
52 /// Round-level clearance over these rows, excluding `self_projects`
53 /// from the tracked-dependency tally.
54 #[must_use]
55 pub fn clearance(&self, self_projects: &BTreeSet<ProjectName>) -> RoundClearance {
56 RoundClearance::from_results(&self.results, self_projects)
57 }
58
59 /// The producer generation that wrote `self_projects`,
60 /// `resolver_coverage`, and `fingerprint_version`.
61 #[must_use]
62 pub fn producer(&self) -> Producer<'_> {
63 Producer::from_fields(
64 self.self_projects.as_ref(),
65 self.resolver_coverage.as_ref(),
66 self.fingerprint_version,
67 )
68 }
69
70 /// Clearance from the self-projects the producer recorded on the
71 /// wire. `None` when the producer predates the field; the caller
72 /// then passes its own set to `clearance`.
73 #[must_use]
74 pub fn clearance_self_inferred(&self) -> Option<RoundClearance> {
75 match self.producer() {
76 Producer::Current { self_projects, .. } => Some(self.clearance(self_projects)),
77 Producer::BeforeV12 => None,
78 }
79 }
80}
81
82/// One series queried by the batch. Holds the pin set used during
83/// evaluation so consumers can correlate per-row results.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
86pub struct BatchQuery {
87 pub series: SeriesName,
88 pub pins: Vec<PinPayload>,
89}
90
91/// On-wire pin shape used inside `BatchQuery`. Distinct from
92/// `crate::model::pin::Pin` because the wire form is a `(project, tag)`
93/// projection without the resolution logic.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
96pub struct PinPayload {
97 pub project: ProjectName,
98 pub tag: TagName,
99}
100
101impl From<&Pin> for PinPayload {
102 fn from(pin: &Pin) -> Self {
103 Self {
104 project: pin.project.clone(),
105 tag: pin.tag.clone(),
106 }
107 }
108}
109
110/// One row in the batch result set.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
113pub struct BatchResult {
114 pub commit: CommitSha,
115 pub series: SeriesName,
116 pub verdict: SeriesVerdict,
117
118 #[serde(default, skip_serializing_if = "Diagnostics::is_empty")]
119 pub diagnostics: Diagnostics,
120
121 #[serde(default, skip_serializing_if = "PatchFacts::is_empty")]
122 pub patch_facts: PatchFacts,
123
124 /// Paths the analysed patch touched, in diff-encounter order.
125 /// Always emitted; an empty vec means the patch touched zero
126 /// files.
127 #[serde(default)]
128 pub touched_paths: Vec<RelativePath>,
129
130 /// Inner PR-branch commits for 2-parent merge SHAs. `None` for
131 /// non-2-parent merges and non-merge SHAs.
132 ///
133 /// Do NOT add `skip_serializing_if = "Option::is_none"`. The
134 /// `None` vs `Some(vec![])` distinction is part of the wire contract.
135 #[serde(default)]
136 pub pr_commits: Option<Vec<PrCommit>>,
137
138 /// Number of parents of the evaluated commit: 1 for plain
139 /// commits, 2 for PR merges, 3+ for octopus merges. `None` only
140 /// when the envelope was produced by a pre-v7 binary; v7
141 /// producers always emit it.
142 #[serde(default)]
143 pub parent_count: Option<NonZeroU32>,
144
145 /// Stable join key tying this row's verdict to a later build
146 /// outcome. Absent for uncacheable rows (`--no-cache`, an
147 /// unresolvable pin) and pre-fingerprint producers.
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub verdict_fingerprint: Option<VerdictFingerprint>,
150
151 /// Apply-axis prediction for this row. `None` means no target
152 /// context was supplied, so the axis was not evaluated: never
153 /// "clean". A producer with a target context always emits `Some`,
154 /// even when the patch touched zero files.
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub apply: Option<ApplyForecast>,
157
158 /// Symbol-axis target findings for this row. `None` means no
159 /// target context was supplied, so the axis was not evaluated:
160 /// never "clean". A producer with a target context always emits
161 /// `Some`, even when no check produced a finding.
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub target_findings: Option<TargetFindings>,
164}