Skip to main content

imp_core/tools/
edit.rs

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        // Multi-edit mode: if `edits` array is present, delegate to MultiEditTool
83        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, &params, 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        // Check for unread or stale file — warn but don't block.
126        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        // Normalize to LF for internal processing
144        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        // Restore original line endings if needed
183        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
364/// Apply a single edit, returning the new content and whether fuzzy matching was used.
365/// Extracted so multi_edit can reuse it.
366pub(crate) fn apply_edit(
367    content: &str,
368    old_text: &str,
369    new_text: &str,
370) -> std::result::Result<(String, bool), ToolOutput> {
371    // Try exact match first
372    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    // Try fuzzy match
381    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    // No match — build helpful error
390    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        // File has trailing spaces on lines
602        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        // File has smart quotes
628        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        // CRLF line endings should be preserved
673        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        // Should replace only the first occurrence
700        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        // Use a fresh tracker (file never read)
801        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}