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 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 .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}