Skip to main content

pawan/tools/
bash.rs

1//! Bash command execution tool
2
3use 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
12/// Tool for executing bash commands
13pub 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        // Validate workdir exists
75        if !workdir.exists() {
76            return Err(crate::PawanError::NotFound(format!(
77                "Working directory not found: {}",
78                workdir.display()
79            )));
80        }
81
82        // Build command
83        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        // Execute with timeout
92        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                // Truncate output if too long
115                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
157/// Helper struct for commonly used cargo commands
158pub struct CargoCommands;
159
160impl CargoCommands {
161    /// Build the project
162    pub fn build() -> Value {
163        json!({
164            "command": "cargo build 2>&1",
165            "description": "Build the project"
166        })
167    }
168
169    /// Build with all features
170    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    /// Run tests
178    pub fn test() -> Value {
179        json!({
180            "command": "cargo test 2>&1",
181            "description": "Run all tests"
182        })
183    }
184
185    /// Run a specific test
186    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    /// Run clippy
194    pub fn clippy() -> Value {
195        json!({
196            "command": "cargo clippy 2>&1",
197            "description": "Run clippy linter"
198        })
199    }
200
201    /// Run rustfmt check
202    pub fn fmt_check() -> Value {
203        json!({
204            "command": "cargo fmt --check 2>&1",
205            "description": "Check code formatting"
206        })
207    }
208
209    /// Run rustfmt
210    pub fn fmt() -> Value {
211        json!({
212            "command": "cargo fmt 2>&1",
213            "description": "Format code"
214        })
215    }
216
217    /// Check compilation
218    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