opendev_tools_impl/
custom_tool.rs1use std::collections::HashMap;
28use std::path::{Path, PathBuf};
29
30use async_trait::async_trait;
31use serde::Deserialize;
32use tracing::{debug, warn};
33
34use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
35
36#[derive(Debug, Clone, Deserialize)]
38pub struct CustomToolManifest {
39 pub name: String,
41 pub description: String,
43 pub command: String,
45 #[serde(default = "default_params_schema")]
47 pub parameters: serde_json::Value,
48 #[serde(default = "default_timeout")]
50 pub timeout_secs: u64,
51}
52
53fn default_params_schema() -> serde_json::Value {
54 serde_json::json!({
55 "type": "object",
56 "properties": {
57 "input": {
58 "type": "string",
59 "description": "Input to the tool"
60 }
61 }
62 })
63}
64
65fn default_timeout() -> u64 {
66 30
67}
68
69#[derive(Debug)]
71pub struct CustomTool {
72 manifest: CustomToolManifest,
73 base_dir: PathBuf,
75}
76
77impl CustomTool {
78 pub fn new(manifest: CustomToolManifest, base_dir: PathBuf) -> Self {
80 Self { manifest, base_dir }
81 }
82
83 fn resolve_command(&self) -> PathBuf {
85 let cmd = Path::new(&self.manifest.command);
86 if cmd.is_absolute() {
87 cmd.to_path_buf()
88 } else {
89 self.base_dir.join(cmd)
90 }
91 }
92}
93
94#[async_trait]
95impl BaseTool for CustomTool {
96 fn name(&self) -> &str {
97 &self.manifest.name
98 }
99
100 fn description(&self) -> &str {
101 &self.manifest.description
102 }
103
104 fn parameter_schema(&self) -> serde_json::Value {
105 self.manifest.parameters.clone()
106 }
107
108 async fn execute(
109 &self,
110 args: HashMap<String, serde_json::Value>,
111 ctx: &ToolContext,
112 ) -> ToolResult {
113 let cmd_path = self.resolve_command();
114
115 if !cmd_path.exists() {
116 return ToolResult::fail(format!(
117 "Custom tool command not found: {}",
118 cmd_path.display()
119 ));
120 }
121
122 let input_json = match serde_json::to_string(&args) {
124 Ok(j) => j,
125 Err(e) => return ToolResult::fail(format!("Failed to serialize args: {e}")),
126 };
127
128 let timeout = std::time::Duration::from_secs(self.manifest.timeout_secs);
130 let result = tokio::time::timeout(timeout, async {
131 let mut child = match tokio::process::Command::new(cmd_path.as_os_str())
132 .current_dir(&ctx.working_dir)
133 .stdin(std::process::Stdio::piped())
134 .stdout(std::process::Stdio::piped())
135 .stderr(std::process::Stdio::piped())
136 .spawn()
137 {
138 Ok(c) => c,
139 Err(e) => return Err(e),
140 };
141
142 if let Some(mut stdin) = child.stdin.take() {
144 use tokio::io::AsyncWriteExt;
145 let _ = stdin.write_all(input_json.as_bytes()).await;
146 drop(stdin);
147 }
148
149 child.wait_with_output().await
150 })
151 .await;
152
153 match result {
154 Ok(Ok(output)) => {
155 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
156 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
157
158 if output.status.success() {
159 debug!(
160 tool = self.manifest.name,
161 exit_code = 0,
162 "Custom tool executed successfully"
163 );
164 if stdout.is_empty() {
165 ToolResult::ok("(no output)")
166 } else {
167 ToolResult::ok(stdout)
168 }
169 } else {
170 let code = output.status.code().unwrap_or(-1);
171 let error_msg = if stderr.is_empty() {
172 format!("Custom tool exited with code {code}")
173 } else {
174 format!("Exit code {code}: {stderr}")
175 };
176 ToolResult::fail(error_msg)
177 }
178 }
179 Ok(Err(e)) => ToolResult::fail(format!("Failed to execute custom tool: {e}")),
180 Err(_) => ToolResult::fail(format!(
181 "Custom tool timed out after {}s",
182 self.manifest.timeout_secs
183 )),
184 }
185 }
186}
187
188pub fn discover_custom_tools(working_dir: &Path) -> Vec<CustomTool> {
196 let search_dirs = [
197 working_dir.join(".opendev").join("tools"),
198 working_dir.join(".opencode").join("tool"),
199 ];
200
201 let mut tools = Vec::new();
202 let mut seen_names = std::collections::HashSet::new();
203
204 for dir in &search_dirs {
205 if !dir.is_dir() {
206 continue;
207 }
208
209 let entries = match std::fs::read_dir(dir) {
210 Ok(e) => e,
211 Err(e) => {
212 warn!(dir = %dir.display(), error = %e, "Failed to read custom tools directory");
213 continue;
214 }
215 };
216
217 for entry in entries.flatten() {
218 let path = entry.path();
219
220 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
222 if !name.ends_with(".tool.json") {
223 continue;
224 }
225
226 match std::fs::read_to_string(&path) {
227 Ok(content) => match serde_json::from_str::<CustomToolManifest>(&content) {
228 Ok(manifest) => {
229 if seen_names.contains(&manifest.name) {
230 warn!(
231 name = manifest.name,
232 path = %path.display(),
233 "Duplicate custom tool name, skipping"
234 );
235 continue;
236 }
237 debug!(
238 name = manifest.name,
239 path = %path.display(),
240 "Discovered custom tool"
241 );
242 seen_names.insert(manifest.name.clone());
243 tools.push(CustomTool::new(manifest, dir.clone()));
244 }
245 Err(e) => {
246 warn!(
247 path = %path.display(),
248 error = %e,
249 "Failed to parse custom tool manifest"
250 );
251 }
252 },
253 Err(e) => {
254 warn!(
255 path = %path.display(),
256 error = %e,
257 "Failed to read custom tool manifest"
258 );
259 }
260 }
261 }
262 }
263
264 tools
265}
266
267#[cfg(test)]
268#[path = "custom_tool_tests.rs"]
269mod tests;