返回 CodeWhale
test_runner.rs
根目录 / crates / tui / src / tools / test_runner.rs
1 //! Cargo test runner tool: `run_tests`.
2 //!
3 //! `cargo test` runs workspace code, so this tool follows the same explicit
4 //! approval policy as the other code-executing tools.
5
6 use std::path::Path;
7
8 use async_trait::async_trait;
9 use serde::{Deserialize, Serialize};
10 use serde_json::{Value, json};
11
12 use super::cargo_failure_summary::summarize_cargo_failure;
13 use super::spec::{
14 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
15 optional_bool, optional_str,
16 };
17
18 use crate::dependencies::ExternalTool;
19
20 const MAX_OUTPUT_CHARS: usize = 40_000;
21
22 /// Tool for running `cargo test` in the workspace root.
23 pub struct RunTestsTool;
24
25 #[derive(Debug, Clone, Serialize, Deserialize)]
26 struct RunTestsOutput {
27 success: bool,
28 exit_code: i32,
29 stdout: String,
30 stderr: String,
31 command: String,
32 }
33
34 #[async_trait]
35 impl ToolSpec for RunTestsTool {
36 fn name(&self) -> &'static str {
37 "run_tests"
38 }
39
40 fn model_visible(&self) -> bool {
41 false
42 }
43
44 fn description(&self) -> &'static str {
45 "Run `cargo test` in the workspace root with optional extra arguments."
46 }
47
48 fn input_schema(&self) -> Value {
49 json!({
50 "type": "object",
51 "properties": {
52 "args": {
53 "type": "string",
54 "description": "Optional extra arguments to pass to `cargo test` (shell-style)."
55 },
56 "all_features": {
57 "type": "boolean",
58 "description": "When true, include `--all-features`."
59 }
60 },
61 "additionalProperties": false
62 })
63 }
64
65 fn capabilities(&self) -> Vec<ToolCapability> {
66 vec![ToolCapability::ExecutesCode, ToolCapability::Sandboxable]
67 }
68
69 fn approval_requirement(&self) -> ApprovalRequirement {
70 // `run_tests` declares `ToolCapability::ExecutesCode` — match the
71 // default approval policy for code-executing tools.
72 ApprovalRequirement::Required
73 }
74
75 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
76 let all_features = optional_bool(&input, "all_features", false)?;
77 let extra_args = optional_str(&input, "args")?
78 .map(str::trim)
79 .filter(|s| !s.is_empty());
80
81 let mut args = vec!["test".to_string()];
82 if all_features {
83 args.push("--all-features".to_string());
84 }
85 if let Some(extra) = extra_args {
86 let split = shlex::split(extra).ok_or_else(|| {
87 ToolError::invalid_input("Failed to parse 'args' as shell-style tokens")
88 })?;
89 args.extend(split);
90 }
91
92 let command_str = format_command(&context.workspace, &args);
93 let output = run_cargo(&context.workspace, &args)?;
94
95 let exit_code = output.status.code().unwrap_or(-1);
96 let stdout_raw = String::from_utf8_lossy(&output.stdout);
97 let stderr_raw = String::from_utf8_lossy(&output.stderr);
98 let stdout = truncate_with_note(&stdout_raw, MAX_OUTPUT_CHARS);
99 let stderr = truncate_with_note(&stderr_raw, MAX_OUTPUT_CHARS);
100
101 let result = RunTestsOutput {
102 success: output.status.success(),
103 exit_code,
104 stdout,
105 stderr,
106 command: command_str,
107 };
108
109 let mut tool_result =
110 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))?;
111 if let Some(summary) = summarize_cargo_failure(
112 &result.command,
113 &result.stdout,
114 &result.stderr,
115 Some(result.exit_code),
116 ) {
117 tool_result = tool_result.with_metadata(json!({
118 "summary": summary.summary,
119 "cargo_failure_summary": summary.to_metadata_value(),
120 }));
121 }
122 Ok(tool_result)
123 }
124 }
125
126 // === Helpers ===
127
128 fn run_cargo(workspace: &Path, args: &[String]) -> Result<std::process::Output, ToolError> {
129 let Some(mut cmd) = crate::dependencies::Cargo::command() else {
130 return Err(ToolError::not_available(
131 "cargo is not installed or not in PATH",
132 ));
133 };
134 cmd.args(args).current_dir(workspace);
135 cmd.output().map_err(|e| {
136 if e.kind() == std::io::ErrorKind::NotFound {
137 ToolError::not_available("cargo is not installed or not in PATH")
138 } else {
139 ToolError::execution_failed(format!("Failed to run cargo: {e}"))
140 }
141 })
142 }
143
144 fn format_command(workspace: &Path, args: &[String]) -> String {
145 format!(
146 "(cd {} && cargo {})",
147 workspace.display(),
148 args.iter()
149 .map(String::as_str)
150 .collect::<Vec<_>>()
151 .join(" ")
152 )
153 }
154
155 fn truncate_with_note(text: &str, max_chars: usize) -> String {
156 if text.chars().count() <= max_chars {
157 return text.to_string();
158 }
159 let end = char_boundary_index(text, max_chars);
160 let truncated = &text[..end];
161 let omitted_chars = text
162 .chars()
163 .count()
164 .saturating_sub(truncated.chars().count());
165 let note = format!(
166 "\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]"
167 );
168 format!("{truncated}{note}")
169 }
170
171 fn char_boundary_index(text: &str, max_chars: usize) -> usize {
172 if max_chars == 0 {
173 return 0;
174 }
175 for (count, (idx, _)) in text.char_indices().enumerate() {
176 if count == max_chars {
177 return idx;
178 }
179 }
180 text.len()
181 }
182
183 #[cfg(test)]
184 mod tests {
185 use super::*;
186 use std::fs;
187 use std::process::Command;
188 use std::sync::atomic::{AtomicU64, Ordering};
189 use tempfile::tempdir;
190
191 static NEXT_CARGO_PROJECT: AtomicU64 = AtomicU64::new(0);
192
193 fn cargo_available() -> bool {
194 Command::new("cargo")
195 .arg("--version")
196 .output()
197 .map(|o| o.status.success())
198 .unwrap_or(false)
199 }
200
201 fn init_cargo_project(root: &Path) -> std::path::PathBuf {
202 let project_dir = root.join("project");
203 let package_name = format!(
204 "eval_project_{}_{}",
205 std::process::id(),
206 NEXT_CARGO_PROJECT.fetch_add(1, Ordering::Relaxed)
207 );
208 fs::create_dir_all(&project_dir).expect("create project dir");
209 let status = crate::dependencies::Cargo::command()
210 .expect("cargo not found")
211 .args(["init", "--lib", "--vcs", "none", "-q"])
212 .arg("--name")
213 .arg(package_name)
214 .current_dir(&project_dir)
215 .status()
216 .expect("cargo should spawn");
217 assert!(status.success(), "cargo init failed");
218 project_dir
219 }
220
221 /// `run_tests` is `ToolCapability::ExecutesCode`, so it must follow the
222 /// explicit-approval policy that applies to other code-executing tools.
223 #[test]
224 fn run_tests_requires_user_approval() {
225 let tool = RunTestsTool;
226 assert_eq!(
227 tool.approval_requirement(),
228 ApprovalRequirement::Required,
229 "run_tests must gate cargo test behind user approval"
230 );
231 }
232
233 #[tokio::test]
234 async fn run_tests_succeeds_on_fresh_project() {
235 if !cargo_available() {
236 return;
237 }
238 let tmp = tempdir().expect("tempdir");
239 // Release jobs commonly export one CARGO_TARGET_DIR for the whole
240 // workspace. Give concurrent nested Cargo fixtures distinct package
241 // identities so their test artifacts cannot replace each other.
242 let project_dir = init_cargo_project(tmp.path());
243
244 let ctx = ToolContext::new(&project_dir);
245 let tool = RunTestsTool;
246 let result = tool.execute(json!({}), &ctx).await.expect("execute");
247 assert!(result.success);
248
249 let parsed: RunTestsOutput =
250 serde_json::from_str(&result.content).expect("tool result should be json");
251 assert!(
252 parsed.success,
253 "nested cargo test unexpectedly failed:\n{}",
254 parsed.stderr
255 );
256 assert_eq!(parsed.exit_code, 0);
257 assert!(parsed.command.contains("cargo test"));
258 }
259
260 #[tokio::test]
261 async fn run_tests_reports_failures_without_hard_error() {
262 if !cargo_available() {
263 return;
264 }
265 let tmp = tempdir().expect("tempdir");
266 let project_dir = init_cargo_project(tmp.path());
267
268 let lib_rs = project_dir.join("src/lib.rs");
269 let failing = r#"
270 pub fn add(a: i32, b: i32) -> i32 { a + b }
271
272 #[cfg(test)]
273 mod tests {
274 #[test]
275 fn fails() {
276 assert_eq!(2 + 2, 5);
277 }
278 }
279 "#;
280 fs::write(&lib_rs, failing).expect("write failing test");
281
282 let ctx = ToolContext::new(&project_dir);
283 let tool = RunTestsTool;
284 let result = tool.execute(json!({}), &ctx).await.expect("execute");
285 assert!(result.success);
286
287 let parsed: RunTestsOutput =
288 serde_json::from_str(&result.content).expect("tool result should be json");
289 assert!(
290 !parsed.success,
291 "nested cargo test unexpectedly passed:\nstdout:\n{}\nstderr:\n{}",
292 parsed.stdout, parsed.stderr
293 );
294 assert_ne!(parsed.exit_code, 0);
295 let metadata = result.metadata.expect("metadata");
296 assert_eq!(
297 metadata["cargo_failure_summary"]["kind"],
298 json!("test_failure")
299 );
300 assert!(
301 metadata["cargo_failure_summary"]["summary"]
302 .as_str()
303 .unwrap()
304 .contains("Failing tests:")
305 );
306 }
307
308 #[test]
309 fn truncation_adds_note() {
310 let long = "x".repeat(MAX_OUTPUT_CHARS + 128);
311 let truncated = truncate_with_note(&long, MAX_OUTPUT_CHARS);
312 assert!(truncated.contains("output truncated"));
313 }
314 }
315
315 lines RUST