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