Skip to main content

quillmark_content/
delta.rs

1//! The per-field edit surface: a [`Delta`] of text splices over the USV content,
2//! plus the **stale-text writer** path, cold-parse a full new markdown document,
3//! char-diff it against the base, and rebase the base's identity marks
4//! (anchors/comments) through the diff so annotations survive an LLM
5//! full-document rewrite with no preservation contract on the LLM.
6//!
7//! ## Text splices, not attributed ops
8//!
9//! [`Delta`] is `retain` / `insert` / `delete` over the character sequence:
10//! CodeMirror `ChangeSet` / OT text semantics, **not** Quill-Delta. It carries
11//! no formatting attributes: marks and islands are separate `(range, kind)` data
12//! that *rebase through* a delta ([`Delta::map_pos`]), they do not ride it as op
13//! attributes. This is deliberate: an attribute map is a per-character property
14//! map and cannot represent overlapping same-kind marks or two distinct
15//! identity anchors over one range, the exact algebra the content model keeps
16//! (Peritext free overlap + identity handles). Editing marks and line/block
17//! attributes are their own op channels, not attributes on this delta. The
18//! positional channel stays isomorphic to a text CRDT's op stream: the shape
19//! real-time collaborative editing would need.
20//!
21//! [`diff`] computes a Myers/LCS minimal edit script and pairs it with a
22//! **move detector** that re-homes an anchor across a verbatim block move.
23//! Position mapping ([`Delta::map_pos`]) follows CodeMirror's
24//! `ChangeDesc.mapPos` / ProseMirror mapping semantics. Anchoring a captured
25//! position across edits is the editor's job (its own transaction mapping); the
26//! content carries no session-side change log.
27//!
28//! ## The move weak spot (documented limit)
29//!
30//! A paragraph reorder is delete-here + insert-there to any char differ, so a
31//! naive rebase collapses an anchor in the moved text to the deletion point. The
32//! detector re-homes an anchor onto a **single, verbatim block move** by locating
33//! the moved text in the new content. Text both *moved and rewritten* in one round
34//! (the match is lost) drops the anchor: the accepted residual, stated not
35//! hidden.
36
37use crate::model::{Mark, MarkKind, Content};
38use serde::{Deserialize, Serialize};
39use similar::{ChangeTag, TextDiff};
40
41/// A per-field edit against a base content. Ops apply left-to-right, consuming
42/// base positions; `Retain`/`Delete` advance the base cursor, `Insert` adds new
43/// text. USV throughout.
44///
45/// Serializes as `{ "ops": [ {"retain": n} | {"insert": s} | {"delete": n} ] }`:
46/// plain, structured-clone-able data an editor bridge stores in a change
47/// record and maps its own positions through ([`map_pos`](Self::map_pos)). The
48/// serde shape is the wire the `rebase` codec and `applyChange` bundle carry
49/// across the language bindings.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Delta {
52    pub ops: Vec<Op>,
53}
54
55/// One delta operation. Serializes externally-tagged with a lowercase key
56/// (`{"retain": 5}`, `{"insert": "x"}`, `{"delete": 2}`).
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59#[non_exhaustive]
60pub enum Op {
61    /// Keep `n` chars of the base unchanged.
62    Retain(usize),
63    /// Insert this text at the cursor.
64    Insert(String),
65    /// Drop `n` chars of the base.
66    Delete(usize),
67}
68
69/// Which side of a same-position insertion a mapped point lands on. Serializes
70/// as `"before"` / `"after"`.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "lowercase")]
73#[non_exhaustive]
74pub enum Assoc {
75    /// Stay before inserted text.
76    Before,
77    /// Move after inserted text.
78    After,
79}
80
81/// A delta's expected base length disagreed with the text it was applied to:
82/// the delta was built against a different revision of the base.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct BaseLengthMismatch {
85    pub expected: usize,
86    pub actual: usize,
87}
88
89impl Delta {
90    /// Chars of base the `Retain`/`Delete` ops together consume: the base
91    /// length this delta was built against.
92    pub fn expected_base_len(&self) -> usize {
93        self.ops
94            .iter()
95            .map(|op| match op {
96                Op::Retain(n) | Op::Delete(n) => *n,
97                Op::Insert(_) => 0,
98            })
99            .sum()
100    }
101
102    /// Apply to `base`, producing the new text. Base beyond what the ops
103    /// consume is retained implicitly: a *short* delta names only the region
104    /// it changes (a bare prepend, an edit near the start) and the untouched
105    /// remainder carries through. **Panics** if the ops consume *more* base than
106    /// exists (`expected_base_len() > base.chars().count()`): a delta built
107    /// against a longer revision. This is the trusted-provenance path; clamping
108    /// an over-long delta silently is corruption, so where the base's provenance
109    /// isn't already trusted use [`Self::try_apply`], which returns the mismatch
110    /// as an error instead.
111    pub fn apply(&self, base: &str) -> String {
112        let chars: Vec<char> = base.chars().collect();
113        let mut out = String::new();
114        let mut i = 0usize;
115        for op in &self.ops {
116            match op {
117                // Over-long Retain/Delete index past `chars` and panic here:
118                // the intended failure on a wrong-revision base.
119                Op::Retain(n) => {
120                    out.extend(&chars[i..i + n]);
121                    i += n;
122                }
123                Op::Delete(n) => i += n,
124                Op::Insert(s) => out.push_str(s),
125            }
126        }
127        out.extend(&chars[i..]);
128        out
129    }
130
131    /// [`Self::apply`], but returns [`BaseLengthMismatch`] instead of panicking
132    /// when the ops consume *more* base than `base` has: a delta built against
133    /// a longer revision. Implicit trailing retain is the contract: a *short*
134    /// delta (ops consuming less than `base`) is accepted and the untouched
135    /// remainder is retained, matching [`map_pos`](Self::map_pos)'s implicit
136    /// trailing retain so a producer that names only the changed region need not
137    /// pad a bare trailing [`Op::Retain`].
138    ///
139    /// Cost of the leniency: a short delta carries no full-base-length check, so
140    /// one replayed against a wrong but *longer* base applies silently instead
141    /// of failing. An abbreviated delta forfeits that tripwire by construction;
142    /// over-consumption still fails.
143    pub fn try_apply(&self, base: &str) -> Result<String, BaseLengthMismatch> {
144        let expected = self.expected_base_len();
145        let actual = base.chars().count();
146        if expected > actual {
147            return Err(BaseLengthMismatch { expected, actual });
148        }
149        Ok(self.apply(base))
150    }
151
152    /// Map a base char position to its new position. `assoc` decides the side of
153    /// a same-position insertion (`After` moves past it).
154    pub fn map_pos(&self, pos: usize, assoc: Assoc) -> usize {
155        let mut old = 0usize;
156        let mut new = 0usize;
157        for op in &self.ops {
158            match op {
159                Op::Retain(n) => {
160                    // Strictly inside the retain resolves here; the right
161                    // boundary (pos == old + n) falls through, so a following
162                    // Insert can apply its `assoc`.
163                    if pos < old + n {
164                        return new + (pos - old);
165                    }
166                    old += n;
167                    new += n;
168                }
169                Op::Delete(n) => {
170                    if pos < old + n {
171                        // Inside (or at the start of) the deletion: collapse to
172                        // the deletion point.
173                        return new;
174                    }
175                    old += n;
176                }
177                Op::Insert(s) => {
178                    let len = s.chars().count();
179                    if pos == old {
180                        match assoc {
181                            Assoc::Before => return new,
182                            Assoc::After => new += len, // fall through past insert
183                        }
184                    } else {
185                        new += len;
186                    }
187                }
188            }
189        }
190        new + pos.saturating_sub(old)
191    }
192
193    /// Whether base position `pos` sits strictly inside a deleted span. The
194    /// deletion's left edge (`pos == old`) survives (a point anchor there stays
195    /// put) so only `old < pos < old + n` counts as deleted.
196    fn is_deleted(&self, pos: usize) -> bool {
197        let mut old = 0usize;
198        for op in &self.ops {
199            match op {
200                Op::Retain(n) => old += n,
201                Op::Delete(n) => {
202                    if pos > old && pos < old + n {
203                        return true;
204                    }
205                    old += n;
206                }
207                Op::Insert(_) => {}
208            }
209        }
210        false
211    }
212
213    /// New-text char ranges covered by `Insert` ops: the only regions an anchor
214    /// may be re-homed into (moved text must have been *inserted*, not merely
215    /// present in surviving text elsewhere).
216    fn inserted_spans(&self) -> Vec<(usize, usize)> {
217        let mut spans = Vec::new();
218        let mut new = 0usize;
219        for op in &self.ops {
220            match op {
221                Op::Retain(n) => new += n,
222                Op::Insert(s) => {
223                    let len = s.chars().count();
224                    if len > 0 {
225                        spans.push((new, new + len));
226                    }
227                    new += len;
228                }
229                Op::Delete(_) => {}
230            }
231        }
232        spans
233    }
234}
235
236/// A relocation match shorter than this many chars is too weak to trust: the
237/// verbatim-move detector's length floor.
238const MIN_MOVE: usize = 4;
239
240/// Above this many USV chars, the single-line path skips `similar`'s
241/// char-level Myers diff and falls back to [`coarse_replace`].
242/// `TextDiff::from_chars` is O(N·D) with no deadline; on two long, unrelated
243/// single-line strings (no newlines to fall back to line granularity: the
244/// realistic shape of an LLM full-document rewrite) D grows with N, so cost
245/// is effectively quadratic. Two unrelated 30,000-char lines measured 86s in
246/// a debug build. This threshold sits comfortably below that (6x headroom)
247/// while still covering a real single-paragraph field, which plausibly runs
248/// to a few thousand chars. A fixed cutoff was chosen over
249/// `TextDiffConfig::timeout`: nothing in this crate uses `TextDiffConfig`
250/// today, and a char budget is deterministic (no wall-clock flakiness in
251/// CI, no partial-diff result to reason about).
252const CHAR_DIFF_LIMIT: usize = 5_000;
253
254/// Char-level Myers/LCS diff over USV: a minimal `Retain` / `Delete` / `Insert`
255/// script. Disjoint edits stay separate ops rather than collapsing the span
256/// between them into one delete+insert, so anchors sitting in unchanged middle
257/// text survive rebase without relying on the move detector.
258///
259/// Single-line text diffs at char granularity; multi-line text diffs at line
260/// granularity so a paragraph reorder surfaces as whole-line insert spans the
261/// move detector can match (char Myers fragments reordered blocks). Above
262/// `CHAR_DIFF_LIMIT` chars, the single-line path skips Myers entirely and
263/// uses `coarse_replace` instead.
264pub fn diff(base: &str, new: &str) -> Delta {
265    let multiline = base.contains('\n') || new.contains('\n');
266    if !multiline
267        && (base.chars().count() > CHAR_DIFF_LIMIT || new.chars().count() > CHAR_DIFF_LIMIT)
268    {
269        return coarse_replace(base, new);
270    }
271    let text_diff = if multiline {
272        TextDiff::from_lines(base, new)
273    } else {
274        TextDiff::from_chars(base, new)
275    };
276    let mut ops = Vec::new();
277    for change in text_diff.iter_all_changes() {
278        match change.tag() {
279            ChangeTag::Equal => push_retain(&mut ops, change.value().chars().count()),
280            ChangeTag::Delete => push_delete(&mut ops, change.value().chars().count()),
281            ChangeTag::Insert => push_insert(&mut ops, change.value()),
282        }
283    }
284    Delta { ops }
285}
286
287/// Linear-time fallback for [`diff`] above [`CHAR_DIFF_LIMIT`]: trims the
288/// longest common prefix and suffix (plain char comparison, no Myers) and
289/// replaces only the middle. Not a minimal edit script, but still useful for
290/// anchor rebasing: an anchor sitting in the untouched prefix or suffix maps
291/// through a real `Retain` exactly as it would from a full diff; only an
292/// anchor inside the replaced middle depends on the move detector.
293fn coarse_replace(base: &str, new: &str) -> Delta {
294    let base_chars: Vec<char> = base.chars().collect();
295    let new_chars: Vec<char> = new.chars().collect();
296    let max_common = base_chars.len().min(new_chars.len());
297
298    let mut prefix = 0;
299    while prefix < max_common && base_chars[prefix] == new_chars[prefix] {
300        prefix += 1;
301    }
302    let mut suffix = 0;
303    while suffix < max_common - prefix
304        && base_chars[base_chars.len() - 1 - suffix] == new_chars[new_chars.len() - 1 - suffix]
305    {
306        suffix += 1;
307    }
308
309    let mut ops = Vec::new();
310    push_retain(&mut ops, prefix);
311    push_delete(&mut ops, base_chars.len() - prefix - suffix);
312    let inserted: String = new_chars[prefix..new_chars.len() - suffix].iter().collect();
313    push_insert(&mut ops, &inserted);
314    push_retain(&mut ops, suffix);
315    Delta { ops }
316}
317
318fn push_retain(ops: &mut Vec<Op>, n: usize) {
319    if n == 0 {
320        return;
321    }
322    if let Some(Op::Retain(last)) = ops.last_mut() {
323        *last += n;
324    } else {
325        ops.push(Op::Retain(n));
326    }
327}
328
329fn push_delete(ops: &mut Vec<Op>, n: usize) {
330    if n == 0 {
331        return;
332    }
333    if let Some(Op::Delete(last)) = ops.last_mut() {
334        *last += n;
335    } else {
336        ops.push(Op::Delete(n));
337    }
338}
339
340fn push_insert(ops: &mut Vec<Op>, s: &str) {
341    if s.is_empty() {
342        return;
343    }
344    if let Some(Op::Insert(last)) = ops.last_mut() {
345        last.push_str(s);
346    } else {
347        ops.push(Op::Insert(s.to_owned()));
348    }
349}
350
351/// The stale-text writer path: cold-parse `new_markdown`, char-diff it against
352/// `base`, and carry `base`'s identity marks (anchors) forward, rebased through
353/// the diff (re-homing verbatim block moves). The returned content is `new_rt`
354/// (structure/marks/islands from the fresh import) plus the surviving anchors.
355///
356/// Returns the new content and the [`Delta`] used: the text change an editor
357/// bridge can map its own positions through.
358pub fn diff_import(
359    base: &Content,
360    new_markdown: &str,
361) -> Result<(Content, Delta), crate::import::ImportError> {
362    let mut new_rt = crate::import::from_markdown(new_markdown)?;
363    let delta = diff(&base.text, &new_rt.text);
364
365    let base_chars: Vec<char> = base.text.chars().collect();
366    let new_chars: Vec<char> = new_rt.text.chars().collect();
367    let inserted = delta.inserted_spans();
368    for m in &base.marks {
369        // Only identity marks live in the content but not in markdown; formatting
370        // marks are re-derived by the fresh import, so we do not carry them.
371        let MarkKind::Anchor { .. } = &m.kind else {
372            continue;
373        };
374        if let Some((ns, ne)) = rebase_anchor(&delta, &base_chars, &new_chars, &inserted, m) {
375            new_rt.marks.push(Mark {
376                start: ns,
377                end: ne,
378                kind: m.kind.clone(),
379            });
380        }
381        // else: detached: the accepted residual drop.
382    }
383    new_rt.normalize();
384    Ok((new_rt, delta))
385}
386
387/// Rebase one anchor through the delta. Returns its new range, or `None` if it
388/// detaches (its text was deleted and no verbatim move re-homes it).
389fn rebase_anchor(
390    delta: &Delta,
391    base_chars: &[char],
392    new_chars: &[char],
393    inserted: &[(usize, usize)],
394    m: &Mark,
395) -> Option<(usize, usize)> {
396    if m.start == m.end {
397        // Zero-width point anchor.
398        if !delta.is_deleted(m.start) {
399            let p = delta.map_pos(m.start, Assoc::Before);
400            return Some((p, p));
401        }
402        // Its surrounding text was deleted: relocate only if that text was
403        // re-inserted verbatim elsewhere (a move).
404        return relocate_point(base_chars, new_chars, inserted, m.start);
405    }
406
407    let ns = delta.map_pos(m.start, Assoc::After);
408    let ne = delta.map_pos(m.end, Assoc::Before);
409    if ns < ne {
410        return Some((ns, ne)); // survived a surrounding edit
411    }
412    // Collapsed, try a verbatim block move: the annotated span must reappear
413    // inside inserted text (not merely somewhere in the surviving content).
414    relocate_span(base_chars, new_chars, inserted, m.start, m.end)
415}
416
417/// Find the annotated span `base[start..end]` inside an inserted region of the
418/// new text. Requires a length floor and containment in inserted text, so an
419/// unrelated surviving occurrence of the same words cannot capture the anchor.
420fn relocate_span(
421    base_chars: &[char],
422    new_chars: &[char],
423    inserted: &[(usize, usize)],
424    start: usize,
425    end: usize,
426) -> Option<(usize, usize)> {
427    if end > base_chars.len() {
428        return None;
429    }
430    let needle = &base_chars[start..end];
431    find_in_spans(new_chars, needle, inserted).map(|pos| (pos, pos + needle.len()))
432}
433
434/// Relocate a point anchor by its left context (text immediately before it),
435/// but only if that context reappears inside inserted text: the same
436/// move-only, length-floored discipline as [`relocate_span`].
437fn relocate_point(
438    base_chars: &[char],
439    new_chars: &[char],
440    inserted: &[(usize, usize)],
441    pos: usize,
442) -> Option<(usize, usize)> {
443    const K: usize = 24;
444    let l0 = pos.saturating_sub(K);
445    let left = &base_chars[l0..pos];
446    if let Some(p) = find_in_spans(new_chars, left, inserted) {
447        return Some((p + left.len(), p + left.len()));
448    }
449    let r1 = (pos + K).min(base_chars.len());
450    let right = &base_chars[pos..r1];
451    if let Some(p) = find_in_spans(new_chars, right, inserted) {
452        return Some((p, p));
453    }
454    None
455}
456
457/// First index where `needle` occurs in `hay` while *overlapping* an inserted
458/// span; i.e. the match touches text the rewrite actually inserted, not purely
459/// surviving text. Overlap (not full containment) is required because a diff
460/// can split a moved block across an inserted region and the retained
461/// suffix; demanding containment would miss real moves, while demanding overlap
462/// still rejects an unrelated occurrence sitting entirely in retained text.
463/// Enforces [`MIN_MOVE`]. O(hay × needle) naive scan: fine at memo/document
464/// scale; a large-document target would want a substring-search algorithm
465/// (e.g. KMP) here.
466fn find_in_spans(hay: &[char], needle: &[char], spans: &[(usize, usize)]) -> Option<usize> {
467    if needle.len() < MIN_MOVE || needle.len() > hay.len() {
468        return None;
469    }
470    (0..=hay.len() - needle.len()).find(|&i| {
471        &hay[i..i + needle.len()] == needle
472            && spans.iter().any(|&(s, e)| i < e && i + needle.len() > s)
473    })
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::import::from_markdown;
480    use crate::model::MarkKind;
481
482    #[test]
483    fn diff_apply_round_trips() {
484        let d = diff("the quick brown fox", "the slow brown fox");
485        assert_eq!(d.apply("the quick brown fox"), "the slow brown fox");
486    }
487
488    #[test]
489    fn map_pos_insertion() {
490        // Insert "XY" at position 3 of "abcdef".
491        let d = diff("abcdef", "abcXYdef");
492        assert_eq!(d.apply("abcdef"), "abcXYdef");
493        // A point before the insert is unmoved; after it shifts by 2.
494        assert_eq!(d.map_pos(2, Assoc::After), 2);
495        assert_eq!(d.map_pos(4, Assoc::Before), 6);
496    }
497
498    #[test]
499    fn try_apply_accepts_short_delta_with_implicit_trailing_retain() {
500        // A bare prepend consumes no base; the untouched remainder is retained
501        // implicitly rather than tripping the base-length check.
502        let short = Delta {
503            ops: vec![Op::Insert("NEW ".into())],
504        };
505        assert_eq!(short.expected_base_len(), 0);
506        assert_eq!(short.try_apply("hello").unwrap(), "NEW hello");
507
508        // An edit near the start, naming only its region, applies against the
509        // whole base: same result whether or not a trailing retain is written.
510        let partial = Delta {
511            ops: vec![Op::Retain(1), Op::Insert("X".into())],
512        };
513        assert_eq!(partial.try_apply("hello").unwrap(), "hXello");
514    }
515
516    #[test]
517    fn try_apply_rejects_over_long_delta() {
518        // Consuming more base than exists is a wrong-revision delta, not an
519        // abbreviated one: it errors, it does not clamp.
520        let over = Delta {
521            ops: vec![Op::Retain(9)],
522        };
523        assert_eq!(
524            over.try_apply("hello"),
525            Err(BaseLengthMismatch {
526                expected: 9,
527                actual: 5,
528            })
529        );
530
531        // Over-consumption via Delete fails the same way.
532        let over_del = Delta {
533            ops: vec![Op::Delete(9)],
534        };
535        assert!(over_del.try_apply("hello").is_err());
536    }
537
538    #[test]
539    #[should_panic]
540    fn apply_panics_on_over_long_delta() {
541        // The trusted-provenance path panics rather than clamping an over-long
542        // delta to silent garbage.
543        let over = Delta {
544            ops: vec![Op::Retain(9)],
545        };
546        let _ = over.apply("hello");
547    }
548
549    #[test]
550    fn anchor_rehomed_on_block_move() {
551        // Two paragraphs; anchor on the first; the rewrite swaps their order.
552        let mut base = from_markdown("first para here\n\nsecond para here").unwrap();
553        // "first para here" is chars 0..15
554        base.marks.push(Mark {
555            start: 0,
556            end: 15,
557            kind: MarkKind::Anchor { id: "c1".into() },
558        });
559        base.normalize();
560        let (new_rt, _) = diff_import(&base, "second para here\n\nfirst para here").unwrap();
561        let anchor = new_rt
562            .marks
563            .iter()
564            .find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
565            .expect("anchor re-homed onto moved block");
566        assert_eq!(
567            new_rt.text[byte(&new_rt.text, anchor.start)..byte(&new_rt.text, anchor.end)]
568                .to_string(),
569            "first para here"
570        );
571    }
572
573    #[test]
574    fn anchor_dropped_when_text_deleted() {
575        let mut base = from_markdown("keep this and drop that").unwrap();
576        // Anchor on "drop that" (14..23).
577        base.marks.push(Mark {
578            start: 14,
579            end: 23,
580            kind: MarkKind::Anchor { id: "c1".into() },
581        });
582        base.normalize();
583        let (new_rt, _) = diff_import(&base, "keep this").unwrap();
584        assert!(
585            !new_rt
586                .marks
587                .iter()
588                .any(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1")),
589            "anchor on deleted text detaches (accepted residual)"
590        );
591    }
592
593    #[test]
594    fn anchor_not_rehomed_onto_unrelated_survivor() {
595        // Regression (review finding 5): an anchor on deleted text must NOT
596        // capture an unrelated *surviving* occurrence of the same words.
597        let mut base = from_markdown("target one to drop\n\nkeep the target two").unwrap();
598        base.marks.push(Mark {
599            start: 0,
600            end: 6, // "target" in the first (deleted) paragraph
601            kind: MarkKind::Anchor { id: "c1".into() },
602        });
603        base.normalize();
604        // First paragraph deleted; the second (with its own "target") survives
605        // as retained text: the anchor must drop, not jump to it.
606        let (new_rt, _) = diff_import(&base, "keep the target two").unwrap();
607        assert!(
608            !new_rt
609                .marks
610                .iter()
611                .any(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1")),
612            "anchor wrongly re-homed onto surviving unrelated text"
613        );
614    }
615
616    #[test]
617    fn map_pos_after_moves_past_boundary_insertion() {
618        // Regression (finding 7): a point at a retain|insert boundary with
619        // Assoc::After lands after the inserted text.
620        let d = diff("abcdef", "abcXYdef");
621        assert_eq!(d.map_pos(3, Assoc::After), 5);
622        assert_eq!(d.map_pos(3, Assoc::Before), 3);
623    }
624
625    #[test]
626    fn point_anchor_at_deletion_left_edge_survives() {
627        // Regression (finding 13): the deletion's left edge is not "deleted".
628        let d = diff("abcdef", "abef"); // delete "cd" (span [2,4))
629        assert!(!d.is_deleted(2), "left edge of deletion survives");
630        assert!(d.is_deleted(3), "interior of deletion is deleted");
631    }
632
633    #[test]
634    fn disjoint_edits_are_separate_ops() {
635        // Myers/LCS (char): prefix and suffix edits must not collapse the middle.
636        let d = diff("aaaMIDDLEbbb", "AAAMIDDLEZZZ");
637        assert_eq!(d.apply("aaaMIDDLEbbb"), "AAAMIDDLEZZZ");
638        let retained: usize = d
639            .ops
640            .iter()
641            .filter_map(|op| match op {
642                Op::Retain(n) => Some(*n),
643                _ => None,
644            })
645            .sum();
646        assert!(
647            retained >= 6,
648            "unchanged middle span retained ({retained} USV): {ops:?}",
649            ops = d.ops
650        );
651        assert!(
652            !matches!(d.ops.as_slice(), [Op::Delete(_), Op::Insert(_)]),
653            "coarse single replace: {ops:?}",
654            ops = d.ops
655        );
656    }
657
658    #[test]
659    fn anchor_survives_between_disjoint_edits() {
660        let mut base = from_markdown("aaaMIDDLEbbb").unwrap();
661        base.marks.push(Mark {
662            start: 3,
663            end: 9,
664            kind: MarkKind::Anchor { id: "c1".into() },
665        });
666        base.normalize();
667        let (new_rt, _) = diff_import(&base, "AAAMIDDLEZZZ").unwrap();
668        let anchor = new_rt
669            .marks
670            .iter()
671            .find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
672            .expect("anchor between disjoint edits survives without move detector");
673        assert_eq!(
674            new_rt.text[byte(&new_rt.text, anchor.start)..byte(&new_rt.text, anchor.end)]
675                .to_string(),
676            "MIDDLE"
677        );
678    }
679
680    fn byte(s: &str, char_idx: usize) -> usize {
681        crate::usv::char_to_byte(s, char_idx)
682    }
683
684    /// Deterministic filler with no long common substring between the two
685    /// variants: worst case for a char-level Myers diff.
686    fn filler(n: usize, offset: u8) -> String {
687        (0..n)
688            .map(|i| char::from(b'a' + ((i as u8).wrapping_mul(7).wrapping_add(offset)) % 26))
689            .collect()
690    }
691
692    #[test]
693    fn large_single_line_diff_stays_fast() {
694        // Two long, unrelated single-line strings: exactly the shape
695        // `similar::TextDiff::from_chars` chokes on with no cutoff
696        // (30,000 unrelated chars measured 86s in a debug build). Above
697        // CHAR_DIFF_LIMIT, `diff` must skip Myers and stay far under budget
698        // regardless of input size.
699        let base = format!("PREFIX-{}-BASE-SUFFIX", filler(25_000, 0));
700        let new = format!("PREFIX-{}-NEW-SUFFIX", filler(25_000, 13));
701
702        let start = std::time::Instant::now();
703        let d = diff(&base, &new);
704        let elapsed = start.elapsed();
705        assert!(
706            elapsed < std::time::Duration::from_secs(2),
707            "large single-line diff took {elapsed:?}, expected well under the 2s budget"
708        );
709
710        // Sensible, not just fast: still round-trips exactly.
711        assert_eq!(d.apply(&base), new);
712    }
713
714    #[test]
715    fn large_single_line_diff_retains_common_prefix_and_suffix() {
716        // The coarse fallback must still be *usable* for anchor rebasing,
717        // not merely fast: a shared prefix/suffix around a large rewritten
718        // middle should come back as real Retain ops, not a single
719        // whole-field Delete+Insert that would force every anchor through
720        // the move detector.
721        let base = format!("shared-prefix-{}-shared-suffix", filler(20_000, 0));
722        let new = format!("shared-prefix-{}-shared-suffix", filler(20_000, 5));
723        let d = diff(&base, &new);
724        assert_eq!(d.apply(&base), new);
725
726        let Some(Op::Retain(prefix_len)) = d.ops.first() else {
727            panic!("expected a leading Retain for the shared prefix: {:?}", d.ops);
728        };
729        assert!(
730            *prefix_len >= "shared-prefix-".len(),
731            "shared prefix should be retained, got Retain({prefix_len})"
732        );
733        let Some(Op::Retain(suffix_len)) = d.ops.last() else {
734            panic!("expected a trailing Retain for the shared suffix: {:?}", d.ops);
735        };
736        assert!(
737            *suffix_len >= "shared-suffix".len(),
738            "shared suffix should be retained, got Retain({suffix_len})"
739        );
740    }
741
742    #[test]
743    fn diff_import_large_single_line_rewrite_keeps_prefix_anchor() {
744        // End-to-end through the real DoS-exposed path: `diff_import` is
745        // what a full-document LLM rewrite hits. A large, single-line,
746        // unrelated-middle rewrite must complete quickly *and* still rebase
747        // an anchor sitting in unchanged (shared) text.
748        let base_text = format!("hello target world-{}-end", filler(30_000, 0));
749        let mut base = from_markdown(&base_text).unwrap();
750        base.marks.push(Mark {
751            start: 6,
752            end: 12, // "target"
753            kind: MarkKind::Anchor { id: "c1".into() },
754        });
755        base.normalize();
756
757        let new_markdown = format!("hello target world-{}-end", filler(30_000, 11));
758        let start = std::time::Instant::now();
759        let (new_rt, _delta) = diff_import(&base, &new_markdown).unwrap();
760        let elapsed = start.elapsed();
761        assert!(
762            elapsed < std::time::Duration::from_secs(2),
763            "diff_import took {elapsed:?}, expected well under the 2s budget"
764        );
765
766        let anchor = new_rt
767            .marks
768            .iter()
769            .find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
770            .expect("anchor in shared prefix survives the coarse fallback diff");
771        assert_eq!(
772            new_rt.text[byte(&new_rt.text, anchor.start)..byte(&new_rt.text, anchor.end)]
773                .to_string(),
774            "target"
775        );
776    }
777}