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}