Skip to main content

bamboo_tools/tools/
edit.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, ToolExecutionContext, ToolResult};
3use serde::Deserialize;
4use serde_json::json;
5use std::collections::HashSet;
6use std::path::Path;
7
8use super::read_tracker::ReadState;
9use super::{content_diagnostics, file_change, read_tracker};
10
11const MAX_PATCH_BYTES: usize = 256 * 1024;
12const MAX_PATCH_BLOCKS: usize = 128;
13const MAX_PATCH_BLOCK_BYTES: usize = 64 * 1024;
14
15#[derive(Debug, Deserialize)]
16struct EditArgs {
17    file_path: String,
18    #[serde(default)]
19    old_string: Option<String>,
20    #[serde(default)]
21    new_string: Option<String>,
22    #[serde(default)]
23    replace_all: Option<bool>,
24    #[serde(default)]
25    patch: Option<String>,
26    #[serde(default)]
27    line_number: Option<usize>,
28}
29
30pub struct EditTool;
31
32#[derive(Debug, Clone)]
33struct ReplacementCandidate {
34    start: usize,
35    matched_len: usize,
36    replacement: String,
37    start_line: usize,
38    end_line: usize,
39}
40
41impl EditTool {
42    pub fn new() -> Self {
43        Self
44    }
45
46    fn to_lf(value: &str) -> String {
47        value.replace("\r\n", "\n")
48    }
49
50    fn to_crlf(value: &str) -> String {
51        Self::to_lf(value).replace('\n', "\r\n")
52    }
53
54    fn has_meaningful_optional_text(value: Option<&str>) -> bool {
55        value.is_some_and(|text| !text.is_empty())
56    }
57
58    fn line_starts(content: &str) -> Vec<usize> {
59        let mut starts = vec![0usize];
60        for (idx, byte) in content.bytes().enumerate() {
61            if byte == b'\n' && idx + 1 < content.len() {
62                starts.push(idx + 1);
63            }
64        }
65        starts
66    }
67
68    fn line_for_offset(line_starts: &[usize], offset: usize) -> usize {
69        line_starts.partition_point(|line_start| *line_start <= offset)
70    }
71
72    fn replacement_variants(
73        content: &str,
74        old_text: &str,
75        new_text: &str,
76    ) -> Vec<(String, String)> {
77        let mut variants: Vec<(String, String)> = Vec::new();
78        let mut seen_variants: HashSet<(String, String)> = HashSet::new();
79        let mut push_variant = |search: String, replace: String| {
80            if seen_variants.insert((search.clone(), replace.clone())) {
81                variants.push((search, replace));
82            }
83        };
84
85        push_variant(old_text.to_string(), new_text.to_string());
86        push_variant(Self::to_lf(old_text), Self::to_lf(new_text));
87        if content.contains("\r\n") {
88            push_variant(Self::to_crlf(old_text), Self::to_crlf(new_text));
89        }
90
91        variants
92    }
93
94    fn collect_candidates(
95        content: &str,
96        old_text: &str,
97        new_text: &str,
98    ) -> Vec<ReplacementCandidate> {
99        let variants = Self::replacement_variants(content, old_text, new_text);
100        let line_starts = Self::line_starts(content);
101        let mut out: Vec<ReplacementCandidate> = Vec::new();
102        let mut seen_matches: HashSet<(usize, usize, String)> = HashSet::new();
103
104        for (search, replacement) in variants {
105            if search.is_empty() {
106                continue;
107            }
108            for (start, _) in content.match_indices(&search) {
109                let matched_len = search.len();
110                let end = start + matched_len - 1;
111                let candidate = ReplacementCandidate {
112                    start,
113                    matched_len,
114                    replacement: replacement.clone(),
115                    start_line: Self::line_for_offset(&line_starts, start),
116                    end_line: Self::line_for_offset(&line_starts, end),
117                };
118                if seen_matches.insert((start, matched_len, candidate.replacement.clone())) {
119                    out.push(candidate);
120                }
121            }
122        }
123
124        out.sort_by_key(|candidate| candidate.start);
125        out
126    }
127
128    fn candidate_line_summary(candidates: &[ReplacementCandidate]) -> String {
129        let mut lines = candidates
130            .iter()
131            .map(|candidate| candidate.start_line.to_string())
132            .collect::<Vec<_>>();
133        lines.sort();
134        lines.dedup();
135        lines.join(", ")
136    }
137
138    fn choose_candidate_with_line_hint(
139        candidates: &[ReplacementCandidate],
140        line_number: usize,
141    ) -> Option<ReplacementCandidate> {
142        let containing = candidates
143            .iter()
144            .filter(|candidate| {
145                candidate.start_line <= line_number && line_number <= candidate.end_line
146            })
147            .cloned()
148            .collect::<Vec<_>>();
149
150        let pool = if containing.is_empty() {
151            candidates.to_vec()
152        } else {
153            containing
154        };
155
156        let mut best: Option<ReplacementCandidate> = None;
157        let mut best_distance = usize::MAX;
158        let mut tie = false;
159
160        for candidate in pool {
161            let distance = candidate.start_line.abs_diff(line_number);
162            if distance < best_distance {
163                best_distance = distance;
164                best = Some(candidate);
165                tie = false;
166            } else if distance == best_distance {
167                tie = true;
168            }
169        }
170
171        if tie {
172            None
173        } else {
174            best
175        }
176    }
177
178    fn apply_single_replacement(
179        content: &str,
180        old_string: &str,
181        new_string: &str,
182        replace_all: bool,
183        line_number: Option<usize>,
184    ) -> Result<(String, usize), ToolError> {
185        if old_string == new_string {
186            return Err(ToolError::InvalidArguments(
187                "new_string must be different from old_string".to_string(),
188            ));
189        }
190        if old_string.is_empty() {
191            return Err(ToolError::InvalidArguments(
192                "old_string must be non-empty".to_string(),
193            ));
194        }
195
196        if let Some(line) = line_number {
197            if line == 0 {
198                return Err(ToolError::InvalidArguments(
199                    "line_number must be >= 1".to_string(),
200                ));
201            }
202            if replace_all {
203                return Err(ToolError::InvalidArguments(
204                    "line_number cannot be combined with replace_all=true".to_string(),
205                ));
206            }
207        }
208
209        let candidates = Self::collect_candidates(content, old_string, new_string);
210
211        if candidates.is_empty() {
212            return Err(ToolError::Execution(
213                "old_string not found in target file".to_string(),
214            ));
215        }
216
217        if !replace_all && candidates.len() != 1 && line_number.is_none() {
218            return Err(ToolError::Execution(format!(
219                "old_string matched {} times; provide a more specific old_string, set line_number, use patch mode, or set replace_all=true",
220                candidates.len()
221            )));
222        }
223
224        let updated = if replace_all {
225            let variants = Self::replacement_variants(content, old_string, new_string);
226            let mut replaced = None;
227            let mut updated = None;
228            for (search, replacement) in variants {
229                let matches = content.match_indices(&search).count();
230                if matches > 0 {
231                    replaced = Some(matches);
232                    updated = Some(content.replace(&search, &replacement));
233                    break;
234                }
235            }
236            return Ok((
237                updated.unwrap_or_else(|| content.to_string()),
238                replaced.unwrap_or(0),
239            ));
240        } else {
241            let chosen = if let Some(line) = line_number {
242                Self::choose_candidate_with_line_hint(&candidates, line).ok_or_else(|| {
243                    ToolError::Execution(format!(
244                        "old_string matched {} times and line_number={} was not unique; candidate start lines: {}. Provide a more specific old_string or patch context",
245                        candidates.len(),
246                        line,
247                        Self::candidate_line_summary(&candidates),
248                    ))
249                })?
250            } else {
251                candidates[0].clone()
252            };
253
254            let mut next = String::with_capacity(
255                content.len().saturating_sub(chosen.matched_len) + chosen.replacement.len(),
256            );
257            next.push_str(&content[..chosen.start]);
258            next.push_str(&chosen.replacement);
259            next.push_str(&content[chosen.start + chosen.matched_len..]);
260            next
261        };
262
263        Ok((updated, 1))
264    }
265
266    fn parse_patch_blocks(patch: &str) -> Result<Vec<(String, String)>, ToolError> {
267        const SEARCH: &str = "<<<<<<< SEARCH\n";
268        const SEP: &str = "\n=======\n";
269        const REPLACE: &str = "\n>>>>>>> REPLACE";
270
271        let normalized = patch.replace("\r\n", "\n");
272        if normalized.trim().is_empty() {
273            return Err(ToolError::InvalidArguments(
274                "patch must be non-empty".to_string(),
275            ));
276        }
277        if normalized.len() > MAX_PATCH_BYTES {
278            return Err(ToolError::InvalidArguments(format!(
279                "patch exceeds max size of {} bytes",
280                MAX_PATCH_BYTES
281            )));
282        }
283
284        let mut cursor = 0usize;
285        let mut blocks = Vec::new();
286        while let Some(start_rel) = normalized[cursor..].find(SEARCH) {
287            if blocks.len() >= MAX_PATCH_BLOCKS {
288                return Err(ToolError::InvalidArguments(format!(
289                    "patch exceeds max block count of {}",
290                    MAX_PATCH_BLOCKS
291                )));
292            }
293            let search_start = cursor + start_rel + SEARCH.len();
294            let sep_rel = normalized[search_start..].find(SEP).ok_or_else(|| {
295                ToolError::InvalidArguments("Malformed patch block: missing =======".to_string())
296            })?;
297            let sep_idx = search_start + sep_rel;
298            let replace_start = sep_idx + SEP.len();
299            let replace_rel = normalized[replace_start..].find(REPLACE).ok_or_else(|| {
300                ToolError::InvalidArguments(
301                    "Malformed patch block: missing >>>>>>> REPLACE".to_string(),
302                )
303            })?;
304            let replace_idx = replace_start + replace_rel;
305
306            let old_block = normalized[search_start..sep_idx].to_string();
307            let new_block = normalized[replace_start..replace_idx].to_string();
308            if old_block.is_empty() {
309                return Err(ToolError::InvalidArguments(
310                    "Patch SEARCH block must be non-empty".to_string(),
311                ));
312            }
313            if old_block.len() > MAX_PATCH_BLOCK_BYTES || new_block.len() > MAX_PATCH_BLOCK_BYTES {
314                return Err(ToolError::InvalidArguments(format!(
315                    "Patch block exceeds max block size of {} bytes",
316                    MAX_PATCH_BLOCK_BYTES
317                )));
318            }
319            blocks.push((old_block, new_block));
320
321            cursor = replace_idx + REPLACE.len();
322            if normalized[cursor..].starts_with('\n') {
323                cursor += 1;
324            }
325        }
326
327        if blocks.is_empty() {
328            return Err(ToolError::InvalidArguments(
329                "patch must contain at least one SEARCH/REPLACE block".to_string(),
330            ));
331        }
332
333        Ok(blocks)
334    }
335
336    fn apply_patch_mode(
337        content: &str,
338        patch: &str,
339        line_number: Option<usize>,
340    ) -> Result<(String, usize), ToolError> {
341        if let Some(line) = line_number {
342            if line == 0 {
343                return Err(ToolError::InvalidArguments(
344                    "line_number must be >= 1".to_string(),
345                ));
346            }
347        }
348        let blocks = Self::parse_patch_blocks(patch)?;
349        let mut updated = content.to_string();
350        let mut replacements = 0usize;
351
352        for (idx, (old_block, new_block)) in blocks.iter().enumerate() {
353            let candidates = Self::collect_candidates(&updated, old_block, new_block);
354
355            if candidates.is_empty() {
356                return Err(ToolError::Execution(format!(
357                    "Patch block {} SEARCH content not found in target file",
358                    idx + 1
359                )));
360            }
361
362            let chosen = if candidates.len() == 1 {
363                candidates[0].clone()
364            } else if let Some(line) = line_number {
365                Self::choose_candidate_with_line_hint(&candidates, line).ok_or_else(|| {
366                    ToolError::Execution(format!(
367                        "Patch block {} SEARCH content matched {} times and line_number={} was not unique; candidate start lines: {}. Add more context to make it unique",
368                        idx + 1,
369                        candidates.len(),
370                        line,
371                        Self::candidate_line_summary(&candidates),
372                    ))
373                })?
374            } else {
375                return Err(ToolError::Execution(format!(
376                    "Patch block {} SEARCH content matched {} times; set line_number or add more context to make it unique",
377                    idx + 1,
378                    candidates.len()
379                )));
380            };
381
382            let mut next = String::with_capacity(
383                updated.len().saturating_sub(chosen.matched_len) + chosen.replacement.len(),
384            );
385            next.push_str(&updated[..chosen.start]);
386            next.push_str(&chosen.replacement);
387            next.push_str(&updated[chosen.start + chosen.matched_len..]);
388            updated = next;
389            replacements += 1;
390        }
391
392        Ok((updated, replacements))
393    }
394}
395
396impl Default for EditTool {
397    fn default() -> Self {
398        Self::new()
399    }
400}
401
402#[async_trait]
403impl Tool for EditTool {
404    fn name(&self) -> &str {
405        "Edit"
406    }
407
408    fn description(&self) -> &str {
409        "Edit existing files via exact replacements or SEARCH/REPLACE patch blocks. IMPORTANT: call Read first in this session or Edit will fail."
410    }
411
412    fn parameters_schema(&self) -> serde_json::Value {
413        json!({
414            "type": "object",
415            "properties": {
416                "file_path": {
417                    "type": "string",
418                    "description": "The absolute path to the file to modify"
419                },
420                "old_string": {
421                    "type": "string",
422                    "description": "Legacy mode only: exact text to replace. Do not send with patch mode."
423                },
424                "new_string": {
425                    "type": "string",
426                    "description": "Legacy mode only: replacement text. Do not send with patch mode."
427                },
428                "replace_all": {
429                    "type": "boolean",
430                    "default": false,
431                    "description": "Legacy mode only: replace all occurrences. Do not send with patch mode."
432                },
433                "patch": {
434                    "type": "string",
435                    "description": "Patch mode: one or more blocks using <<<<<<< SEARCH / ======= / >>>>>>> REPLACE. Preferred mode. Do not combine with non-empty old_string/new_string or replace_all=true."
436                },
437                "line_number": {
438                    "type": "integer",
439                    "minimum": 1,
440                    "description": "Optional 1-based line hint to disambiguate duplicate matches"
441                }
442            },
443            "required": ["file_path"],
444            "additionalProperties": false
445        })
446    }
447
448    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
449        self.execute_with_context(args, ToolExecutionContext::none("Edit"))
450            .await
451    }
452
453    async fn execute_with_context(
454        &self,
455        args: serde_json::Value,
456        ctx: ToolExecutionContext<'_>,
457    ) -> Result<ToolResult, ToolError> {
458        let parsed: EditArgs = serde_json::from_value(args)
459            .map_err(|e| ToolError::InvalidArguments(format!("Invalid Edit args: {}", e)))?;
460
461        let file_path = parsed.file_path.trim();
462        let path = Path::new(file_path);
463        if !path.is_absolute() {
464            return Err(ToolError::InvalidArguments(
465                "file_path must be an absolute path".to_string(),
466            ));
467        }
468
469        if let Some(session_id) = ctx.session_id {
470            match read_tracker::read_state(session_id, file_path).await {
471                ReadState::Unread => {
472                    return Err(ToolError::Execution(
473                        "Edit requires reading the target file first via Read".to_string(),
474                    ));
475                }
476                ReadState::Stale => {
477                    return Err(ToolError::Execution(
478                        "Target file changed after last Read; call Read again before Edit"
479                            .to_string(),
480                    ));
481                }
482                ReadState::Fresh => {}
483            }
484        }
485
486        let content = tokio::fs::read_to_string(path)
487            .await
488            .map_err(|e| ToolError::Execution(format!("Failed to read file: {}", e)))?;
489
490        let patch = parsed
491            .patch
492            .as_deref()
493            .map(str::trim)
494            .filter(|value| !value.is_empty());
495        let old_string = parsed.old_string.as_deref();
496        let new_string = parsed.new_string.as_deref();
497
498        let requested_replace_all = parsed.replace_all.unwrap_or(false);
499        let line_number_hint = parsed.line_number;
500        let used_patch_mode = patch.is_some();
501
502        let (updated, replacements, mode_label) = if let Some(patch_text) = patch {
503            if Self::has_meaningful_optional_text(old_string)
504                || Self::has_meaningful_optional_text(new_string)
505                || requested_replace_all
506            {
507                return Err(ToolError::InvalidArguments(
508                    "patch mode cannot be combined with old_string/new_string/replace_all"
509                        .to_string(),
510                ));
511            }
512            let (next, count) = Self::apply_patch_mode(&content, patch_text, parsed.line_number)?;
513            (next, count, "patch")
514        } else {
515            let old = old_string.ok_or_else(|| {
516                ToolError::InvalidArguments(
517                    "old_string is required unless patch mode is used".to_string(),
518                )
519            })?;
520            let new = new_string.ok_or_else(|| {
521                ToolError::InvalidArguments(
522                    "new_string is required unless patch mode is used".to_string(),
523                )
524            })?;
525            let (next, count) = Self::apply_single_replacement(
526                &content,
527                old,
528                new,
529                requested_replace_all,
530                parsed.line_number,
531            )?;
532            (next, count, "legacy")
533        };
534
535        let checkpoint = file_change::create_checkpoint(path, Some(content.as_bytes())).await?;
536
537        file_change::atomic_write_text(path, &updated).await?;
538
539        let changed_bytes = updated.len().abs_diff(content.len());
540        let changed_lines = updated.lines().count().abs_diff(content.lines().count());
541
542        let mut payload = file_change::build_file_change_payload_value(
543            "Edit",
544            path,
545            format!(
546                "Edited file: {} (mode: {}, replacements: {})",
547                file_path, mode_label, replacements
548            ),
549            checkpoint,
550            &content,
551            &updated,
552        );
553        if let Some(obj) = payload.as_object_mut() {
554            obj.insert("edit_mode".to_string(), json!(mode_label));
555            obj.insert("replacements".to_string(), json!(replacements));
556            obj.insert(
557                "requested_replace_all".to_string(),
558                json!(requested_replace_all),
559            );
560            obj.insert("used_patch_mode".to_string(), json!(used_patch_mode));
561            obj.insert("line_number_hint".to_string(), json!(line_number_hint));
562            obj.insert("changed_bytes".to_string(), json!(changed_bytes));
563            obj.insert("changed_lines".to_string(), json!(changed_lines));
564        }
565        content_diagnostics::attach_file_diagnostics(&mut payload, path, &updated);
566
567        Ok(ToolResult {
568            success: true,
569            result: payload.to_string(),
570            display_preference: Some("Default".to_string()),
571        })
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use crate::tools::ReadTool;
579    use serde_json::json;
580
581    #[tokio::test]
582    async fn edit_requires_unique_match_without_replace_all() {
583        let file = tempfile::NamedTempFile::new().unwrap();
584        tokio::fs::write(file.path(), "foo\nfoo\n").await.unwrap();
585
586        let tool = EditTool::new();
587        let result = tool
588            .execute(json!({
589                "file_path": file.path(),
590                "old_string": "foo",
591                "new_string": "bar"
592            }))
593            .await;
594
595        assert!(result.is_err());
596    }
597
598    #[tokio::test]
599    async fn edit_supports_replace_all() {
600        let file = tempfile::NamedTempFile::new().unwrap();
601        tokio::fs::write(file.path(), "foo\nfoo\n").await.unwrap();
602
603        let tool = EditTool::new();
604        let result = tool
605            .execute(json!({
606                "file_path": file.path(),
607                "old_string": "foo",
608                "new_string": "bar",
609                "replace_all": true
610            }))
611            .await
612            .unwrap();
613
614        assert!(result.success);
615        let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
616        assert_eq!(updated, "bar\nbar\n");
617    }
618
619    #[tokio::test]
620    async fn edit_replace_all_does_not_reprocess_newly_inserted_matches() {
621        let file = tempfile::NamedTempFile::new().unwrap();
622        tokio::fs::write(file.path(), "a\n").await.unwrap();
623
624        let tool = EditTool::new();
625        let result = tool
626            .execute(json!({
627                "file_path": file.path(),
628                "old_string": "a",
629                "new_string": "aa",
630                "replace_all": true
631            }))
632            .await
633            .unwrap();
634
635        assert!(result.success);
636        let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
637        assert_eq!(updated, "aa\n");
638    }
639
640    #[tokio::test]
641    async fn edit_requires_read_first_when_session_context_exists() {
642        let file = tempfile::NamedTempFile::new().unwrap();
643        tokio::fs::write(file.path(), "hello world\n")
644            .await
645            .unwrap();
646        let call_id = "call_1";
647
648        let edit_tool = EditTool::new();
649        let read_tool = ReadTool::new();
650
651        let denied = edit_tool
652            .execute_with_context(
653                json!({
654                    "file_path": file.path(),
655                    "old_string": "world",
656                    "new_string": "rust"
657                }),
658                ToolExecutionContext {
659                    session_id: Some("session_1"),
660                    tool_call_id: call_id,
661                    event_tx: None,
662                    available_tool_schemas: None,
663                },
664            )
665            .await;
666        assert!(denied.is_err());
667
668        let _ = read_tool
669            .execute_with_context(
670                json!({"file_path": file.path()}),
671                ToolExecutionContext {
672                    session_id: Some("session_1"),
673                    tool_call_id: call_id,
674                    event_tx: None,
675                    available_tool_schemas: None,
676                },
677            )
678            .await
679            .unwrap();
680
681        let allowed = edit_tool
682            .execute_with_context(
683                json!({
684                    "file_path": file.path(),
685                    "old_string": "world",
686                    "new_string": "rust"
687                }),
688                ToolExecutionContext {
689                    session_id: Some("session_1"),
690                    tool_call_id: call_id,
691                    event_tx: None,
692                    available_tool_schemas: None,
693                },
694            )
695            .await
696            .unwrap();
697
698        assert!(allowed.success);
699    }
700
701    #[tokio::test]
702    async fn edit_rejects_empty_old_string() {
703        let file = tempfile::NamedTempFile::new().unwrap();
704        tokio::fs::write(file.path(), "hello").await.unwrap();
705
706        let tool = EditTool::new();
707        let result = tool
708            .execute(json!({
709                "file_path": file.path(),
710                "old_string": "",
711                "new_string": "x",
712                "replace_all": true
713            }))
714            .await;
715
716        assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
717    }
718
719    #[tokio::test]
720    async fn edit_legacy_mode_handles_crlf_when_old_string_uses_lf() {
721        let file = tempfile::NamedTempFile::new().unwrap();
722        tokio::fs::write(file.path(), "alpha\r\nbeta\r\n")
723            .await
724            .unwrap();
725
726        let tool = EditTool::new();
727        let result = tool
728            .execute(json!({
729                "file_path": file.path(),
730                "old_string": "alpha\nbeta\n",
731                "new_string": "gamma\ndelta\n"
732            }))
733            .await
734            .unwrap();
735
736        assert!(result.success);
737        let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
738        assert_eq!(updated, "gamma\r\ndelta\r\n");
739    }
740
741    #[tokio::test]
742    async fn edit_legacy_mode_line_number_disambiguates_duplicates() {
743        let file = tempfile::NamedTempFile::new().unwrap();
744        tokio::fs::write(file.path(), "foo\nbar\nfoo\n")
745            .await
746            .unwrap();
747
748        let tool = EditTool::new();
749        let result = tool
750            .execute(json!({
751                "file_path": file.path(),
752                "old_string": "foo",
753                "new_string": "baz",
754                "line_number": 3
755            }))
756            .await
757            .unwrap();
758        assert!(result.success);
759
760        let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
761        assert_eq!(updated, "foo\nbar\nbaz\n");
762    }
763
764    #[tokio::test]
765    async fn edit_legacy_mode_rejects_line_number_with_replace_all() {
766        let file = tempfile::NamedTempFile::new().unwrap();
767        tokio::fs::write(file.path(), "foo\nfoo\n").await.unwrap();
768
769        let tool = EditTool::new();
770        let result = tool
771            .execute(json!({
772                "file_path": file.path(),
773                "old_string": "foo",
774                "new_string": "bar",
775                "replace_all": true,
776                "line_number": 1
777            }))
778            .await;
779
780        assert!(
781            matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("line_number cannot be combined"))
782        );
783    }
784
785    #[tokio::test]
786    async fn edit_patch_mode_can_target_second_duplicate_with_context() {
787        let file = tempfile::NamedTempFile::new().unwrap();
788        tokio::fs::write(
789            file.path(),
790            "fn a() {\n    let v = 1;\n}\n\nfn b() {\n    let v = 1;\n}\n",
791        )
792        .await
793        .unwrap();
794
795        let tool = EditTool::new();
796        let result = tool
797            .execute(json!({
798                "file_path": file.path(),
799                "patch": "<<<<<<< SEARCH\nfn b() {\n    let v = 1;\n}\n=======\nfn b() {\n    let v = 2;\n}\n>>>>>>> REPLACE"
800            }))
801            .await
802            .unwrap();
803        assert!(result.success);
804
805        let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
806        assert!(updated.contains("fn a() {\n    let v = 1;\n}"));
807        assert!(updated.contains("fn b() {\n    let v = 2;\n}"));
808    }
809
810    #[tokio::test]
811    async fn edit_patch_mode_handles_crlf_when_patch_uses_lf() {
812        let file = tempfile::NamedTempFile::new().unwrap();
813        tokio::fs::write(file.path(), "fn b() {\r\n    let v = 1;\r\n}\r\n")
814            .await
815            .unwrap();
816
817        let tool = EditTool::new();
818        let result = tool
819            .execute(json!({
820                "file_path": file.path(),
821                "patch": "<<<<<<< SEARCH\nfn b() {\n    let v = 1;\n}\n=======\nfn b() {\n    let v = 2;\n}\n>>>>>>> REPLACE"
822            }))
823            .await
824            .unwrap();
825        assert!(result.success);
826
827        let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
828        assert_eq!(updated, "fn b() {\r\n    let v = 2;\r\n}\r\n");
829    }
830
831    #[tokio::test]
832    async fn edit_patch_mode_line_number_disambiguates_duplicates() {
833        let file = tempfile::NamedTempFile::new().unwrap();
834        tokio::fs::write(file.path(), "x = 1;\nx = 1;\n")
835            .await
836            .unwrap();
837
838        let tool = EditTool::new();
839        let result = tool
840            .execute(json!({
841                "file_path": file.path(),
842                "line_number": 2,
843                "patch": "<<<<<<< SEARCH\nx = 1;\n=======\nx = 2;\n>>>>>>> REPLACE"
844            }))
845            .await
846            .unwrap();
847        assert!(result.success);
848
849        let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
850        assert_eq!(updated, "x = 1;\nx = 2;\n");
851    }
852
853    #[tokio::test]
854    async fn edit_patch_mode_rejects_ambiguous_search_block() {
855        let file = tempfile::NamedTempFile::new().unwrap();
856        tokio::fs::write(file.path(), "x = 1;\nx = 1;\n")
857            .await
858            .unwrap();
859
860        let tool = EditTool::new();
861        let result = tool
862            .execute(json!({
863                "file_path": file.path(),
864                "patch": "<<<<<<< SEARCH\nx = 1;\n=======\nx = 2;\n>>>>>>> REPLACE"
865            }))
866            .await;
867
868        assert!(
869            matches!(result, Err(ToolError::Execution(msg)) if msg.contains("matched 2 times"))
870        );
871    }
872
873    #[tokio::test]
874    async fn edit_rejects_mixed_patch_and_legacy_args() {
875        let file = tempfile::NamedTempFile::new().unwrap();
876        tokio::fs::write(file.path(), "hello").await.unwrap();
877
878        let tool = EditTool::new();
879        let result = tool
880            .execute(json!({
881                "file_path": file.path(),
882                "old_string": "hello",
883                "new_string": "world",
884                "patch": "<<<<<<< SEARCH\nhello\n=======\nworld\n>>>>>>> REPLACE"
885            }))
886            .await;
887
888        assert!(
889            matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("cannot be combined"))
890        );
891    }
892
893    #[tokio::test]
894    async fn edit_patch_mode_ignores_empty_legacy_placeholders() {
895        let file = tempfile::NamedTempFile::new().unwrap();
896        tokio::fs::write(file.path(), "hello").await.unwrap();
897
898        let tool = EditTool::new();
899        let result = tool
900            .execute(json!({
901                "file_path": file.path(),
902                "old_string": "",
903                "new_string": "",
904                "replace_all": false,
905                "patch": "<<<<<<< SEARCH\nhello\n=======\nworld\n>>>>>>> REPLACE"
906            }))
907            .await
908            .unwrap();
909
910        assert!(result.success);
911        let updated = tokio::fs::read_to_string(file.path()).await.unwrap();
912        assert_eq!(updated, "world");
913    }
914
915    #[tokio::test]
916    async fn edit_patch_rejects_oversized_patch_payload() {
917        let file = tempfile::NamedTempFile::new().unwrap();
918        tokio::fs::write(file.path(), "hello world").await.unwrap();
919        let huge = "a".repeat(MAX_PATCH_BYTES + 1);
920
921        let tool = EditTool::new();
922        let result = tool
923            .execute(json!({
924                "file_path": file.path(),
925                "patch": huge
926            }))
927            .await;
928
929        assert!(
930            matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("max size"))
931        );
932    }
933
934    #[tokio::test]
935    async fn edit_patch_rejects_excessive_block_count() {
936        let file = tempfile::NamedTempFile::new().unwrap();
937        tokio::fs::write(file.path(), "hello world").await.unwrap();
938        let mut patch = String::new();
939        for _ in 0..=MAX_PATCH_BLOCKS {
940            patch.push_str("<<<<<<< SEARCH\nx\n=======\ny\n>>>>>>> REPLACE\n");
941        }
942
943        let tool = EditTool::new();
944        let result = tool
945            .execute(json!({
946                "file_path": file.path(),
947                "patch": patch
948            }))
949            .await;
950
951        assert!(
952            matches!(result, Err(ToolError::InvalidArguments(msg)) if msg.contains("max block count"))
953        );
954    }
955
956    #[tokio::test]
957    async fn edit_includes_json_diagnostics_after_change() {
958        let file = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
959        tokio::fs::write(file.path(), r#"{"ok":true}"#)
960            .await
961            .unwrap();
962
963        let read_tool = ReadTool::new();
964        let _ = read_tool
965            .execute_with_context(
966                json!({ "file_path": file.path() }),
967                ToolExecutionContext {
968                    session_id: Some("session_edit_diag"),
969                    tool_call_id: "call_1",
970                    event_tx: None,
971                    available_tool_schemas: None,
972                },
973            )
974            .await
975            .unwrap();
976
977        let tool = EditTool::new();
978        let result = tool
979            .execute_with_context(
980                json!({
981                    "file_path": file.path(),
982                    "old_string": r#"{"ok":true}"#,
983                    "new_string": "{"
984                }),
985                ToolExecutionContext {
986                    session_id: Some("session_edit_diag"),
987                    tool_call_id: "call_2",
988                    event_tx: None,
989                    available_tool_schemas: None,
990                },
991            )
992            .await
993            .unwrap();
994
995        let payload: serde_json::Value = serde_json::from_str(&result.result).unwrap();
996        assert_eq!(payload["diagnostics"]["format"], "json");
997        assert_eq!(payload["diagnostics"]["valid"], false);
998    }
999}