Skip to main content

sparrow/
project_test.rs

1use serde::Serialize;
2use std::path::Path;
3
4#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
5pub struct TestRunner {
6    pub name: String,
7    pub command: String,
8    pub args: Vec<String>,
9}
10
11impl TestRunner {
12    pub fn display_command(&self) -> String {
13        std::iter::once(self.command.as_str())
14            .chain(self.args.iter().map(String::as_str))
15            .collect::<Vec<_>>()
16            .join(" ")
17    }
18}
19
20pub fn detect_test_runner(root: &Path) -> Option<TestRunner> {
21    if root.join("Cargo.toml").exists() {
22        return Some(TestRunner {
23            name: "cargo".into(),
24            command: "cargo".into(),
25            args: vec!["test".into(), "--all-targets".into()],
26        });
27    }
28    if root.join("package.json").exists() {
29        return Some(TestRunner {
30            name: "npm".into(),
31            command: "npm".into(),
32            args: vec!["test".into()],
33        });
34    }
35    if root.join("pyproject.toml").exists()
36        || root.join("pytest.ini").exists()
37        || root.join("setup.cfg").exists()
38    {
39        return Some(TestRunner {
40            name: "pytest".into(),
41            command: "python".into(),
42            args: vec!["-m".into(), "pytest".into()],
43        });
44    }
45    None
46}
47
48/// Ground-truth verification result: did the project's build/test actually pass?
49#[derive(Debug, Clone)]
50pub struct VerificationOutcome {
51    pub passed: bool,
52    pub summary: String,
53    pub command: String,
54}
55
56fn tail(s: &str, max: usize) -> String {
57    if s.len() <= max {
58        s.to_string()
59    } else {
60        format!("…{}", &s[s.len() - max..])
61    }
62}
63
64/// Run a ground-truth verification in `root`, sandboxed to the workspace: the
65/// explicit `verify_command` if set, else an auto-detected test runner. Returns
66/// `None` when there is nothing to run (no command and no recognized project),
67/// so callers can fall back to a softer review.
68///
69/// This is the compiler/test-backed signal that turns a verifier from "an LLM's
70/// opinion" into "it actually ran" — the strongest verification signal there is.
71pub async fn run_verification(
72    root: &Path,
73    verify_command: Option<&str>,
74) -> Option<VerificationOutcome> {
75    use crate::sandbox::{Command, Limits, LocalSandbox, Sandbox};
76    use std::collections::HashMap;
77
78    let (program, args, display) = match verify_command.map(str::trim).filter(|c| !c.is_empty()) {
79        Some(cmd) => {
80            let (sh, flag) = if cfg!(windows) {
81                ("cmd", "/c")
82            } else {
83                ("sh", "-c")
84            };
85            (
86                sh.to_string(),
87                vec![flag.to_string(), cmd.to_string()],
88                cmd.to_string(),
89            )
90        }
91        None => {
92            let runner = detect_test_runner(root)?;
93            (
94                runner.command.clone(),
95                runner.args.clone(),
96                runner.display_command(),
97            )
98        }
99    };
100
101    let sandbox = LocalSandbox::new(root.to_path_buf());
102    let command = Command {
103        program,
104        args,
105        env: HashMap::new(),
106        workdir: root.to_path_buf(),
107    };
108    let limits = Limits {
109        timeout_ms: 180_000,
110        max_output_bytes: 256 * 1024,
111    };
112    let result = sandbox.exec(&command, &limits).await.ok()?;
113
114    let mut summary = String::new();
115    if !result.stdout.trim().is_empty() {
116        summary.push_str(&tail(result.stdout.trim(), 1500));
117    }
118    if !result.stderr.trim().is_empty() {
119        if !summary.is_empty() {
120            summary.push('\n');
121        }
122        summary.push_str(&tail(result.stderr.trim(), 1500));
123    }
124
125    Some(VerificationOutcome {
126        passed: result.exit_code == 0,
127        summary: summary.trim().to_string(),
128        command: display,
129    })
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn detects_cargo_runner_first() {
138        let temp = tempfile::tempdir().unwrap();
139        std::fs::write(temp.path().join("Cargo.toml"), "[package]\nname='x'\n").unwrap();
140        let runner = detect_test_runner(temp.path()).unwrap();
141        assert_eq!(runner.name, "cargo");
142        assert_eq!(runner.display_command(), "cargo test --all-targets");
143    }
144
145    #[test]
146    fn detects_node_and_python_runners() {
147        let node = tempfile::tempdir().unwrap();
148        std::fs::write(node.path().join("package.json"), "{}").unwrap();
149        assert_eq!(detect_test_runner(node.path()).unwrap().name, "npm");
150
151        let py = tempfile::tempdir().unwrap();
152        std::fs::write(py.path().join("pyproject.toml"), "[project]\nname='x'\n").unwrap();
153        assert_eq!(detect_test_runner(py.path()).unwrap().name, "pytest");
154    }
155
156    #[tokio::test]
157    async fn verification_runs_and_reports_exit_status() {
158        let dir = tempfile::tempdir().unwrap();
159        // Explicit verify_command: a passing and a failing shell command.
160        let ok = run_verification(dir.path(), Some("exit 0"))
161            .await
162            .expect("command was run");
163        assert!(ok.passed, "exit 0 → passed");
164
165        let bad = run_verification(dir.path(), Some("exit 1"))
166            .await
167            .expect("command was run");
168        assert!(!bad.passed, "exit 1 → failed");
169
170        // Empty workspace, no explicit command → nothing to run.
171        assert!(run_verification(dir.path(), None).await.is_none());
172
173        // Whitespace-only command is treated as no command.
174        assert!(run_verification(dir.path(), Some("   ")).await.is_none());
175    }
176}