Skip to main content

ev/
runner.rs

1//! ev check --run: execute a bound test locally and produce a run-receipt. A THIN runner —
2//! the production receipt-writer is CI / a supervisor hook; --run is for local verification.
3//! exit == the configured green_exit_code => green, anything else => red (gray comes from
4//! external writers, never from --run).
5use crate::receipt::Receipt;
6use std::path::Path;
7use std::process::Command;
8use time::format_description::well_known::Rfc3339;
9use time::OffsetDateTime;
10
11/// Run the bound `reference` as a shell command in `repo`; return a receipt stamped for
12/// `platform`, the current git commit (HEAD), and now (UTC). exit == `green_exit_code` => green,
13/// else red.
14pub fn run_check(
15    repo: &Path,
16    reference: &str,
17    platform: &str,
18    green_exit_code: i32,
19) -> Result<Receipt, String> {
20    let commit = crate::capture::resolve_sha(repo, &None)?;
21    let ran_at = OffsetDateTime::now_utc()
22        .format(&Rfc3339)
23        .map_err(|e| format!("timestamp: {e}"))?;
24    let status = Command::new("sh")
25        .arg("-c")
26        .arg(reference)
27        .current_dir(repo)
28        .status()
29        .map_err(|e| format!("cannot run {reference:?}: {e}"))?;
30    // 126 (not executable) / 127 (command not found) from `sh -c` mean the selector could not be
31    // EXECUTED as a command — a typo, a missing binary. That is NOT a clean pass/fail: treating it
32    // as `red` would let a broken counter-test "flip" against a passing check and read as a proven
33    // green (a false-green). Surface it as an error so the caller records it honestly (the check
34    // path → not-run; the counter-test path → unproven), never as a meaningful result.
35    // LIMIT (honest, unavoidable under `sh -c`): a command that *intentionally* exits 126/127 is
36    // indistinguishable from a missing one, so it is treated as un-executed too. We err toward
37    // not-run/unproven (which GATE), never toward a false-green; a check should not use 126/127 as a
38    // meaningful exit code.
39    if matches!(status.code(), Some(126) | Some(127)) {
40        return Err(format!(
41            "{reference:?} could not be executed (exit {})",
42            status.code().unwrap_or(127)
43        ));
44    }
45    // exit == the configured green code is green; anything else (incl. signal kills) is red.
46    let result = if status.code() == Some(green_exit_code) {
47        "green"
48    } else {
49        "red"
50    };
51    Ok(Receipt {
52        test: reference.to_string(),
53        platform: platform.to_string(),
54        commit,
55        ran_at,
56        result: result.to_string(),
57        falsifiable: None,
58    })
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    // A fresh git repo (with one empty commit, so HEAD resolves) for resolve_sha.
66    fn git_repo() -> std::path::PathBuf {
67        use std::sync::atomic::{AtomicU64, Ordering};
68        static N: AtomicU64 = AtomicU64::new(0);
69        let p = std::env::temp_dir().join(format!(
70            "ev-runner-{}-{}",
71            std::process::id(),
72            N.fetch_add(1, Ordering::Relaxed)
73        ));
74        let _ = std::fs::remove_dir_all(&p);
75        std::fs::create_dir_all(&p).unwrap();
76        for args in [
77            ["init"].as_slice(),
78            ["config", "user.email", "t@e.st"].as_slice(),
79            ["config", "user.name", "Tester"].as_slice(),
80            ["commit", "--allow-empty", "-m", "init"].as_slice(),
81        ] {
82            Command::new("git")
83                .args(args)
84                .current_dir(&p)
85                .output()
86                .unwrap();
87        }
88        p
89    }
90
91    #[test]
92    fn run_check_should_record_green_when_the_command_exits_zero() {
93        // given: a git repo and a bound command that succeeds
94        let repo = git_repo();
95
96        // when: the bound ref is run on platform "local"
97        let r = run_check(&repo, "true", "local", 0).expect("ok");
98
99        // then: the receipt is green for that platform, test, and a 40-hex commit
100        assert_eq!(r.result, "green");
101        assert_eq!(r.platform, "local");
102        assert_eq!(r.test, "true");
103        assert_eq!(r.commit.len(), 40);
104    }
105
106    #[test]
107    fn run_check_should_record_red_when_the_command_exits_nonzero() {
108        // given: a git repo and a bound command that fails
109        let repo = git_repo();
110
111        // when: the bound ref is run
112        let r = run_check(&repo, "false", "local", 0).expect("ok");
113
114        // then: the receipt is red
115        assert_eq!(r.result, "red");
116    }
117
118    #[test]
119    fn run_check_should_error_when_the_command_cannot_execute() {
120        // given: a git repo and a selector that is not a runnable command (sh exits 127)
121        let repo = git_repo();
122
123        // when: the bound ref is run
124        let r = run_check(&repo, "this_is_not_a_real_command_xyz123", "local", 0);
125
126        // then: it is an Err (could-not-execute), NOT a clean red — so a green check is never paired
127        // with a counter that merely failed to run (which would read as a false-green "proven")
128        assert!(
129            r.is_err(),
130            "a non-runnable selector must error, not return a red receipt"
131        );
132    }
133}