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