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