Skip to main content

speccoder_runtime/verify/
mod.rs

1//! `speccoder verify`: run the project's declared checks and judge the result.
2
3pub 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
18/// Largest JUnit report read.
19const MAX_REPORT_BYTES: u64 = 64 * 1024 * 1024;
20/// Largest baseline file read.
21const MAX_BASELINE_BYTES: u64 = 64 * 1024 * 1024;
22/// Output kept in the report per stream.
23const TAIL_BYTES: usize = 4 * 1024;
24
25/// Runs the checks of the project containing `path` with `runner`.
26///
27/// `tier`: `None` runs every check; `Some(Fast)` only fast ones.
28/// `against`: a file saved from `speccoder --json verify`.
29pub 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        // No check at all: add one — `init` would only say the file exists.
53        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
70/// Runs the applicable checks of the project containing `path` and returns
71/// the report (no comparison yet). `Err` is a configuration problem: no
72/// `speccoder.toml`, an invalid one, or nothing applicable — never a pass.
73pub 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
120/// The argv of every configured check (for the command policy).
121pub 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
129/// The nearest ancestor of `start` (inclusive) holding `speccoder.toml`.
130pub 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    // A report left from before (or planted) is removed: only what this
177    // run writes is read.
178    // A tracked file is never a run's report (and is never deleted).
179    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        // An empty suite (pytest's exit 5) is no tests yet, as `init` reads
218        // it — nothing to regress — like nextest's `--no-tests=warn`.
219        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
244/// Reads a JUnit report only if it was written by this run (not a leftover).
245fn 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    // Allow one second of filesystem timestamp granularity.
258    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    // Written after the run ended: not the run's report.
267    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
274/// The last part of an output stream, without terminal escape sequences
275/// (reports are read in logs and JSON, not terminals).
276fn tail(bytes: &[u8]) -> String {
277    let start = bytes.len().saturating_sub(TAIL_BYTES);
278    strip_ansi(&String::from_utf8_lossy(&bytes[start..]))
279}
280
281/// Removes ESC sequences: CSI (`ESC [ … final`) and two-byte escapes (`ESC (B`).
282fn 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                // Parameters and intermediates, then one final byte in @..~.
293                for c in chars.by_ref() {
294                    if ('@'..='~').contains(&c) {
295                        break;
296                    }
297                }
298            }
299            // Charset designations like `ESC ( B` carry one more byte.
300            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
351/// A failed check runs once more: failing again, the second run stands;
352/// passing, it is `flaky` — named, and never task evidence. Timeouts and
353/// errors are not re-run.
354pub 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}