1use std::path::Path;
7
8use crate::models::{CheckMode, ToolResult};
9use crate::session::DockerSession;
10
11const ERROR_PATTERNS: &[&str] = &[
13 "Error:",
14 "error:",
15 "ERROR:",
16 "ModuleNotFoundError",
17 "ImportError",
18 "No such file or directory",
19 "SyntaxError",
20 "TypeError",
21 "ValueError",
22 "Traceback (most recent call last)",
23 "FileNotFoundError",
24 "NameError",
25 "AttributeError",
26];
27
28pub struct DockerToolHandler {
30 session: DockerSession,
31 workspace_dir: String,
32 shell_init: String,
33}
34
35impl DockerToolHandler {
36 pub fn new(
38 session: DockerSession,
39 workspace_dir: impl Into<String>,
40 shell_init: impl Into<String>,
41 ) -> Self {
42 Self {
43 session,
44 workspace_dir: workspace_dir.into(),
45 shell_init: shell_init.into(),
46 }
47 }
48
49 pub fn session(&self) -> &DockerSession {
51 &self.session
52 }
53
54 pub fn session_mut(&mut self) -> &mut DockerSession {
56 &mut self.session
57 }
58
59 pub async fn run_command(
61 &self,
62 command: &str,
63 timeout: f64,
64 working_dir: Option<&str>,
65 ) -> ToolResult {
66 if command.is_empty() {
67 return ToolResult {
68 success: false,
69 output: None,
70 error: Some("command is required".into()),
71 exit_code: None,
72 };
73 }
74
75 let mut full_command = String::new();
76
77 if let Some(wd) = working_dir {
79 let container_path = self.translate_path(wd);
80 full_command.push_str(&format!("cd {} && ", container_path));
81 }
82
83 if !self.shell_init.is_empty() {
85 full_command.push_str(&format!("{} && ", self.shell_init));
86 }
87
88 full_command.push_str(command);
89
90 match self
91 .session
92 .exec_command(&full_command, timeout, CheckMode::Silent)
93 .await
94 {
95 Ok(obs) => {
96 let success = obs.exit_code == Some(0) || obs.exit_code.is_none();
97 ToolResult {
98 success,
99 output: Some(obs.output),
100 error: obs.failure_reason,
101 exit_code: obs.exit_code,
102 }
103 }
104 Err(e) => ToolResult {
105 success: false,
106 output: None,
107 error: Some(e.to_string()),
108 exit_code: None,
109 },
110 }
111 }
112
113 pub async fn read_file(&self, path: &str) -> ToolResult {
115 if path.is_empty() {
116 return ToolResult {
117 success: false,
118 output: None,
119 error: Some("path is required".into()),
120 exit_code: None,
121 };
122 }
123
124 let container_path = self.translate_path(path);
125 let cmd = format!("cat '{}'", container_path);
126
127 match self
128 .session
129 .exec_command(&cmd, 30.0, CheckMode::Silent)
130 .await
131 {
132 Ok(obs) if obs.exit_code == Some(0) || obs.exit_code.is_none() => ToolResult {
133 success: true,
134 output: Some(obs.output),
135 error: None,
136 exit_code: obs.exit_code,
137 },
138 Ok(obs) => ToolResult {
139 success: false,
140 output: None,
141 error: Some(obs.output),
142 exit_code: obs.exit_code,
143 },
144 Err(e) => ToolResult {
145 success: false,
146 output: None,
147 error: Some(e.to_string()),
148 exit_code: None,
149 },
150 }
151 }
152
153 pub async fn write_file(&self, path: &str, content: &str) -> ToolResult {
155 if path.is_empty() {
156 return ToolResult {
157 success: false,
158 output: None,
159 error: Some("path is required".into()),
160 exit_code: None,
161 };
162 }
163
164 let container_path = self.translate_path(path);
165 let parent = Path::new(&container_path)
166 .parent()
167 .map(|p| p.to_string_lossy().to_string())
168 .unwrap_or_else(|| ".".into());
169
170 let escaped = content.replace('\'', "'\\''");
171 let cmd = format!(
172 "mkdir -p '{}' && printf '%s' '{}' > '{}'",
173 parent, escaped, container_path
174 );
175
176 match self
177 .session
178 .exec_command(&cmd, 30.0, CheckMode::Silent)
179 .await
180 {
181 Ok(obs) if obs.exit_code == Some(0) || obs.exit_code.is_none() => ToolResult {
182 success: true,
183 output: Some(format!(
184 "Wrote {} bytes to {}",
185 content.len(),
186 container_path
187 )),
188 error: None,
189 exit_code: obs.exit_code,
190 },
191 Ok(obs) => ToolResult {
192 success: false,
193 output: None,
194 error: Some(obs.output),
195 exit_code: obs.exit_code,
196 },
197 Err(e) => ToolResult {
198 success: false,
199 output: None,
200 error: Some(e.to_string()),
201 exit_code: None,
202 },
203 }
204 }
205
206 pub async fn list_files(
208 &self,
209 path: &str,
210 pattern: Option<&str>,
211 recursive: bool,
212 ) -> ToolResult {
213 let container_path = self.translate_path(if path.is_empty() { "." } else { path });
214 let pat = pattern.unwrap_or("*");
215
216 let cmd = if recursive {
217 format!(
218 "find {} -name '{}' -type f 2>/dev/null | head -100",
219 container_path, pat
220 )
221 } else {
222 format!("ls -la {} 2>/dev/null", container_path)
223 };
224
225 match self
226 .session
227 .exec_command(&cmd, 30.0, CheckMode::Silent)
228 .await
229 {
230 Ok(obs) => ToolResult {
231 success: obs.exit_code == Some(0) || obs.exit_code.is_none(),
232 output: Some(if obs.output.is_empty() {
233 "(empty directory)".into()
234 } else {
235 obs.output
236 }),
237 error: obs.failure_reason,
238 exit_code: obs.exit_code,
239 },
240 Err(e) => ToolResult {
241 success: false,
242 output: None,
243 error: Some(e.to_string()),
244 exit_code: None,
245 },
246 }
247 }
248
249 pub async fn search(&self, query: &str, path: Option<&str>) -> ToolResult {
251 if query.is_empty() {
252 return ToolResult {
253 success: false,
254 output: None,
255 error: Some("query is required".into()),
256 exit_code: None,
257 };
258 }
259
260 let container_path = self.translate_path(path.unwrap_or("."));
261 let cmd = format!(
262 "grep -rn '{}' {} 2>/dev/null | head -50",
263 query, container_path
264 );
265
266 match self
267 .session
268 .exec_command(&cmd, 60.0, CheckMode::Silent)
269 .await
270 {
271 Ok(obs) => ToolResult {
272 success: true,
273 output: Some(if obs.output.is_empty() {
274 "No matches found".into()
275 } else {
276 obs.output
277 }),
278 error: None,
279 exit_code: obs.exit_code,
280 },
281 Err(e) => ToolResult {
282 success: false,
283 output: None,
284 error: Some(e.to_string()),
285 exit_code: None,
286 },
287 }
288 }
289
290 pub fn translate_path(&self, path: &str) -> String {
292 if path.is_empty() {
293 return self.workspace_dir.clone();
294 }
295
296 if path.starts_with("/testbed") || path.starts_with("/workspace") {
298 return path.to_string();
299 }
300
301 if !path.starts_with('/') {
303 let clean = path.trim_start_matches("./");
304 return format!("{}/{}", self.workspace_dir, clean);
305 }
306
307 if let Some(name) = Path::new(path).file_name() {
309 return format!("{}/{}", self.workspace_dir, name.to_string_lossy());
310 }
311
312 format!("{}/{}", self.workspace_dir, path)
313 }
314
315 pub fn check_command_has_error(exit_code: i32, output: &str) -> bool {
317 if exit_code != 0 {
318 return true;
319 }
320 ERROR_PATTERNS.iter().any(|p| output.contains(p))
321 }
322}
323
324#[cfg(test)]
325#[path = "tool_handler_tests.rs"]
326mod tests;