Skip to main content

robit_agent/tool/
edit.rs

1//! `edit` tool — exact string replacement in files.
2//!
3//! Matches old_string exactly in the target file and replaces with new_string.
4//! Requires unique match; returns diagnostic info on zero or multiple matches.
5
6use async_trait::async_trait;
7use serde::Deserialize;
8use serde_json::Value;
9
10use super::{resolve_path, Tool, ToolContext, ToolResult};
11use crate::error::Result;
12
13/// Maximum similar matches to show when old_string is not found.
14const MAX_SIMILAR_MATCHES: usize = 3;
15
16#[derive(Debug, Deserialize)]
17struct EditArgs {
18    file_path: String,
19    old_string: String,
20    new_string: String,
21}
22
23pub struct EditTool;
24
25impl EditTool {
26    pub fn new() -> Self {
27        Self
28    }
29}
30
31impl Default for EditTool {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37#[async_trait]
38impl Tool for EditTool {
39    fn name(&self) -> &str {
40        "edit"
41    }
42
43    fn description(&self) -> &str {
44        "Precisely replace text in a file. old_string must have a unique match in the file. Returns similar matches on failure to help with correction."
45    }
46
47    fn parameters_schema(&self) -> Value {
48        serde_json::json!({
49            "type": "object",
50            "properties": {
51                "file_path": {
52                    "type": "string",
53                    "description": "Target file path (relative or absolute)"
54                },
55                "old_string": {
56                    "type": "string",
57                    "description": "The original text to replace (must exist uniquely in the file)"
58                },
59                "new_string": {
60                    "type": "string",
61                    "description": "The replacement text"
62                }
63            },
64            "required": ["file_path", "old_string", "new_string"]
65        })
66    }
67
68    fn requires_confirmation(&self) -> bool {
69        true
70    }
71
72    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
73        let parsed: EditArgs = match serde_json::from_value(args) {
74            Ok(a) => a,
75            Err(e) => return Ok(ToolResult::error(format!("Argument parsing failed: {}", e))),
76        };
77
78        // Validate inputs
79        if parsed.old_string.is_empty() {
80            return Ok(ToolResult::error("old_string cannot be empty".to_string()));
81        }
82        if parsed.file_path.trim().is_empty() {
83            return Ok(ToolResult::error("File path cannot be empty".to_string()));
84        }
85
86        let path = resolve_path(&parsed.file_path, &ctx.working_dir);
87
88        if !path.exists() {
89            return Ok(ToolResult::error(format!("File not found: {}", path.display())));
90        }
91
92        if path.is_dir() {
93            return Ok(ToolResult::error(format!(
94                "'{}' is a directory, not a file",
95                path.display()
96            )));
97        }
98
99        let content = match tokio::fs::read_to_string(&path).await {
100            Ok(c) => c,
101            Err(e) => {
102                return Ok(ToolResult::error(format!(
103                    "Failed to read file '{}': {}",
104                    path.display(),
105                    e
106                )));
107            }
108        };
109
110        // Find all match positions
111        let matches: Vec<usize> = content
112            .match_indices(&parsed.old_string)
113            .map(|(pos, _)| pos)
114            .collect();
115
116        match matches.len() {
117            0 => {
118                // No exact match — find similar lines
119                let similar = find_similar_matches(&content, &parsed.old_string);
120                let mut msg = format!(
121                    "No exact match found for old_string in the file.\n\
122                     The following are the {} most similar matches. Please check if the selection is correct:\n\n",
123                    similar.len()
124                );
125                for (i, m) in similar.iter().enumerate() {
126                    msg.push_str(&format!(
127                        "Match {} (line {}, score {}):\n  expected: {}\n  actual: {}\n",
128                        i + 1,
129                        m.line_number,
130                        m.score,
131                        truncate(&parsed.old_string, 120),
132                        truncate(&m.actual, 120)
133                    ));
134                    if i + 1 < similar.len() {
135                        msg.push('\n');
136                    }
137                }
138                Ok(ToolResult::error(msg))
139            }
140            1 => {
141                // Unique match — perform replacement
142                let new_content = content.replacen(&parsed.old_string, &parsed.new_string, 1);
143
144                match tokio::fs::write(&path, &new_content).await {
145                    Ok(()) => {
146                        let line_num = count_lines_before(&content, matches[0]);
147                        Ok(ToolResult::success(format!(
148                            "Modified file: {} (line {})",
149                            path.display(),
150                            line_num
151                        )))
152                    }
153                    Err(e) => Ok(ToolResult::error(format!(
154                        "Failed to write file '{}': {}",
155                        path.display(),
156                        e
157                    ))),
158                }
159            }
160            n => {
161                // Multiple matches — show all positions
162                let lines: Vec<&str> = content.lines().collect();
163                let line_positions: Vec<usize> = matches
164                    .iter()
165                    .map(|&pos| count_lines_before(&content, pos))
166                    .collect();
167
168                let mut msg = format!(
169                    "old_string appears {} times",
170                    n
171                );
172                let lines_str: Vec<String> = line_positions.iter().map(|l| l.to_string()).collect();
173                msg.push_str(&format!(" (lines {})", lines_str.join(", ")));
174                msg.push_str(", cannot determine a unique replacement location.\nPlease provide more context to make old_string unique.\n\n");
175
176                // Show context for each match (up to first 5)
177                for &line_1based in line_positions.iter().take(5) {
178                    let line_idx = line_1based - 1; // 0-based index into lines[]
179                    let start = line_idx.saturating_sub(1);
180                    let end = (line_idx + 2).min(lines.len());
181                    msg.push_str("---\n");
182                    msg.push_str(&format!("Line {}:\n", line_1based));
183                    for (j, line_text) in lines.iter().enumerate().skip(start).take(end - start) {
184                        if j == line_idx {
185                            msg.push_str(&format!("> {}\n", line_text));
186                        } else {
187                            msg.push_str(&format!("  {}\n", line_text));
188                        }
189                    }
190                    msg.push_str("---\n");
191                }
192                if n > 5 {
193                    msg.push_str(&format!("... and {} more matches not shown\n", n - 5));
194                }
195
196                Ok(ToolResult::error(msg))
197            }
198        }
199    }
200}
201
202/// Match info for similar-match results.
203struct SimilarMatch {
204    line_number: usize,
205    actual: String,
206    score: usize, // number of matching characters (higher = better)
207}
208
209/// Find lines most similar to old_string using word overlap scoring.
210fn find_similar_matches(content: &str, target: &str) -> Vec<SimilarMatch> {
211    let lines: Vec<&str> = content.lines().collect();
212    let target_lower = target.to_lowercase();
213    // Filter out words shorter than 2 chars to avoid noise (e.g., "a", "i")
214    let target_words: Vec<&str> = target_lower
215        .split_whitespace()
216        .filter(|w| w.len() >= 2)
217        .collect();
218
219    let mut scored: Vec<(usize, usize, String)> = lines
220        .iter()
221        .enumerate()
222        .map(|(idx, &line)| {
223            let line_lower = line.to_lowercase();
224            let score = word_overlap_score(&target_words, &line_lower);
225            (idx + 1, score, line.trim().to_string())
226        })
227        .filter(|(_, score, _)| *score > 0)
228        .collect();
229
230    // Sort by score descending
231    scored.sort_by_key(|b| std::cmp::Reverse(b.1));
232
233    // Take top MAX_SIMILAR_MATCHES
234    scored
235        .into_iter()
236        .take(MAX_SIMILAR_MATCHES)
237        .map(|(line_number, score, actual)| SimilarMatch {
238            line_number,
239            actual,
240            score,
241        })
242        .collect()
243}
244
245/// Count how many words from target appear as exact matches in the line.
246fn word_overlap_score(target_words: &[&str], line: &str) -> usize {
247    let line_words: Vec<&str> = line.split_whitespace().collect();
248    let mut score = 0;
249    for &tw in target_words {
250        if line_words.contains(&tw) {
251            score += 1;
252        }
253    }
254    score
255}
256
257/// Count 1-based line number for a given byte position in content.
258fn count_lines_before(content: &str, byte_pos: usize) -> usize {
259    content[..byte_pos].matches('\n').count() + 1
260}
261
262/// Truncate text to max_len characters.
263fn truncate(s: &str, max_len: usize) -> String {
264    if s.chars().count() <= max_len {
265        s.to_string()
266    } else {
267        let truncated: String = s.chars().take(max_len).collect();
268        format!("{}...", truncated)
269    }
270}