1use std::path::Path;
2
3use async_trait::async_trait;
4use imp_llm::truncate_chars_with_suffix;
5use serde_json::json;
6
7use super::fuzzy;
8use super::{generate_diff, suggest_similar_files, Tool, ToolContext, ToolOutput};
9use crate::error::Result;
10
11pub struct EditTool;
12
13#[async_trait]
14impl Tool for EditTool {
15 fn name(&self) -> &str {
16 "edit"
17 }
18 fn label(&self) -> &str {
19 "Edit File"
20 }
21 fn description(&self) -> &str {
22 "Edit files by exact replacement, anchored range, or edits[] transaction."
23 }
24 fn parameters(&self) -> serde_json::Value {
25 json!({
26 "type": "object",
27 "properties": {
28 "path": { "type": "string", "description": "File path or default edits[] path" },
29 "old_text": { "type": "string", "description": "Text to replace" },
30 "new_text": { "type": "string", "description": "Replacement text" },
31 "dry_run": {
32 "type": "boolean",
33 "description": "Dry run; return diff only"
34 },
35 "expected_occurrences": {
36 "type": "integer",
37 "description": "Required exact old_text match count"
38 },
39 "replace_all": {
40 "type": "boolean",
41 "description": "Replace all exact matches"
42 },
43 "anchor_start": {
44 "type": "string",
45 "description": "Start anchor from read(anchors=true)"
46 },
47 "anchor_end": {
48 "type": "string",
49 "description": "Optional end anchor"
50 },
51 "edits": {
52 "type": "array",
53 "description": "Transactional edits[]",
54 "items": {
55 "type": "object",
56 "properties": {
57 "path": { "type": "string", "description": "Per-edit path" },
58 "old_text": { "type": "string" },
59 "new_text": { "type": "string" }
60 },
61 "required": ["old_text", "new_text"]
62 }
63 }
64 },
65 "required": []
66 })
67 }
68 fn is_readonly(&self) -> bool {
69 false
70 }
71
72 async fn execute(
73 &self,
74 call_id: &str,
75 params: serde_json::Value,
76 ctx: ToolContext,
77 ) -> Result<ToolOutput> {
78 if params.get("edits").is_some_and(|v| v.is_array()) {
80 return super::multi_edit::MultiEditTool
81 .execute(call_id, params, ctx)
82 .await;
83 }
84
85 let raw_path = params["path"].as_str().unwrap_or("");
86 let old_text = get_str_param(¶ms, "old_text", "oldText").unwrap_or("");
87 let new_text = get_str_param(¶ms, "new_text", "newText").unwrap_or("");
88 let dry_run = get_bool_param(¶ms, "dry_run", "dryRun").unwrap_or(false);
89 let replace_all = get_bool_param(¶ms, "replace_all", "replaceAll").unwrap_or(false);
90 let expected_occurrences = params
91 .get("expected_occurrences")
92 .or_else(|| params.get("expectedOccurrences"))
93 .and_then(|v| v.as_u64())
94 .map(|v| v as usize);
95
96 if raw_path.is_empty() {
97 return Ok(ToolOutput::error("Missing required parameter: path"));
98 }
99
100 let path = super::resolve_path(&ctx.cwd, raw_path);
101
102 if get_str_param(¶ms, "anchor_start", "anchorStart").is_some() {
103 return execute_anchor_edit(&path, raw_path, ¶ms, ctx).await;
104 }
105
106 if old_text.is_empty() {
107 return Ok(ToolOutput::error("Missing required parameter: old_text"));
108 }
109
110 if let Err(error) = ctx.check_write_path(&path) {
111 return Ok(ToolOutput::error(error));
112 }
113 if !path.exists() {
114 let suggestions = suggest_similar_files(&ctx.cwd, raw_path);
115 let mut msg = format!("File not found: {}", path.display());
116 if !suggestions.is_empty() {
117 msg.push_str("\n\nDid you mean:");
118 for s in &suggestions {
119 msg.push_str(&format!("\n {s}"));
120 }
121 }
122 return Ok(ToolOutput::error(msg));
123 }
124
125 let tracker_warning = {
127 let tracker = ctx.file_tracker.lock().ok();
128 match tracker {
129 Some(t) if !t.was_read(&path) => Some(format!(
130 "Warning: editing {} without reading it first. Consider reading to verify current content.",
131 path.display()
132 )),
133 Some(t) if t.is_stale(&path) => Some(format!(
134 "Warning: {} was modified externally since last read. Re-read to verify current content.",
135 path.display()
136 )),
137 _ => None,
138 }
139 };
140
141 let raw_content = tokio::fs::read_to_string(&path).await?;
142
143 let content = raw_content.replace("\r\n", "\n");
145 let has_crlf = raw_content.contains("\r\n");
146 let old_normalized = old_text.replace("\r\n", "\n");
147 let new_normalized = new_text.replace("\r\n", "\n");
148
149 let exact_occurrences = count_occurrences(&content, &old_normalized);
150 if let Some(expected) = expected_occurrences {
151 if exact_occurrences != expected {
152 return Ok(ToolOutput::error(format!(
153 "Expected {expected} exact occurrence(s) of old_text in {raw_path}, found {exact_occurrences}. No changes made."
154 )));
155 }
156 }
157
158 let (new_content, was_fuzzy, replacements) = if replace_all {
159 if exact_occurrences == 0 {
160 return match apply_edit(&content, &old_normalized, &new_normalized) {
161 Ok((_, true)) => Ok(ToolOutput::error(
162 "replaceAll requires exact matches and does not use fuzzy matching. Found 0 exact matches, but a fuzzy match exists. No changes made.",
163 )),
164 Ok(_) => unreachable!("apply_edit cannot exact-match when exact_occurrences is 0"),
165 Err(output) => Ok(output),
166 };
167 }
168 (
169 content.replace(&old_normalized, &new_normalized),
170 false,
171 exact_occurrences,
172 )
173 } else {
174 match apply_edit(&content, &old_normalized, &new_normalized) {
175 Ok((new_content, was_fuzzy)) => (new_content, was_fuzzy, 1),
176 Err(output) => return Ok(output),
177 }
178 };
179
180 let diff = generate_diff(raw_path, &content, &new_content);
181
182 let final_content = if has_crlf {
184 new_content.replace('\n', "\r\n")
185 } else {
186 new_content
187 };
188
189 if !dry_run {
190 ctx.checkpoint_state.snapshot_paths(
191 std::slice::from_ref(&path),
192 Some(format!("edit {}", path.display())),
193 )?;
194 tokio::fs::write(&path, &final_content).await?;
195 }
196
197 let mut msg = diff;
198 if dry_run {
199 msg.push_str("\n(dry run: no changes written)");
200 }
201 if was_fuzzy {
202 msg.push_str(
203 "\n(matched using fuzzy matching: trailing whitespace/unicode normalized)",
204 );
205 }
206 if let Some(warning) = tracker_warning {
207 msg.push('\n');
208 msg.push_str(&warning);
209 }
210
211 Ok(ToolOutput {
212 content: vec![imp_llm::ContentBlock::Text { text: msg }],
213 details: json!({
214 "action": "edit",
215 "mode": "single",
216 "path": path.display().to_string(),
217 "fuzzy_match": was_fuzzy,
218 "dry_run": dry_run,
219 "replace_all": replace_all,
220 "exact_occurrences": exact_occurrences,
221 "replacements": replacements,
222 }),
223 is_error: false,
224 })
225 }
226}
227
228fn get_str_param<'a>(
229 params: &'a serde_json::Value,
230 primary: &str,
231 legacy: &str,
232) -> Option<&'a str> {
233 params
234 .get(primary)
235 .and_then(|v| v.as_str())
236 .or_else(|| params.get(legacy).and_then(|v| v.as_str()))
237}
238
239fn get_bool_param(params: &serde_json::Value, primary: &str, legacy: &str) -> Option<bool> {
240 params
241 .get(primary)
242 .and_then(|v| v.as_bool())
243 .or_else(|| params.get(legacy).and_then(|v| v.as_bool()))
244}
245
246async fn execute_anchor_edit(
247 path: &Path,
248 raw_path: &str,
249 params: &serde_json::Value,
250 ctx: ToolContext,
251) -> Result<ToolOutput> {
252 let Some(anchor_start_id) = get_str_param(params, "anchor_start", "anchorStart") else {
253 return Ok(ToolOutput::error(
254 "Missing required parameter: anchor_start",
255 ));
256 };
257 let anchor_end_id = get_str_param(params, "anchor_end", "anchorEnd").unwrap_or(anchor_start_id);
258 let Some(replacement) = get_str_param(params, "new_text", "replacement") else {
259 return Ok(ToolOutput::error(
260 "Missing required parameter: new_text for anchored edit mode",
261 ));
262 };
263 let dry_run = get_bool_param(params, "dry_run", "dryRun").unwrap_or(false);
264
265 if let Err(error) = ctx.check_write_path(path) {
266 return Ok(ToolOutput::error(error));
267 }
268 if !path.exists() {
269 let suggestions = suggest_similar_files(&ctx.cwd, raw_path);
270 let mut msg = format!("File not found: {}", path.display());
271 if !suggestions.is_empty() {
272 msg.push_str("\n\nDid you mean:");
273 for s in &suggestions {
274 msg.push_str(&format!("\n {s}"));
275 }
276 }
277 return Ok(ToolOutput::error(msg));
278 }
279
280 let Some(start_anchor) = ctx.anchor_store.get(path, anchor_start_id) else {
281 return Ok(ToolOutput::error(format!(
282 "Anchor not found or expired for {raw_path}: {anchor_start_id}. Re-read with anchors=true before editing."
283 )));
284 };
285 let Some(end_anchor) = ctx.anchor_store.get(path, anchor_end_id) else {
286 return Ok(ToolOutput::error(format!(
287 "Anchor not found or expired for {raw_path}: {anchor_end_id}. Re-read with anchors=true before editing."
288 )));
289 };
290 if start_anchor.line > end_anchor.line {
291 return Ok(ToolOutput::error(
292 "anchorStart must refer to a line before or equal to anchorEnd",
293 ));
294 }
295
296 let raw_content = tokio::fs::read_to_string(path).await?;
297 let content = raw_content.replace("\r\n", "\n");
298 let has_crlf = raw_content.contains("\r\n");
299 let lines = content.lines().collect::<Vec<_>>();
300 let start_idx = start_anchor.line.saturating_sub(1);
301 let end_idx = end_anchor.line.saturating_sub(1);
302 if start_idx >= lines.len() || end_idx >= lines.len() {
303 return Ok(ToolOutput::error(
304 "Anchor line is outside the current file. Re-read with anchors=true before editing.",
305 ));
306 }
307 if super::stable_hash(lines[start_idx]) != start_anchor.content_hash {
308 return Ok(ToolOutput::error(format!(
309 "Stale anchor at line {} in {raw_path}. Re-read with anchors=true before editing.",
310 start_anchor.line
311 )));
312 }
313 if super::stable_hash(lines[end_idx]) != end_anchor.content_hash {
314 return Ok(ToolOutput::error(format!(
315 "Stale anchor at line {} in {raw_path}. Re-read with anchors=true before editing.",
316 end_anchor.line
317 )));
318 }
319
320 let mut replacement_normalized = replacement.replace("\r\n", "\n");
321 let had_trailing_newline = content.ends_with('\n');
322 let mut new_lines = Vec::with_capacity(lines.len() + replacement_normalized.lines().count());
323 new_lines.extend_from_slice(&lines[..start_idx]);
324 if replacement_normalized.ends_with('\n') {
325 replacement_normalized.pop();
326 }
327 if !replacement_normalized.is_empty() {
328 new_lines.extend(replacement_normalized.lines());
329 }
330 new_lines.extend_from_slice(&lines[end_idx + 1..]);
331 let mut new_content = new_lines.join("\n");
332 if had_trailing_newline {
333 new_content.push('\n');
334 }
335
336 let diff = generate_diff(raw_path, &content, &new_content);
337 let final_content = if has_crlf {
338 new_content.replace('\n', "\r\n")
339 } else {
340 new_content.clone()
341 };
342
343 if !dry_run {
344 ctx.checkpoint_state.snapshot_paths(
345 std::slice::from_ref(&path.to_path_buf()),
346 Some(format!("anchored edit {}", path.display())),
347 )?;
348 tokio::fs::write(path, &final_content).await?;
349 if let Ok(mut tracker) = ctx.file_tracker.lock() {
350 tracker.record_read(path);
351 }
352 }
353
354 let refreshed_lines = new_content.lines().collect::<Vec<_>>();
355 let refreshed =
356 ctx.anchor_store
357 .record_lines(path, super::stable_hash(&new_content), 1, &refreshed_lines);
358 let mut msg = diff;
359 if dry_run {
360 msg.push_str("\n(dry run: no changes written)");
361 }
362 msg.push_str("\n(anchored edit: anchors validated before replacement)");
363
364 Ok(ToolOutput {
365 content: vec![imp_llm::ContentBlock::Text { text: msg }],
366 details: json!({
367 "action": "edit",
368 "mode": "anchored",
369 "path": path.display().to_string(),
370 "dry_run": dry_run,
371 "anchored": true,
372 "start_line": start_anchor.line,
373 "end_line": end_anchor.line,
374 "refreshed_anchors": refreshed.iter().map(|anchor| json!({
375 "line": anchor.line,
376 "anchor": anchor.id,
377 "content_hash": format!("{:016x}", anchor.content_hash),
378 })).collect::<Vec<_>>(),
379 }),
380 is_error: false,
381 })
382}
383
384fn count_occurrences(content: &str, needle: &str) -> usize {
385 if needle.is_empty() {
386 return 0;
387 }
388 content.match_indices(needle).count()
389}
390
391pub(crate) fn apply_edit(
394 content: &str,
395 old_text: &str,
396 new_text: &str,
397) -> std::result::Result<(String, bool), ToolOutput> {
398 if let Some(pos) = content.find(old_text) {
400 let mut result = String::with_capacity(content.len());
401 result.push_str(&content[..pos]);
402 result.push_str(new_text);
403 result.push_str(&content[pos + old_text.len()..]);
404 return Ok((result, false));
405 }
406
407 if let Some(m) = fuzzy::fuzzy_find(content, old_text) {
409 let mut result = String::with_capacity(content.len());
410 result.push_str(&content[..m.start]);
411 result.push_str(new_text);
412 result.push_str(&content[m.end..]);
413 return Ok((result, true));
414 }
415
416 let preview = truncate_chars_with_suffix(content, 200, "");
418 let msg = format!(
419 "Could not find the specified text to replace.\n\
420 First 200 chars of file:\n{preview}"
421 );
422 Err(ToolOutput::error(msg))
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use crate::tools::ToolContext;
429 use std::sync::Arc;
430
431 fn test_ctx(dir: &std::path::Path) -> ToolContext {
432 let (tx, _rx) = tokio::sync::mpsc::channel(16);
433 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
434 ToolContext {
435 cwd: dir.to_path_buf(),
436 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
437 update_tx: tx,
438 command_tx: cmd_tx,
439 ui: Arc::new(crate::ui::NullInterface),
440 file_cache: Arc::new(crate::tools::FileCache::new()),
441 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
442 file_tracker: Arc::new(std::sync::Mutex::new(crate::tools::FileTracker::new())),
443 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
444 lua_tool_loader: None,
445 mode: crate::config::AgentMode::Full,
446 read_max_lines: 500,
447 turn_mana_review: Arc::new(std::sync::Mutex::new(
448 crate::mana_review::TurnManaReviewAccumulator::default(),
449 )),
450 config: Arc::new(crate::config::Config::default()),
451 run_policy: Default::default(),
452 supporting_provenance: Vec::new(),
453 }
454 }
455
456 #[tokio::test]
457 async fn edit_exact_match() {
458 let dir = tempfile::tempdir().unwrap();
459 let file = dir.path().join("test.rs");
460 std::fs::write(&file, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
461
462 let tool = EditTool;
463 let result = tool
464 .execute(
465 "c1",
466 json!({
467 "path": "test.rs",
468 "oldText": "println!(\"hello\")",
469 "newText": "println!(\"world\")"
470 }),
471 test_ctx(dir.path()),
472 )
473 .await
474 .unwrap();
475
476 assert!(!result.is_error);
477 let written = std::fs::read_to_string(&file).unwrap();
478 assert!(written.contains("world"));
479 assert!(!written.contains("hello"));
480 }
481
482 #[tokio::test]
483 async fn edit_dry_run_returns_diff_without_writing() {
484 let dir = tempfile::tempdir().unwrap();
485 let file = dir.path().join("dry.txt");
486 std::fs::write(&file, "alpha\n").unwrap();
487
488 let tool = EditTool;
489 let ctx = test_ctx(dir.path());
490 let checkpoint_state = ctx.checkpoint_state.clone();
491 let result = tool
492 .execute(
493 "c-dry",
494 json!({
495 "path": "dry.txt",
496 "oldText": "alpha",
497 "newText": "beta",
498 "dryRun": true
499 }),
500 ctx,
501 )
502 .await
503 .unwrap();
504
505 assert!(!result.is_error);
506 assert_eq!(std::fs::read_to_string(&file).unwrap(), "alpha\n");
507 assert!(checkpoint_state.checkpoints().is_empty());
508 assert_eq!(result.details["dry_run"], true);
509 let text = result.text_content().unwrap();
510 assert!(text.contains("beta"));
511 assert!(text.contains("dry run"));
512 }
513
514 #[tokio::test]
515 async fn edit_expected_occurrences_mismatch_does_not_write() {
516 let dir = tempfile::tempdir().unwrap();
517 let file = dir.path().join("expected-mismatch.txt");
518 std::fs::write(&file, "foo foo\n").unwrap();
519
520 let tool = EditTool;
521 let result = tool
522 .execute(
523 "c-expected-mismatch",
524 json!({
525 "path": "expected-mismatch.txt",
526 "oldText": "foo",
527 "newText": "bar",
528 "expectedOccurrences": 1
529 }),
530 test_ctx(dir.path()),
531 )
532 .await
533 .unwrap();
534
535 assert!(result.is_error);
536 assert_eq!(std::fs::read_to_string(&file).unwrap(), "foo foo\n");
537 assert!(result.text_content().unwrap().contains("found 2"));
538 }
539
540 #[tokio::test]
541 async fn edit_expected_occurrences_success_writes() {
542 let dir = tempfile::tempdir().unwrap();
543 let file = dir.path().join("expected-success.txt");
544 std::fs::write(&file, "foo\n").unwrap();
545
546 let tool = EditTool;
547 let result = tool
548 .execute(
549 "c-expected-success",
550 json!({
551 "path": "expected-success.txt",
552 "oldText": "foo",
553 "newText": "bar",
554 "expectedOccurrences": 1
555 }),
556 test_ctx(dir.path()),
557 )
558 .await
559 .unwrap();
560
561 assert!(!result.is_error);
562 assert_eq!(std::fs::read_to_string(&file).unwrap(), "bar\n");
563 assert_eq!(result.details["exact_occurrences"], 1);
564 assert_eq!(result.details["replacements"], 1);
565 }
566
567 #[tokio::test]
568 async fn edit_replace_all_replaces_exact_matches() {
569 let dir = tempfile::tempdir().unwrap();
570 let file = dir.path().join("replace-all.txt");
571 std::fs::write(&file, "foo bar foo baz foo\n").unwrap();
572
573 let tool = EditTool;
574 let result = tool
575 .execute(
576 "c-replace-all",
577 json!({
578 "path": "replace-all.txt",
579 "oldText": "foo",
580 "newText": "zap",
581 "replaceAll": true,
582 "expectedOccurrences": 3
583 }),
584 test_ctx(dir.path()),
585 )
586 .await
587 .unwrap();
588
589 assert!(!result.is_error);
590 assert_eq!(
591 std::fs::read_to_string(&file).unwrap(),
592 "zap bar zap baz zap\n"
593 );
594 assert_eq!(result.details["replace_all"], true);
595 assert_eq!(result.details["replacements"], 3);
596 }
597
598 #[tokio::test]
599 async fn edit_creates_checkpoint_snapshot() {
600 let dir = tempfile::tempdir().unwrap();
601 let file = dir.path().join("checkpoint.txt");
602 std::fs::write(&file, "alpha\n").unwrap();
603
604 let tool = EditTool;
605 let ctx = test_ctx(dir.path());
606 let checkpoint_state = ctx.checkpoint_state.clone();
607
608 let result = tool
609 .execute(
610 "c-checkpoint",
611 json!({
612 "path": "checkpoint.txt",
613 "oldText": "alpha",
614 "newText": "beta"
615 }),
616 ctx,
617 )
618 .await
619 .unwrap();
620
621 assert!(!result.is_error);
622 assert_eq!(checkpoint_state.original(&file).as_deref(), Some("alpha\n"));
623 assert_eq!(checkpoint_state.checkpoints().len(), 1);
624 }
625
626 #[tokio::test]
627 async fn edit_fuzzy_trailing_whitespace() {
628 let dir = tempfile::tempdir().unwrap();
629 let file = dir.path().join("ws.txt");
630 std::fs::write(&file, "hello \nworld \n").unwrap();
632
633 let tool = EditTool;
634 let result = tool
635 .execute(
636 "c2",
637 json!({
638 "path": "ws.txt",
639 "oldText": "hello\nworld",
640 "newText": "goodbye\nuniverse"
641 }),
642 test_ctx(dir.path()),
643 )
644 .await
645 .unwrap();
646
647 assert!(!result.is_error, "Expected success but got error");
648 let written = std::fs::read_to_string(&file).unwrap();
649 assert!(written.contains("goodbye"));
650 }
651
652 #[tokio::test]
653 async fn edit_fuzzy_unicode_quotes() {
654 let dir = tempfile::tempdir().unwrap();
655 let file = dir.path().join("uni.txt");
656 std::fs::write(&file, "he said \u{201C}hello\u{201D}\n").unwrap();
658
659 let tool = EditTool;
660 let result = tool
661 .execute(
662 "c3",
663 json!({
664 "path": "uni.txt",
665 "oldText": "he said \"hello\"",
666 "newText": "she said \"bye\""
667 }),
668 test_ctx(dir.path()),
669 )
670 .await
671 .unwrap();
672
673 assert!(!result.is_error, "Expected success but got error");
674 let written = std::fs::read_to_string(&file).unwrap();
675 assert!(written.contains("bye"));
676 }
677
678 #[tokio::test]
679 async fn edit_crlf_preserved() {
680 let dir = tempfile::tempdir().unwrap();
681 let file = dir.path().join("crlf.txt");
682 std::fs::write(&file, "line1\r\nline2\r\nline3\r\n").unwrap();
683
684 let tool = EditTool;
685 let result = tool
686 .execute(
687 "c5",
688 json!({
689 "path": "crlf.txt",
690 "oldText": "line2",
691 "newText": "replaced"
692 }),
693 test_ctx(dir.path()),
694 )
695 .await
696 .unwrap();
697
698 assert!(!result.is_error);
699 let written = std::fs::read_to_string(&file).unwrap();
700 assert!(written.contains("replaced"));
701 assert!(written.contains("\r\n"));
703 assert!(!written.contains("line2"));
704 }
705
706 #[tokio::test]
707 async fn edit_replaces_first_occurrence_only() {
708 let dir = tempfile::tempdir().unwrap();
709 let file = dir.path().join("multi.txt");
710 std::fs::write(&file, "foo bar foo baz foo\n").unwrap();
711
712 let tool = EditTool;
713 let result = tool
714 .execute(
715 "c6",
716 json!({
717 "path": "multi.txt",
718 "oldText": "foo",
719 "newText": "REPLACED"
720 }),
721 test_ctx(dir.path()),
722 )
723 .await
724 .unwrap();
725
726 assert!(!result.is_error);
727 let written = std::fs::read_to_string(&file).unwrap();
728 assert_eq!(written, "REPLACED bar foo baz foo\n");
730 }
731
732 #[tokio::test]
733 async fn edit_empty_old_text_error() {
734 let dir = tempfile::tempdir().unwrap();
735 let file = dir.path().join("empty.txt");
736 std::fs::write(&file, "some content\n").unwrap();
737
738 let tool = EditTool;
739 let result = tool
740 .execute(
741 "c7",
742 json!({
743 "path": "empty.txt",
744 "oldText": "",
745 "newText": "replacement"
746 }),
747 test_ctx(dir.path()),
748 )
749 .await
750 .unwrap();
751
752 assert!(result.is_error);
753 let text = result
754 .content
755 .iter()
756 .find_map(|b| match b {
757 imp_llm::ContentBlock::Text { text } => Some(text.as_str()),
758 _ => None,
759 })
760 .unwrap();
761 assert!(text.contains("old_text"));
762 }
763
764 #[tokio::test]
765 async fn edit_nonexistent_file_error() {
766 let dir = tempfile::tempdir().unwrap();
767
768 let tool = EditTool;
769 let result = tool
770 .execute(
771 "c8",
772 json!({
773 "path": "does_not_exist.txt",
774 "oldText": "hello",
775 "newText": "world"
776 }),
777 test_ctx(dir.path()),
778 )
779 .await
780 .unwrap();
781
782 assert!(result.is_error);
783 let text = result
784 .content
785 .iter()
786 .find_map(|b| match b {
787 imp_llm::ContentBlock::Text { text } => Some(text.as_str()),
788 _ => None,
789 })
790 .unwrap();
791 assert!(text.contains("File not found"));
792 }
793
794 #[tokio::test]
795 async fn edit_missing_path_error() {
796 let dir = tempfile::tempdir().unwrap();
797
798 let tool = EditTool;
799 let result = tool
800 .execute(
801 "c9",
802 json!({
803 "oldText": "hello",
804 "newText": "world"
805 }),
806 test_ctx(dir.path()),
807 )
808 .await
809 .unwrap();
810
811 assert!(result.is_error);
812 let text = result
813 .content
814 .iter()
815 .find_map(|b| match b {
816 imp_llm::ContentBlock::Text { text } => Some(text.as_str()),
817 _ => None,
818 })
819 .unwrap();
820 assert!(text.contains("path"));
821 }
822
823 #[tokio::test]
824 async fn edit_warns_on_unread_file() {
825 let dir = tempfile::tempdir().unwrap();
826 let file = dir.path().join("unread.txt");
827 std::fs::write(&file, "original content here\n").unwrap();
828
829 let tool = EditTool;
831 let result = tool
832 .execute(
833 "c10",
834 json!({
835 "path": "unread.txt",
836 "oldText": "original content",
837 "newText": "changed content"
838 }),
839 test_ctx(dir.path()),
840 )
841 .await
842 .unwrap();
843
844 assert!(
845 !result.is_error,
846 "edit should succeed even without prior read"
847 );
848 let text = result
849 .content
850 .iter()
851 .find_map(|b| match b {
852 imp_llm::ContentBlock::Text { text } => Some(text.as_str()),
853 _ => None,
854 })
855 .unwrap();
856 assert!(
857 text.contains("Warning"),
858 "expected unread-file warning in output, got: {text}"
859 );
860 }
861
862 #[tokio::test]
863 async fn anchored_edit_replaces_validated_range_and_checkpoints() {
864 let dir = tempfile::tempdir().unwrap();
865 let file = dir.path().join("anchored.txt");
866 std::fs::write(&file, "alpha\nbeta\ngamma\n").unwrap();
867 let ctx = test_ctx(dir.path());
868 let lines = ["beta"];
869 let anchors = ctx.anchor_store.record_lines(
870 &file,
871 super::super::stable_hash("alpha\nbeta\ngamma\n"),
872 2,
873 &lines,
874 );
875
876 let result = EditTool
877 .execute(
878 "c-anchor",
879 json!({
880 "path": "anchored.txt",
881 "anchor_start": anchors[0].id,
882 "new_text": "BETA",
883 }),
884 ctx.clone(),
885 )
886 .await
887 .unwrap();
888
889 assert!(!result.is_error);
890 assert_eq!(
891 std::fs::read_to_string(&file).unwrap(),
892 "alpha\nBETA\ngamma\n"
893 );
894 assert_eq!(
895 ctx.checkpoint_state.original(&file).as_deref(),
896 Some("alpha\nbeta\ngamma\n")
897 );
898 assert_eq!(result.details["anchored"], true);
899 assert_eq!(result.details["action"], "edit");
900 assert_eq!(result.details["mode"], "anchored");
901 assert_eq!(result.details["start_line"], 2);
902 assert_eq!(result.details["end_line"], 2);
903 assert!(
904 result.details["refreshed_anchors"]
905 .as_array()
906 .unwrap()
907 .len()
908 >= 3
909 );
910 }
911
912 #[tokio::test]
913 async fn anchored_edit_rejects_stale_anchor_without_writing() {
914 let dir = tempfile::tempdir().unwrap();
915 let file = dir.path().join("stale.txt");
916 std::fs::write(&file, "alpha\nbeta\ngamma\n").unwrap();
917 let ctx = test_ctx(dir.path());
918 let lines = ["beta"];
919 let anchors = ctx.anchor_store.record_lines(
920 &file,
921 super::super::stable_hash("alpha\nbeta\ngamma\n"),
922 2,
923 &lines,
924 );
925 std::fs::write(&file, "alpha\nchanged\ngamma\n").unwrap();
926
927 let result = EditTool
928 .execute(
929 "c-anchor-stale",
930 json!({
931 "path": "stale.txt",
932 "anchor_start": anchors[0].id,
933 "new_text": "BETA",
934 }),
935 ctx,
936 )
937 .await
938 .unwrap();
939
940 assert!(result.is_error);
941 assert!(result.text_content().unwrap().contains("Stale anchor"));
942 assert_eq!(
943 std::fs::read_to_string(&file).unwrap(),
944 "alpha\nchanged\ngamma\n"
945 );
946 }
947
948 #[tokio::test]
949 async fn anchored_edit_dry_run_does_not_write() {
950 let dir = tempfile::tempdir().unwrap();
951 let file = dir.path().join("dry-anchor.txt");
952 std::fs::write(&file, "alpha\nbeta\n").unwrap();
953 let ctx = test_ctx(dir.path());
954 let lines = ["beta"];
955 let anchors = ctx.anchor_store.record_lines(
956 &file,
957 super::super::stable_hash("alpha\nbeta\n"),
958 2,
959 &lines,
960 );
961
962 let result = EditTool
963 .execute(
964 "c-anchor-dry",
965 json!({
966 "path": "dry-anchor.txt",
967 "anchor_start": anchors[0].id,
968 "new_text": "BETA",
969 "dry_run": true,
970 }),
971 ctx.clone(),
972 )
973 .await
974 .unwrap();
975
976 assert!(!result.is_error);
977 assert_eq!(std::fs::read_to_string(&file).unwrap(), "alpha\nbeta\n");
978 assert!(ctx.checkpoint_state.checkpoints().is_empty());
979 assert!(result.text_content().unwrap().contains("dry run"));
980 }
981
982 #[tokio::test]
983 async fn edit_with_edits_uses_transaction_path() {
984 let dir = tempfile::tempdir().unwrap();
985 let file = dir.path().join("transaction.txt");
986 std::fs::write(&file, "alpha\nbeta\n").unwrap();
987
988 let result = EditTool
989 .execute(
990 "c-transaction",
991 json!({
992 "path": "transaction.txt",
993 "edits": [
994 {"oldText": "alpha", "newText": "ALPHA"},
995 {"oldText": "beta", "newText": "BETA"}
996 ]
997 }),
998 test_ctx(dir.path()),
999 )
1000 .await
1001 .unwrap();
1002
1003 assert!(!result.is_error);
1004 assert_eq!(std::fs::read_to_string(&file).unwrap(), "ALPHA\nBETA\n");
1005 assert_eq!(result.details["transaction"], true);
1006 assert_eq!(result.details["edit_count"], 2);
1007 }
1008
1009 #[tokio::test]
1010 async fn edit_no_match_error() {
1011 let dir = tempfile::tempdir().unwrap();
1012 let file = dir.path().join("nope.txt");
1013 std::fs::write(&file, "some content here\n").unwrap();
1014
1015 let tool = EditTool;
1016 let result = tool
1017 .execute(
1018 "c4",
1019 json!({
1020 "path": "nope.txt",
1021 "oldText": "this text does not exist",
1022 "newText": "replacement"
1023 }),
1024 test_ctx(dir.path()),
1025 )
1026 .await
1027 .unwrap();
1028
1029 assert!(result.is_error);
1030 let text = result
1031 .content
1032 .iter()
1033 .find_map(|b| match b {
1034 imp_llm::ContentBlock::Text { text } => Some(text.as_str()),
1035 _ => None,
1036 })
1037 .unwrap();
1038 assert!(text.contains("Could not find"));
1039 }
1040}