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