Skip to main content

patchloom/api/
content_edits.rs

1//! Multi-op in-memory content edits for agent hosts (#1459 Feature 2).
2//!
3//! Compose sequential text operations on one buffer with all-or-nothing
4//! semantics, then optionally write once via [`apply_content_edits_to_file`].
5//! Results include rolled-up [`ContentEditsResult::match_count`] from replace
6//! ops (and the file helper surfaces the same on [`EditResult::match_count`]).
7
8use 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/// A single ordered edit on an in-memory buffer.
30#[derive(Debug, Clone)]
31// ReplaceOptions carries post_write hooks (#1690); boxing would break callers.
32#[allow(clippy::large_enum_variant)]
33pub enum ContentEdit {
34    /// Text replacement (reuses [`ReplaceOptions`]).
35    Replace {
36        old: String,
37        new: String,
38        options: ReplaceOptions,
39    },
40    /// Insert `content` immediately before the first exact match of `anchor`.
41    InsertBefore { anchor: String, content: String },
42    /// Insert `content` immediately after the first exact match of `anchor`.
43    InsertAfter { anchor: String, content: String },
44    /// Append content to the end of the buffer.
45    Append { content: String },
46    /// Prepend content to the start of the buffer.
47    Prepend { content: String },
48}
49
50/// Per-op match honesty for a successful [`ContentEdit::Replace`] (#2006).
51///
52/// Hosts should call [`super::fuzzy_span_suspicious`] with this entry's `old`,
53/// `matched_text`, and `match_score` rather than rollup fields alone (rollup
54/// widest span and min score may come from different ops). For a full batch
55/// refuse with the same error shape as `for_agent`, prefer
56/// [`refuse_batch_if_suspicious_fuzzy`] (#2064).
57///
58/// Prefer building this via real [`apply_content_edits`] / replace for
59/// integration tests. Downstream crates cannot use struct literals
60/// (`#[non_exhaustive]`); use [`Self::exact`] / [`Self::fuzzy`] for host
61/// refuse-glue unit tests (#2033).
62#[derive(Debug, Clone)]
63#[non_exhaustive]
64pub struct ContentEditHonesty {
65    /// Index into the `edits` slice (0-based).
66    pub op_index: usize,
67    /// Match strategy for this replace.
68    pub match_mode: Option<MatchMode>,
69    /// Fuzzy similarity when `match_mode` is fuzzy.
70    pub match_score: Option<f64>,
71    /// Live span that was replaced (fuzzy/anchored) when available.
72    pub matched_text: Option<String>,
73    /// The replace `old` string for this op (for refuse with matching old).
74    pub old: String,
75}
76
77impl ContentEditHonesty {
78    /// Construct a minimal exact-match honesty row for host unit tests (#2033).
79    ///
80    /// Future fields get internal defaults; prefer live edits for integration
81    /// coverage.
82    #[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    /// Construct a minimal fuzzy-match honesty row for host unit tests (#2033).
94    #[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/// Result of applying a sequence of [`ContentEdit`]s to a buffer.
112///
113/// Marked `non_exhaustive` so new honesty fields can land in minor releases
114/// without breaking external struct literals.
115#[derive(Debug, Clone)]
116#[non_exhaustive]
117pub struct ContentEditsResult {
118    /// Original buffer before any edits.
119    pub original: String,
120    /// Buffer after all edits (or unchanged when `changed` is false).
121    pub modified: String,
122    /// Unified diff from original to modified.
123    pub diff: String,
124    /// Whether any edit changed the buffer.
125    pub changed: bool,
126    /// Number of ops that successfully applied (equals `edits.len()` on success).
127    pub ops_applied: usize,
128    /// Sum of replace match counts across all [`ContentEdit::Replace`] ops.
129    /// Insert/append/prepend contribute 0. Useful for agent ambiguity policies.
130    pub match_count: usize,
131    /// Worst-case match strategy across replace ops (`Fuzzy` > `Anchored` > `Exact`).
132    /// `None` when no replace ops ran or none matched.
133    pub match_mode: Option<MatchMode>,
134    /// Lowest fuzzy similarity score across replace ops in the batch (worst-case
135    /// confidence when several fuzzy ops ran).
136    pub match_score: Option<f64>,
137    /// Matched span for host policy: **widest** fuzzy/anchored span in the batch
138    /// by Unicode char count (worst-case over-wide for refuse; #1736 / #1981).
139    /// Not the first non-empty only (that hid later over-wide fuzzy ops).
140    ///
141    /// **Pairing note:** `match_score` is the **minimum** fuzzy score across ops;
142    /// `matched_text` is the **widest** span. They may come from different ops.
143    /// Prefer [`Self::op_honesty`] with the corresponding `old` when possible; do not
144    /// treat rolled-up fields as a single joint (old, span, score) triple.
145    pub matched_text: Option<String>,
146    /// Per successful replace-like op, in apply order (#2006). Empty when no
147    /// replace ops reported match honesty.
148    pub op_honesty: Vec<ContentEditHonesty>,
149}
150
151/// Apply ordered edits to an in-memory buffer.
152///
153/// **All-or-nothing:** if any op fails, returns `Err` and does not expose a
154/// partial buffer. On success, every op has been applied in order; each op
155/// sees the result of the previous one.
156///
157/// Diff headers use `<buffer>`. Prefer [`apply_content_edits_with_label`] when
158/// the host knows a real path (#1665).
159///
160/// Available with default features off as long as the replace path is
161/// compiled (always; no `ast` required).
162pub 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
169/// Like [`apply_content_edits`], but labels unified-diff headers with
170/// `path_label` when set (e.g. `src/lib.rs`). `None` keeps `<buffer>` (#1665).
171pub 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(&current, 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            // Worst-case confidence: lowest fuzzy score across ops.
205            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            // Widest span wins so multi-op hosts can refuse over-wide fuzzy
211            // even when an earlier Exact/small Fuzzy left a short matched_text (#1981).
212            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, &current);
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/// Read a file, apply multi-op content edits, and write according to `mode`.
234///
235/// Requires `files` or `cli` for PathGuard / write policy integration.
236///
237/// For over-wide fuzzy refuse before any disk write, use
238/// [`apply_content_edits_to_file_with_span_policy`] (#2008). When each replace
239/// uses [`super::ReplaceOptions::for_agent`], refuse already runs in memory
240/// via `refuse_suspicious_fuzzy` (#2005).
241#[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/// Like [`apply_content_edits_to_file`], with optional pre-write fuzzy span
252/// refuse (#2008).
253///
254/// When `fuzzy_span_policy` is `Some`, after a successful in-memory batch and
255/// **before** `write_if_apply`, each fuzzy replace in `op_honesty` is checked
256/// with [`super::fuzzy_span_suspicious_with_policy`]. On refuse:
257/// [`EditErrorKind::FuzzySpanSuspicious`], no disk write, no backup session.
258///
259/// `None` keeps historical behavior (no extra gate). Prefer
260/// [`super::FuzzySpanPolicy::default`] for the same thresholds as
261/// [`super::ReplaceOptions::for_agent`].
262#[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        // Preserve content SoftSkip peels (#1963) and NotFound for hosts that
277        // branch on is_not_found (create/retry). Do not collapse either to
278        // OperationFailed.
279        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    // Prefer real path labels in multi-op preview (#1665 / #1500).
285    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    // build_edit_result uses path for headers (#1500) and stays field-complete.
292    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    // Honor post_write from the last Replace edit that set hooks (#1690).
306    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
311/// Refuse when any fuzzy op in a multi-op batch is over-wide (#2008 / #2064).
312///
313/// Intended for **buffer multi-op** hosts that call [`apply_content_edits`] /
314/// [`apply_content_edits_with_label`] then write themselves (hardlink-aware
315/// Apply, custom backup). Same thresholds as
316/// [`super::fuzzy_span_suspicious_with_policy`]; same kind as single-op
317/// `for_agent` refuse (`EditErrorKind::FuzzySpanSuspicious`).
318///
319/// Only rows with [`MatchMode::Fuzzy`] are checked. Exact and anchored
320/// honesty rows are skipped. Unchanged batches (`changed == false`) pass.
321///
322/// Disk multi-op with a single write path should prefer
323/// [`apply_content_edits_to_file_with_span_policy`] (calls this helper before
324/// write/backup). Per-op [`super::ReplaceOptions::for_agent`] still refuses
325/// inside each replace when `refuse_suspicious_fuzzy` is set.
326///
327/// # Example
328///
329/// ```rust,no_run
330/// use patchloom::api::{
331///     apply_content_edits, refuse_batch_if_suspicious_fuzzy, ContentEdit,
332///     FuzzySpanPolicy, ReplaceOptions,
333/// };
334///
335/// let edits = [ContentEdit::Replace {
336///     old: "old".into(),
337///     new: "new".into(),
338///     options: ReplaceOptions {
339///         fuzzy: true,
340///         refuse_suspicious_fuzzy: false, // host will gate
341///         ..ReplaceOptions::default()
342///     },
343/// }];
344/// let batch = apply_content_edits("old value\n", &edits)?;
345/// refuse_batch_if_suspicious_fuzzy(&batch, &FuzzySpanPolicy::default())?;
346/// // host write of batch.modified ...
347/// # Ok::<(), anyhow::Error>(())
348/// ```
349pub 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/// Last replace-side post_write wins (agent hosts set hooks once on the batch).
391#[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
408/// Intermediate apply_one result (keeps the match-honesty fields together).
409struct 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
417/// Apply a single content edit; returns the new buffer plus replace honesty.
418fn 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            // First-match only (same as String::find). Callers that need
440            // unique anchors should use Replace with unique:true or a longer
441            // anchor span.
442            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            // First-match only (see InsertBefore).
479            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        // Pure buffer helper keeps the `<buffer>` path label (#1500).
546        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        // Mid-line bare inserts stay byte-exact (no leading indent / newline).
599        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        // Whole-line anchor "B": bare payloads get newlines (#1885).
616        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        // Before: "A\n" + "B" → "A\nB"; After: "B" + "\nC" on that buffer → "A\nB\nC"
628        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        // Exact first, then context-anchored: worst-case is Anchored (#1673).
838        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        // Exact first leaves matched_text None; anchored span is the only honesty text.
858        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        // Context-anchored replace must not touch the first TODO: fix
866        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    /// #1981: multi-op keeps the **widest** matched_text, not the first.
887    #[test]
888    fn content_edits_matched_text_prefers_widest_span() {
889        // First fuzzy: short identifier. Second: much wider live span (simulated
890        // by two separate replaces on different content). After first apply the
891        // buffer changes; second uses a different old that still fuzzy-matches.
892        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                // Typo of a whole-line-ish target so fuzzy can land a wide span.
906                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        // Widest honesty span should reflect the longer identifier line, not
925        // only the first short function name match.
926        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        // Reverse order: wide first, short second still keeps the widest.
936        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}