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 old_text: String,
28 new_text: String,
30}
31
32#[derive(Clone, Debug, Deserialize, JsonSchema)]
33#[serde(deny_unknown_fields)]
34struct EditArgs {
35 path: String,
37 edits: Vec<EditReplacement>,
39}
40
41#[derive(Clone, Debug, Serialize, JsonSchema)]
42#[serde(rename_all = "camelCase")]
43struct EditDetails {
44 diff: String,
46 patch: String,
48 #[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}