1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use serde::Deserialize;
4use serde_json::json;
5
6fn deserialize_lenient_usize<'de, D>(
8 deserializer: D,
9) -> std::result::Result<Option<usize>, D::Error>
10where
11 D: serde::Deserializer<'de>,
12{
13 use serde::de;
14
15 struct LenientUsize;
16
17 impl<'de> de::Visitor<'de> for LenientUsize {
18 type Value = Option<usize>;
19 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
20 f.write_str("a usize or a string containing a usize")
21 }
22 fn visit_none<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
23 Ok(None)
24 }
25 fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
26 Ok(None)
27 }
28 fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
29 Ok(Some(v as usize))
30 }
31 fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
32 if v >= 0 {
33 Ok(Some(v as usize))
34 } else {
35 Err(de::Error::custom("negative line number"))
36 }
37 }
38 fn visit_f64<E: de::Error>(self, v: f64) -> std::result::Result<Self::Value, E> {
39 Ok(Some(v as usize))
40 }
41 fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
42 v.trim()
43 .parse::<usize>()
44 .map(Some)
45 .map_err(de::Error::custom)
46 }
47 }
48
49 deserializer.deserialize_any(LenientUsize)
50}
51
52async fn atomic_write(path: &str, content: &str) -> Result<()> {
56 let temp = format!("{}.atomcode.tmp", path);
57 tokio::fs::write(&temp, content)
58 .await
59 .with_context(|| format!("Failed to write temp file {}", temp))?;
60 match tokio::fs::rename(&temp, path).await {
61 Ok(()) => Ok(()),
62 Err(_) => {
63 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
65 match tokio::fs::rename(&temp, path).await {
66 Ok(()) => Ok(()),
67 Err(_) => {
68 let _ = tokio::fs::remove_file(&temp).await;
70 tokio::fs::write(path, content)
71 .await
72 .with_context(|| format!("Failed to write {}", path))?;
73 Ok(())
74 }
75 }
76 }
77 }
78}
79
80use super::auto_fix;
81use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
82
83async fn validate_write_check(
87 content: &str,
88 file_path: &str,
89 new_string: &str,
90 original_content: &str,
91 result: ToolResult,
92 ctx: &ToolContext,
93) -> Result<(ToolResult, String)> {
94 if !result.success {
95 return Ok((result, content.to_string()));
96 }
97 let validated =
98 auto_fix::validate_and_fix(content, file_path, new_string, original_content).await;
99
100 if validated.rejected {
102 let errors = validated.warnings.join("\n");
103 return Ok((
104 ToolResult {
105 call_id: result.call_id,
106 output: format!(
107 "EDIT REJECTED — duplicate code detected:\n{}\n\
108 Fix your new_string and retry. The file was NOT modified.",
109 errors
110 ),
111 success: false,
112 },
113 content.to_string(),
114 ));
115 }
116
117 atomic_write(file_path, &validated.fixed_content).await?;
118
119 let raw_path = std::path::Path::new(file_path);
127 let canon_path = std::fs::canonicalize(raw_path).unwrap_or_else(|_| raw_path.to_path_buf());
128
129 ctx.notify_lsp_file_changed(&canon_path, &validated.fixed_content)
131 .await;
132 ctx.file_store.write().await.invalidate(&canon_path);
137 ctx.read_cache
143 .write()
144 .await
145 .retain(|(p, _, _), _| p != &canon_path);
146
147 let syntax_warn = auto_fix::post_edit_syntax_check(file_path).await;
149
150 let mut all_warnings: Vec<String> = validated.warnings;
151 if !syntax_warn.is_empty() {
152 all_warnings.push(syntax_warn);
153 }
154
155 let context_snippet = build_edit_context(&validated.fixed_content, new_string);
159 let result = ToolResult {
160 output: format!("{}{}", result.output, context_snippet),
161 ..result
162 };
163
164 if all_warnings.is_empty() {
165 Ok((result, validated.fixed_content))
166 } else {
167 let combined = all_warnings.join("");
168 Ok((
169 ToolResult {
170 output: format!("{}{}", result.output, combined),
171 ..result
172 },
173 validated.fixed_content,
174 ))
175 }
176}
177
178pub struct EditFileTool;
179
180#[derive(Deserialize)]
181struct EditFileArgs {
182 file_path: String,
183 #[serde(default)]
185 old_string: Option<String>,
186 #[serde(default)]
188 new_string: Option<String>,
189 #[serde(default)]
190 replace_all: bool,
191 #[serde(default)]
193 symbol: Option<String>,
194 #[serde(default, deserialize_with = "deserialize_lenient_usize")]
197 start_line: Option<usize>,
198 #[serde(default, deserialize_with = "deserialize_lenient_usize")]
199 end_line: Option<usize>,
200 #[serde(default)]
203 edits: Option<Vec<SingleEdit>>,
204}
205
206#[derive(Deserialize)]
207struct SingleEdit {
208 #[serde(default, deserialize_with = "deserialize_lenient_usize")]
209 start_line: Option<usize>,
210 #[serde(default, deserialize_with = "deserialize_lenient_usize")]
211 end_line: Option<usize>,
212 #[serde(default)]
213 old_string: Option<String>,
214 new_string: String,
215}
216
217#[async_trait]
218impl Tool for EditFileTool {
219 fn definition(&self) -> ToolDef {
228 ToolDef {
229 name: "edit_file",
230 description: "Replace text in a file. ALWAYS prefer this over write_file for existing files.\n\
231 Two modes:\n\
232 1. Line mode: use start_line + end_line + new_string. Line numbers from read_file or grep output.\n\
233 2. Text mode: use old_string + new_string. old_string must match exactly.\n\
234 Both modes work. Use whichever is faster — if grep already showed the code, edit directly.\n\
235 For multiple changes in one file: make separate edit_file calls, one per region.".to_string(),
236 parameters: json!({
237 "type": "object",
238 "properties": {
239 "file_path": {
240 "type": "string",
241 "description": "Path to the file to edit"
242 },
243 "old_string": {
244 "type": "string",
245 "description": "Text mode: exact text to find and replace. Include enough context to be unique."
246 },
247 "new_string": {
248 "type": "string",
249 "description": "Replacement text. Use empty string to delete."
250 },
251 "start_line": {
252 "type": "integer",
253 "description": "Line mode: first line to replace (1-indexed, from read_file output)"
254 },
255 "end_line": {
256 "type": "integer",
257 "description": "Line mode: last line to replace (inclusive)"
258 },
259 "replace_all": {
260 "type": "boolean",
261 "description": "Replace ALL occurrences (default: first only). Only for text mode."
262 }
263 },
264 "required": ["file_path"]
271 }),
272 }
273 }
274
275 fn validate_args(&self, args: &str) -> std::result::Result<(), String> {
276 super::diagnose_args(
281 "edit_file",
282 args,
283 &[&["file_path"]],
284 "edit_file({\"file_path\": \"<abs>\", \"old_string\": \"<old>\", \"new_string\": \"<new>\"}) \
285 — text mode; or use start_line+end_line+new_string for line mode",
286 )?;
287 let parsed: EditFileArgs = serde_json::from_str(args).map_err(|e| {
288 format!(
289 "edit_file: {e}. Check that file_path is a string, line numbers are integers, \
290 and old_string/new_string are strings."
291 )
292 })?;
293 let has_string_mode = parsed.old_string.is_some() || parsed.new_string.is_some();
301 let has_line_mode = parsed.start_line.is_some() || parsed.end_line.is_some();
302 let has_edits = parsed.edits.is_some();
303 let has_symbol = parsed.symbol.is_some();
304 if !has_string_mode && !has_line_mode && !has_edits && !has_symbol {
305 return Err(
306 "edit_file arguments missing edit content. Provide `old_string`+`new_string`, \
307 `start_line`+`end_line`+`new_string`, an `edits` array, or `symbol`+`new_string`."
308 .to_string(),
309 );
310 }
311 Ok(())
312 }
313
314 fn approval(&self, args: &str) -> ApprovalRequirement {
315 let parsed = match serde_json::from_str::<EditFileArgs>(args) {
321 Ok(p) => p,
322 Err(_) => {
323 return ApprovalRequirement::RequireApproval(
327 "Could not parse edit_file arguments for safety check.".to_string(),
328 );
329 }
330 };
331
332 if super::is_sensitive_input_path(&parsed.file_path) {
333 return ApprovalRequirement::RequireApproval(
334 format!("Editing sensitive system path: {}", parsed.file_path),
335 );
336 }
337
338 ApprovalRequirement::AutoApprove
339 }
340
341 fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
342 let parsed = match serde_json::from_str::<EditFileArgs>(args) {
343 Ok(parsed) => parsed,
344 Err(_) => return self.approval(args),
345 };
346 let working_dir = match ctx.working_dir.try_read() {
347 Ok(wd) => wd.clone(),
348 Err(_) => return self.approval(args),
349 };
350 match super::approval_for_path(
351 &parsed.file_path,
352 &working_dir,
353 super::ExternalPathAction::Write,
354 ) {
355 Ok(approval) => approval,
356 Err(_) => self.approval(args),
357 }
358 }
359
360 async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
361 if let Err(msg) = super::diagnose_args(
365 "edit_file",
366 args,
367 &[&["file_path"]],
368 "edit_file({\"file_path\": \"<abs>\", \"old_string\": \"<old>\", \"new_string\": \"<new>\"})",
369 ) {
370 return Ok(ToolResult {
371 call_id: String::new(),
372 output: msg,
373 success: false,
374 });
375 }
376 let parsed: EditFileArgs = match serde_json::from_str(args) {
377 Ok(p) => p,
378 Err(e) => {
379 return Ok(ToolResult {
380 call_id: String::new(),
381 output: format!(
382 "edit_file: {e}. Check that file_path is a string, line numbers are \
383 integers, and old_string/new_string are strings."
384 ),
385 success: false,
386 });
387 }
388 };
389 let working_dir = ctx.working_dir.read().await.clone();
390 let file_path = match super::inspect_path_access(&parsed.file_path, &working_dir) {
391 Ok(access) => access.path,
392 Err(err) => {
393 return Ok(ToolResult {
394 call_id: String::new(),
395 output: err.to_string(),
396 success: false,
397 });
398 }
399 };
400 let file_path_str = file_path.to_string_lossy().to_string();
401
402 ctx.file_history
404 .lock()
405 .await
406 .backup_before_write(&file_path_str)
407 .await;
408
409 let content = tokio::fs::read_to_string(&file_path)
410 .await
411 .with_context(|| format!("Failed to read {}", file_path.display()))?;
412
413 if let Some(edits) = parsed.edits {
418 if edits.is_empty() {
419 return Ok(ToolResult {
420 call_id: String::new(),
421 output: "Error: edits array is empty.".to_string(),
422 success: false,
423 });
424 }
425 return self
426 .execute_multi_edit(&file_path_str, &content, edits, ctx)
427 .await;
428 }
429
430 let new_string = match parsed.new_string {
432 Some(s) => s,
433 None => {
434 return Ok(ToolResult {
435 call_id: String::new(),
436 output: "Error: missing new_string.\n\
437 To REPLACE: edit_file({file_path, old_string: \"old code\", new_string: \"new code\"})\n\
438 To DELETE: edit_file({file_path, old_string: \"old code\", new_string: \"\"})\n\
439 You MUST include new_string in every edit_file call.".to_string(),
440 success: false,
441 });
442 }
443 };
444
445 if let (Some(mut start), Some(mut end)) = (parsed.start_line, parsed.end_line) {
448 let lines: Vec<&str> = content.lines().collect();
449 let total = lines.len();
450
451 if end < start {
453 std::mem::swap(&mut start, &mut end);
454 }
455 if start == 0 || start > total {
456 return Ok(ToolResult {
457 call_id: String::new(),
458 output: format!(
459 "Invalid line range: {}-{} (file has {} lines)",
460 start, end, total
461 ),
462 success: false,
463 });
464 }
465 let mut end = end.min(total);
466
467 let ns_lines: Vec<&str> = new_string.lines().collect();
470 if !ns_lines.is_empty() {
471 let mut extra = 0usize;
472 for i in 0..ns_lines.len() {
473 let ns_idx = ns_lines.len() - 1 - i;
474 let orig_idx = end + extra; if orig_idx >= total {
476 break;
477 }
478 if ns_lines[ns_idx].trim() == lines[orig_idx].trim()
479 && !ns_lines[ns_idx].trim().is_empty()
480 {
481 extra += 1;
482 } else {
483 break;
484 }
485 }
486 if extra > 0 {
487 end = (end + extra).min(total);
488 }
489 }
490
491 if !ns_lines.is_empty() {
494 let mut extra = 0usize;
495 for i in 0..ns_lines.len() {
496 if start <= 1 + extra {
497 break;
498 } let orig_idx = start - 2 - extra; if ns_lines[i].trim() == lines[orig_idx].trim()
501 && !ns_lines[i].trim().is_empty()
502 {
503 extra += 1;
504 } else {
505 break;
506 }
507 }
508 if extra > 0 {
509 start = start.saturating_sub(extra).max(1);
510 }
511 }
512
513 let old_text: String = lines[start - 1..end].join("\n");
515 let removed = end - start + 1;
516 let added = new_string.lines().count();
517
518 let ext = parsed.file_path.rsplit('.').next().unwrap_or("");
522 let _large_edit_warning =
523 if removed > 50 && matches!(ext, "vue" | "html" | "svelte" | "tsx" | "jsx") {
524 format!(
525 "\n⚠ Large edit ({} lines replaced). Verify HTML tag balance after this edit.",
526 removed
527 )
528 } else {
529 String::new()
530 };
531
532 let mut new_lines: Vec<&str> = Vec::with_capacity(total);
534 new_lines.extend_from_slice(&lines[..start - 1]);
535 let new_content_lines: Vec<&str> = new_string.lines().collect();
537 new_lines.extend_from_slice(&new_content_lines);
538 if end < total {
539 new_lines.extend_from_slice(&lines[end..]);
540 }
541 let new_content = if content.ends_with('\n') {
542 format!("{}\n", new_lines.join("\n"))
543 } else {
544 new_lines.join("\n")
545 };
546
547 let diff = build_compact_diff(&old_text, &new_string);
548 let _new_end = start + added.saturating_sub(1);
549 let result = ToolResult {
551 call_id: String::new(),
552 output: format!(
553 "Edited {} lines {}-{} (-{} +{} lines).\n{}",
554 parsed.file_path, start, end, removed, added, diff
555 ),
556 success: true,
557 };
558 let (result, _final_content) = validate_write_check(
559 &new_content,
560 &file_path_str,
561 &new_string,
562 &content,
563 result,
564 ctx,
565 )
566 .await?;
567 return Ok(result);
568 }
569
570 let old_string = match parsed.old_string {
572 Some(ref s) if !s.is_empty() => s.clone(),
573 _ => {
574 return Ok(ToolResult {
577 call_id: String::new(),
578 output: "Error: old_string is required for editing existing files. \
579 Provide the exact text you want to replace, or use start_line/end_line for line-based editing.".to_string(),
580 success: false,
581 });
582 }
583 };
584
585 if let Some(ref symbol_name) = parsed.symbol {
588 let path = file_path.as_path();
589 let mut searcher = ctx.semantic.lock().await;
590 if let Some(slice) = searcher.extract_symbol(path, symbol_name) {
591 let sym_text = &content[slice.start_byte..slice.end_byte];
592 let sym_count = sym_text.matches(&old_string).count();
593
594 if sym_count == 0 {
595 let (hint, _) = find_closest_match_with_suggestion(sym_text, &old_string);
596 let reread = auto_reread_content(&content, &old_string);
597 return Ok(ToolResult {
598 call_id: String::new(),
599 output: format!(
600 "Error: old_string not found in symbol '{}' (lines {}-{}).\n{}\n{}\n\
601 [HINT: Copy the EXACT text from the returned content as your new old_string.]",
602 symbol_name, slice.start_line, slice.end_line, hint, reread
603 ),
604 success: false,
605 });
606 }
607
608 if !parsed.replace_all && sym_count > 1 {
609 return Ok(ToolResult {
610 call_id: String::new(),
611 output: format!(
612 "Error: old_string found {} times in symbol '{}'. Use replace_all=true or provide more context.",
613 sym_count, symbol_name
614 ),
615 success: false,
616 });
617 }
618
619 let new_sym_text = if parsed.replace_all {
621 sym_text.replace(&old_string, &new_string)
622 } else {
623 sym_text.replacen(&old_string, &new_string, 1)
624 };
625 let new_content = format!(
626 "{}{}{}",
627 &content[..slice.start_byte],
628 new_sym_text,
629 &content[slice.end_byte..]
630 );
631
632 let diff = build_compact_diff(&old_string, &new_string);
633 let label = if parsed.replace_all {
634 format!("replaced {} occurrences in {}", sym_count, symbol_name)
635 } else {
636 format!(
637 "in {} (lines {}-{})",
638 symbol_name, slice.start_line, slice.end_line
639 )
640 };
641 let result = ToolResult {
642 call_id: String::new(),
643 output: format!("Edited {} {}.\n{}", parsed.file_path, label, diff),
644 success: true,
645 };
646 let (result, _final_content) = validate_write_check(
647 &new_content,
648 &file_path_str,
649 &new_string,
650 &content,
651 result,
652 ctx,
653 )
654 .await?;
655 drop(searcher); let mut searcher = ctx.semantic.lock().await;
658 searcher.invalidate(path);
659 return Ok(result);
660 } else {
661 let hint = match searcher.list_symbols(path) {
663 Some(syms) => {
664 let names: Vec<&str> = syms.iter().map(|s| s.name.as_str()).collect();
665 format!(
666 "Symbol '{}' not found. Available: {}",
667 symbol_name,
668 names.join(", ")
669 )
670 }
671 None => format!("Symbol '{}' not found in {}", symbol_name, parsed.file_path),
672 };
673 return Ok(ToolResult {
674 call_id: String::new(),
675 output: hint,
676 success: false,
677 });
678 }
679 }
680
681 let count = content.matches(&old_string).count();
683
684 if count == 0 {
685 if let Some((fuzzy_result, fuzzy_count)) =
688 try_fuzzy_replace(&content, &old_string, &new_string, parsed.replace_all)
689 {
690 let diff = build_compact_diff(&old_string, &new_string);
691 let result = ToolResult {
692 call_id: String::new(),
693 output: format!(
694 "Edited {} (fuzzy match, {} occurrence{}).\n{}",
695 parsed.file_path,
696 fuzzy_count,
697 if fuzzy_count > 1 { "s" } else { "" },
698 diff
699 ),
700 success: true,
701 };
702 let (result, _final_content) = validate_write_check(
703 &fuzzy_result,
704 &file_path_str,
705 &new_string,
706 &content,
707 result,
708 ctx,
709 )
710 .await?;
711 return Ok(result);
712 }
713
714 let (hint, _suggested_old) = find_closest_match_with_suggestion(&content, &old_string);
715
716 let line_hint = {
719 let old_first = old_string
720 .lines()
721 .find(|l| !l.trim().is_empty())
722 .map(|l| l.trim());
723 let lines: Vec<&str> = content.lines().collect();
724 old_first
725 .and_then(|needle| {
726 lines
727 .iter()
728 .position(|l| l.trim().contains(needle))
729 .map(|center| {
730 let old_line_count = old_string.lines().count();
731 let start = center + 1; let end = (center + old_line_count).min(lines.len());
733 format!(
734 "\n[TIP: Use line mode instead — edit_file(file_path=\"{}\", \
735 start_line={}, end_line={}, new_string=\"...\")]",
736 parsed.file_path, start, end
737 )
738 })
739 })
740 .unwrap_or_default()
741 };
742
743 let reread = auto_reread_content(&content, &old_string);
744 return Ok(ToolResult {
745 call_id: String::new(),
746 output: format!(
747 "Error: old_string not found in {}.\n{}{}\n{}\n\
748 [HINT: old_string did not match. The file content around your target has been \
749 returned above. Copy the EXACT text from the returned content as your new old_string.]\n\
750 [Do not fall back to shell file modification (in-place editors, redirects, \
751 write scripts) — re-issue edit_file with the corrected old_string so the \
752 change is tracked and reversible via /undo.]",
753 parsed.file_path, hint, line_hint, reread
754 ),
755 success: false,
756 });
757 }
758
759 let old_lines = old_string.lines().count();
761 let new_lines = new_string.lines().count();
762 let net_deleted = old_lines.saturating_sub(new_lines);
763 let _deletion_warning = if net_deleted > 10 {
764 format!(
765 "\nWARNING: You removed {} more lines than you added. If you only meant to ADD a skeleton/loading section, \
766 use v-if/v-else to show it ALONGSIDE the existing content, not INSTEAD of it.",
767 net_deleted
768 )
769 } else {
770 String::new()
771 };
772
773 if parsed.replace_all {
774 let _replace_warning = if count > 10 {
776 format!(
777 "\nWARNING: Replaced {} occurrences. This many replacements may have changed structural \
778 elements (tags, brackets) that should not be bulk-replaced. Verify the file structure.",
779 count
780 )
781 } else {
782 String::new()
783 };
784
785 let new_content = content.replace(&old_string, &new_string);
786 let diff = build_compact_diff(&old_string, &new_string);
787 let result = ToolResult {
788 call_id: String::new(),
789 output: format!(
790 "Edited {} (replaced {} occurrence{}).\n{}",
791 parsed.file_path,
792 count,
793 if count > 1 { "s" } else { "" },
794 diff,
795 ),
796 success: true,
797 };
798 let (result, _final_content) = validate_write_check(
799 &new_content,
800 &file_path_str,
801 &new_string,
802 &content,
803 result,
804 ctx,
805 )
806 .await?;
807 Ok(result)
808 } else {
809 if count > 1 {
810 let path = file_path.as_path();
813 let mut searcher = ctx.semantic.lock().await;
814 if let Some(symbols) = searcher.list_symbols(path) {
815 let matching_syms: Vec<&crate::semantic::Symbol> = symbols
817 .iter()
818 .filter(|sym| {
819 let sym_text =
820 &content[sym.start_byte..sym.end_byte.min(content.len())];
821 sym_text.contains(&*old_string)
822 })
823 .collect();
824
825 if matching_syms.len() == 1 {
826 let sym = matching_syms[0];
828 let sym_text = &content[sym.start_byte..sym.end_byte.min(content.len())];
829 let new_sym = sym_text.replacen(&*old_string, &new_string, 1);
830 let new_content = format!(
831 "{}{}{}",
832 &content[..sym.start_byte],
833 new_sym,
834 &content[sym.end_byte.min(content.len())..]
835 );
836 drop(searcher);
837 let diff = build_compact_diff(&old_string, &new_string);
838 let result = ToolResult {
839 call_id: String::new(),
840 output: format!(
841 "Edited {} in {}() (auto-scoped, {} global matches).\n{}",
842 parsed.file_path, sym.name, count, diff
843 ),
844 success: true,
845 };
846 let (result, _final_content) = validate_write_check(
847 &new_content,
848 &file_path_str,
849 &new_string,
850 &content,
851 result,
852 ctx,
853 )
854 .await?;
855 return Ok(result);
856 }
857 }
858 drop(searcher);
859
860 return Ok(ToolResult {
861 call_id: String::new(),
862 output: format!(
863 "Error: old_string found {} times in {}. Use replace_all=true to replace all, or provide more context to make it unique.",
864 count, parsed.file_path
865 ),
866 success: false,
867 });
868 }
869
870 let new_content = content.replacen(&old_string, &new_string, 1);
871
872 let removed = old_string.lines().count();
873 let added = new_string.lines().count();
874 let diff = build_compact_diff(&old_string, &new_string);
875 let result = ToolResult {
876 call_id: String::new(),
877 output: format!(
878 "Edited {} (-{} +{} lines).\n{}",
879 parsed.file_path, removed, added, diff,
880 ),
881 success: true,
882 };
883 let (result, _final_content) = validate_write_check(
884 &new_content,
885 &file_path_str,
886 &new_string,
887 &content,
888 result,
889 ctx,
890 )
891 .await?;
892 Ok(result)
893 }
894 }
895}
896
897impl EditFileTool {
898 async fn execute_multi_edit(
901 &self,
902 file_path: &str,
903 content: &str,
904 edits: Vec<SingleEdit>,
905 ctx: &ToolContext,
906 ) -> Result<ToolResult> {
907 let lines: Vec<&str> = content.lines().collect();
908 let total = lines.len();
909
910 let mut resolved: Vec<(usize, usize, String)> = Vec::with_capacity(edits.len());
912
913 for (i, edit) in edits.iter().enumerate() {
914 if let (Some(start), Some(end)) = (edit.start_line, edit.end_line) {
915 let (start, end) = if end < start {
917 (end, start)
918 } else {
919 (start, end)
920 };
921 if start == 0 || start > total {
922 return Ok(ToolResult {
923 call_id: String::new(),
924 output: format!(
925 "Error in edit #{}: invalid line range {}-{} (file has {} lines)",
926 i + 1,
927 start,
928 end,
929 total
930 ),
931 success: false,
932 });
933 }
934 resolved.push((start, end.min(total), edit.new_string.clone()));
935 } else if let Some(ref old_str) = edit.old_string {
936 if old_str.is_empty() {
937 return Ok(ToolResult {
938 call_id: String::new(),
939 output: format!("Error in edit #{}: old_string is empty", i + 1),
940 success: false,
941 });
942 }
943 match find_text_line_range(content, old_str) {
945 Some((start, end)) => {
946 resolved.push((start, end, edit.new_string.clone()));
947 }
948 None => {
949 return Ok(ToolResult {
950 call_id: String::new(),
951 output: format!(
952 "Error in edit #{}: old_string not found in {}.\nSearched for: {:?}",
953 i + 1, file_path, old_str.lines().next().unwrap_or("")
954 ),
955 success: false,
956 });
957 }
958 }
959 } else {
960 return Ok(ToolResult {
961 call_id: String::new(),
962 output: format!(
963 "Error in edit #{}: must specify start_line/end_line or old_string",
964 i + 1
965 ),
966 success: false,
967 });
968 }
969 }
970
971 for (start, end, new_str) in &mut resolved {
975 let new_lines: Vec<&str> = new_str.lines().collect();
976 if new_lines.is_empty() {
977 continue;
978 }
979
980 let mut trail_extra = 0usize;
982 for i in 0..new_lines.len() {
983 let new_idx = new_lines.len() - 1 - i;
984 let orig_idx = *end + trail_extra;
985 if orig_idx >= total {
986 break;
987 }
988 if new_lines[new_idx].trim() == lines[orig_idx].trim()
989 && !new_lines[new_idx].trim().is_empty()
990 {
991 trail_extra += 1;
992 } else {
993 break;
994 }
995 }
996 if trail_extra > 0 {
997 *end = (*end + trail_extra).min(total);
998 }
999
1000 let mut lead_extra = 0usize;
1002 for i in 0..new_lines.len() {
1003 if *start <= 1 + lead_extra {
1004 break;
1005 }
1006 let orig_idx = *start - 2 - lead_extra;
1007 if new_lines[i].trim() == lines[orig_idx].trim() && !new_lines[i].trim().is_empty()
1008 {
1009 lead_extra += 1;
1010 } else {
1011 break;
1012 }
1013 }
1014 if lead_extra > 0 {
1015 *start = start.saturating_sub(lead_extra).max(1);
1016 }
1017 }
1018
1019 resolved.sort_by_key(|(start, _, _)| *start);
1025 let mut merged: Vec<(usize, usize, String)> = Vec::new();
1026 for edit in resolved {
1027 if let Some(last) = merged.last_mut() {
1028 if edit.0 <= last.1 {
1029 if edit.1 >= last.1 {
1033 last.1 = edit.1;
1037 last.2 = edit.2;
1038 }
1039 continue;
1041 }
1042 }
1043 merged.push(edit);
1044 }
1045 let mut resolved = merged;
1046
1047 resolved.sort_by(|a, b| b.0.cmp(&a.0));
1049
1050 let mut result_lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
1051 let mut summary_parts: Vec<String> = Vec::new();
1052
1053 let _ext = file_path.rsplit('.').next().unwrap_or("");
1056 if false { }
1058
1059 for (start, end, new_str) in &resolved {
1060 let removed = end - start + 1;
1061 let new_edit_lines: Vec<String> = new_str.lines().map(|l| l.to_string()).collect();
1062 let added = new_edit_lines.len();
1063 result_lines.splice((start - 1)..*end, new_edit_lines);
1064 summary_parts.push(format!("L{}-{} (-{} +{})", start, end, removed, added));
1065 }
1066 summary_parts.reverse();
1068
1069 let new_content = if content.ends_with('\n') {
1070 format!("{}\n", result_lines.join("\n"))
1071 } else {
1072 result_lines.join("\n")
1073 };
1074
1075 let edit_count = resolved.len();
1076 let all_new_strings: String = edits
1077 .iter()
1078 .map(|e| e.new_string.as_str())
1079 .collect::<Vec<_>>()
1080 .join("\n");
1081 let short_name = std::path::Path::new(file_path)
1082 .file_name()
1083 .map(|n| n.to_string_lossy().to_string())
1084 .unwrap_or_else(|| file_path.to_string());
1085 let result = ToolResult {
1086 call_id: String::new(),
1087 output: format!(
1088 "Multi-edit: {} edits applied to {} [{}].\n\u{2713} {} updated. Proceed to your next file.",
1089 edit_count, file_path, summary_parts.join(", "), short_name),
1090 success: true,
1091 };
1092 let (result, _final_content) =
1093 validate_write_check(&new_content, file_path, &all_new_strings, content, result, ctx)
1094 .await?;
1095 Ok(result)
1096 }
1097}
1098
1099fn find_text_line_range(content: &str, needle: &str) -> Option<(usize, usize)> {
1102 let needle_lines: Vec<&str> = needle.lines().collect();
1103 if needle_lines.is_empty() {
1104 return None;
1105 }
1106
1107 let content_lines: Vec<&str> = content.lines().collect();
1108 let mut matches: Vec<usize> = Vec::new();
1109
1110 for i in 0..content_lines.len().saturating_sub(needle_lines.len() - 1) {
1112 if content_lines[i..i + needle_lines.len()] == needle_lines[..] {
1113 matches.push(i);
1114 }
1115 }
1116
1117 if matches.is_empty() {
1119 let needle_trimmed: Vec<&str> = needle_lines.iter().map(|l| l.trim()).collect();
1120 for i in 0..content_lines.len().saturating_sub(needle_trimmed.len() - 1) {
1121 let window: Vec<&str> = content_lines[i..i + needle_trimmed.len()]
1122 .iter()
1123 .map(|l| l.trim())
1124 .collect();
1125 if window == needle_trimmed {
1126 matches.push(i);
1127 }
1128 }
1129 }
1130
1131 if matches.len() == 1 {
1132 let start = matches[0] + 1; let end = start + needle_lines.len() - 1;
1134 Some((start, end))
1135 } else {
1136 None }
1138}
1139
1140fn try_fuzzy_replace(
1143 content: &str,
1144 old_string: &str,
1145 new_string: &str,
1146 replace_all: bool,
1147) -> Option<(String, usize)> {
1148 let old_normalized: Vec<&str> = old_string.lines().map(|l| l.trim()).collect();
1149 if old_normalized.is_empty() || old_normalized.iter().all(|l| l.is_empty()) {
1150 return None;
1151 }
1152
1153 let content_lines: Vec<&str> = content.lines().collect();
1154 let has_trailing_newline = content.ends_with('\n');
1155 let mut matches: Vec<(usize, usize)> = Vec::new();
1156
1157 let total_non_ws: usize = old_normalized.iter().map(|l| l.len()).sum();
1159 if total_non_ws < 10 {
1160 return None; }
1162
1163 let mut i = 0;
1165 while i + old_normalized.len() <= content_lines.len() {
1166 let window: Vec<&str> = content_lines[i..i + old_normalized.len()]
1167 .iter()
1168 .map(|l| l.trim())
1169 .collect();
1170 if window == old_normalized {
1171 matches.push((i, i + old_normalized.len()));
1172 i += old_normalized.len(); } else {
1174 i += 1;
1175 }
1176 }
1177
1178 if matches.is_empty() {
1179 return None;
1180 }
1181
1182 if !replace_all && matches.len() > 1 {
1184 return None; }
1186
1187 let new_lines: Vec<&str> = new_string.lines().collect();
1205 let new_base_indent = new_lines.iter()
1206 .find(|l| !l.trim().is_empty())
1207 .map(|l| l.len() - l.trim_start().len())
1208 .unwrap_or(0);
1209
1210 let mut result_lines: Vec<String> = content_lines.iter().map(|l| l.to_string()).collect();
1211
1212 let to_replace = if replace_all {
1214 &matches[..]
1215 } else {
1216 &matches[..1]
1217 };
1218 for &(start, end) in to_replace.iter().rev() {
1219 let original_line = content_lines[start];
1221 let file_indent = original_line.len() - original_line.trim_start().len();
1222 let file_indent_str: String = original_line.chars().take(file_indent).collect();
1223
1224 let replacement: Vec<String> = new_lines.iter().map(|l| {
1228 if l.trim().is_empty() {
1229 String::new()
1230 } else {
1231 let line_indent = l.len() - l.trim_start().len();
1232 let signed_relative = line_indent as isize - new_base_indent as isize;
1233 let total_indent = if signed_relative >= 0 {
1234 format!("{}{}", file_indent_str, " ".repeat(signed_relative as usize))
1237 } else {
1238 let drop = (-signed_relative) as usize;
1242 let keep = file_indent.saturating_sub(drop);
1243 file_indent_str.chars().take(keep).collect()
1244 };
1245 format!("{}{}", total_indent, l.trim())
1246 }
1247 }).collect();
1248
1249 result_lines.splice(start..end, replacement);
1250 }
1251
1252 let mut result = result_lines.join("\n");
1253 if has_trailing_newline && !result.ends_with('\n') {
1255 result.push('\n');
1256 }
1257
1258 let count = if replace_all { matches.len() } else { 1 };
1259 Some((result, count))
1260}
1261
1262fn build_compact_diff(old: &str, new: &str) -> String {
1264 let mut diff = String::new();
1265 let old_lines: Vec<&str> = old.lines().collect();
1266 let new_lines: Vec<&str> = new.lines().collect();
1267
1268 let max_show = 4; for (i, line) in old_lines.iter().take(max_show).enumerate() {
1272 diff.push_str(&format!("- {}\n", line));
1273 if i == max_show - 1 && old_lines.len() > max_show {
1274 diff.push_str(&format!(
1275 " ... ({} more removed)\n",
1276 old_lines.len() - max_show
1277 ));
1278 }
1279 }
1280
1281 for (i, line) in new_lines.iter().take(max_show).enumerate() {
1283 diff.push_str(&format!("+ {}\n", line));
1284 if i == max_show - 1 && new_lines.len() > max_show {
1285 diff.push_str(&format!(
1286 " ... ({} more added)\n",
1287 new_lines.len() - max_show
1288 ));
1289 }
1290 }
1291
1292 diff.trim_end().to_string()
1293}
1294
1295fn build_edit_context(content: &str, new_string: &str) -> String {
1304 let lines: Vec<&str> = content.lines().collect();
1305 if lines.len() <= 20 {
1306 return String::new();
1307 }
1308
1309 let new_trimmed = new_string.trim();
1312 if new_trimmed.is_empty() {
1313 return String::new();
1314 }
1315
1316 let search_line = new_trimmed
1318 .lines()
1319 .find(|l| l.trim().len() >= 5)
1320 .unwrap_or("");
1321 if search_line.is_empty() {
1322 return String::new();
1323 }
1324
1325 let center = match lines.iter().position(|l| l.contains(search_line.trim())) {
1326 Some(idx) => idx,
1327 None => return String::new(),
1328 };
1329
1330 let ctx = 4;
1331 let new_lines_count = new_string.lines().count();
1332 let start = center.saturating_sub(ctx);
1333 let end = (center + new_lines_count + ctx).min(lines.len());
1334
1335 let mut snippet = format!("\n[File after edit, lines {}-{}:]\n", start + 1, end);
1336 for (i, line) in lines[start..end].iter().enumerate() {
1337 snippet.push_str(&format!("{:>4}| {}\n", start + i + 1, line));
1338 }
1339 snippet
1340}
1341
1342fn auto_reread_content(content: &str, old_string: &str) -> String {
1348 let lines: Vec<&str> = content.lines().collect();
1349 let total = lines.len();
1350
1351 if total == 0 {
1352 return String::new();
1353 }
1354
1355 let mut out = String::new();
1356
1357 let target_line = old_string
1359 .lines()
1360 .find(|l| !l.trim().is_empty())
1361 .map(|first| first.trim());
1362
1363 let center = target_line
1364 .and_then(|needle| lines.iter().position(|l| l.trim().contains(needle)))
1365 .unwrap_or(0);
1366
1367 if total <= 100 {
1372 out.push_str(&format!(
1373 "\n[Edit failed. Full file ({} lines) — copy EXACT text for old_string:]\n",
1374 total
1375 ));
1376 for (i, line) in lines.iter().enumerate() {
1377 out.push_str(&format!("{:>4}| {}\n", i + 1, line));
1378 }
1379 } else {
1380 let half = if total <= 300 { 15 } else { 7 };
1381 let start = center.saturating_sub(half);
1382 let end = (center + half + 1).min(total);
1383
1384 out.push_str(&format!(
1385 "\n[Edit failed. Lines {}-{} of {} (use EXACT text from below as old_string):]\n",
1386 start + 1,
1387 end,
1388 total
1389 ));
1390 for i in start..end {
1391 out.push_str(&format!("{:>4}| {}\n", i + 1, lines[i]));
1392 }
1393 }
1394
1395 out
1396}
1397
1398fn find_closest_match_with_suggestion(content: &str, old_string: &str) -> (String, Option<String>) {
1403 let old_lines: Vec<&str> = old_string.lines().collect();
1404 let content_lines: Vec<&str> = content.lines().collect();
1405
1406 if old_lines.is_empty() {
1407 return (
1408 "old_string is empty. Use read_file to re-read the file.".to_string(),
1409 None,
1410 );
1411 }
1412
1413 let old_first_trimmed = old_lines[0].trim();
1414 if old_first_trimmed.is_empty() && old_lines.len() > 1 {
1415 let hint = find_closest_match(content, old_string);
1416 return (hint, None);
1417 }
1418
1419 for (i, line) in content_lines.iter().enumerate() {
1421 if line.trim() == old_first_trimmed {
1422 let end = (i + old_lines.len()).min(content_lines.len());
1424 let actual_lines = &content_lines[i..end];
1425
1426 let matching = actual_lines
1428 .iter()
1429 .zip(old_lines.iter())
1430 .filter(|(a, b)| a.trim() == b.trim())
1431 .count();
1432
1433 if matching >= old_lines.len() / 3 || matching >= 2 {
1434 let suggested = actual_lines.join("\n");
1435 let hint = find_closest_match(content, old_string);
1436 return (hint, Some(suggested));
1437 }
1438 }
1439 }
1440
1441 let hint = find_closest_match(content, old_string);
1442 (hint, None)
1443}
1444
1445fn find_closest_match(content: &str, old_string: &str) -> String {
1448 let old_lines: Vec<&str> = old_string.lines().collect();
1449 let content_lines: Vec<&str> = content.lines().collect();
1450
1451 if old_lines.is_empty() {
1452 return "old_string is empty. Use read_file to re-read the file.".to_string();
1453 }
1454
1455 let old_first_trimmed = old_lines[0].trim();
1456 if old_first_trimmed.is_empty() && old_lines.len() > 1 {
1457 return find_closest_match_inner(content, &content_lines, old_lines[1].trim(), &old_lines);
1459 }
1460
1461 find_closest_match_inner(content, &content_lines, old_first_trimmed, &old_lines)
1462}
1463
1464fn find_closest_match_inner(
1465 _content: &str,
1466 content_lines: &[&str],
1467 first_line_trimmed: &str,
1468 old_lines: &[&str],
1469) -> String {
1470 if first_line_trimmed.is_empty() {
1471 return "old_string appears empty after trimming. Use read_file to re-read the file."
1472 .to_string();
1473 }
1474
1475 let mut candidates: Vec<(usize, usize)> = Vec::new(); for (i, line) in content_lines.iter().enumerate() {
1479 let trimmed = line.trim();
1480 if trimmed == first_line_trimmed {
1482 let mut match_count = 1;
1484 for j in 1..old_lines.len() {
1485 if i + j >= content_lines.len() {
1486 break;
1487 }
1488 if content_lines[i + j].trim() == old_lines[j].trim() {
1489 match_count += 1;
1490 } else {
1491 break;
1492 }
1493 }
1494 candidates.push((i, match_count));
1495 }
1496 else if trimmed.len() >= 4
1508 && first_line_trimmed.len() >= 4
1509 && (trimmed.contains(first_line_trimmed) || first_line_trimmed.contains(trimmed))
1510 {
1511 candidates.push((i, 0));
1512 }
1513 else if trimmed.len() > 15
1515 && first_line_trimmed.len() > 15
1516 && trimmed.chars().take(25).collect::<String>()
1517 == first_line_trimmed.chars().take(25).collect::<String>()
1518 {
1519 candidates.push((i, 0));
1520 }
1521 }
1522
1523 candidates.sort_by(|a, b| b.1.cmp(&a.1));
1525
1526 if let Some(&(best_idx, match_count)) = candidates.first() {
1527 let start = best_idx.saturating_sub(1);
1528 let end = (best_idx + old_lines.len().min(18) + 2).min(content_lines.len());
1530
1531 let mut snippet = String::new();
1532 for i in start..end {
1533 snippet.push_str(&format!("{:>4}| {}\n", i + 1, content_lines[i]));
1534 }
1535 if best_idx + old_lines.len() + 2 > end {
1536 snippet.push_str(&format!(
1537 " ... ({} more lines in file)\n",
1538 content_lines.len() - end
1539 ));
1540 }
1541
1542 if match_count > 0
1544 && match_count < old_lines.len()
1545 && best_idx + match_count < content_lines.len()
1546 {
1547 let diverge_idx = match_count;
1548 let file_line = content_lines[best_idx + diverge_idx].trim();
1549 let old_line = old_lines[diverge_idx].trim();
1550
1551 let file_indent =
1553 content_lines[best_idx].len() - content_lines[best_idx].trim_start().len();
1554 let old_indent = old_lines[0].len() - old_lines[0].trim_start().len();
1555
1556 let mut hint = format!(
1557 "First {} line(s) match (trimmed) but line {} diverges:\n\
1558 YOUR old_string line {}: \"{}\"\n\
1559 ACTUAL file line {}: \"{}\"\n",
1560 match_count,
1561 diverge_idx + 1,
1562 diverge_idx + 1,
1563 old_line,
1564 best_idx + diverge_idx + 1,
1565 file_line,
1566 );
1567
1568 if file_indent != old_indent {
1569 hint.push_str(&format!(
1570 "INDENTATION MISMATCH: file uses {} spaces, your old_string uses {} spaces.\n",
1571 file_indent, old_indent,
1572 ));
1573 }
1574
1575 return format!(
1576 "Partial match at lines {}-{} ({}/{} lines match).\n{}\n{}\n\
1577 Copy the EXACT text from above (including indentation) for old_string.",
1578 best_idx + 1,
1579 end,
1580 match_count,
1581 old_lines.len(),
1582 snippet,
1583 hint
1584 );
1585 }
1586
1587 if match_count == 0 {
1589 let file_indent =
1590 content_lines[best_idx].len() - content_lines[best_idx].trim_start().len();
1591 let old_indent = old_lines[0].len() - old_lines[0].trim_start().len();
1592 if file_indent != old_indent && content_lines[best_idx].trim() == old_lines[0].trim() {
1593 return format!(
1594 "INDENTATION MISMATCH at line {}. File uses {} spaces, your old_string uses {} spaces.\n\
1595 Actual file content:\n{}\n\
1596 Copy the EXACT text (with correct indentation) for old_string.",
1597 best_idx + 1, file_indent, old_indent, snippet
1598 );
1599 }
1600 }
1601
1602 return format!(
1603 "Closest match found near line {}:\n{}\n\
1604 Copy the EXACT text from above for old_string (preserve indentation).",
1605 best_idx + 1,
1606 snippet
1607 );
1608 }
1609
1610 let keywords: Vec<&str> = first_line_trimmed
1612 .split_whitespace()
1613 .filter(|w| {
1614 w.len() > 3
1615 && !matches!(
1616 *w,
1617 "const"
1618 | "let"
1619 | "var"
1620 | "this"
1621 | "self"
1622 | "return"
1623 | "from"
1624 | "import"
1625 | "function"
1626 )
1627 })
1628 .take(3)
1629 .collect();
1630
1631 if !keywords.is_empty() {
1632 for (i, line) in content_lines.iter().enumerate() {
1633 let lower = line.to_lowercase();
1634 if keywords.iter().all(|kw| lower.contains(&kw.to_lowercase())) {
1635 let start = i.saturating_sub(2);
1636 let end = (i + 5).min(content_lines.len());
1637 let mut snippet = String::new();
1638 for j in start..end {
1639 snippet.push_str(&format!("{:>4}| {}\n", j + 1, content_lines[j]));
1640 }
1641 return format!(
1642 "No exact match, but keywords [{}] found near line {}:\n{}\n\
1643 Use read_file with offset={} limit=20 to see the exact content.",
1644 keywords.join(", "),
1645 i + 1,
1646 snippet,
1647 start + 1
1648 );
1649 }
1650 }
1651 }
1652
1653 format!(
1654 "No similar text found in the file ({} lines total). \
1655 The content may have changed. Use read_file to re-read the file.",
1656 content_lines.len()
1657 )
1658}
1659#[cfg(test)]
1660mod security_tests {
1661 use super::*;
1662 use crate::tool::{ApprovalRequirement, Tool, ToolContext};
1663 use serial_test::serial;
1664 use tempfile::TempDir;
1665
1666 #[test]
1667 fn edit_file_requires_approval_for_sensitive_paths() {
1668 let tool = EditFileTool;
1669 let args = serde_json::json!({
1670 "file_path": "/etc/hosts",
1671 "old_string": "old",
1672 "new_string": "new"
1673 })
1674 .to_string();
1675
1676 assert!(matches!(
1677 tool.approval(&args),
1678 ApprovalRequirement::RequireApproval(_)
1679 ));
1680 }
1681
1682 #[test]
1683 fn edit_file_auto_approves_regular_paths() {
1684 let tool = EditFileTool;
1685 let args = serde_json::json!({
1686 "file_path": "src/main.rs",
1687 "old_string": "old",
1688 "new_string": "new"
1689 })
1690 .to_string();
1691
1692 assert!(matches!(tool.approval(&args), ApprovalRequirement::AutoApprove));
1693 }
1694
1695 #[test]
1696 fn edit_file_requires_approval_when_args_do_not_parse() {
1697 let tool = EditFileTool;
1698 assert!(matches!(
1699 tool.approval("{not valid json"),
1700 ApprovalRequirement::RequireApproval(_)
1701 ));
1702 }
1703
1704 #[tokio::test]
1705 #[serial]
1706 async fn edit_file_writes_relative_path_against_tool_working_dir() {
1707 let workspace = TempDir::new().unwrap();
1708 let process_cwd = TempDir::new().unwrap();
1709 std::fs::create_dir_all(workspace.path().join("src")).unwrap();
1710 std::fs::create_dir_all(process_cwd.path().join("src")).unwrap();
1711 std::fs::write(workspace.path().join("src/app.rs"), "fn main() {\n old();\n}\n")
1712 .unwrap();
1713
1714 let original_cwd = std::env::current_dir().unwrap();
1715 std::env::set_current_dir(process_cwd.path()).unwrap();
1716 let result = EditFileTool
1717 .execute(
1718 r#"{"file_path":"src/app.rs","old_string":"old();","new_string":"new();"}"#,
1719 &ToolContext::new(workspace.path().to_path_buf()),
1720 )
1721 .await;
1722 std::env::set_current_dir(original_cwd).unwrap();
1723
1724 let result = result.unwrap();
1725 assert!(result.success, "{}", result.output);
1726 assert_eq!(
1727 std::fs::read_to_string(workspace.path().join("src/app.rs")).unwrap(),
1728 "fn main() {\n new();\n}\n"
1729 );
1730 assert!(
1731 !process_cwd.path().join("src/app.rs").exists(),
1732 "edit_file must not write relative paths against the process cwd"
1733 );
1734 }
1735}