返回 DeepSeek-TUI-2026
test_runner.rs
根目录 / crates / tui / src / tools / test_runner.rs
1 //! Cargo test runner tool: `run_tests`.
2 //!
3 //! This tool intentionally auto-approves test execution to encourage
4 //! frequent verification loops while still scoping execution to the workspace.
5
6 use std::path::Path;
7 use std::process::Command;
8
9 use async_trait::async_trait;
10 use serde::{Deserialize, Serialize};
11 use serde_json::{Value, json};
12
13 use super::spec::{
14 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
15 optional_bool, optional_str,
16 };
17
18 const MAX_OUTPUT_CHARS: usize = 40_000;
19
20 /// Tool for running `cargo test` in the workspace root.
21 pub struct RunTestsTool;
22
23 #[derive(Debug, Clone, Serialize, Deserialize)]
24 struct RunTestsOutput {
25 success: bool,
26 exit_code: i32,
27 stdout: String,
28 stderr: String,
29 command: String,
30 }
31
32 #[async_trait]
33 impl ToolSpec for RunTestsTool {
34 fn name(&self) -> &'static str {
35 "run_tests"
36 }
37
38 fn description(&self) -> &'static str {
39 "Run `cargo test` in the workspace root with optional extra arguments."
40 }
41
42 fn input_schema(&self) -> Value {
43 json!({
44 "type": "object",
45 "properties": {
46 "args": {
47 "type": "string",
48 "description": "Optional extra arguments to pass to `cargo test` (shell-style)."
49 },
50 "all_features": {
51 "type": "boolean",
52 "description": "When true, include `--all-features`."
53 }
54 },
55 "additionalProperties": false
56 })
57 }
58
59 fn capabilities(&self) -> Vec<ToolCapability> {
60 vec![ToolCapability::ExecutesCode, ToolCapability::Sandboxable]
61 }
62
63 fn approval_requirement(&self) -> ApprovalRequirement {
64 // Tests are encouraged, so avoid gating them behind approval.
65 ApprovalRequirement::Auto
66 }
67
68 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
69 let all_features = optional_bool(&input, "all_features", false);
70 let extra_args = optional_str(&input, "args")
71 .map(str::trim)
72 .filter(|s| !s.is_empty());
73
74 let mut args = vec!["test".to_string()];
75 if all_features {
76 args.push("--all-features".to_string());
77 }
78 if let Some(extra) = extra_args {
79 let split = shlex::split(extra).ok_or_else(|| {
80 ToolError::invalid_input("Failed to parse 'args' as shell-style tokens")
81 })?;
82 args.extend(split);
83 }
84
85 let command_str = format_command(&context.workspace, &args);
86 let output = run_cargo(&context.workspace, &args)?;
87
88 let exit_code = output.status.code().unwrap_or(-1);
89 let stdout_raw = String::from_utf8_lossy(&output.stdout);
90 let stderr_raw = String::from_utf8_lossy(&output.stderr);
91 let stdout = truncate_with_note(&stdout_raw, MAX_OUTPUT_CHARS);
92 let stderr = truncate_with_note(&stderr_raw, MAX_OUTPUT_CHARS);
93
94 let result = RunTestsOutput {
95 success: output.status.success(),
96 exit_code,
97 stdout,
98 stderr,
99 command: command_str,
100 };
101
102 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
103 }
104 }
105
106 // === Helpers ===
107
108 fn run_cargo(workspace: &Path, args: &[String]) -> Result<std::process::Output, ToolError> {
109 let mut cmd = Command::new("cargo");
110 cmd.args(args).current_dir(workspace);
111 cmd.output().map_err(|e| {
112 if e.kind() == std::io::ErrorKind::NotFound {
113 ToolError::not_available("cargo is not installed or not in PATH")
114 } else {
115 ToolError::execution_failed(format!("Failed to run cargo: {e}"))
116 }
117 })
118 }
119
120 fn format_command(workspace: &Path, args: &[String]) -> String {
121 format!(
122 "(cd {} && cargo {})",
123 workspace.display(),
124 args.iter()
125 .map(String::as_str)
126 .collect::<Vec<_>>()
127 .join(" ")
128 )
129 }
130
131 fn truncate_with_note(text: &str, max_chars: usize) -> String {
132 if text.chars().count() <= max_chars {
133 return text.to_string();
134 }
135 let end = char_boundary_index(text, max_chars);
136 let truncated = &text[..end];
137 let omitted_chars = text
138 .chars()
139 .count()
140 .saturating_sub(truncated.chars().count());
141 let note = format!(
142 "\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]"
143 );
144 format!("{truncated}{note}")
145 }
146
147 fn char_boundary_index(text: &str, max_chars: usize) -> usize {
148 if max_chars == 0 {
149 return 0;
150 }
151 for (count, (idx, _)) in text.char_indices().enumerate() {
152 if count == max_chars {
153 return idx;
154 }
155 }
156 text.len()
157 }
158
159 #[cfg(test)]
160 mod tests {
161 use super::*;
162 use std::fs;
163 use std::process::Command;
164 use tempfile::tempdir;
165
166 fn cargo_available() -> bool {
167 Command::new("cargo")
168 .arg("--version")
169 .output()
170 .map(|o| o.status.success())
171 .unwrap_or(false)
172 }
173
174 fn init_cargo_project(root: &Path) -> std::path::PathBuf {
175 let project_dir = root.join("project");
176 fs::create_dir_all(&project_dir).expect("create project dir");
177 let status = Command::new("cargo")
178 .args([
179 "init",
180 "--lib",
181 "--vcs",
182 "none",
183 "-q",
184 "--name",
185 "eval_project",
186 ])
187 .current_dir(&project_dir)
188 .status()
189 .expect("cargo should spawn");
190 assert!(status.success(), "cargo init failed");
191 project_dir
192 }
193
194 #[tokio::test]
195 async fn run_tests_succeeds_on_fresh_project() {
196 if !cargo_available() {
197 return;
198 }
199 let tmp = tempdir().expect("tempdir");
200 let project_dir = init_cargo_project(tmp.path());
201
202 let ctx = ToolContext::new(&project_dir);
203 let tool = RunTestsTool;
204 let result = tool.execute(json!({}), &ctx).await.expect("execute");
205 assert!(result.success);
206
207 let parsed: RunTestsOutput =
208 serde_json::from_str(&result.content).expect("tool result should be json");
209 assert!(parsed.success);
210 assert_eq!(parsed.exit_code, 0);
211 assert!(parsed.command.contains("cargo test"));
212 }
213
214 #[tokio::test]
215 async fn run_tests_reports_failures_without_hard_error() {
216 if !cargo_available() {
217 return;
218 }
219 let tmp = tempdir().expect("tempdir");
220 let project_dir = init_cargo_project(tmp.path());
221
222 let lib_rs = project_dir.join("src/lib.rs");
223 let failing = r#"
224 pub fn add(a: i32, b: i32) -> i32 { a + b }
225
226 #[cfg(test)]
227 mod tests {
228 #[test]
229 fn fails() {
230 assert_eq!(2 + 2, 5);
231 }
232 }
233 "#;
234 fs::write(&lib_rs, failing).expect("write failing test");
235
236 let ctx = ToolContext::new(&project_dir);
237 let tool = RunTestsTool;
238 let result = tool.execute(json!({}), &ctx).await.expect("execute");
239 assert!(result.success);
240
241 let parsed: RunTestsOutput =
242 serde_json::from_str(&result.content).expect("tool result should be json");
243 assert!(!parsed.success);
244 assert_ne!(parsed.exit_code, 0);
245 }
246
247 #[test]
248 fn truncation_adds_note() {
249 let long = "x".repeat(MAX_OUTPUT_CHARS + 128);
250 let truncated = truncate_with_note(&long, MAX_OUTPUT_CHARS);
251 assert!(truncated.contains("output truncated"));
252 }
253 }
254
254 lines RUST