1use anyhow::Context;
9
10use crate::ops::file::{append_content, prepend_content};
11
12use super::{ContentEditResult, MatchMode, ReplaceOptions, make_diff, replace_in_content};
13use crate::fallback::{EditError, EditErrorKind};
14
15#[cfg(any(feature = "cli", feature = "files"))]
16use std::path::Path;
17
18#[cfg(any(feature = "cli", feature = "files"))]
19use crate::containment::PathGuard;
20
21#[cfg(any(feature = "cli", feature = "files"))]
22use super::{
23 ApplyMode, EditResult, PostWriteHooks, build_edit_result, maybe_post_write, write_if_apply,
24};
25
26#[cfg(any(feature = "cli", feature = "files"))]
27use crate::write::WritePolicy;
28
29#[derive(Debug, Clone)]
31#[allow(clippy::large_enum_variant)]
33pub enum ContentEdit {
34 Replace {
36 old: String,
37 new: String,
38 options: ReplaceOptions,
39 },
40 InsertBefore { anchor: String, content: String },
42 InsertAfter { anchor: String, content: String },
44 Append { content: String },
46 Prepend { content: String },
48}
49
50#[derive(Debug, Clone)]
63#[non_exhaustive]
64pub struct ContentEditHonesty {
65 pub op_index: usize,
67 pub match_mode: Option<MatchMode>,
69 pub match_score: Option<f64>,
71 pub matched_text: Option<String>,
73 pub old: String,
75}
76
77impl ContentEditHonesty {
78 #[must_use]
83 pub fn exact(op_index: usize, old: impl Into<String>, matched_text: impl Into<String>) -> Self {
84 Self {
85 op_index,
86 match_mode: Some(MatchMode::Exact),
87 match_score: None,
88 matched_text: Some(matched_text.into()),
89 old: old.into(),
90 }
91 }
92
93 #[must_use]
95 pub fn fuzzy(
96 op_index: usize,
97 old: impl Into<String>,
98 score: f64,
99 matched_text: impl Into<String>,
100 ) -> Self {
101 Self {
102 op_index,
103 match_mode: Some(MatchMode::Fuzzy),
104 match_score: Some(score),
105 matched_text: Some(matched_text.into()),
106 old: old.into(),
107 }
108 }
109}
110
111#[derive(Debug, Clone)]
116#[non_exhaustive]
117pub struct ContentEditsResult {
118 pub original: String,
120 pub modified: String,
122 pub diff: String,
124 pub changed: bool,
126 pub ops_applied: usize,
128 pub match_count: usize,
131 pub match_mode: Option<MatchMode>,
134 pub match_score: Option<f64>,
137 pub matched_text: Option<String>,
146 pub op_honesty: Vec<ContentEditHonesty>,
149}
150
151pub fn apply_content_edits(
163 content: &str,
164 edits: &[ContentEdit],
165) -> anyhow::Result<ContentEditsResult> {
166 apply_content_edits_with_label(content, edits, None)
167}
168
169pub fn apply_content_edits_with_label(
172 content: &str,
173 edits: &[ContentEdit],
174 path_label: Option<&str>,
175) -> anyhow::Result<ContentEditsResult> {
176 let original = content.to_string();
177 let mut current = content.to_string();
178 let mut ops_applied = 0usize;
179 let mut match_count = 0usize;
180 let mut match_mode: Option<MatchMode> = None;
181 let mut match_score: Option<f64> = None;
182 let mut matched_text: Option<String> = None;
183 let mut op_honesty: Vec<ContentEditHonesty> = Vec::new();
184
185 for (i, edit) in edits.iter().enumerate() {
186 let one = apply_one(¤t, edit)
187 .with_context(|| format!("content edit {} of {} failed", i + 1, edits.len()))?;
188 current = one.content;
189 match_count = match_count.saturating_add(one.match_count);
190 ops_applied += 1;
191 if let ContentEdit::Replace { old, .. } = edit
192 && (one.match_mode.is_some() || one.matched_text.is_some())
193 {
194 op_honesty.push(ContentEditHonesty {
195 op_index: i,
196 match_mode: one.match_mode,
197 match_score: one.match_score,
198 matched_text: one.matched_text.clone(),
199 old: old.clone(),
200 });
201 }
202 if let Some(m) = one.match_mode {
203 match_mode = Some(super::merge_match_modes(match_mode, m));
204 if m == MatchMode::Fuzzy
206 && let Some(s) = one.match_score
207 {
208 match_score = Some(match_score.map_or(s, |prev| prev.min(s)));
209 }
210 matched_text = super::prefer_widest_matched_text(matched_text, one.matched_text);
213 }
214 }
215
216 let changed = current != original;
217 let label = path_label.unwrap_or("<buffer>");
218 let diff = make_diff(label, &original, ¤t);
219 Ok(ContentEditsResult {
220 original,
221 modified: current,
222 diff,
223 changed,
224 ops_applied,
225 match_count,
226 match_mode,
227 match_score,
228 matched_text,
229 op_honesty,
230 })
231}
232
233#[cfg(any(feature = "cli", feature = "files"))]
242pub fn apply_content_edits_to_file(
243 path: &Path,
244 edits: &[ContentEdit],
245 mode: ApplyMode,
246 guard: Option<&PathGuard>,
247) -> anyhow::Result<EditResult> {
248 apply_content_edits_to_file_with_span_policy(path, edits, mode, guard, None)
249}
250
251#[cfg(any(feature = "cli", feature = "files"))]
263pub fn apply_content_edits_to_file_with_span_policy(
264 path: &Path,
265 edits: &[ContentEdit],
266 mode: ApplyMode,
267 guard: Option<&PathGuard>,
268 fuzzy_span_policy: Option<&super::FuzzySpanPolicy>,
269) -> anyhow::Result<EditResult> {
270 if let Some(g) = guard {
271 g.check_path(&path.to_string_lossy())
272 .map_err(EditError::guard_rejected)?;
273 }
274 let path_str = path.to_string_lossy().into_owned();
275 let original = crate::files::load_text_strict(path, &path_str).map_err(|e| {
276 if crate::exit::is_load_text_strict_fail(&e) || crate::exit::is_io_not_found(&e) {
280 return e;
281 }
282 EditError::new(EditErrorKind::OperationFailed, e.to_string()).into()
283 })?;
284 let batch = apply_content_edits_with_label(&original, edits, Some(path_str.as_str()))?;
286 if let Some(policy) = fuzzy_span_policy {
287 refuse_batch_if_suspicious_fuzzy(&batch, policy)?;
288 }
289 let policy = WritePolicy::default();
290 let (applied, backup_session) = write_if_apply(path, &batch.modified, mode, &policy, guard)?;
291 let mut result = build_edit_result(
293 &path_str,
294 batch.original,
295 batch.modified,
296 applied,
297 "content.edits",
298 None,
299 );
300 result.backup_session = backup_session.clone();
301 result.match_count = batch.match_count;
302 result.match_mode = batch.match_mode;
303 result.match_score = batch.match_score;
304 result.matched_text = batch.matched_text;
305 let (hooks, hooks_cwd) = post_write_from_edits(edits);
307 maybe_post_write(applied, path, hooks, hooks_cwd, backup_session.as_deref())?;
308 Ok(result)
309}
310
311pub fn refuse_batch_if_suspicious_fuzzy(
350 batch: &ContentEditsResult,
351 policy: &super::FuzzySpanPolicy,
352) -> anyhow::Result<()> {
353 use super::{MatchMode, fuzzy_span_suspicious_with_policy};
354 if !batch.changed {
355 return Ok(());
356 }
357 for h in &batch.op_honesty {
358 if h.match_mode != Some(MatchMode::Fuzzy) {
359 continue;
360 }
361 if fuzzy_span_suspicious_with_policy(
362 &h.old,
363 h.matched_text.as_deref(),
364 h.match_score,
365 policy,
366 ) {
367 let matched = h.matched_text.as_deref().unwrap_or("");
368 let score = h
369 .match_score
370 .map(|s| format!("{s:.3}"))
371 .unwrap_or_else(|| "none".into());
372 return Err(EditError::new(
373 EditErrorKind::FuzzySpanSuspicious,
374 format!(
375 "fuzzy span suspicious on content edit {}: old {:?} matched {:?} (score {score}); span policy refuse before write",
376 h.op_index + 1,
377 crate::fallback::truncate_str(&h.old, 60),
378 crate::fallback::truncate_str(matched, 80),
379 ),
380 )
381 .with_suggestion(
382 "tighten old, omit span policy, or use refuse_suspicious_fuzzy on each Replace",
383 )
384 .into());
385 }
386 }
387 Ok(())
388}
389
390#[cfg(any(feature = "cli", feature = "files"))]
392fn post_write_from_edits(
393 edits: &[ContentEdit],
394) -> (Option<&PostWriteHooks>, Option<&std::path::Path>) {
395 let mut hooks = None;
396 let mut cwd = None;
397 for e in edits {
398 if let ContentEdit::Replace { options, .. } = e
399 && options.post_write.is_some()
400 {
401 hooks = options.post_write.as_ref();
402 cwd = options.post_write_cwd.as_deref();
403 }
404 }
405 (hooks, cwd)
406}
407
408struct OneEdit {
410 content: String,
411 match_count: usize,
412 match_mode: Option<MatchMode>,
413 match_score: Option<f64>,
414 matched_text: Option<String>,
415}
416
417fn apply_one(content: &str, edit: &ContentEdit) -> anyhow::Result<OneEdit> {
419 match edit {
420 ContentEdit::Replace { old, new, options } => {
421 let result: ContentEditResult = replace_in_content(content, old, new, options)?;
422 Ok(OneEdit {
423 content: result.new_content,
424 match_count: result.match_count,
425 match_mode: result.match_mode,
426 match_score: result.match_score,
427 matched_text: result.matched_text,
428 })
429 }
430 ContentEdit::InsertBefore {
431 anchor,
432 content: insert,
433 } => {
434 if anchor.is_empty() {
435 return Err(anyhow::Error::new(crate::exit::InvalidInputError {
436 msg: "insert_before anchor must not be empty".into(),
437 }));
438 }
439 match content.find(anchor.as_str()) {
443 Some(idx) => {
444 let insert = crate::ops::replace::normalize_line_insert(
445 content,
446 anchor,
447 insert,
448 crate::ops::replace::InsertSide::Before,
449 );
450 let mut out = String::with_capacity(content.len() + insert.len());
451 out.push_str(&content[..idx]);
452 out.push_str(&insert);
453 out.push_str(&content[idx..]);
454 Ok(OneEdit {
455 content: out,
456 match_count: 0,
457 match_mode: None,
458 match_score: None,
459 matched_text: None,
460 })
461 }
462 None => Err(EditError::new(
463 EditErrorKind::NoMatch,
464 format!("insert_before anchor not found: {anchor:?}"),
465 )
466 .into()),
467 }
468 }
469 ContentEdit::InsertAfter {
470 anchor,
471 content: insert,
472 } => {
473 if anchor.is_empty() {
474 return Err(anyhow::Error::new(crate::exit::InvalidInputError {
475 msg: "insert_after anchor must not be empty".into(),
476 }));
477 }
478 match content.find(anchor.as_str()) {
480 Some(idx) => {
481 let insert = crate::ops::replace::normalize_line_insert(
482 content,
483 anchor,
484 insert,
485 crate::ops::replace::InsertSide::After,
486 );
487 let end = idx + anchor.len();
488 let mut out = String::with_capacity(content.len() + insert.len());
489 out.push_str(&content[..end]);
490 out.push_str(&insert);
491 out.push_str(&content[end..]);
492 Ok(OneEdit {
493 content: out,
494 match_count: 0,
495 match_mode: None,
496 match_score: None,
497 matched_text: None,
498 })
499 }
500 None => Err(EditError::new(
501 EditErrorKind::NoMatch,
502 format!("insert_after anchor not found: {anchor:?}"),
503 )
504 .into()),
505 }
506 }
507 ContentEdit::Append { content: inject } => Ok(OneEdit {
508 content: append_content(content, inject),
509 match_count: 0,
510 match_mode: None,
511 match_score: None,
512 matched_text: None,
513 }),
514 ContentEdit::Prepend { content: inject } => Ok(OneEdit {
515 content: prepend_content(content, inject),
516 match_count: 0,
517 match_mode: None,
518 match_score: None,
519 matched_text: None,
520 }),
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn multi_op_replace_then_append() {
530 let edits = [
531 ContentEdit::Replace {
532 old: "hello".into(),
533 new: "hi".into(),
534 options: ReplaceOptions::default(),
535 },
536 ContentEdit::Append {
537 content: "\nworld".into(),
538 },
539 ];
540 let r = apply_content_edits("hello\n", &edits).unwrap();
541 assert!(r.changed);
542 assert_eq!(r.ops_applied, 2);
543 assert_eq!(r.match_count, 1, "replace match_count should roll up");
544 assert_eq!(r.modified, "hi\n\nworld");
545 assert!(
547 r.diff.contains("--- a/<buffer>") && r.diff.contains("+++ b/<buffer>"),
548 "buffer helper must label headers as <buffer>, got:\n{}",
549 r.diff
550 );
551 assert!(
552 !r.diff.contains("a//") && !r.diff.contains("b//"),
553 "buffer headers must not double-slash: {}",
554 r.diff
555 );
556 }
557
558 #[test]
559 fn multi_op_all_or_nothing_on_failure() {
560 let edits = [
561 ContentEdit::Replace {
562 old: "a".into(),
563 new: "A".into(),
564 options: ReplaceOptions::default(),
565 },
566 ContentEdit::InsertBefore {
567 anchor: "missing".into(),
568 content: "x".into(),
569 },
570 ];
571 let err = apply_content_edits("a b\n", &edits).unwrap_err();
572 assert!(
573 err.to_string().contains("content edit 2"),
574 "error should identify failing op: {err}"
575 );
576 }
577
578 #[test]
579 fn multi_op_unique_failure() {
580 let edits = [ContentEdit::Replace {
581 old: "x".into(),
582 new: "y".into(),
583 options: ReplaceOptions {
584 unique: true,
585 ..Default::default()
586 },
587 }];
588 let err = apply_content_edits("x x\n", &edits).unwrap_err();
589 let msg = format!("{err:#}");
590 assert!(
591 msg.contains("ambiguous") || msg.contains("matches"),
592 "expected unique/ambiguous failure, got: {msg}"
593 );
594 }
595
596 #[test]
597 fn multi_op_insert_before_after() {
598 let edits = [
600 ContentEdit::InsertBefore {
601 anchor: "B".into(),
602 content: "A".into(),
603 },
604 ContentEdit::InsertAfter {
605 anchor: "B".into(),
606 content: "C".into(),
607 },
608 ];
609 let r = apply_content_edits("xBy", &edits).unwrap();
610 assert_eq!(r.modified, "xABCy");
611 }
612
613 #[test]
614 fn multi_op_insert_line_oriented_on_whole_line_anchor() {
615 let edits = [
617 ContentEdit::InsertBefore {
618 anchor: "B".into(),
619 content: "A".into(),
620 },
621 ContentEdit::InsertAfter {
622 anchor: "B".into(),
623 content: "C".into(),
624 },
625 ];
626 let r = apply_content_edits("B", &edits).unwrap();
627 assert_eq!(r.modified, "A\nB\nC");
629 }
630
631 #[test]
632 fn empty_edits_is_noop() {
633 let r = apply_content_edits("same\n", &[]).unwrap();
634 assert!(!r.changed);
635 assert_eq!(r.ops_applied, 0);
636 assert_eq!(r.match_count, 0);
637 assert_eq!(r.modified, "same\n");
638 }
639
640 #[test]
641 fn match_count_sums_multiple_replaces() {
642 let edits = [
643 ContentEdit::Replace {
644 old: "a".into(),
645 new: "A".into(),
646 options: ReplaceOptions::default(),
647 },
648 ContentEdit::Replace {
649 old: "b".into(),
650 new: "B".into(),
651 options: ReplaceOptions::default(),
652 },
653 ];
654 let r = apply_content_edits("a a b\n", &edits).unwrap();
655 assert_eq!(r.match_count, 3, "two a's + one b: {r:?}");
656 assert_eq!(r.modified, "A A B\n");
657 }
658
659 #[test]
660 fn multi_op_prepend() {
661 let edits = [ContentEdit::Prepend {
662 content: "pre\n".into(),
663 }];
664 let r = apply_content_edits("body\n", &edits).unwrap();
665 assert_eq!(r.modified, "pre\nbody\n");
666 assert_eq!(r.ops_applied, 1);
667 }
668
669 #[test]
670 fn empty_anchor_is_rejected() {
671 let before = apply_content_edits(
672 "body\n",
673 &[ContentEdit::InsertBefore {
674 anchor: String::new(),
675 content: "x".into(),
676 }],
677 )
678 .unwrap_err();
679 let before_msg = format!("{before:#}");
680 assert!(
681 before_msg.contains("empty"),
682 "insert_before empty anchor: {before_msg}"
683 );
684 assert_eq!(
685 crate::fallback::edit_error_kind(&before),
686 Some(EditErrorKind::InvalidInput)
687 );
688 let after = apply_content_edits(
689 "body\n",
690 &[ContentEdit::InsertAfter {
691 anchor: String::new(),
692 content: "x".into(),
693 }],
694 )
695 .unwrap_err();
696 let after_msg = format!("{after:#}");
697 assert!(
698 after_msg.contains("empty"),
699 "insert_after empty anchor: {after_msg}"
700 );
701 assert_eq!(
702 crate::fallback::edit_error_kind(&after),
703 Some(EditErrorKind::InvalidInput)
704 );
705 }
706
707 #[test]
708 fn require_change_true_missing_is_no_match() {
709 let edits = [ContentEdit::Replace {
710 old: "missing".into(),
711 new: "x".into(),
712 options: ReplaceOptions {
713 require_change: true,
714 ..Default::default()
715 },
716 }];
717 let err = apply_content_edits("hello\n", &edits).unwrap_err();
718 assert_eq!(
719 crate::fallback::edit_error_kind(&err),
720 Some(EditErrorKind::NoMatch)
721 );
722 }
723
724 #[test]
725 fn require_change_false_missing_is_ok_unchanged() {
726 let edits = [ContentEdit::Replace {
727 old: "missing".into(),
728 new: "x".into(),
729 options: ReplaceOptions::default(),
730 }];
731 let r = apply_content_edits("hello\n", &edits).unwrap();
732 assert!(!r.changed);
733 assert_eq!(r.modified, "hello\n");
734 }
735
736 #[test]
737 fn require_change_unique_multi_is_ambiguous() {
738 let edits = [ContentEdit::Replace {
739 old: "x".into(),
740 new: "y".into(),
741 options: ReplaceOptions {
742 unique: true,
743 require_change: true,
744 ..Default::default()
745 },
746 }];
747 let err = apply_content_edits("x x\n", &edits).unwrap_err();
748 assert_eq!(
749 crate::fallback::edit_error_kind(&err),
750 Some(EditErrorKind::AmbiguousTarget)
751 );
752 }
753
754 #[test]
755 fn require_change_batch_fails_whole_batch() {
756 let edits = [
757 ContentEdit::Replace {
758 old: "a".into(),
759 new: "A".into(),
760 options: ReplaceOptions {
761 require_change: true,
762 ..Default::default()
763 },
764 },
765 ContentEdit::Replace {
766 old: "missing".into(),
767 new: "z".into(),
768 options: ReplaceOptions {
769 require_change: true,
770 ..Default::default()
771 },
772 },
773 ];
774 let err = apply_content_edits("a b\n", &edits).unwrap_err();
775 let msg = format!("{err:#}");
776 assert!(msg.contains("content edit 2"), "got: {msg}");
777 assert_eq!(
778 crate::fallback::edit_error_kind(&err),
779 Some(EditErrorKind::NoMatch)
780 );
781 }
782 #[test]
783 fn command_position_in_batch_match_count() {
784 let edits = [ContentEdit::Replace {
785 old: "pip".into(),
786 new: "uv".into(),
787 options: ReplaceOptions {
788 command_position: true,
789 require_change: true,
790 ..Default::default()
791 },
792 }];
793 let r = apply_content_edits("timeout 5 pip install\nuv pip install\n", &edits).unwrap();
794 assert_eq!(r.match_count, 1);
795 assert_eq!(r.modified, "timeout 5 uv install\nuv pip install\n");
796 }
797
798 #[test]
799 fn insert_before_uses_first_anchor_only() {
800 let edits = [ContentEdit::InsertBefore {
801 anchor: "x".into(),
802 content: "IN:".into(),
803 }];
804 let r = apply_content_edits("a x b x c\n", &edits).unwrap();
805 assert_eq!(r.modified, "a IN:x b x c\n");
806 }
807
808 #[test]
809 fn content_edit_fuzzy_match_mode_rollup() {
810 let edits = [ContentEdit::Replace {
811 old: "fn proccess_data() {}".into(),
812 new: "fn handle_data() {}".into(),
813 options: ReplaceOptions {
814 fuzzy: true,
815 min_fuzzy_score: None,
816 allow_absent_old: true,
817 require_change: true,
818 ..Default::default()
819 },
820 }];
821 let r = apply_content_edits("fn process_data() {}\n", &edits).unwrap();
822 assert!(r.changed);
823 assert_eq!(r.match_mode, Some(MatchMode::Fuzzy));
824 assert!(r.match_score.is_some_and(|s| s > 0.85));
825 let matched = r
826 .matched_text
827 .as_deref()
828 .expect("content_edits fuzzy must surface matched_text");
829 assert!(
830 matched.contains("process_data"),
831 "matched_text should be live span: {matched:?}"
832 );
833 }
834
835 #[test]
836 fn content_edit_match_mode_rollup_anchored_over_exact() {
837 let edits = [
839 ContentEdit::Replace {
840 old: "alpha".into(),
841 new: "ALPHA".into(),
842 options: ReplaceOptions::default(),
843 },
844 ContentEdit::Replace {
845 old: "TODO: fix".into(),
846 new: "TODO: done".into(),
847 options: ReplaceOptions {
848 before_context: Some("gamma".into()),
849 ..Default::default()
850 },
851 },
852 ];
853 let r = apply_content_edits("alpha\nTODO: fix\nbeta\ngamma\nTODO: fix\n", &edits).unwrap();
854 assert!(r.changed);
855 assert_eq!(r.match_mode, Some(MatchMode::Anchored));
856 assert_eq!(r.match_count, 2);
857 assert_eq!(
859 r.matched_text.as_deref(),
860 Some("TODO: fix"),
861 "batch should report widest anchored/fuzzy matched_text (#1736 / #1981)"
862 );
863 assert!(r.modified.starts_with("ALPHA\n"));
864 assert!(r.modified.contains("gamma\nTODO: done"));
865 assert!(
867 r.modified.contains("ALPHA\nTODO: fix\nbeta"),
868 "first TODO must remain: {}",
869 r.modified
870 );
871 }
872
873 #[test]
874 fn merge_match_modes_precedence_table() {
875 use super::super::merge_match_modes;
876 use MatchMode::*;
877 assert_eq!(merge_match_modes(None, Exact), Exact);
878 assert_eq!(merge_match_modes(Some(Exact), Anchored), Anchored);
879 assert_eq!(merge_match_modes(Some(Anchored), Exact), Anchored);
880 assert_eq!(merge_match_modes(Some(Exact), Fuzzy), Fuzzy);
881 assert_eq!(merge_match_modes(Some(Anchored), Fuzzy), Fuzzy);
882 assert_eq!(merge_match_modes(Some(Fuzzy), Anchored), Fuzzy);
883 assert_eq!(merge_match_modes(Some(Fuzzy), Exact), Fuzzy);
884 }
885
886 #[test]
888 fn content_edits_matched_text_prefers_widest_span() {
889 let edits = [
893 ContentEdit::Replace {
894 old: "fn small_typo_name() {}".into(),
895 new: "fn small_name() {}".into(),
896 options: ReplaceOptions {
897 fuzzy: true,
898 allow_absent_old: true,
899 min_fuzzy_score: None,
900 require_change: true,
901 ..Default::default()
902 },
903 },
904 ContentEdit::Replace {
905 old: "let CONFIGURATION_VALUE_PRIMRY = 1;".into(),
907 new: "let CONFIGURATION_VALUE_SECONDARY = 1;".into(),
908 options: ReplaceOptions {
909 fuzzy: true,
910 allow_absent_old: true,
911 min_fuzzy_score: None,
912 require_change: true,
913 ..Default::default()
914 },
915 },
916 ];
917 let src = "fn small_name() {}\nlet CONFIGURATION_VALUE_PRIMARY = 1;\n";
918 let r = apply_content_edits(src, &edits).unwrap();
919 assert!(r.changed, "edits should apply: {:?}", r.diff);
920 let matched = r
921 .matched_text
922 .as_deref()
923 .expect("batch must surface matched_text");
924 assert!(
927 matched.contains("CONFIGURATION_VALUE_PRIMARY"),
928 "widest matched_text should come from the longer second-op span, got {matched:?}"
929 );
930 assert!(
931 matched.chars().count() > "small_name".chars().count(),
932 "widest span must exceed first-op identifier length, got {matched:?}"
933 );
934
935 let edits_rev = [
937 ContentEdit::Replace {
938 old: "let CONFIGURATION_VALUE_PRIMRY = 1;".into(),
939 new: "let CONFIGURATION_VALUE_SECONDARY = 1;".into(),
940 options: ReplaceOptions {
941 fuzzy: true,
942 allow_absent_old: true,
943 min_fuzzy_score: None,
944 require_change: true,
945 ..Default::default()
946 },
947 },
948 ContentEdit::Replace {
949 old: "fn small_typo_name() {}".into(),
950 new: "fn small_name() {}".into(),
951 options: ReplaceOptions {
952 fuzzy: true,
953 allow_absent_old: true,
954 min_fuzzy_score: None,
955 require_change: true,
956 ..Default::default()
957 },
958 },
959 ];
960 let r2 = apply_content_edits(src, &edits_rev).unwrap();
961 assert!(r2.changed);
962 let matched2 = r2
963 .matched_text
964 .as_deref()
965 .expect("reverse-order batch must surface matched_text");
966 assert!(
967 matched2.contains("CONFIGURATION_VALUE_PRIMARY"),
968 "widest must win when it is the first op, got {matched2:?}"
969 );
970 }
971}