1use super::Tool;
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::path::PathBuf;
7use std::process::Stdio;
8use tokio::io::AsyncReadExt;
9use tokio::process::Command;
10use tokio::time::{timeout, Duration};
11
12pub struct BashTool {
14 workspace_root: PathBuf,
15}
16
17impl BashTool {
18 pub fn new(workspace_root: PathBuf) -> Self {
19 Self { workspace_root }
20 }
21}
22
23#[async_trait]
24impl Tool for BashTool {
25 fn name(&self) -> &str {
26 "bash"
27 }
28
29 fn description(&self) -> &str {
30 "Execute a bash command. Use for git, cargo, and other shell operations. \
31 Commands run in the workspace root directory."
32 }
33
34 fn parameters_schema(&self) -> Value {
35 json!({
36 "type": "object",
37 "properties": {
38 "command": {
39 "type": "string",
40 "description": "The bash command to execute"
41 },
42 "workdir": {
43 "type": "string",
44 "description": "Working directory (optional, defaults to workspace root)"
45 },
46 "timeout_secs": {
47 "type": "integer",
48 "description": "Timeout in seconds (default: 120)"
49 },
50 "description": {
51 "type": "string",
52 "description": "Brief description of what this command does"
53 }
54 },
55 "required": ["command"]
56 })
57 }
58
59 async fn execute(&self, args: Value) -> crate::Result<Value> {
60 let command = args["command"]
61 .as_str()
62 .ok_or_else(|| crate::PawanError::Tool("command is required".into()))?;
63
64 let workdir = args["workdir"]
65 .as_str()
66 .map(|p| self.workspace_root.join(p))
67 .unwrap_or_else(|| self.workspace_root.clone());
68
69 let timeout_secs = args["timeout_secs"]
70 .as_u64()
71 .unwrap_or(crate::DEFAULT_BASH_TIMEOUT);
72 let description = args["description"].as_str().unwrap_or("");
73
74 if !workdir.exists() {
76 return Err(crate::PawanError::NotFound(format!(
77 "Working directory not found: {}",
78 workdir.display()
79 )));
80 }
81
82 let mut cmd = Command::new("bash");
84 cmd.arg("-c")
85 .arg(command)
86 .current_dir(&workdir)
87 .stdout(Stdio::piped())
88 .stderr(Stdio::piped())
89 .stdin(Stdio::null());
90
91 let result = timeout(Duration::from_secs(timeout_secs), async {
93 let mut child = cmd.spawn().map_err(crate::PawanError::Io)?;
94
95 let mut stdout = String::new();
96 let mut stderr = String::new();
97
98 if let Some(mut stdout_handle) = child.stdout.take() {
99 stdout_handle.read_to_string(&mut stdout).await.ok();
100 }
101
102 if let Some(mut stderr_handle) = child.stderr.take() {
103 stderr_handle.read_to_string(&mut stderr).await.ok();
104 }
105
106 let status = child.wait().await.map_err(crate::PawanError::Io)?;
107
108 Ok::<_, crate::PawanError>((status, stdout, stderr))
109 })
110 .await;
111
112 match result {
113 Ok(Ok((status, stdout, stderr))) => {
114 let max_output = 50000;
116 let stdout_truncated = stdout.len() > max_output;
117 let stderr_truncated = stderr.len() > max_output;
118
119 let stdout_display = if stdout_truncated {
120 format!(
121 "{}...[truncated, {} bytes total]",
122 &stdout[..max_output],
123 stdout.len()
124 )
125 } else {
126 stdout
127 };
128
129 let stderr_display = if stderr_truncated {
130 format!(
131 "{}...[truncated, {} bytes total]",
132 &stderr[..max_output],
133 stderr.len()
134 )
135 } else {
136 stderr
137 };
138
139 Ok(json!({
140 "success": status.success(),
141 "exit_code": status.code().unwrap_or(-1),
142 "stdout": stdout_display,
143 "stderr": stderr_display,
144 "description": description,
145 "command": command
146 }))
147 }
148 Ok(Err(e)) => Err(e),
149 Err(_) => Err(crate::PawanError::Timeout(format!(
150 "Command timed out after {} seconds: {}",
151 timeout_secs, command
152 ))),
153 }
154 }
155}
156
157pub struct CargoCommands;
159
160impl CargoCommands {
161 pub fn build() -> Value {
163 json!({
164 "command": "cargo build 2>&1",
165 "description": "Build the project"
166 })
167 }
168
169 pub fn build_all_features() -> Value {
171 json!({
172 "command": "cargo build --all-features 2>&1",
173 "description": "Build with all features enabled"
174 })
175 }
176
177 pub fn test() -> Value {
179 json!({
180 "command": "cargo test 2>&1",
181 "description": "Run all tests"
182 })
183 }
184
185 pub fn test_name(name: &str) -> Value {
187 json!({
188 "command": format!("cargo test {} 2>&1", name),
189 "description": format!("Run test: {}", name)
190 })
191 }
192
193 pub fn clippy() -> Value {
195 json!({
196 "command": "cargo clippy 2>&1",
197 "description": "Run clippy linter"
198 })
199 }
200
201 pub fn fmt_check() -> Value {
203 json!({
204 "command": "cargo fmt --check 2>&1",
205 "description": "Check code formatting"
206 })
207 }
208
209 pub fn fmt() -> Value {
211 json!({
212 "command": "cargo fmt 2>&1",
213 "description": "Format code"
214 })
215 }
216
217 pub fn check() -> Value {
219 json!({
220 "command": "cargo check 2>&1",
221 "description": "Check compilation without building"
222 })
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use tempfile::TempDir;
230
231 #[tokio::test]
232 async fn test_bash_echo() {
233 let temp_dir = TempDir::new().unwrap();
234
235 let tool = BashTool::new(temp_dir.path().to_path_buf());
236 let result = tool
237 .execute(json!({
238 "command": "echo 'hello world'"
239 }))
240 .await
241 .unwrap();
242
243 assert!(result["success"].as_bool().unwrap());
244 assert!(result["stdout"].as_str().unwrap().contains("hello world"));
245 }
246
247 #[tokio::test]
248 async fn test_bash_failing_command() {
249 let temp_dir = TempDir::new().unwrap();
250
251 let tool = BashTool::new(temp_dir.path().to_path_buf());
252 let result = tool
253 .execute(json!({
254 "command": "exit 1"
255 }))
256 .await
257 .unwrap();
258
259 assert!(!result["success"].as_bool().unwrap());
260 assert_eq!(result["exit_code"], 1);
261 }
262
263 #[tokio::test]
264 async fn test_bash_timeout() {
265 let temp_dir = TempDir::new().unwrap();
266
267 let tool = BashTool::new(temp_dir.path().to_path_buf());
268 let result = tool
269 .execute(json!({
270 "command": "sleep 10",
271 "timeout_secs": 1
272 }))
273 .await;
274
275 assert!(result.is_err());
276 match result {
277 Err(crate::PawanError::Timeout(_)) => {}
278 _ => panic!("Expected timeout error"),
279 }
280 }
281
282 #[tokio::test]
283 async fn test_bash_tool_name() {
284 let tmp = TempDir::new().unwrap();
285 let tool = BashTool::new(tmp.path().to_path_buf());
286 assert_eq!(tool.name(), "bash");
287 }
288
289 #[tokio::test]
290 async fn test_bash_exit_code() {
291 let tmp = TempDir::new().unwrap();
292 let tool = BashTool::new(tmp.path().to_path_buf());
293 let r = tool.execute(serde_json::json!({"command": "false"})).await.unwrap();
294 assert!(!r["success"].as_bool().unwrap());
295 assert_eq!(r["exit_code"].as_i64().unwrap(), 1);
296 }
297
298 #[tokio::test]
299 async fn test_bash_cwd() {
300 let tmp = TempDir::new().unwrap();
301 let tool = BashTool::new(tmp.path().to_path_buf());
302 let r = tool.execute(serde_json::json!({"command": "pwd"})).await.unwrap();
303 let stdout = r["stdout"].as_str().unwrap();
304 assert!(stdout.contains(tmp.path().to_str().unwrap()));
305 }
306
307 #[tokio::test]
308 async fn test_bash_missing_command() {
309 let tmp = TempDir::new().unwrap();
310 let tool = BashTool::new(tmp.path().to_path_buf());
311 let r = tool.execute(serde_json::json!({})).await;
312 assert!(r.is_err());
313 }
314}
315