1use std::collections::HashMap;
7use std::path::Path;
8
9use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
10
11use crate::path_utils::{resolve_file_path, validate_path_access};
12
13fn execute_insert(
18 args: &HashMap<String, serde_json::Value>,
19 ctx: &ToolContext,
20 position: InsertPosition,
21) -> ToolResult {
22 let file_path_str = match args.get("file_path").and_then(|v| v.as_str()) {
23 Some(p) if !p.is_empty() => p,
24 _ => return ToolResult::fail("file_path is required"),
25 };
26
27 let symbol_name = match args.get("symbol_name").and_then(|v| v.as_str()) {
28 Some(s) if !s.is_empty() => s,
29 _ => return ToolResult::fail("symbol_name is required"),
30 };
31
32 let content = match args.get("content").and_then(|v| v.as_str()) {
33 Some(c) if !c.is_empty() => c,
34 _ => return ToolResult::fail("content is required"),
35 };
36
37 let path = resolve_file_path(file_path_str, &ctx.working_dir);
38
39 if let Err(msg) = validate_path_access(&path, &ctx.working_dir) {
40 return ToolResult::fail(msg);
41 }
42
43 if !path.exists() {
44 return ToolResult::fail(format!("File not found: {file_path_str}"));
45 }
46
47 let file_content = match std::fs::read_to_string(&path) {
48 Ok(c) => c,
49 Err(e) => return ToolResult::fail(format!("Failed to read file: {e}")),
50 };
51
52 let lines: Vec<&str> = file_content.lines().collect();
57
58 let symbol_range = match find_symbol_range(&lines, symbol_name) {
59 Some(range) => range,
60 None => {
61 return ToolResult::fail(format!(
62 "Symbol '{}' not found in {}",
63 symbol_name, file_path_str
64 ));
65 }
66 };
67
68 let new_content = insert_content(&file_content, &lines, content, &symbol_range, position);
70
71 let dir = path.parent().unwrap_or(Path::new("."));
73 let tmp_path = dir.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
74
75 if let Err(e) = std::fs::write(&tmp_path, &new_content) {
76 return ToolResult::fail(format!("Failed to write temp file: {e}"));
77 }
78 if let Err(e) = std::fs::rename(&tmp_path, &path) {
79 let _ = std::fs::remove_file(&tmp_path);
80 return ToolResult::fail(format!("Failed to rename temp file: {e}"));
81 }
82
83 let label = match position {
84 InsertPosition::Before => "before",
85 InsertPosition::After => "after",
86 };
87
88 ToolResult::ok(format!(
89 "Inserted content {label} symbol '{}' in {}",
90 symbol_name, file_path_str
91 ))
92}
93
94#[derive(Debug, Clone, Copy)]
95enum InsertPosition {
96 Before,
97 After,
98}
99
100#[derive(Debug)]
102struct SymbolRange {
103 start_line: usize,
105 end_line: usize,
107}
108
109fn find_symbol_range(lines: &[&str], symbol_name: &str) -> Option<SymbolRange> {
115 let keywords = [
117 "fn ",
118 "pub fn ",
119 "pub(crate) fn ",
120 "pub(super) fn ",
121 "struct ",
122 "pub struct ",
123 "pub(crate) struct ",
124 "enum ",
125 "pub enum ",
126 "pub(crate) enum ",
127 "trait ",
128 "pub trait ",
129 "pub(crate) trait ",
130 "impl ",
131 "pub impl ",
132 "type ",
133 "pub type ",
134 "pub(crate) type ",
135 "const ",
136 "pub const ",
137 "pub(crate) const ",
138 "static ",
139 "pub static ",
140 "pub(crate) static ",
141 "let ",
142 "let mut ",
143 "class ",
144 "def ",
145 "interface ",
146 "function ",
147 "var ",
148 "export ",
149 "async fn ",
150 "pub async fn ",
151 "pub(crate) async fn ",
152 "unsafe fn ",
153 "pub unsafe fn ",
154 "macro_rules! ",
155 ];
156
157 let mut start_line = None;
158
159 for (i, line) in lines.iter().enumerate() {
160 let trimmed = line.trim();
161
162 let is_definition = keywords.iter().any(|kw| {
164 if let Some(rest) = trimmed.strip_prefix(kw) {
165 let name_part = rest
168 .split(|c: char| !c.is_alphanumeric() && c != '_')
169 .next()
170 .unwrap_or("");
171 name_part == symbol_name
172 } else {
173 false
174 }
175 });
176
177 if is_definition {
178 start_line = Some(i);
179 break;
180 }
181 }
182
183 let start = start_line?;
184
185 let end = find_symbol_end(lines, start);
187
188 Some(SymbolRange {
189 start_line: start,
190 end_line: end,
191 })
192}
193
194fn find_symbol_end(lines: &[&str], start_line: usize) -> usize {
198 let start_trimmed = lines[start_line].trim();
199
200 let is_python_style = start_trimmed.starts_with("def ") || start_trimmed.starts_with("class ");
202
203 if is_python_style {
204 return find_symbol_end_by_indent(lines, start_line);
205 }
206
207 let mut depth: i32 = 0;
209 let mut found_open_brace = false;
210
211 for (i, line) in lines.iter().enumerate().skip(start_line) {
212 for ch in line.chars() {
213 if ch == '{' {
214 depth += 1;
215 found_open_brace = true;
216 } else if ch == '}' {
217 depth -= 1;
218 if found_open_brace && depth == 0 {
219 return i;
220 }
221 }
222 }
223 if !found_open_brace && line.trim().ends_with(';') {
225 return i;
226 }
227 }
228
229 start_line
231}
232
233fn find_symbol_end_by_indent(lines: &[&str], start_line: usize) -> usize {
235 let start_indent = lines[start_line].len() - lines[start_line].trim_start().len();
236 let mut last_body_line = start_line;
237
238 for (i, line) in lines.iter().enumerate().skip(start_line + 1) {
239 if line.trim().is_empty() {
240 continue; }
242 let indent = line.len() - line.trim_start().len();
243 if indent <= start_indent {
244 break; }
246 last_body_line = i;
247 }
248
249 last_body_line
250}
251
252fn insert_content(
254 original: &str,
255 lines: &[&str],
256 content: &str,
257 range: &SymbolRange,
258 position: InsertPosition,
259) -> String {
260 let mut result = String::with_capacity(original.len() + content.len() + 2);
261
262 match position {
263 InsertPosition::Before => {
264 for line in &lines[..range.start_line] {
266 result.push_str(line);
267 result.push('\n');
268 }
269 result.push_str(content);
271 if !content.ends_with('\n') {
272 result.push('\n');
273 }
274 for line in &lines[range.start_line..] {
276 result.push_str(line);
277 result.push('\n');
278 }
279 }
280 InsertPosition::After => {
281 for line in &lines[..=range.end_line] {
283 result.push_str(line);
284 result.push('\n');
285 }
286 result.push_str(content);
288 if !content.ends_with('\n') {
289 result.push('\n');
290 }
291 if range.end_line + 1 < lines.len() {
293 for line in &lines[range.end_line + 1..] {
294 result.push_str(line);
295 result.push('\n');
296 }
297 }
298 }
299 }
300
301 if !original.ends_with('\n') && result.ends_with('\n') {
303 result.pop();
304 }
305
306 result
307}
308
309#[derive(Debug)]
315pub struct InsertBeforeSymbolTool;
316
317#[async_trait::async_trait]
318impl BaseTool for InsertBeforeSymbolTool {
319 fn name(&self) -> &str {
320 "insert_before_symbol"
321 }
322
323 fn description(&self) -> &str {
324 "Insert content before a symbol (function, class, struct, etc.) in a file. \
325 The symbol is located by name using pattern matching on common definition keywords."
326 }
327
328 fn parameter_schema(&self) -> serde_json::Value {
329 serde_json::json!({
330 "type": "object",
331 "properties": {
332 "file_path": {
333 "type": "string",
334 "description": "Absolute path to the file containing the symbol"
335 },
336 "symbol_name": {
337 "type": "string",
338 "description": "Name of the symbol to insert before (e.g. function name, struct name)"
339 },
340 "content": {
341 "type": "string",
342 "description": "The text content to insert before the symbol"
343 }
344 },
345 "required": ["file_path", "symbol_name", "content"]
346 })
347 }
348
349 async fn execute(
350 &self,
351 args: HashMap<String, serde_json::Value>,
352 ctx: &ToolContext,
353 ) -> ToolResult {
354 execute_insert(&args, ctx, InsertPosition::Before)
355 }
356}
357
358#[derive(Debug)]
364pub struct InsertAfterSymbolTool;
365
366#[async_trait::async_trait]
367impl BaseTool for InsertAfterSymbolTool {
368 fn name(&self) -> &str {
369 "insert_after_symbol"
370 }
371
372 fn description(&self) -> &str {
373 "Insert content after a symbol (function, class, struct, etc.) in a file. \
374 The symbol is located by name using pattern matching on common definition keywords."
375 }
376
377 fn parameter_schema(&self) -> serde_json::Value {
378 serde_json::json!({
379 "type": "object",
380 "properties": {
381 "file_path": {
382 "type": "string",
383 "description": "Absolute path to the file containing the symbol"
384 },
385 "symbol_name": {
386 "type": "string",
387 "description": "Name of the symbol to insert after (e.g. function name, struct name)"
388 },
389 "content": {
390 "type": "string",
391 "description": "The text content to insert after the symbol"
392 }
393 },
394 "required": ["file_path", "symbol_name", "content"]
395 })
396 }
397
398 async fn execute(
399 &self,
400 args: HashMap<String, serde_json::Value>,
401 ctx: &ToolContext,
402 ) -> ToolResult {
403 execute_insert(&args, ctx, InsertPosition::After)
404 }
405}
406
407#[cfg(test)]
408#[path = "insert_symbol_tests.rs"]
409mod tests;