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        "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        // Multi-edit mode: if `edits` array is present, delegate to MultiEditTool
79        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(&params, "old_text", "oldText").unwrap_or("");
87        let new_text = get_str_param(&params, "new_text", "newText").unwrap_or("");
88        let dry_run = get_bool_param(&params, "dry_run", "dryRun").unwrap_or(false);
89        let replace_all = get_bool_param(&params, "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(&params, "anchor_start", "anchorStart").is_some() {
103            return execute_anchor_edit(&path, raw_path, &params, 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        // 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 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        // 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                "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
391/// Apply a single edit, returning the new content and whether fuzzy matching was used.
392/// Extracted so multi_edit can reuse it.
393pub(crate) fn apply_edit(
394    content: &str,
395    old_text: &str,
396    new_text: &str,
397) -> std::result::Result<(String, bool), ToolOutput> {
398    // Try exact match first
399    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    // Try fuzzy match
408    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    // No match — build helpful error
417    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        // File has trailing spaces on lines
631        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        // File has smart quotes
657        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        // CRLF line endings should be preserved
702        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        // Should replace only the first occurrence
729        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        // Use a fresh tracker (file never read)
830        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}