speccoder_runtime/verify/
mod.rs1pub mod config;
4pub mod junit;
5pub mod process;
6pub mod summary;
7
8use std::path::{Path, PathBuf};
9use std::time::UNIX_EPOCH;
10
11use crate::format::Workspace;
12use speccoder_core::verify::{CheckResult, CheckStatus, VerifyReport, compare, passes};
13use speccoder_core::{CommandData, CommandResult, CommandStatus, SCHEMA_VERSION};
14
15use self::config::{Check, Tier};
16use self::process::{CommandRunner, Invocation, OUTPUT_CAP, scrubbed_env};
17
18const MAX_REPORT_BYTES: u64 = 64 * 1024 * 1024;
20const MAX_BASELINE_BYTES: u64 = 64 * 1024 * 1024;
22const TAIL_BYTES: usize = 4 * 1024;
24
25pub fn verify(
30 path: &Path,
31 tier: Option<Tier>,
32 against: Option<&Path>,
33 runner: &dyn CommandRunner,
34) -> CommandResult {
35 let config_error = |msg: String| CommandResult::new("verify", CommandStatus::ConfigError, &msg);
36 let baseline = match against
37 .map(|p| {
38 let root = path
39 .canonicalize()
40 .ok()
41 .and_then(|p| find_config(&p))
42 .unwrap_or_default();
43 load_baseline(p, &root)
44 })
45 .transpose()
46 {
47 Ok(b) => b,
48 Err(e) => return config_error(e),
49 };
50 let mut report = match run_checks(path, tier, runner) {
51 Ok(r) => r,
52 Err(e) if e.starts_with("no applicable checks") => {
54 return config_error(e).with_code("NO-CHECKS");
55 }
56 Err(e) => return config_error(e),
57 };
58 report.comparison = baseline.as_ref().map(|b| compare(b, &report));
59 report.passed = passes(&report.checks, report.comparison.as_ref());
60
61 let status = if report.passed {
62 CommandStatus::Ok
63 } else {
64 CommandStatus::VerificationFailed
65 };
66 let message = summary(&report);
67 CommandResult::new("verify", status, &message).with_data(CommandData::Verify(Box::new(report)))
68}
69
70pub fn run_checks(
74 path: &Path,
75 tier: Option<Tier>,
76 runner: &dyn CommandRunner,
77) -> Result<VerifyReport, String> {
78 let root = path.canonicalize().ok().and_then(|p| find_config(&p)).ok_or_else(|| {
79 format!(
80 "no {} at or above {}; declare checks first, e.g. [checks.test] argv = [\"cargo\", \"test\"]",
81 config::FILE,
82 path.display()
83 )
84 })?;
85 let ws = Workspace::at(&root).ok_or_else(|| format!("cannot open {}", root.display()))?;
86 let checks = ws
87 .read_text(config::FILE)
88 .map_err(|e| format!("{e:?}"))
89 .and_then(|t| config::parse(&t))
90 .map_err(|e| format!("{}: {e}", config::FILE))?;
91
92 let env = scrubbed_env(std::env::vars());
93 let results: Vec<CheckResult> = checks
94 .iter()
95 .filter(|c| tier.is_none_or(|t| c.tier == t))
96 .map(|c| {
97 rerun_once(run_check(&ws, c, &env, runner), || {
98 run_check(&ws, c, &env, runner)
99 })
100 })
101 .collect();
102 if results.iter().all(|r| r.status == CheckStatus::Skipped) {
103 return Err(format!(
104 "no applicable checks in {}{} — nothing would be verified",
105 config::FILE,
106 tier.map(|t| format!(" for tier {}", t.as_str()))
107 .unwrap_or_default()
108 ));
109 }
110 let passed = passes(&results, None);
111 Ok(VerifyReport {
112 root: ws.root().display().to_string(),
113 tier: tier.map_or("all", Tier::as_str).to_owned(),
114 checks: results,
115 comparison: None,
116 passed,
117 })
118}
119
120pub fn check_argvs(root: &Path) -> Vec<Vec<String>> {
122 std::fs::read_to_string(root.join(config::FILE))
123 .ok()
124 .and_then(|t| config::parse(&t).ok())
125 .map(|cs| cs.into_iter().map(|c| c.argv).collect())
126 .unwrap_or_default()
127}
128
129pub fn find_config(start: &Path) -> Option<PathBuf> {
131 start
132 .ancestors()
133 .find(|d| d.join(config::FILE).is_file())
134 .map(Path::to_path_buf)
135}
136
137fn run_check(
138 ws: &Workspace,
139 check: &Check,
140 env: &[(String, String)],
141 runner: &dyn CommandRunner,
142) -> CheckResult {
143 let mut result = CheckResult {
144 name: check.name.clone(),
145 argv: check.argv.clone(),
146 status: CheckStatus::Skipped,
147 exit_code: None,
148 duration_ms: 0,
149 junit: check.junit.is_some(),
150 tests: None,
151 stdout_tail: String::new(),
152 stderr_tail: String::new(),
153 truncated: false,
154 note: None,
155 flaky: false,
156 sandbox: Default::default(),
157 };
158 if let Some(path) = &check.when_exists
159 && ws.resolve(path).is_err()
160 {
161 result.note = Some(format!("not applicable: {path} does not exist"));
162 return result;
163 }
164 let cwd = match ws.resolve(&check.cwd) {
165 Ok(p) if p.is_dir() => p,
166 _ => {
167 result.status = CheckStatus::Error;
168 result.note = Some(format!(
169 "cwd `{}` is not a directory inside the project",
170 check.cwd
171 ));
172 return result;
173 }
174 };
175
176 let mut tracked_report = false;
180 if let Some(report) = &check.junit
181 && let Ok(path) = ws.resolve(report)
182 && path.is_file()
183 {
184 tracked_report = crate::git::Git::at(ws.root())
185 .succeeds(&["ls-files", "--error-unmatch", "--", report])
186 .unwrap_or(false);
187 if !tracked_report {
188 let _ = std::fs::remove_file(&path);
189 }
190 }
191 let outcome = runner.run(&Invocation {
192 argv: check.argv.clone(),
193 cwd,
194 env: env.to_vec(),
195 timeout: check.timeout,
196 output_cap: OUTPUT_CAP,
197 });
198 result.duration_ms = u64::try_from(outcome.duration.as_millis()).unwrap_or(u64::MAX);
199 result.exit_code = outcome.exit_code;
200 result.stdout_tail = tail(&outcome.stdout.kept);
201 result.stderr_tail = tail(&outcome.stderr.kept);
202 result.truncated = outcome.stdout.truncated() || outcome.stderr.truncated();
203 result.sandbox = outcome.sandbox.clone();
204 result.status = if let Some(e) = &outcome.spawn_error {
205 result.note = Some(e.clone());
206 CheckStatus::Error
207 } else if outcome.timed_out {
208 result.note = Some(format!("timed out after {}s", check.timeout.as_secs()));
209 CheckStatus::TimedOut
210 } else if outcome.exit_code == Some(0) {
211 CheckStatus::Passed
212 } else if crate::init::no_tests(
213 &check.argv,
214 outcome.exit_code,
215 &format!("{}\n{}", result.stdout_tail, result.stderr_tail),
216 ) {
217 result.note = Some("no tests yet".to_owned());
220 CheckStatus::Passed
221 } else {
222 CheckStatus::Failed
223 };
224
225 if let Some(report) = check.junit.as_ref().filter(|_| tracked_report) {
226 result
227 .note
228 .get_or_insert(format!("{report} is tracked by git: not a run's report"));
229 } else if let Some(report) = &check.junit {
230 let finished = outcome.started + outcome.duration;
231 match fresh_report(ws, report, outcome.started, finished) {
232 Ok(xml) => match junit::parse(&xml) {
233 Ok(tests) => result.tests = Some(tests),
234 Err(e) => result.note = Some(format!("{report}: {e}")),
235 },
236 Err(e) => {
237 result.note.get_or_insert(e);
238 }
239 }
240 }
241 result
242}
243
244fn fresh_report(
246 ws: &Workspace,
247 rel: &str,
248 started: std::time::SystemTime,
249 finished: std::time::SystemTime,
250) -> Result<String, String> {
251 let path = ws
252 .resolve(rel)
253 .map_err(|_| format!("no test report at {rel}"))?;
254 let modified = std::fs::metadata(&path)
255 .and_then(|m| m.modified())
256 .map_err(|e| format!("{rel}: {e}"))?;
257 let since = |t: std::time::SystemTime| {
259 t.duration_since(UNIX_EPOCH)
260 .map(|d| d.as_secs())
261 .unwrap_or(0)
262 };
263 if since(modified) + 1 < since(started) {
264 return Err(format!("{rel} predates this run; ignored"));
265 }
266 if since(modified) > since(finished) + 1 {
268 return Err(format!("{rel} is dated after this run ended; ignored"));
269 }
270 ws.read_text_limited(rel, MAX_REPORT_BYTES)
271 .map_err(|e| format!("{rel}: {e:?}"))
272}
273
274fn tail(bytes: &[u8]) -> String {
277 let start = bytes.len().saturating_sub(TAIL_BYTES);
278 strip_ansi(&String::from_utf8_lossy(&bytes[start..]))
279}
280
281fn strip_ansi(s: &str) -> String {
283 let mut out = String::with_capacity(s.len());
284 let mut chars = s.chars().peekable();
285 while let Some(c) = chars.next() {
286 if c != '\u{1b}' {
287 out.push(c);
288 continue;
289 }
290 match chars.next() {
291 Some('[') => {
292 for c in chars.by_ref() {
294 if ('@'..='~').contains(&c) {
295 break;
296 }
297 }
298 }
299 Some(_) if chars.peek().is_some_and(char::is_ascii_alphanumeric) => {
301 chars.next();
302 }
303 Some(_) | None => {}
304 }
305 }
306 out
307}
308
309fn load_baseline(path: &Path, root: &Path) -> Result<VerifyReport, String> {
310 let bad = |why: &str| format!("baseline {}: {why}", path.display());
311 let meta = std::fs::metadata(path).map_err(|e| bad(&e.to_string()))?;
312 if meta.len() > MAX_BASELINE_BYTES {
313 return Err(bad("too large"));
314 }
315 let text = std::fs::read_to_string(path).map_err(|e| bad(&e.to_string()))?;
316 let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| bad(&e.to_string()))?;
317 if value["schema_version"] != SCHEMA_VERSION || value["command"] != "verify" {
318 return Err(bad(
319 "not a `speccoder --json verify` result of schema version 1",
320 ));
321 }
322 let report: VerifyReport =
323 serde_json::from_value(value["data"].clone()).map_err(|e| bad(&e.to_string()))?;
324 if Path::new(&report.root) != root {
325 return Err(bad(&format!(
326 "recorded for {}, not this project",
327 report.root
328 )));
329 }
330 Ok(report)
331}
332
333fn summary(r: &VerifyReport) -> String {
334 let count = |s: CheckStatus| r.checks.iter().filter(|c| c.status == s).count();
335 let failed =
336 count(CheckStatus::Failed) + count(CheckStatus::TimedOut) + count(CheckStatus::Error);
337 let mut s = format!(
338 "{} passed, {failed} failed, {} skipped",
339 count(CheckStatus::Passed),
340 count(CheckStatus::Skipped)
341 );
342 if let Some(c) = &r.comparison {
343 s.push_str(&format!(
344 "; {} regressions vs baseline",
345 c.regressions.len()
346 ));
347 }
348 s
349}
350
351pub fn rerun_once(first: CheckResult, again: impl FnOnce() -> CheckResult) -> CheckResult {
355 if first.status != CheckStatus::Failed {
356 return first;
357 }
358 let mut second = again();
359 if second.status == CheckStatus::Passed {
360 let last = format!("{}\n{}", first.stdout_tail, first.stderr_tail)
361 .lines()
362 .rev()
363 .find(|l| !l.trim().is_empty())
364 .unwrap_or_default()
365 .trim()
366 .to_owned();
367 second.flaky = true;
368 second.note = Some(if last.is_empty() {
369 "failed once, passed when run again".to_owned()
370 } else {
371 format!("failed once ({last}), passed when run again")
372 });
373 }
374 second
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn strips_terminal_escapes() {
383 let colored = "\u{1b}(B\u{1b}[m\u{1b}[32m+ ] {\u{1b}[0m done";
384 assert_eq!(super::strip_ansi(colored), "+ ] { done");
385 }
386
387 #[test]
388 fn a_failed_check_runs_once_more() {
389 let result = |status: CheckStatus, out: &str| CheckResult {
390 name: "test".to_owned(),
391 argv: vec!["t".to_owned()],
392 status,
393 exit_code: None,
394 duration_ms: 0,
395 junit: false,
396 tests: None,
397 stdout_tail: out.to_owned(),
398 stderr_tail: String::new(),
399 truncated: false,
400 note: None,
401 sandbox: Default::default(),
402 flaky: false,
403 };
404 let passed = result(CheckStatus::Passed, "");
405 assert!(!rerun_once(passed.clone(), || unreachable!()).flaky);
406 let timed_out = result(CheckStatus::TimedOut, "");
407 assert_eq!(
408 rerun_once(timed_out, || unreachable!()).status,
409 CheckStatus::TimedOut
410 );
411 let flaky = rerun_once(result(CheckStatus::Failed, "boom\n"), || passed.clone());
412 assert!(flaky.flaky);
413 assert!(flaky.note.as_deref().unwrap().contains("boom"));
414 let twice = rerun_once(result(CheckStatus::Failed, "a"), || {
415 result(CheckStatus::Failed, "b")
416 });
417 assert_eq!((twice.flaky, twice.stdout_tail.as_str()), (false, "b"));
418 }
419}