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}