Skip to main content

lash_tools/files/
edit.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4use unicode_normalization::UnicodeNormalization;
5
6use lash_core::{ToolCall, ToolDefinition, ToolResult, ToolScheduling};
7
8use lash_tool_support::{
9    StaticToolExecute, StaticToolProvider, ToolDefinitionLashlangExt, compact_diff,
10    display_relative, execute_typed_tool_result, invalid_tool_args, non_empty_string,
11    resolve_under, run_blocking,
12};
13
14const EDIT_DESCRIPTION: &str = "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.";
15
16#[derive(Default)]
17pub struct Edit;
18
19pub fn edit_provider() -> StaticToolProvider<Edit> {
20    StaticToolProvider::new(vec![edit_tool_definition()], Edit)
21}
22
23#[derive(Clone, Debug, Deserialize, JsonSchema)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25struct EditReplacement {
26    /// Exact text for one targeted replacement.
27    old_text: String,
28    /// Replacement text for this targeted edit.
29    new_text: String,
30}
31
32#[derive(Clone, Debug, Deserialize, JsonSchema)]
33#[serde(deny_unknown_fields)]
34struct EditArgs {
35    /// Path to the file to edit (relative or absolute).
36    path: String,
37    /// One or more targeted replacements.
38    edits: Vec<EditReplacement>,
39}
40
41#[derive(Clone, Debug, Serialize, JsonSchema)]
42#[serde(rename_all = "camelCase")]
43struct EditDetails {
44    /// Display-oriented unified diff, capped for model readability.
45    diff: String,
46    /// Full unified patch preview for the changed file.
47    patch: String,
48    /// Line number of the first change in the new file.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    first_changed_line: Option<usize>,
51}
52
53#[derive(Clone, Debug, Serialize, JsonSchema)]
54struct EditOutput {
55    summary: String,
56    path: String,
57    replacements: usize,
58    details: EditDetails,
59}
60
61#[async_trait::async_trait]
62impl StaticToolExecute for Edit {
63    async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
64        execute_typed_tool_result::<EditArgs, _, _>(call.args, |args| async move {
65            if let Err(err) = validate_edit_args(&args) {
66                return err;
67            }
68            run_blocking(move || edit_file(args)).await
69        })
70        .await
71    }
72}
73
74fn edit_tool_definition() -> ToolDefinition {
75    ToolDefinition::typed::<EditArgs, EditOutput>("tool:edit", "edit", EDIT_DESCRIPTION)
76        .with_examples(vec![
77            r#"await files.edit({ path: "src/main.rs", edits: [{ oldText: "old();", newText: "new();" }] })?"#.into(),
78            r#"await files.edit({ path: "README.md", edits: [{ oldText: "alpha", newText: "ALPHA" }, { oldText: "omega", newText: "OMEGA" }] })?"#.into(),
79        ])
80        .with_lashlang_binding(lash_tool_support::lashlang_binding(
81            ["files"],
82            "edit",
83            &["replace", "edit_file"],
84        ))
85        .with_scheduling(ToolScheduling::Serial)
86}
87
88fn validate_edit_args(args: &EditArgs) -> Result<(), ToolResult> {
89    non_empty_string(&args.path, "path")?;
90    if args.edits.is_empty() {
91        return Err(invalid_tool_args(
92            "Edit tool input is invalid. edits must contain at least one replacement.",
93        ));
94    }
95    Ok(())
96}
97
98fn edit_file(args: EditArgs) -> ToolResult {
99    if let Err(err) = validate_edit_args(&args) {
100        return err;
101    }
102    let cwd = match std::env::current_dir() {
103        Ok(cwd) => cwd,
104        Err(err) => return ToolResult::err_fmt(format_args!("Failed to determine cwd: {err}")),
105    };
106    let absolute_path = resolve_under(&cwd, Path::new(&args.path));
107    let display_path = display_relative(&cwd, &absolute_path);
108
109    if let Err(err) = ensure_editable_file(&absolute_path, &args.path) {
110        return ToolResult::err_fmt(err);
111    }
112
113    let raw_content = match std::fs::read_to_string(&absolute_path) {
114        Ok(content) => content,
115        Err(err) => {
116            return ToolResult::err_fmt(format_args!("Could not edit file: {}. {err}.", args.path));
117        }
118    };
119
120    let (bom, content) = strip_bom(&raw_content);
121    let original_ending = detect_line_ending(content);
122    let normalized_content = normalize_to_lf(content);
123    let applied =
124        match apply_edits_to_normalized_content(&normalized_content, &args.edits, &args.path) {
125            Ok(applied) => applied,
126            Err(err) => return ToolResult::err_fmt(err),
127        };
128
129    let final_content = format!(
130        "{bom}{}",
131        restore_line_endings(&applied.new_content, original_ending)
132    );
133    if let Err(err) = std::fs::write(&absolute_path, final_content) {
134        return ToolResult::err_fmt(format_args!("Could not edit file: {}. {err}.", args.path));
135    }
136
137    let diff = compact_diff(
138        &applied.base_content,
139        &applied.new_content,
140        &display_path,
141        240,
142    );
143    let patch = compact_diff(
144        &applied.base_content,
145        &applied.new_content,
146        &display_path,
147        usize::MAX,
148    );
149    let replacements = args.edits.len();
150    lash_tool_support::typed_tool_ok(EditOutput {
151        summary: format!(
152            "Successfully replaced {replacements} block(s) in {}.",
153            args.path
154        ),
155        path: args.path,
156        replacements,
157        details: EditDetails {
158            diff,
159            patch,
160            first_changed_line: first_changed_line(&applied.base_content, &applied.new_content),
161        },
162    })
163}
164
165fn ensure_editable_file(path: &Path, input_path: &str) -> Result<(), String> {
166    match std::fs::metadata(path) {
167        Ok(metadata) if metadata.is_file() => Ok(()),
168        Ok(_) => Err(format!(
169            "Could not edit file: {input_path}. Path is not a file."
170        )),
171        Err(err) => Err(format!("Could not edit file: {input_path}. {err}.")),
172    }
173}
174
175#[derive(Clone, Debug)]
176struct AppliedEdits {
177    base_content: String,
178    new_content: String,
179}
180
181#[derive(Clone, Debug)]
182struct MatchedEdit {
183    edit_index: usize,
184    match_index: usize,
185    match_length: usize,
186    new_text: String,
187}
188
189#[derive(Clone, Debug)]
190struct FuzzyMatch {
191    found: bool,
192    index: usize,
193    match_length: usize,
194    used_fuzzy_match: bool,
195}
196
197#[derive(Clone, Debug)]
198struct LineSpan {
199    start: usize,
200    end: usize,
201}
202
203fn apply_edits_to_normalized_content(
204    normalized_content: &str,
205    edits: &[EditReplacement],
206    path: &str,
207) -> Result<AppliedEdits, String> {
208    let normalized_edits = edits
209        .iter()
210        .map(|edit| EditReplacement {
211            old_text: normalize_to_lf(&edit.old_text),
212            new_text: normalize_to_lf(&edit.new_text),
213        })
214        .collect::<Vec<_>>();
215
216    for (index, edit) in normalized_edits.iter().enumerate() {
217        if edit.old_text.is_empty() {
218            return Err(empty_old_text_error(path, index, normalized_edits.len()));
219        }
220    }
221
222    let used_fuzzy_match = normalized_edits
223        .iter()
224        .map(|edit| fuzzy_find_text(normalized_content, &edit.old_text))
225        .any(|matched| matched.used_fuzzy_match);
226    let replacement_base_content = if used_fuzzy_match {
227        normalize_for_fuzzy_match(normalized_content)
228    } else {
229        normalized_content.to_string()
230    };
231
232    let mut matched_edits = Vec::new();
233    for (index, edit) in normalized_edits.iter().enumerate() {
234        let matched = fuzzy_find_text(&replacement_base_content, &edit.old_text);
235        if !matched.found {
236            return Err(not_found_error(path, index, normalized_edits.len()));
237        }
238
239        let occurrences = count_occurrences(&replacement_base_content, &edit.old_text);
240        if occurrences > 1 {
241            return Err(duplicate_error(
242                path,
243                index,
244                normalized_edits.len(),
245                occurrences,
246            ));
247        }
248
249        matched_edits.push(MatchedEdit {
250            edit_index: index,
251            match_index: matched.index,
252            match_length: matched.match_length,
253            new_text: edit.new_text.clone(),
254        });
255    }
256
257    matched_edits.sort_by_key(|edit| edit.match_index);
258    for pair in matched_edits.windows(2) {
259        let previous = &pair[0];
260        let current = &pair[1];
261        if previous.match_index + previous.match_length > current.match_index {
262            return Err(format!(
263                "edits[{}] and edits[{}] overlap in {path}. Merge them into one edit or target disjoint regions.",
264                previous.edit_index, current.edit_index
265            ));
266        }
267    }
268
269    let base_content = normalized_content.to_string();
270    let new_content = if used_fuzzy_match {
271        apply_replacements_preserving_unchanged_lines(
272            normalized_content,
273            &replacement_base_content,
274            &matched_edits,
275        )?
276    } else {
277        apply_replacements(&replacement_base_content, &matched_edits, 0)
278    };
279
280    if base_content == new_content {
281        return Err(no_change_error(path, normalized_edits.len()));
282    }
283
284    Ok(AppliedEdits {
285        base_content,
286        new_content,
287    })
288}
289
290fn fuzzy_find_text(content: &str, old_text: &str) -> FuzzyMatch {
291    if let Some(index) = content.find(old_text) {
292        return FuzzyMatch {
293            found: true,
294            index,
295            match_length: old_text.len(),
296            used_fuzzy_match: false,
297        };
298    }
299
300    let fuzzy_content = normalize_for_fuzzy_match(content);
301    let fuzzy_old_text = normalize_for_fuzzy_match(old_text);
302    if let Some(index) = fuzzy_content.find(&fuzzy_old_text) {
303        return FuzzyMatch {
304            found: true,
305            index,
306            match_length: fuzzy_old_text.len(),
307            used_fuzzy_match: true,
308        };
309    }
310
311    FuzzyMatch {
312        found: false,
313        index: 0,
314        match_length: 0,
315        used_fuzzy_match: false,
316    }
317}
318
319fn count_occurrences(content: &str, old_text: &str) -> usize {
320    let fuzzy_content = normalize_for_fuzzy_match(content);
321    let fuzzy_old_text = normalize_for_fuzzy_match(old_text);
322    fuzzy_content.match_indices(&fuzzy_old_text).count()
323}
324
325fn normalize_for_fuzzy_match(text: &str) -> String {
326    let normalized = text.nfkc().collect::<String>();
327    normalized
328        .split('\n')
329        .map(str::trim_end)
330        .collect::<Vec<_>>()
331        .join("\n")
332        .chars()
333        .map(|ch| match ch {
334            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
335            | '\u{2212}' => '-',
336            '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
337            '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
338            '\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
339            | '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
340            | '\u{3000}' => ' ',
341            other => other,
342        })
343        .collect()
344}
345
346fn apply_replacements(content: &str, replacements: &[MatchedEdit], offset: usize) -> String {
347    let mut result = content.to_string();
348    for replacement in replacements.iter().rev() {
349        let match_index = replacement.match_index - offset;
350        result.replace_range(
351            match_index..match_index + replacement.match_length,
352            &replacement.new_text,
353        );
354    }
355    result
356}
357
358fn apply_replacements_preserving_unchanged_lines(
359    original_content: &str,
360    base_content: &str,
361    replacements: &[MatchedEdit],
362) -> Result<String, String> {
363    let original_lines = split_lines_with_endings(original_content);
364    let base_lines = get_line_spans(base_content);
365    if original_lines.len() != base_lines.len() {
366        return Err(
367            "Cannot preserve unchanged lines because the base content has a different line count."
368                .to_string(),
369        );
370    }
371
372    let mut groups: Vec<(usize, usize, Vec<MatchedEdit>)> = Vec::new();
373    let mut sorted_replacements = replacements.to_vec();
374    sorted_replacements.sort_by_key(|replacement| replacement.match_index);
375    for replacement in sorted_replacements {
376        let (start_line, end_line) = replacement_line_range(&base_lines, &replacement)?;
377        if let Some((_, current_end, current_replacements)) = groups.last_mut()
378            && start_line < *current_end
379        {
380            *current_end = (*current_end).max(end_line);
381            current_replacements.push(replacement);
382            continue;
383        }
384        groups.push((start_line, end_line, vec![replacement]));
385    }
386
387    let mut original_line_index = 0;
388    let mut result = String::new();
389    for (start_line, end_line, replacements) in groups {
390        result.push_str(&original_lines[original_line_index..start_line].join(""));
391
392        let group_start_offset = base_lines[start_line].start;
393        let group_end_offset = base_lines[end_line - 1].end;
394        result.push_str(&apply_replacements(
395            &base_content[group_start_offset..group_end_offset],
396            &replacements,
397            group_start_offset,
398        ));
399        original_line_index = end_line;
400    }
401    result.push_str(&original_lines[original_line_index..].join(""));
402    Ok(result)
403}
404
405fn split_lines_with_endings(content: &str) -> Vec<&str> {
406    content.split_inclusive('\n').collect()
407}
408
409fn get_line_spans(content: &str) -> Vec<LineSpan> {
410    let mut offset = 0;
411    split_lines_with_endings(content)
412        .into_iter()
413        .map(|line| {
414            let span = LineSpan {
415                start: offset,
416                end: offset + line.len(),
417            };
418            offset = span.end;
419            span
420        })
421        .collect()
422}
423
424fn replacement_line_range(
425    lines: &[LineSpan],
426    replacement: &MatchedEdit,
427) -> Result<(usize, usize), String> {
428    let replacement_start = replacement.match_index;
429    let replacement_end = replacement.match_index + replacement.match_length;
430    let start_line = lines
431        .iter()
432        .position(|line| replacement_start >= line.start && replacement_start < line.end)
433        .ok_or_else(|| "Replacement range is outside the base content.".to_string())?;
434    let mut end_line = start_line;
435    while end_line < lines.len() && lines[end_line].end < replacement_end {
436        end_line += 1;
437    }
438    if end_line >= lines.len() {
439        return Err("Replacement range is outside the base content.".to_string());
440    }
441    Ok((start_line, end_line + 1))
442}
443
444fn detect_line_ending(content: &str) -> &'static str {
445    if let Some(index) = content.find('\n')
446        && index > 0
447        && content.as_bytes()[index - 1] == b'\r'
448    {
449        return "\r\n";
450    }
451    "\n"
452}
453
454fn normalize_to_lf(text: &str) -> String {
455    text.replace("\r\n", "\n").replace('\r', "\n")
456}
457
458fn restore_line_endings(text: &str, ending: &str) -> String {
459    if ending == "\r\n" {
460        text.replace('\n', "\r\n")
461    } else {
462        text.to_string()
463    }
464}
465
466fn strip_bom(content: &str) -> (&'static str, &str) {
467    content
468        .strip_prefix('\u{feff}')
469        .map(|text| ("\u{feff}", text))
470        .unwrap_or(("", content))
471}
472
473fn first_changed_line(old: &str, new: &str) -> Option<usize> {
474    let mut old_lines = old.split('\n');
475    let mut new_lines = new.split('\n');
476    let mut line = 1;
477    loop {
478        match (old_lines.next(), new_lines.next()) {
479            (Some(old_line), Some(new_line)) if old_line == new_line => line += 1,
480            (Some(_), Some(_)) | (Some(_), None) | (None, Some(_)) => return Some(line),
481            (None, None) => return None,
482        }
483    }
484}
485
486fn not_found_error(path: &str, edit_index: usize, total_edits: usize) -> String {
487    if total_edits == 1 {
488        format!(
489            "Could not find the exact text in {path}. The old text must match exactly including all whitespace and newlines."
490        )
491    } else {
492        format!(
493            "Could not find edits[{edit_index}] in {path}. The oldText must match exactly including all whitespace and newlines."
494        )
495    }
496}
497
498fn duplicate_error(
499    path: &str,
500    edit_index: usize,
501    total_edits: usize,
502    occurrences: usize,
503) -> String {
504    if total_edits == 1 {
505        format!(
506            "Found {occurrences} occurrences of the text in {path}. The text must be unique. Please provide more context to make it unique."
507        )
508    } else {
509        format!(
510            "Found {occurrences} occurrences of edits[{edit_index}] in {path}. Each oldText must be unique. Please provide more context to make it unique."
511        )
512    }
513}
514
515fn empty_old_text_error(path: &str, edit_index: usize, total_edits: usize) -> String {
516    if total_edits == 1 {
517        format!("oldText must not be empty in {path}.")
518    } else {
519        format!("edits[{edit_index}].oldText must not be empty in {path}.")
520    }
521}
522
523fn no_change_error(path: &str, total_edits: usize) -> String {
524    if total_edits == 1 {
525        format!(
526            "No changes made to {path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected."
527        )
528    } else {
529        format!("No changes made to {path}. The replacements produced identical content.")
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use serde_json::json;
537    use tempfile::TempDir;
538
539    fn replacement(old_text: impl Into<String>, new_text: impl Into<String>) -> EditReplacement {
540        EditReplacement {
541            old_text: old_text.into(),
542            new_text: new_text.into(),
543        }
544    }
545
546    fn run_edit(dir: &TempDir, path: &str, edits: Vec<EditReplacement>) -> ToolResult {
547        let path = dir.path().join(path).to_string_lossy().to_string();
548        edit_file(EditArgs { path, edits })
549    }
550
551    #[test]
552    fn edit_contract_documents_pi_shape() {
553        let definition = edit_tool_definition();
554        let rendered = definition.compact_contract().render_signature();
555
556        let schema = serde_json::to_string(&definition.contract.input_schema.canonical).unwrap();
557        assert!(schema.contains("oldText"), "{schema}");
558        assert!(schema.contains("newText"), "{schema}");
559        assert!(rendered.contains("firstChangedLine"), "{rendered}");
560        assert!(
561            definition
562                .manifest()
563                .description
564                .contains("exact text replacement")
565        );
566    }
567
568    #[test]
569    fn edit_replaces_one_unique_block() {
570        let dir = TempDir::new().unwrap();
571        std::fs::write(dir.path().join("main.rs"), "fn main() {\n    old();\n}\n").unwrap();
572
573        let result = run_edit(&dir, "main.rs", vec![replacement("old();", "new();")]);
574
575        assert!(result.is_success(), "{}", result.value_for_projection());
576        assert_eq!(
577            std::fs::read_to_string(dir.path().join("main.rs")).unwrap(),
578            "fn main() {\n    new();\n}\n"
579        );
580        let value = result.value_for_projection();
581        assert!(
582            value["summary"]
583                .as_str()
584                .unwrap()
585                .contains("Successfully replaced 1 block(s)")
586        );
587        assert_eq!(value["details"]["firstChangedLine"], json!(2));
588        assert!(
589            value["details"]["patch"]
590                .as_str()
591                .unwrap()
592                .contains("-    old();")
593        );
594    }
595
596    #[test]
597    fn edit_replaces_multiple_disjoint_blocks_against_original_file() {
598        let dir = TempDir::new().unwrap();
599        std::fs::write(dir.path().join("notes.txt"), "alpha\nbeta\ngamma\n").unwrap();
600
601        let result = run_edit(
602            &dir,
603            "notes.txt",
604            vec![
605                replacement("alpha\n", "ALPHA\n"),
606                replacement("gamma\n", "GAMMA\n"),
607            ],
608        );
609
610        assert!(result.is_success(), "{}", result.value_for_projection());
611        assert_eq!(
612            std::fs::read_to_string(dir.path().join("notes.txt")).unwrap(),
613            "ALPHA\nbeta\nGAMMA\n"
614        );
615        assert_eq!(result.value_for_projection()["replacements"], json!(2));
616    }
617
618    #[test]
619    fn edit_rejects_empty_edit_list() {
620        let result = edit_file(EditArgs {
621            path: "missing.txt".to_string(),
622            edits: Vec::new(),
623        });
624
625        assert!(!result.is_success());
626        assert!(
627            result
628                .value_for_projection()
629                .to_string()
630                .contains("edits must contain at least one replacement")
631        );
632    }
633
634    #[test]
635    fn edit_rejects_empty_old_text() {
636        let dir = TempDir::new().unwrap();
637        std::fs::write(dir.path().join("a.txt"), "alpha\n").unwrap();
638
639        let result = run_edit(&dir, "a.txt", vec![replacement("", "x")]);
640
641        assert!(!result.is_success());
642        assert!(
643            result
644                .value_for_projection()
645                .to_string()
646                .contains("oldText must not be empty")
647        );
648    }
649
650    #[test]
651    fn edit_rejects_missing_file() {
652        let dir = TempDir::new().unwrap();
653
654        let result = run_edit(&dir, "missing.txt", vec![replacement("a", "b")]);
655
656        assert!(!result.is_success());
657        assert!(
658            result
659                .value_for_projection()
660                .to_string()
661                .contains("Could not edit file")
662        );
663    }
664
665    #[test]
666    fn edit_rejects_duplicate_matches() {
667        let dir = TempDir::new().unwrap();
668        std::fs::write(dir.path().join("dup.txt"), "same\nsame\n").unwrap();
669
670        let result = run_edit(&dir, "dup.txt", vec![replacement("same\n", "other\n")]);
671
672        assert!(!result.is_success());
673        assert!(
674            result
675                .value_for_projection()
676                .to_string()
677                .contains("Found 2 occurrences")
678        );
679    }
680
681    #[test]
682    fn edit_rejects_overlapping_matches() {
683        let dir = TempDir::new().unwrap();
684        std::fs::write(dir.path().join("overlap.txt"), "abcdef\n").unwrap();
685
686        let result = run_edit(
687            &dir,
688            "overlap.txt",
689            vec![replacement("abc", "ABC"), replacement("bcd", "BCD")],
690        );
691
692        assert!(!result.is_success());
693        assert!(
694            result
695                .value_for_projection()
696                .to_string()
697                .contains("overlap")
698        );
699    }
700
701    #[test]
702    fn edit_does_not_match_second_edit_against_first_replacement() {
703        let dir = TempDir::new().unwrap();
704        std::fs::write(dir.path().join("original.txt"), "alpha\n").unwrap();
705
706        let result = run_edit(
707            &dir,
708            "original.txt",
709            vec![replacement("alpha", "beta"), replacement("beta", "gamma")],
710        );
711
712        assert!(!result.is_success());
713        assert!(
714            result
715                .value_for_projection()
716                .to_string()
717                .contains("Could not find edits[1]")
718        );
719    }
720
721    #[test]
722    fn edit_preserves_crlf_and_bom() {
723        let dir = TempDir::new().unwrap();
724        std::fs::write(
725            dir.path().join("windows.txt"),
726            "\u{feff}first\r\nsecond\r\nthird\r\n",
727        )
728        .unwrap();
729
730        let result = run_edit(
731            &dir,
732            "windows.txt",
733            vec![replacement("second\n", "SECOND\n")],
734        );
735
736        assert!(result.is_success(), "{}", result.value_for_projection());
737        assert_eq!(
738            std::fs::read_to_string(dir.path().join("windows.txt")).unwrap(),
739            "\u{feff}first\r\nSECOND\r\nthird\r\n"
740        );
741    }
742
743    #[test]
744    fn edit_fuzzy_matches_common_unicode_and_trailing_whitespace() {
745        let dir = TempDir::new().unwrap();
746        std::fs::write(
747            dir.path().join("unicode.txt"),
748            "before\nquote \u{201C}value\u{201D} uses dash \u{2013} and space\u{00A0}   \nafter\n",
749        )
750        .unwrap();
751
752        let result = run_edit(
753            &dir,
754            "unicode.txt",
755            vec![replacement(
756                "quote \"value\" uses dash - and space ",
757                "normalized line",
758            )],
759        );
760
761        assert!(result.is_success(), "{}", result.value_for_projection());
762        assert_eq!(
763            std::fs::read_to_string(dir.path().join("unicode.txt")).unwrap(),
764            "before\nnormalized line\nafter\n"
765        );
766    }
767
768    #[test]
769    fn edit_fuzzy_matching_preserves_untouched_lines() {
770        let dir = TempDir::new().unwrap();
771        std::fs::write(
772            dir.path().join("preserve.txt"),
773            "keep \u{201C}smart\u{201D}\nchange \u{2013}\nkeep \u{00A0}space\n",
774        )
775        .unwrap();
776
777        let result = run_edit(
778            &dir,
779            "preserve.txt",
780            vec![replacement("change -", "changed")],
781        );
782
783        assert!(result.is_success(), "{}", result.value_for_projection());
784        assert_eq!(
785            std::fs::read_to_string(dir.path().join("preserve.txt")).unwrap(),
786            "keep \u{201C}smart\u{201D}\nchanged\nkeep \u{00A0}space\n"
787        );
788    }
789
790    #[test]
791    fn edit_rejects_no_change_replacement() {
792        let dir = TempDir::new().unwrap();
793        std::fs::write(dir.path().join("same.txt"), "alpha\n").unwrap();
794
795        let result = run_edit(&dir, "same.txt", vec![replacement("alpha", "alpha")]);
796
797        assert!(!result.is_success());
798        assert!(
799            result
800                .value_for_projection()
801                .to_string()
802                .contains("No changes")
803        );
804    }
805}