返回 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 "cwd": {
61 "type": "string",
62 "description": "Optional working directory, relative to the workspace, to run `cargo test` in. Must exist inside the workspace."
63 }
64 },
65 "additionalProperties": false
66 })
67 }
68
69 fn capabilities(&self) -> Vec<ToolCapability> {
70 vec![ToolCapability::ExecutesCode, ToolCapability::Sandboxable]
71 }
72
73 fn approval_requirement(&self) -> ApprovalRequirement {
74 // `run_tests` declares `ToolCapability::ExecutesCode` — match the
75 // default approval policy for code-executing tools.
76 ApprovalRequirement::Required
77 }
78
79 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
80 crate::core::engine::tool_catalog::enforce_tool_denial(context, self.name(), &input)?;
81 let all_features = optional_bool(&input, "all_features", false)?;
82 let extra_args = optional_str(&input, "args")?
83 .map(str::trim)
84 .filter(|s| !s.is_empty());
85 let workdir = match optional_str(&input, "cwd")?
86 .map(str::trim)
87 .filter(|s| !s.is_empty())
88 {
89 None => context.workspace.clone(),
90 Some(raw) => context.resolve_existing_dir(raw, "cwd")?,
91 };
92
93 let mut args = vec!["test".to_string()];
94 if all_features {
95 args.push("--all-features".to_string());
96 }
97 if let Some(extra) = extra_args {
98 let split = shlex::split(extra).ok_or_else(|| {
99 ToolError::invalid_input("Failed to parse 'args' as shell-style tokens")
100 })?;
101 args.extend(split);
102 }
103
104 let command_str = format_command(&workdir, &args);
105 let output = run_cargo(&workdir, &args)?;
106
107 let exit_code = output.status.code().unwrap_or(-1);
108 let stdout_raw = String::from_utf8_lossy(&output.stdout);
109 let stderr_raw = String::from_utf8_lossy(&output.stderr);
110 let stdout = truncate_with_note(&stdout_raw, MAX_OUTPUT_CHARS);
111 let stderr = truncate_with_note(&stderr_raw, MAX_OUTPUT_CHARS);
112
113 let result = RunTestsOutput {
114 success: output.status.success(),
115 exit_code,
116 stdout,
117 stderr,
118 command: command_str,
119 };
120
121 let mut tool_result =
122 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))?;
123 if let Some(summary) = summarize_cargo_failure(
124 &result.command,
125 &result.stdout,
126 &result.stderr,
127 Some(result.exit_code),
128 ) {
129 tool_result = tool_result.with_metadata(json!({
130 "summary": summary.summary,
131 "cargo_failure_summary": summary.to_metadata_value(),
132 }));
133 }
134 Ok(tool_result)
135 }
136 }
137
138 // === Helpers ===
139
140 fn run_cargo(workspace: &Path, args: &[String]) -> Result<std::process::Output, ToolError> {
141 let Some(mut cmd) = crate::dependencies::Cargo::command() else {
142 return Err(ToolError::not_available(
143 "cargo is not installed or not in PATH",
144 ));
145 };
146 cmd.args(args).current_dir(workspace);
147 cmd.output().map_err(|e| {
148 if e.kind() == std::io::ErrorKind::NotFound {
149 ToolError::not_available("cargo is not installed or not in PATH")
150 } else {
151 ToolError::execution_failed(format!("Failed to run cargo: {e}"))
152 }
153 })
154 }
155
156 fn format_command(workspace: &Path, args: &[String]) -> String {
157 format!(
158 "(cd {} && cargo {})",
159 workspace.display(),
160 args.iter()
161 .map(String::as_str)
162 .collect::<Vec<_>>()
163 .join(" ")
164 )
165 }
166
167 fn truncate_with_note(text: &str, max_chars: usize) -> String {
168 if text.chars().count() <= max_chars {
169 return text.to_string();
170 }
171 let end = char_boundary_index(text, max_chars);
172 let truncated = &text[..end];
173 let omitted_chars = text
174 .chars()
175 .count()
176 .saturating_sub(truncated.chars().count());
177 let note = format!(
178 "\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]"
179 );
180 format!("{truncated}{note}")
181 }
182
183 fn char_boundary_index(text: &str, max_chars: usize) -> usize {
184 if max_chars == 0 {
185 return 0;
186 }
187 for (count, (idx, _)) in text.char_indices().enumerate() {
188 if count == max_chars {
189 return idx;
190 }
191 }
192 text.len()
193 }
194
195 #[cfg(test)]
196 mod tests {
197 use super::*;
198 use std::fs;
199 use std::process::Command;
200 use std::sync::atomic::{AtomicU64, Ordering};
201 use tempfile::tempdir;
202
203 static NEXT_CARGO_PROJECT: AtomicU64 = AtomicU64::new(0);
204
205 fn cargo_available() -> bool {
206 Command::new("cargo")
207 .arg("--version")
208 .output()
209 .map(|o| o.status.success())
210 .unwrap_or(false)
211 }
212
213 fn init_cargo_project(root: &Path) -> std::path::PathBuf {
214 let project_dir = root.join("project");
215 let package_name = format!(
216 "eval_project_{}_{}",
217 std::process::id(),
218 NEXT_CARGO_PROJECT.fetch_add(1, Ordering::Relaxed)
219 );
220 fs::create_dir_all(&project_dir).expect("create project dir");
221 let status = crate::dependencies::Cargo::command()
222 .expect("cargo not found")
223 .args(["init", "--lib", "--vcs", "none", "-q"])
224 .arg("--name")
225 .arg(package_name)
226 .current_dir(&project_dir)
227 .status()
228 .expect("cargo should spawn");
229 assert!(status.success(), "cargo init failed");
230 project_dir
231 }
232
233 /// `run_tests` is `ToolCapability::ExecutesCode`, so it must follow the
234 /// explicit-approval policy that applies to other code-executing tools.
235 #[test]
236 fn run_tests_requires_user_approval() {
237 let tool = RunTestsTool;
238 assert_eq!(
239 tool.approval_requirement(),
240 ApprovalRequirement::Required,
241 "run_tests must gate cargo test behind user approval"
242 );
243 }
244
245 #[tokio::test]
246 async fn run_tests_succeeds_on_fresh_project() {
247 if !cargo_available() {
248 return;
249 }
250 let tmp = tempdir().expect("tempdir");
251 // Release jobs commonly export one CARGO_TARGET_DIR for the whole
252 // workspace. Give concurrent nested Cargo fixtures distinct package
253 // identities so their test artifacts cannot replace each other.
254 let project_dir = init_cargo_project(tmp.path());
255
256 let ctx = ToolContext::new(&project_dir);
257 let tool = RunTestsTool;
258 let result = tool.execute(json!({}), &ctx).await.expect("execute");
259 assert!(result.success);
260
261 let parsed: RunTestsOutput =
262 serde_json::from_str(&result.content).expect("tool result should be json");
263 assert!(
264 parsed.success,
265 "nested cargo test unexpectedly failed:\n{}",
266 parsed.stderr
267 );
268 assert_eq!(parsed.exit_code, 0);
269 assert!(parsed.command.contains("cargo test"));
270 }
271
272 #[tokio::test]
273 async fn run_tests_reports_failures_without_hard_error() {
274 if !cargo_available() {
275 return;
276 }
277 let tmp = tempdir().expect("tempdir");
278 let project_dir = init_cargo_project(tmp.path());
279
280 let lib_rs = project_dir.join("src/lib.rs");
281 let failing = r#"
282 pub fn add(a: i32, b: i32) -> i32 { a + b }
283
284 #[cfg(test)]
285 mod tests {
286 #[test]
287 fn fails() {
288 assert_eq!(2 + 2, 5);
289 }
290 }
291 "#;
292 fs::write(&lib_rs, failing).expect("write failing test");
293
294 let ctx = ToolContext::new(&project_dir);
295 let tool = RunTestsTool;
296 let result = tool.execute(json!({}), &ctx).await.expect("execute");
297 assert!(result.success);
298
299 let parsed: RunTestsOutput =
300 serde_json::from_str(&result.content).expect("tool result should be json");
301 assert!(
302 !parsed.success,
303 "nested cargo test unexpectedly passed:\nstdout:\n{}\nstderr:\n{}",
304 parsed.stdout, parsed.stderr
305 );
306 assert_ne!(parsed.exit_code, 0);
307 let metadata = result.metadata.expect("metadata");
308 assert_eq!(
309 metadata["cargo_failure_summary"]["kind"],
310 json!("test_failure")
311 );
312 assert!(
313 metadata["cargo_failure_summary"]["summary"]
314 .as_str()
315 .unwrap()
316 .contains("Failing tests:")
317 );
318 }
319
320 #[test]
321 fn truncation_adds_note() {
322 let long = "x".repeat(MAX_OUTPUT_CHARS + 128);
323 let truncated = truncate_with_note(&long, MAX_OUTPUT_CHARS);
324 assert!(truncated.contains("output truncated"));
325 }
326
327 /// A child parked at a workspace root that is not the project root (the
328 /// #6296 verifier) runs the suite where the manifest lives instead of
329 /// failing on cwd.
330 #[tokio::test]
331 async fn run_tests_cwd_scopes_cargo_to_subdir() {
332 if !cargo_available() {
333 return;
334 }
335 let tmp = tempdir().expect("tempdir");
336 let project_dir = init_cargo_project(tmp.path());
337
338 let ctx = ToolContext::new(tmp.path());
339 let result = RunTestsTool
340 .execute(json!({"cwd": "project"}), &ctx)
341 .await
342 .expect("cwd-scoped execute");
343 assert!(result.success);
344
345 let parsed: RunTestsOutput =
346 serde_json::from_str(&result.content).expect("tool result should be json");
347 assert!(
348 parsed.success,
349 "nested cargo test unexpectedly failed:\\n{}",
350 parsed.stderr
351 );
352 // `resolve_existing_dir` returns the canonical path, which on Windows
353 // carries the `\\?\` verbatim prefix the raw tempdir lacks (#6346).
354 let scoped_dir = project_dir.canonicalize().expect("canonical project dir");
355 assert!(
356 parsed.command.contains(&scoped_dir.display().to_string()),
357 "cargo must run in the scoped dir, ran: {}",
358 parsed.command
359 );
360 }
361
362 #[tokio::test]
363 async fn run_tests_cwd_fails_closed_with_a_named_fallback() {
364 let tmp = tempdir().expect("tempdir");
365 let ctx = ToolContext::new(tmp.path());
366
367 let escape = RunTestsTool
368 .execute(json!({"cwd": "../escape"}), &ctx)
369 .await
370 .expect_err("workspace escape must be refused");
371 assert!(escape.to_string().contains("escapes workspace"), "{escape}");
372
373 let missing = RunTestsTool
374 .execute(json!({"cwd": "no-such-dir"}), &ctx)
375 .await
376 .expect_err("missing dir must be refused");
377 let message = missing.to_string();
378 assert!(message.contains("not an existing directory"), "{message}");
379 assert!(message.contains("drop `cwd`"), "{message}");
380 }
381 }
382
382 lines RUST