1use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use super::check_paths::invalidating_paths;
17use super::record::{GateBaseline, GateRecord, read_baseline, read_record};
18use super::{REQUIRED_GATES, gate_dir};
19
20pub use super::scoring::{Comparison, check_record, compare};
21
22#[derive(Debug)]
24pub enum GateStatus {
25 Pass,
27 Fail(Vec<String>),
30 Missing(String),
32}
33
34pub fn records_newest_first(root: &Path, benchmark_id: &str) -> Vec<PathBuf> {
41 let dir = gate_dir(root, benchmark_id);
42 let mut candidates: Vec<PathBuf> = std::fs::read_dir(&dir)
43 .map(|entries| entries.flatten().map(|e| e.path()).collect())
44 .unwrap_or_default();
45 candidates.retain(|p| {
46 p.extension().is_some_and(|e| e == "json")
47 && p.file_name()
48 .is_some_and(|n| n.to_string_lossy() != "BASELINE.json")
49 });
50 let mut keyed: Vec<(u64, PathBuf)> = candidates
51 .into_iter()
52 .map(|p| (read_record(&p).map(|r| r.recorded_at).unwrap_or(0), p))
53 .collect();
54 keyed.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1)));
57 keyed.into_iter().map(|(_, p)| p).collect()
58}
59
60pub fn record_covers(
65 root: &Path,
66 head: &str,
67 record_sha: &str,
68 gate: &super::coverage::GateCoverage,
69) -> bool {
70 invalidating_paths(root, head, record_sha, gate).is_some_and(|p| p.is_empty())
71}
72
73pub(super) fn record_still_stands(
75 root: &Path,
76 sha: &str,
77 record: &GateRecord,
78 gate: &super::coverage::GateCoverage,
79) -> bool {
80 matches!(record_standing(root, sha, record, gate), Standing::Stands)
81}
82
83pub fn record_standing(
92 root: &Path,
93 sha: &str,
94 record: &GateRecord,
95 gate: &super::coverage::GateCoverage,
96) -> Standing {
97 let by_paths = match invalidating_paths(root, sha, &record.git_sha, gate) {
98 None => Standing::Unknown,
99 Some(paths) if paths.is_empty() => Standing::Stands,
100 Some(paths) => {
101 if super::closure::excuses(root, &paths, &record.closure) {
102 Standing::Stands
103 } else {
104 Standing::Invalidated(paths)
105 }
106 }
107 };
108 match (
111 by_paths,
112 super::recipe_closure::recipe_standing(root, record),
113 ) {
114 (Standing::Stands, super::recipe_closure::RecipeStanding::Changed(path)) => {
115 Standing::Invalidated(vec![path])
116 }
117 (standing, _) => standing,
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum Standing {
124 Stands,
127 Unknown,
130 Invalidated(Vec<String>),
132}
133
134pub fn check_gates(root: &Path, sha: &str) -> BTreeMap<String, GateStatus> {
136 let mut out = BTreeMap::new();
137 for id in REQUIRED_GATES {
138 out.insert((*id).to_string(), check_one(root, id, sha));
139 }
140 out
141}
142
143pub(super) fn record_is_for(record: &GateRecord, benchmark_id: &str, path: &Path) -> bool {
150 if record.benchmark_id == benchmark_id {
151 return true;
152 }
153 tracing::warn!(
154 "ignoring {}: it is a `{}` record sitting in the `{benchmark_id}` directory",
155 path.display(),
156 record.benchmark_id,
157 );
158 false
159}
160
161pub(super) fn record_is_required_subject(
168 baseline: &GateBaseline,
169 record: &GateRecord,
170 benchmark_id: &str,
171 path: &Path,
172) -> bool {
173 let hardware = record.hardware.gate_key();
174 let Some(hw) = baseline.hardware.get(&hardware) else {
175 return true;
176 };
177 if hw.default == record.target_model {
178 return true;
179 }
180 tracing::warn!(
181 "ignoring {} for the required {benchmark_id} gate: it measured the {} variant, \
182 and the gate's declared subject on {hardware} is {}",
183 path.display(),
184 record.target_model,
185 hw.default,
186 );
187 false
188}
189
190pub(super) fn check_one(root: &Path, benchmark_id: &str, sha: &str) -> GateStatus {
191 if let Some(group) = super::group::find(benchmark_id) {
194 return super::check_group::check_group(root, group, sha);
195 }
196 let Some(gate) = super::coverage::find(benchmark_id) else {
197 return GateStatus::Missing(format!("{benchmark_id} has no coverage entry"));
200 };
201 let paths = records_newest_first(root, benchmark_id);
202 if paths.is_empty() {
203 return GateStatus::Missing("no gate records committed".into());
204 }
205 let baseline = match read_baseline(root, benchmark_id) {
206 Ok(b) => b,
207 Err(e) => return GateStatus::Missing(format!("baseline unreadable: {e:#}")),
208 };
209 let mut covered: Option<(GateRecord, std::path::PathBuf)> = None;
213 for path in &paths {
214 if let Ok(record) = read_record(path)
215 && record_is_for(&record, benchmark_id, path)
216 && record_is_required_subject(&baseline, &record, benchmark_id, path)
217 && record_still_stands(root, sha, &record, gate)
218 {
219 covered = Some((record, path.clone()));
220 break;
221 }
222 }
223 let Some((record, record_path)) = covered else {
224 let newest = read_record(&paths[0]).ok();
225 return GateStatus::Missing(match newest {
226 Some(newest_record) => {
229 if newest_record.benchmark_id != benchmark_id {
230 return GateStatus::Missing(format!(
231 "latest record belongs to {}, not {benchmark_id} ({})",
232 newest_record.benchmark_id,
233 paths[0].file_name().unwrap_or_default().to_string_lossy()
234 ));
235 }
236 if !record_is_required_subject(&baseline, &newest_record, benchmark_id, &paths[0]) {
237 let hardware = newest_record.hardware.gate_key();
238 let subject = baseline
239 .hardware
240 .get(&hardware)
241 .map(|hw| hw.default.clone())
242 .unwrap_or_default();
243 return GateStatus::Missing(format!(
244 "latest record measured the {} variant; the required subject on \
245 {hardware} is {subject}, which has no covering record",
246 newest_record.target_model
247 ));
248 }
249 let newest = newest_record.git_sha.clone();
250 let Some(why) = invalidating_paths(root, sha, &newest, gate) else {
251 return GateStatus::Missing(format!(
252 "latest record is for {newest} ({}) — git cannot diff that commit \
253 against this one; is it in this clone? (the gate job needs \
254 `fetch-depth: 0`)",
255 paths[0].file_name().unwrap_or_default().to_string_lossy()
256 ));
257 };
258 let because = if why.is_empty() {
259 "its recorded build inputs do not match this commit".to_string()
261 } else {
262 let targets =
264 super::closure::changed_targets(root, &why, &newest_record.closure);
265 match targets.len() {
266 0 => format!("invalidated by {}", super::check_fmt::summarize_paths(&why)),
267 n => format!(
268 "invalidated by {} — device code changed for {n} target(s): {}",
269 super::check_fmt::summarize_paths(&why),
270 super::check_fmt::summarize_paths(&targets)
271 ),
272 }
273 };
274 format!(
275 "latest record is for {newest} ({}) — {because}",
276 paths[0].file_name().unwrap_or_default().to_string_lossy()
277 )
278 }
279 None => "latest record is unreadable".to_string(),
280 });
281 };
282 if record.frame_status_failed() {
283 return GateStatus::Fail(vec![format!(
284 "the run itself failed: {}",
285 record.verdict_reason
286 )]);
287 }
288 let mut problems = Vec::new();
289 if !record.dirty_paths.is_empty() {
293 problems.push(format!(
294 "measured from a dirty tree — {} uncommitted invalidation-set \
295 file(s) when the run started ({}), so the binary was not {}",
296 record.dirty_paths.len(),
297 record.dirty_paths.join(", "),
298 record.git_sha
299 ));
300 }
301 if !record.verdict_passes() {
302 problems.push(format!(
303 "run verdict is not PASS: {}",
304 record.verdict_reason
305 ));
306 }
307 if let Some(breaches) = check_record(&record, &baseline) {
308 problems.extend(breaches);
309 }
310 if let Err(why) =
312 super::signing::verify_record(root, &record_path, &record.git_sha, record.recorded_at)
313 {
314 problems.push(format!("{why}"));
315 }
316 if problems.is_empty() {
317 GateStatus::Pass
318 } else {
319 GateStatus::Fail(problems)
320 }
321}
322
323pub fn exit_code(statuses: &BTreeMap<String, GateStatus>) -> i32 {
329 let open = statuses
330 .values()
331 .filter(|s| !matches!(s, GateStatus::Pass))
332 .count();
333 i32::from(open > 0)
334}
335
336#[cfg(test)]
337#[path = "check_tests.rs"]
338mod check_tests;