Skip to main content

opendev_docker/
tool_handler.rs

1//! Docker tool handler — routes tool execution (bash, file operations)
2//! into a Docker container.
3//!
4//! Ports the Python `DockerToolHandler` and `DockerToolRegistry`.
5
6use std::path::Path;
7
8use crate::models::{CheckMode, ToolResult};
9use crate::session::DockerSession;
10
11/// Common error patterns that indicate a command failure even if exit code is 0.
12const 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
28/// Handle tool execution inside Docker containers.
29pub struct DockerToolHandler {
30    session: DockerSession,
31    workspace_dir: String,
32    shell_init: String,
33}
34
35impl DockerToolHandler {
36    /// Create a new tool handler.
37    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    /// Reference to the underlying session.
50    pub fn session(&self) -> &DockerSession {
51        &self.session
52    }
53
54    /// Mutable reference to the underlying session.
55    pub fn session_mut(&mut self) -> &mut DockerSession {
56        &mut self.session
57    }
58
59    /// Execute a bash command inside the container.
60    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        // Prepend working directory cd
78        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        // Prepend shell init
84        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    /// Read a file from the container.
114    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    /// Write a file inside the container.
154    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    /// List files in a directory inside the container.
207    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    /// Search for text in files inside the container.
250    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    /// Translate a host path to a container path.
291    pub fn translate_path(&self, path: &str) -> String {
292        if path.is_empty() {
293            return self.workspace_dir.clone();
294        }
295
296        // Already a container path
297        if path.starts_with("/testbed") || path.starts_with("/workspace") {
298            return path.to_string();
299        }
300
301        // Relative path
302        if !path.starts_with('/') {
303            let clean = path.trim_start_matches("./");
304            return format!("{}/{}", self.workspace_dir, clean);
305        }
306
307        // Absolute host path — extract filename
308        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    /// Check if command output indicates an error.
316    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;