Skip to main content

termesh_editor/
change.rs

1//! [`ChangeSet`] — the position-composable change representation (ADR-0006 §1).
2//!
3//! A changeset is a *complete traversal* of a document: every character of the input is
4//! either retained or deleted, and new text is inserted between. That totality is what
5//! makes the two operations we actually care about possible — [`ChangeSet::compose`]
6//! (fold two changes into one) and [`ChangeSet::map_pos`] (where does this position end
7//! up?). The Phase-00 stub stored absolute `from`/`to` offsets, which cannot express
8//! either, which is why its `rebase_onto` was a hardcoded `Err`.
9//!
10//! All lengths are **char** counts, matching `ropey`'s native indexing — never bytes,
11//! never graphemes (ADR-0006 §1).
12
13use ropey::Rope;
14
15/// Which side of an insertion a mapped position lands on (ADR-0006 §2).
16///
17/// Only matters when text is inserted at *exactly* the position being mapped. Pending
18/// proposal anchors always map with [`Assoc::After`], so agent text lands after text the
19/// human already typed there.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Assoc {
22    Before,
23    After,
24}
25
26/// What an applied change did to a range something else was anchored to.
27///
28/// The input to ADR-0006 §4's overlap policy: `Untouched` rebases, the other two are the
29/// conflicting cases. Returned by [`ChangeSet::touches`].
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum RangeEffect {
32    /// The range survived; mapping its endpoints is enough.
33    Untouched,
34    /// New text landed strictly inside the range (ADR-0006 §4 case 2). Applying a hunk
35    /// over this would delete text the human typed without ever showing it to them.
36    InsertedInside,
37    /// Some of the range was deleted (ADR-0006 §4 case 4), so where the hunk belongs is
38    /// no longer determined by the text it was written against.
39    PartlyDeleted,
40}
41
42/// One step of a document traversal.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum Operation {
45    /// Advance `n` chars unchanged.
46    Retain(usize),
47    /// Drop the next `n` chars.
48    Delete(usize),
49    /// Insert text at the current position.
50    Insert(String),
51}
52
53/// An ordered, position-composable set of changes.
54///
55/// Construct with [`ChangeSet::builder`]; the ops are private because the canonical form
56/// (merged runs, `Delete` before `Insert` at a replacement site) is an invariant that
57/// [`compose`](Self::compose) and [`map_pos`](Self::map_pos) rely on.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ChangeSet {
60    ops: Vec<Operation>,
61    len_before: usize,
62    len_after: usize,
63}
64
65/// The minimal span this change set replaces, in pre-image and post-image char
66/// offsets. `None` for an identity change set.
67///
68/// One span rather than one event per operation: it is what a single incremental
69/// `didChange` needs, it is cheap on the typing hot path, and it is testable with no
70/// server in the loop (ADR-0011 §6).
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct ChangedSpan {
73    pub before_start: usize,
74    pub before_end: usize,
75    pub after_start: usize,
76    pub after_end: usize,
77}
78
79impl ChangeSet {
80    pub fn builder(len_before: usize) -> ChangeSetBuilder {
81        ChangeSetBuilder { ops: Vec::new(), len_before, consumed: 0 }
82    }
83
84    /// The no-op change over a document of `len` chars.
85    pub fn identity(len: usize) -> Self {
86        ChangeSet::builder(len).build()
87    }
88
89    /// A single replacement: replace chars `from..to` with `text`. The common shape for a
90    /// keystroke, a paste, or one hunk of an agent diff.
91    pub fn replace(len_before: usize, from: usize, to: usize, text: impl Into<String>) -> Self {
92        let mut b = ChangeSet::builder(len_before);
93        b.retain(from).delete(to - from).insert(text);
94        b.build()
95    }
96
97    pub fn len_before(&self) -> usize {
98        self.len_before
99    }
100
101    pub fn len_after(&self) -> usize {
102        self.len_after
103    }
104
105    pub fn ops(&self) -> &[Operation] {
106        &self.ops
107    }
108
109    /// Whether this changes nothing (every char retained, nothing inserted).
110    pub fn is_identity(&self) -> bool {
111        self.ops.iter().all(|op| matches!(op, Operation::Retain(_)))
112    }
113
114    pub fn changed_span(&self) -> Option<ChangedSpan> {
115        let mut before = 0;
116        let mut after = 0;
117        let mut start = None;
118        let mut end = (0, 0);
119
120        for op in &self.ops {
121            match op {
122                Operation::Retain(n) => {
123                    before += n;
124                    after += n;
125                }
126                Operation::Delete(n) => {
127                    start.get_or_insert((before, after));
128                    before += n;
129                    end = (before, after);
130                }
131                Operation::Insert(text) => {
132                    start.get_or_insert((before, after));
133                    after += text.chars().count();
134                    end = (before, after);
135                }
136            }
137        }
138
139        start.map(|(before_start, after_start)| ChangedSpan {
140            before_start,
141            before_end: end.0,
142            after_start,
143            after_end: end.1,
144        })
145    }
146
147    /// Produce the changed document. Does not mutate the input — the caller decides when
148    /// a new revision becomes current.
149    pub fn apply(&self, text: &Rope) -> Rope {
150        debug_assert_eq!(
151            text.len_chars(),
152            self.len_before,
153            "changeset applied to a document it was not authored against"
154        );
155
156        let mut out = Rope::new();
157        let mut pos = 0;
158        for op in &self.ops {
159            match op {
160                Operation::Retain(n) => {
161                    out.append(Rope::from(text.slice(pos..pos + n)));
162                    pos += n;
163                }
164                Operation::Delete(n) => pos += n,
165                Operation::Insert(s) => {
166                    let end = out.len_chars();
167                    out.insert(end, s);
168                }
169            }
170        }
171        out
172    }
173
174    /// The changeset that undoes this one.
175    ///
176    /// Needs the pre-image because [`Operation::Delete`] does not record what it deleted.
177    /// Per ADR-0006 §6 this is called at *apply* time, while `original` is still the live
178    /// document — calling it at undo time would hand it the post-image.
179    pub fn invert(&self, original: &Rope) -> ChangeSet {
180        debug_assert_eq!(original.len_chars(), self.len_before, "invert needs the pre-image");
181
182        let mut b = ChangeSet::builder(self.len_after);
183        let mut pos = 0;
184        for op in &self.ops {
185            match op {
186                Operation::Retain(n) => {
187                    b.retain(*n);
188                    pos += n;
189                }
190                // What we deleted has to come back, so we need its text.
191                Operation::Delete(n) => {
192                    b.insert(original.slice(pos..pos + n).to_string());
193                    pos += n;
194                }
195                Operation::Insert(s) => {
196                    b.delete(s.chars().count());
197                }
198            }
199        }
200        b.build()
201    }
202
203    /// Where `pos` (an index into the *pre*-image) lands in the post-image.
204    ///
205    /// Positions inside a deleted range collapse to the start of the deletion — the text
206    /// they pointed at is gone, and the start of what replaced it is the only honest
207    /// answer. Callers that need to *detect* that case compare against the deletion
208    /// instead of relying on the mapped value (ADR-0006 §4).
209    pub fn map_pos(&self, pos: usize, assoc: Assoc) -> usize {
210        let mut old = 0;
211        let mut new = 0;
212
213        for op in &self.ops {
214            match op {
215                Operation::Retain(n) => {
216                    if pos < old + n {
217                        return new + (pos - old);
218                    }
219                    old += n;
220                    new += n;
221                }
222                Operation::Delete(n) => {
223                    if pos < old + n {
224                        return new;
225                    }
226                    old += n;
227                }
228                Operation::Insert(s) => {
229                    let len = s.chars().count();
230                    // Insertion exactly at the position we are mapping: the tie-break in
231                    // ADR-0006 §2 decides, and it is the only place `assoc` is consulted.
232                    if pos == old {
233                        return match assoc {
234                            Assoc::Before => new,
235                            Assoc::After => new + len,
236                        };
237                    }
238                    new += len;
239                }
240            }
241        }
242        // `pos` at or past the end of the pre-image.
243        new + pos.saturating_sub(old)
244    }
245
246    /// What this change did to `from..to` — a range something *else* is anchored to.
247    ///
248    /// [`map_pos`](Self::map_pos) cannot answer this, by construction. It collapses a
249    /// position inside a deleted range onto the deletion point, which is the same value
250    /// it returns for a position at the deletion's start that was never destroyed; and it
251    /// sees nothing at all when text is inserted *inside* a range, since both endpoints
252    /// shift cleanly. Both are exactly the signals ADR-0006 §4 cases 2 and 4 turn on, so
253    /// they get their own traversal rather than being re-derived from the outside.
254    ///
255    /// A zero-width range is an anchor: it counts as deleted only if the deletion
256    /// strictly contains it, since a deletion merely *starting* there leaves it standing.
257    pub fn touches(&self, from: usize, to: usize) -> RangeEffect {
258        debug_assert!(from <= to, "range bounds reversed");
259
260        let mut old = 0;
261        let mut effect = RangeEffect::Untouched;
262
263        for op in &self.ops {
264            match op {
265                Operation::Retain(n) => old += n,
266                Operation::Delete(n) => {
267                    let (start, end) = (old, old + n);
268                    let overlaps = if from == to {
269                        start < from && from < end
270                    } else {
271                        // Half-open intersection: touching at a boundary is adjacency,
272                        // and ADR-0006 §4 case 6 says adjacency is not overlap.
273                        start < to && from < end
274                    };
275                    if overlaps {
276                        // Strictly worse than an insertion, so it wins immediately.
277                        return RangeEffect::PartlyDeleted;
278                    }
279                    old = end;
280                }
281                Operation::Insert(_) => {
282                    if from < old && old < to {
283                        effect = RangeEffect::InsertedInside;
284                    }
285                }
286            }
287        }
288        effect
289    }
290
291    /// The single changeset equivalent to applying `self` and then `other`.
292    ///
293    /// This is what lets a burst of keystrokes collapse into one undo step, and what a
294    /// proposal is mapped through when the human has typed several times since it arrived.
295    pub fn compose(&self, other: &ChangeSet) -> ChangeSet {
296        assert_eq!(
297            self.len_after, other.len_before,
298            "cannot compose: the second changeset was authored against a different document"
299        );
300
301        let mut b = ChangeSet::builder(self.len_before);
302        let mut a_iter = self.ops.iter().cloned();
303        let mut b_iter = other.ops.iter().cloned();
304        let mut a = a_iter.next();
305        let mut c = b_iter.next();
306
307        loop {
308            match (a.take(), c.take()) {
309                (None, None) => break,
310
311                // Text `self` removed from the original never reaches `other`, so it is
312                // deleted regardless of what `other` is doing. Must be tested before the
313                // insert arm below, or a delete would be starved by a run of inserts.
314                (Some(Operation::Delete(n)), rest) => {
315                    b.delete(n);
316                    a = a_iter.next();
317                    c = rest;
318                }
319
320                // Text `other` adds is new to both — nothing in `self` corresponds to it.
321                (rest, Some(Operation::Insert(s))) => {
322                    b.insert(s);
323                    a = rest;
324                    c = b_iter.next();
325                }
326
327                (Some(Operation::Retain(i)), Some(Operation::Retain(j))) => {
328                    let n = i.min(j);
329                    b.retain(n);
330                    a = carry(Operation::Retain(i - n), &mut a_iter);
331                    c = carry(Operation::Retain(j - n), &mut b_iter);
332                }
333
334                // `self` kept it, `other` drops it.
335                (Some(Operation::Retain(i)), Some(Operation::Delete(j))) => {
336                    let n = i.min(j);
337                    b.delete(n);
338                    a = carry(Operation::Retain(i - n), &mut a_iter);
339                    c = carry(Operation::Delete(j - n), &mut b_iter);
340                }
341
342                // `self` inserted it and `other` keeps it: it survives into the result.
343                (Some(Operation::Insert(s)), Some(Operation::Retain(j))) => {
344                    let len = s.chars().count();
345                    let n = len.min(j);
346                    b.insert(take_chars(&s, n));
347                    a = carry_insert(&s, n, &mut a_iter);
348                    c = carry(Operation::Retain(j - n), &mut b_iter);
349                }
350
351                // `self` inserted it and `other` deletes it: it never existed as far as
352                // the composed change is concerned, so nothing is emitted at all.
353                (Some(Operation::Insert(s)), Some(Operation::Delete(j))) => {
354                    let len = s.chars().count();
355                    let n = len.min(j);
356                    a = carry_insert(&s, n, &mut a_iter);
357                    c = carry(Operation::Delete(j - n), &mut b_iter);
358                }
359
360                // Both traversals cover the same document, so one running dry while the
361                // other still has retains/deletes means the lengths lied.
362                (None, Some(op)) | (Some(op), None) => {
363                    unreachable!("changeset length mismatch, stranded {op:?}")
364                }
365            }
366        }
367
368        b.build()
369    }
370}
371
372/// Keep `op` as the next item if it still has length, otherwise pull from `iter`.
373fn carry(op: Operation, iter: &mut impl Iterator<Item = Operation>) -> Option<Operation> {
374    match &op {
375        Operation::Retain(0) | Operation::Delete(0) => iter.next(),
376        _ => Some(op),
377    }
378}
379
380/// The remainder of an insertion after `consumed` chars have been accounted for.
381fn carry_insert(
382    s: &str,
383    consumed: usize,
384    iter: &mut impl Iterator<Item = Operation>,
385) -> Option<Operation> {
386    let rest: String = s.chars().skip(consumed).collect();
387    if rest.is_empty() {
388        iter.next()
389    } else {
390        Some(Operation::Insert(rest))
391    }
392}
393
394fn take_chars(s: &str, n: usize) -> String {
395    s.chars().take(n).collect()
396}
397
398/// Builds a [`ChangeSet`] in canonical form.
399///
400/// Canonical means: no zero-length or adjacent same-kind operations, and `Delete` always
401/// precedes `Insert` at a replacement site. Both orderings describe the same edit, but
402/// fixing one keeps [`ChangeSet::compose`] and [`ChangeSet::map_pos`] free of
403/// order-dependent special cases.
404#[derive(Debug)]
405pub struct ChangeSetBuilder {
406    ops: Vec<Operation>,
407    len_before: usize,
408    consumed: usize,
409}
410
411impl ChangeSetBuilder {
412    pub fn retain(&mut self, n: usize) -> &mut Self {
413        if n == 0 {
414            return self;
415        }
416        self.consumed += n;
417        if let Some(Operation::Retain(prev)) = self.ops.last_mut() {
418            *prev += n;
419        } else {
420            self.ops.push(Operation::Retain(n));
421        }
422        self
423    }
424
425    pub fn delete(&mut self, n: usize) -> &mut Self {
426        if n == 0 {
427            return self;
428        }
429        self.consumed += n;
430
431        // Slide in front of a trailing insert to keep the canonical Delete-then-Insert
432        // ordering at a replacement site.
433        let at = match self.ops.last() {
434            Some(Operation::Insert(_)) => self.ops.len() - 1,
435            _ => self.ops.len(),
436        };
437        if at > 0 {
438            if let Some(Operation::Delete(prev)) = self.ops.get_mut(at - 1) {
439                *prev += n;
440                return self;
441            }
442        }
443        self.ops.insert(at, Operation::Delete(n));
444        self
445    }
446
447    pub fn insert(&mut self, text: impl Into<String>) -> &mut Self {
448        let text = text.into();
449        if text.is_empty() {
450            return self;
451        }
452        if let Some(Operation::Insert(prev)) = self.ops.last_mut() {
453            prev.push_str(&text);
454        } else {
455            self.ops.push(Operation::Insert(text));
456        }
457        self
458    }
459
460    /// Finish, implicitly retaining any unconsumed tail of the document.
461    pub fn build(mut self) -> ChangeSet {
462        let tail = self
463            .len_before
464            .checked_sub(self.consumed)
465            .expect("changeset consumed more of the document than it has");
466        self.retain(tail);
467
468        let len_after = self
469            .ops
470            .iter()
471            .map(|op| match op {
472                Operation::Retain(n) => *n,
473                Operation::Delete(_) => 0,
474                Operation::Insert(s) => s.chars().count(),
475            })
476            .sum();
477
478        ChangeSet { ops: self.ops, len_before: self.len_before, len_after }
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    fn rope(s: &str) -> Rope {
487        Rope::from_str(s)
488    }
489
490    /// Apply a changeset and read the result back as a `String`.
491    fn applied(text: &str, cs: &ChangeSet) -> String {
492        cs.apply(&rope(text)).to_string()
493    }
494
495    #[test]
496    fn identity_changes_nothing() {
497        let cs = ChangeSet::identity(5);
498        assert!(cs.is_identity());
499        assert_eq!(applied("hello", &cs), "hello");
500        assert_eq!(cs.len_after(), 5);
501    }
502
503    #[test]
504    fn replace_swaps_a_range() {
505        let cs = ChangeSet::replace(11, 6, 11, "there");
506        assert_eq!(applied("hello world", &cs), "hello there");
507    }
508
509    #[test]
510    fn insertion_and_deletion_report_their_output_length() {
511        let mut b = ChangeSet::builder(5);
512        b.retain(2).insert("XY").delete(3);
513        let cs = b.build();
514        assert_eq!(cs.len_before(), 5);
515        assert_eq!(cs.len_after(), 4); // 2 retained + 2 inserted
516        assert_eq!(applied("hello", &cs), "heXY");
517    }
518
519    #[test]
520    fn the_builder_merges_runs_and_orders_delete_before_insert() {
521        let mut b = ChangeSet::builder(10);
522        b.retain(1).retain(1).insert("a").insert("b").delete(2).delete(1);
523        let cs = b.build();
524
525        // Adjacent same-kind ops merge; the delete slides in front of the insert.
526        assert_eq!(
527            cs.ops(),
528            [
529                Operation::Retain(2),
530                Operation::Delete(3),
531                Operation::Insert("ab".into()),
532                Operation::Retain(5),
533            ]
534        );
535    }
536
537    #[test]
538    fn zero_length_operations_are_dropped() {
539        let mut b = ChangeSet::builder(3);
540        b.retain(0).delete(0).insert("");
541        assert!(b.build().is_identity());
542    }
543
544    #[test]
545    fn an_insert_reports_a_zero_width_span_at_the_insert_point() {
546        let mut builder = ChangeSet::builder(5);
547        builder.retain(2).insert("xy").retain(3);
548        let changes = builder.build();
549        let span = changes.changed_span().expect("not identity");
550        assert_eq!((span.before_start, span.before_end), (2, 2));
551        assert_eq!((span.after_start, span.after_end), (2, 4));
552    }
553
554    #[test]
555    fn a_delete_reports_the_removed_span_and_an_empty_replacement() {
556        let mut builder = ChangeSet::builder(5);
557        builder.retain(1).delete(2).retain(2);
558        let changes = builder.build();
559        let span = changes.changed_span().expect("not identity");
560        assert_eq!((span.before_start, span.before_end), (1, 3));
561        assert_eq!((span.after_start, span.after_end), (1, 1));
562    }
563
564    #[test]
565    fn a_replace_reports_both_spans() {
566        let changes = ChangeSet::replace(5, 1, 3, "abc");
567        let span = changes.changed_span().expect("not identity");
568        assert_eq!((span.before_start, span.before_end), (1, 3));
569        assert_eq!((span.after_start, span.after_end), (1, 4));
570    }
571
572    #[test]
573    fn several_edits_collapse_into_one_covering_span() {
574        // Coarse on purpose: one content change per transaction (ADR-0011 §6).
575        let mut builder = ChangeSet::builder(9);
576        builder.retain(1).delete(1).retain(3).insert("zz").retain(4);
577        let changes = builder.build();
578        let span = changes.changed_span().expect("not identity");
579        assert_eq!(span.before_start, 1);
580        assert_eq!(span.before_end, 5);
581    }
582
583    #[test]
584    fn an_identity_changeset_has_no_span() {
585        assert!(ChangeSet::identity(4).changed_span().is_none());
586    }
587
588    // --- multi-byte safety -------------------------------------------------------
589    //
590    // Char indices, not bytes (ADR-0006 §1). These would panic or corrupt if any
591    // arithmetic here were byte-based.
592
593    #[test]
594    fn positions_are_chars_not_bytes() {
595        // "héllo" is 5 chars but 6 bytes.
596        let cs = ChangeSet::replace(5, 1, 2, "e");
597        assert_eq!(applied("héllo", &cs), "hello");
598    }
599
600    #[test]
601    fn multibyte_text_survives_insertion_and_inversion() {
602        let original = rope("naïve");
603        let cs = ChangeSet::replace(5, 0, 0, "très ");
604        let changed = cs.apply(&original);
605        assert_eq!(changed.to_string(), "très naïve");
606        assert_eq!(cs.invert(&original).apply(&changed).to_string(), "naïve");
607    }
608
609    // --- invert ------------------------------------------------------------------
610
611    #[test]
612    fn invert_round_trips_every_edit_shape() {
613        for (text, cs) in [
614            ("hello world", ChangeSet::replace(11, 6, 11, "there")), // replace
615            ("hello", ChangeSet::replace(5, 5, 5, "!")),             // pure insert
616            ("hello", ChangeSet::replace(5, 0, 2, "")),              // pure delete
617            ("hello", ChangeSet::identity(5)),                       // no-op
618        ] {
619            let original = rope(text);
620            let changed = cs.apply(&original);
621            let undone = cs.invert(&original).apply(&changed);
622            assert_eq!(undone.to_string(), text, "round-trip failed for {cs:?}");
623        }
624    }
625
626    #[test]
627    fn invert_of_invert_is_the_original_change() {
628        let original = rope("hello world");
629        let cs = ChangeSet::replace(11, 0, 5, "goodbye");
630        let changed = cs.apply(&original);
631
632        let back = cs.invert(&original);
633        let forward = back.invert(&changed);
634        assert_eq!(forward.apply(&original).to_string(), changed.to_string());
635    }
636
637    // --- compose -----------------------------------------------------------------
638
639    #[test]
640    fn compose_matches_applying_both_in_order() {
641        let original = rope("hello world");
642        let first = ChangeSet::replace(11, 0, 5, "goodbye"); // "goodbye world"
643        let mid = first.apply(&original);
644        let second = ChangeSet::replace(mid.len_chars(), 8, 13, "everyone"); // "goodbye everyone"
645
646        let composed = first.compose(&second);
647        assert_eq!(composed.apply(&original).to_string(), "goodbye everyone");
648        assert_eq!(composed.len_before(), 11);
649        assert_eq!(composed.len_after(), "goodbye everyone".chars().count());
650    }
651
652    #[test]
653    fn compose_drops_text_that_was_inserted_then_deleted() {
654        let original = rope("ac");
655        let first = ChangeSet::replace(2, 1, 1, "b"); // "abc"
656        let second = ChangeSet::replace(3, 1, 2, ""); // back to "ac"
657
658        let composed = first.compose(&second);
659        assert_eq!(composed.apply(&original).to_string(), "ac");
660        assert!(composed.is_identity(), "the round trip should compose away entirely");
661    }
662
663    #[test]
664    fn compose_is_associative() {
665        let original = rope("abcdef");
666        let a = ChangeSet::replace(6, 0, 1, "X"); // Xbcdef
667        let b = ChangeSet::replace(6, 2, 3, "Y"); // XbYdef
668        let c = ChangeSet::replace(6, 4, 5, "Z"); // XbYdZf
669
670        let left = a.compose(&b).compose(&c);
671        let right = a.compose(&b.compose(&c));
672        assert_eq!(left.apply(&original).to_string(), right.apply(&original).to_string());
673        assert_eq!(left, right, "composition should be associative on the nose");
674    }
675
676    #[test]
677    fn compose_with_identity_is_a_no_op() {
678        let cs = ChangeSet::replace(5, 1, 3, "XY");
679        assert_eq!(ChangeSet::identity(5).compose(&cs), cs);
680        assert_eq!(cs.compose(&ChangeSet::identity(cs.len_after())), cs);
681    }
682
683    #[test]
684    fn typing_one_character_at_a_time_composes_into_one_change() {
685        // The undo-grouping case: three keystrokes fold into a single changeset.
686        let original = rope("() {}");
687        let mut composed = ChangeSet::identity(original.len_chars());
688        let mut text = original.clone();
689        for (i, ch) in "abc".chars().enumerate() {
690            let cs = ChangeSet::replace(text.len_chars(), 1 + i, 1 + i, ch.to_string());
691            text = cs.apply(&text);
692            composed = composed.compose(&cs);
693        }
694        assert_eq!(text.to_string(), "(abc) {}");
695        assert_eq!(composed.apply(&original).to_string(), "(abc) {}");
696    }
697
698    #[test]
699    #[should_panic(expected = "authored against a different document")]
700    fn composing_mismatched_changesets_is_a_programming_error() {
701        let a = ChangeSet::identity(5);
702        let b = ChangeSet::identity(9);
703        let _ = a.compose(&b);
704    }
705
706    // --- map_pos -----------------------------------------------------------------
707
708    #[test]
709    fn positions_before_a_change_are_untouched() {
710        let cs = ChangeSet::replace(11, 6, 11, "there");
711        assert_eq!(cs.map_pos(3, Assoc::After), 3);
712    }
713
714    #[test]
715    fn positions_after_an_insertion_shift_by_its_length() {
716        let cs = ChangeSet::replace(11, 0, 0, "abc");
717        assert_eq!(cs.map_pos(5, Assoc::After), 8);
718    }
719
720    #[test]
721    fn assoc_decides_only_at_the_insertion_point() {
722        let cs = ChangeSet::replace(10, 4, 4, "XY");
723        assert_eq!(cs.map_pos(4, Assoc::Before), 4, "Before stays put");
724        assert_eq!(cs.map_pos(4, Assoc::After), 6, "After moves past the insert");
725        // Neighbours are unambiguous, so assoc must not matter there.
726        assert_eq!(cs.map_pos(3, Assoc::Before), cs.map_pos(3, Assoc::After));
727        assert_eq!(cs.map_pos(5, Assoc::Before), cs.map_pos(5, Assoc::After));
728    }
729
730    #[test]
731    fn positions_inside_a_deleted_range_collapse_to_its_start() {
732        let cs = ChangeSet::replace(10, 2, 6, "");
733        for pos in 2..6 {
734            assert_eq!(cs.map_pos(pos, Assoc::After), 2, "pos {pos} should collapse");
735        }
736        assert_eq!(cs.map_pos(6, Assoc::After), 2, "the end of the range lands there too");
737        assert_eq!(cs.map_pos(7, Assoc::After), 3, "past it, positions shift back");
738    }
739
740    #[test]
741    fn the_end_of_the_document_maps_to_the_new_end() {
742        let cs = ChangeSet::replace(5, 2, 5, "XY");
743        assert_eq!(cs.map_pos(5, Assoc::After), cs.len_after());
744    }
745
746    // --- touches: the ADR-0006 §4 overlap table ----------------------------------
747    //
748    // One test per row. These are the inputs the rebase policy branches on, and
749    // ARCHITECTURE.md §18 names this as a required test class.
750
751    /// Case 1 — the human edited somewhere else entirely.
752    #[test]
753    fn an_edit_outside_the_range_leaves_it_untouched() {
754        let hunk = (10, 20);
755        for elsewhere in [
756            ChangeSet::replace(40, 0, 5, "x"),   // before
757            ChangeSet::replace(40, 25, 30, "x"), // after
758        ] {
759            assert_eq!(elsewhere.touches(hunk.0, hunk.1), RangeEffect::Untouched);
760        }
761    }
762
763    /// Case 2 — the human typed inside text the hunk wants to replace. The
764    /// non-negotiable one: applying over this would destroy their edit unseen.
765    #[test]
766    fn an_insertion_inside_the_range_is_detected() {
767        let typed_inside = ChangeSet::replace(40, 15, 15, "hello");
768        assert_eq!(typed_inside.touches(10, 20), RangeEffect::InsertedInside);
769    }
770
771    /// Case 3 — an insertion at the boundary is not inside. Assoc handles it, not this.
772    #[test]
773    fn an_insertion_at_either_boundary_is_not_inside() {
774        for at in [10, 20] {
775            let cs = ChangeSet::replace(40, at, at, "x");
776            assert_eq!(
777                cs.touches(10, 20),
778                RangeEffect::Untouched,
779                "insertion at {at} is a boundary case, resolved by Assoc"
780            );
781        }
782    }
783
784    /// Case 4 — the anchor is partly gone.
785    #[test]
786    fn a_deletion_overlapping_the_range_is_detected() {
787        for (from, to) in [
788            (5, 15),  // straddles the start
789            (15, 25), // straddles the end
790            (12, 18), // strictly inside
791            (5, 25),  // swallows it whole
792            (10, 20), // exactly the range
793        ] {
794            let cs = ChangeSet::replace(40, from, to, "");
795            assert_eq!(
796                cs.touches(10, 20),
797                RangeEffect::PartlyDeleted,
798                "deletion {from}..{to} should be seen"
799            );
800        }
801    }
802
803    /// Case 6 — adjacency is not overlap.
804    #[test]
805    fn a_deletion_ending_or_starting_at_the_boundary_does_not_overlap() {
806        for (from, to) in [(5, 10), (20, 25)] {
807            let cs = ChangeSet::replace(40, from, to, "");
808            assert_eq!(
809                cs.touches(10, 20),
810                RangeEffect::Untouched,
811                "deletion {from}..{to} only touches the boundary"
812            );
813        }
814    }
815
816    #[test]
817    fn a_zero_width_anchor_survives_a_deletion_that_merely_starts_there() {
818        let cs = ChangeSet::replace(40, 10, 15, "");
819        assert_eq!(cs.touches(10, 10), RangeEffect::Untouched, "the anchor still stands");
820
821        let containing = ChangeSet::replace(40, 8, 15, "");
822        assert_eq!(containing.touches(10, 10), RangeEffect::PartlyDeleted, "swallowed");
823    }
824
825    #[test]
826    fn deletion_outranks_insertion_when_a_change_does_both() {
827        // A replace inside the range is a delete *and* an insert; the worse signal wins,
828        // because it is the one that decides the hunk cannot be applied.
829        let cs = ChangeSet::replace(40, 12, 18, "replacement");
830        assert_eq!(cs.touches(10, 20), RangeEffect::PartlyDeleted);
831    }
832
833    #[test]
834    fn an_untouched_range_is_exactly_what_map_pos_can_be_trusted_for() {
835        // The pairing this API exists for: `touches` says whether the answer is
836        // meaningful, `map_pos` says what it is.
837        let cs = ChangeSet::replace(40, 0, 5, "xx");
838        assert_eq!(cs.touches(10, 20), RangeEffect::Untouched);
839        assert_eq!((cs.map_pos(10, Assoc::After), cs.map_pos(20, Assoc::After)), (7, 17));
840    }
841
842    /// ADR-0006 §2's worked example, asserted rather than assumed.
843    ///
844    /// A pending agent hunk anchored at 10 must ride forward over three human keystrokes
845    /// at the same offset and end up *after* the typed text — and mapping through each
846    /// keystroke individually must agree with mapping through the composed change. That
847    /// equivalence is what lets ADR-0006 §3 rebase eagerly on every transaction.
848    #[test]
849    fn a_pending_anchor_rides_forward_over_keystrokes_at_the_same_offset() {
850        let original = rope("0123456789tail");
851        let mut text = original.clone();
852        let mut anchor = 10;
853        let mut composed = ChangeSet::identity(original.len_chars());
854
855        for ch in "abc".chars() {
856            let cs = ChangeSet::replace(text.len_chars(), anchor, anchor, ch.to_string());
857            anchor = cs.map_pos(anchor, Assoc::After);
858            composed = composed.compose(&cs);
859            text = cs.apply(&text);
860        }
861
862        assert_eq!(text.to_string(), "0123456789abctail");
863        assert_eq!(anchor, 13, "the hunk sits after the typed text, not inside it");
864        assert_eq!(
865            composed.map_pos(10, Assoc::After),
866            anchor,
867            "per-keystroke and batched rebasing must agree"
868        );
869    }
870}