Skip to main content

atman_runtime/tools/
test.rs

1use std::path::{Path, PathBuf};
2use std::time::{Duration, Instant};
3
4use crate::error::RuntimeError;
5use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
6use crate::value::Value;
7
8pub struct TestRun;
9
10impl Tool for TestRun {
11    fn name(&self) -> &str {
12        "test.run"
13    }
14
15    fn tier(&self) -> Tier {
16        Tier::Two
17    }
18
19    fn description(&self) -> Option<&str> {
20        Some(
21            "Run tests with auto-detected framework (cargo/npm/pytest/go). Returns exit code, stdout/stderr tail, duration, timed_out flag. Use scope to filter (e.g. scope: 'integration' for cargo).",
22        )
23    }
24
25    fn input_schema(&self) -> serde_json::Value {
26        serde_json::json!({
27            "type": "object",
28            "properties": {
29                "cwd": {"type": "string", "description": "Working directory to run tests in. Defaults to the current process directory."},
30                "framework": {"type": "string", "enum": ["cargo", "npm", "pytest", "go"], "description": "Optional framework override. Auto-detected from project files when omitted."},
31                "scope": {"type": "string", "description": "Optional framework-specific test filter or path, such as 'integration' for cargo."},
32                "timeout_ms": {"type": "integer", "default": 300000, "description": "Maximum runtime in milliseconds before returning timed_out=true."},
33                "tail_lines": {"type": "integer", "default": 80, "description": "Number of stdout/stderr lines to include from the end of each stream."}
34            }
35        })
36    }
37
38    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
39        Box::pin(async move {
40            let cwd = extract_optional_path(&args, "cwd")
41                .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
42            let framework_override = extract_optional_string(&args, "framework");
43            let scope = extract_optional_string(&args, "scope");
44            let timeout_ms = extract_optional_int(&args, "timeout_ms").unwrap_or(300_000) as u64;
45            let tail_lines = extract_optional_int(&args, "tail_lines").unwrap_or(80) as usize;
46
47            let framework = match framework_override {
48                Some(name) => name,
49                None => detect_framework(&cwd)?,
50            };
51            let cmd = build_command(&framework, scope.as_deref())?;
52
53            let start = Instant::now();
54            let mut child = tokio::process::Command::new(&cmd[0]);
55            child.args(&cmd[1..]).current_dir(&cwd);
56            let spawn_start = Instant::now();
57            let output_fut = child.output();
58            let output = match tokio::time::timeout(Duration::from_millis(timeout_ms), output_fut)
59                .await
60            {
61                Ok(Ok(out)) => out,
62                Ok(Err(e)) => {
63                    return Err(RuntimeError::ToolFailed(format!(
64                        "test.run spawn `{}`: {e}",
65                        cmd.join(" ")
66                    )));
67                }
68                Err(_) => {
69                    return Ok(Value::Struct(vec![
70                        ("exit".into(), Value::Int(-1)),
71                        ("framework".into(), Value::Str(framework)),
72                        ("stdout_tail".into(), Value::Str(String::new())),
73                        (
74                            "stderr_tail".into(),
75                            Value::Str(format!("[atman] test.run timeout after {timeout_ms}ms")),
76                        ),
77                        (
78                            "duration_ms".into(),
79                            Value::Int(spawn_start.elapsed().as_millis() as i64),
80                        ),
81                        ("timed_out".into(), Value::Bool(true)),
82                        ("cmd".into(), Value::Str(cmd.join(" "))),
83                    ]));
84                }
85            };
86            let duration_ms = start.elapsed().as_millis() as i64;
87            let exit = output.status.code().unwrap_or(-1) as i64;
88            let stdout = String::from_utf8_lossy(&output.stdout);
89            let stderr = String::from_utf8_lossy(&output.stderr);
90            Ok(Value::Struct(vec![
91                ("exit".into(), Value::Int(exit)),
92                ("framework".into(), Value::Str(framework)),
93                (
94                    "stdout_tail".into(),
95                    Value::Str(tail_of(&stdout, tail_lines)),
96                ),
97                (
98                    "stderr_tail".into(),
99                    Value::Str(tail_of(&stderr, tail_lines)),
100                ),
101                ("duration_ms".into(), Value::Int(duration_ms)),
102                ("timed_out".into(), Value::Bool(false)),
103                ("cmd".into(), Value::Str(cmd.join(" "))),
104            ]))
105        })
106    }
107}
108
109fn detect_framework(cwd: &Path) -> Result<String, RuntimeError> {
110    if cwd.join("Cargo.toml").exists() {
111        return Ok("cargo".into());
112    }
113    if cwd.join("package.json").exists() {
114        return Ok("npm".into());
115    }
116    if cwd.join("pyproject.toml").exists() || cwd.join("pytest.ini").exists() {
117        return Ok("pytest".into());
118    }
119    if cwd.join("go.mod").exists() {
120        return Ok("go".into());
121    }
122    Err(RuntimeError::ToolFailed(format!(
123        "test.run: no known test framework detected in {} (looked for Cargo.toml / package.json / pyproject.toml / go.mod). Pass `framework:` to override.",
124        cwd.display()
125    )))
126}
127
128fn build_command(framework: &str, scope: Option<&str>) -> Result<Vec<String>, RuntimeError> {
129    Ok(match framework {
130        "cargo" => match scope {
131            Some(s) => vec!["cargo".into(), "test".into(), "--".into(), s.into()],
132            None => vec!["cargo".into(), "test".into()],
133        },
134        "npm" => match scope {
135            Some(s) => vec!["npm".into(), "test".into(), "--".into(), s.into()],
136            None => vec!["npm".into(), "test".into()],
137        },
138        "pytest" => match scope {
139            Some(s) => vec!["pytest".into(), s.into()],
140            None => vec!["pytest".into()],
141        },
142        "go" => match scope {
143            Some(s) => vec!["go".into(), "test".into(), s.into()],
144            None => vec!["go".into(), "test".into(), "./...".into()],
145        },
146        other => {
147            return Err(RuntimeError::ToolFailed(format!(
148                "test.run: unknown framework `{other}` (want cargo | npm | pytest | go)"
149            )));
150        }
151    })
152}
153
154fn tail_of(s: &str, n: usize) -> String {
155    let lines: Vec<&str> = s.lines().collect();
156    let start = lines.len().saturating_sub(n);
157    lines[start..].join("\n")
158}
159
160fn extract_optional_string(args: &ToolArgs, name: &str) -> Option<String> {
161    match args.named(name)? {
162        Value::Str(s) => Some(s.clone()),
163        _ => None,
164    }
165}
166
167fn extract_optional_int(args: &ToolArgs, name: &str) -> Option<i64> {
168    match args.named(name)? {
169        Value::Int(n) => Some(*n),
170        _ => None,
171    }
172}
173
174fn extract_optional_path(args: &ToolArgs, name: &str) -> Option<PathBuf> {
175    match args.named(name)? {
176        Value::Path(p) => Some(p.clone()),
177        Value::Str(s) => Some(PathBuf::from(s)),
178        _ => None,
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn detect_cargo_from_cargo_toml() {
188        let dir = tempfile::tempdir().unwrap();
189        std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
190        assert_eq!(detect_framework(dir.path()).unwrap(), "cargo");
191    }
192
193    #[test]
194    fn detect_npm_from_package_json() {
195        let dir = tempfile::tempdir().unwrap();
196        std::fs::write(dir.path().join("package.json"), "{}").unwrap();
197        assert_eq!(detect_framework(dir.path()).unwrap(), "npm");
198    }
199
200    #[test]
201    fn detect_pytest_from_pyproject_toml() {
202        let dir = tempfile::tempdir().unwrap();
203        std::fs::write(dir.path().join("pyproject.toml"), "").unwrap();
204        assert_eq!(detect_framework(dir.path()).unwrap(), "pytest");
205    }
206
207    #[test]
208    fn detect_pytest_from_pytest_ini() {
209        let dir = tempfile::tempdir().unwrap();
210        std::fs::write(dir.path().join("pytest.ini"), "").unwrap();
211        assert_eq!(detect_framework(dir.path()).unwrap(), "pytest");
212    }
213
214    #[test]
215    fn detect_go_from_go_mod() {
216        let dir = tempfile::tempdir().unwrap();
217        std::fs::write(dir.path().join("go.mod"), "module x").unwrap();
218        assert_eq!(detect_framework(dir.path()).unwrap(), "go");
219    }
220
221    #[test]
222    fn detect_cargo_wins_over_go_when_both_present() {
223        let dir = tempfile::tempdir().unwrap();
224        std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
225        std::fs::write(dir.path().join("go.mod"), "module x").unwrap();
226        assert_eq!(detect_framework(dir.path()).unwrap(), "cargo");
227    }
228
229    #[test]
230    fn detect_errors_when_no_markers() {
231        let dir = tempfile::tempdir().unwrap();
232        let err = detect_framework(dir.path()).unwrap_err();
233        assert!(format!("{err}").contains("no known test framework"));
234    }
235
236    #[test]
237    fn build_cargo_command_with_scope_appends_after_dash_dash() {
238        let cmd = build_command("cargo", Some("integration")).unwrap();
239        assert_eq!(cmd, vec!["cargo", "test", "--", "integration"]);
240    }
241
242    #[test]
243    fn build_go_command_defaults_to_all_packages() {
244        let cmd = build_command("go", None).unwrap();
245        assert_eq!(cmd, vec!["go", "test", "./..."]);
246    }
247
248    #[test]
249    fn build_unknown_framework_errors() {
250        let err = build_command("mocha", None).unwrap_err();
251        assert!(format!("{err}").contains("unknown framework"));
252    }
253
254    #[tokio::test]
255    async fn test_run_returns_all_structured_fields() {
256        let dir = tempfile::tempdir().unwrap();
257        std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
258        let tool = TestRun;
259        let ctx = ToolCtx::new();
260        let args = ToolArgs {
261            positional: vec![],
262            named: vec![
263                ("framework".into(), Value::Str("cargo".into())),
264                ("cwd".into(), Value::Path(dir.path().to_path_buf())),
265                ("timeout_ms".into(), Value::Int(30_000)),
266            ],
267        };
268        let v = tool.call(args, &ctx).await.unwrap();
269        let Value::Struct(fields) = v else {
270            panic!("expected struct");
271        };
272        let f = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
273        assert!(matches!(f("framework"), Some(Value::Str(s)) if s == "cargo"));
274        assert!(matches!(f("exit"), Some(Value::Int(_))));
275        assert!(matches!(f("duration_ms"), Some(Value::Int(_))));
276        assert!(matches!(f("timed_out"), Some(Value::Bool(_))));
277        assert!(matches!(f("cmd"), Some(Value::Str(s)) if s.starts_with("cargo test")));
278    }
279
280    #[test]
281    fn tail_of_returns_last_n_lines() {
282        let s = "a\nb\nc\nd\ne";
283        assert_eq!(tail_of(s, 3), "c\nd\ne");
284        assert_eq!(tail_of(s, 10), s);
285        assert_eq!(tail_of("", 3), "");
286    }
287}