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}