Skip to main content

crdt_kit/
text.rs

1use core::fmt;
2
3use crate::rga::{Rga, RgaDelta, RgaError};
4use crate::{Crdt, DeltaCrdt, NodeId};
5
6/// Error type for TextCrdt operations.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum TextError {
9    /// Index is out of bounds for the current visible text length.
10    IndexOutOfBounds {
11        /// The index that was requested.
12        index: usize,
13        /// The current length of the visible text.
14        len: usize,
15    },
16    /// Range is out of bounds for the current visible text length.
17    RangeOutOfBounds {
18        /// Start of the range.
19        start: usize,
20        /// End of the range (exclusive).
21        end: usize,
22        /// The current length of the visible text.
23        len: usize,
24    },
25}
26
27impl fmt::Display for TextError {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Self::IndexOutOfBounds { index, len } => {
31                write!(f, "index {index} out of bounds for text of length {len}")
32            }
33            Self::RangeOutOfBounds { start, end, len } => {
34                write!(f, "range {start}..{end} out of bounds for text of length {len}")
35            }
36        }
37    }
38}
39
40#[cfg(feature = "std")]
41impl std::error::Error for TextError {}
42
43/// A collaborative text CRDT based on RGA (Replicated Growable Array).
44///
45/// This is a thin wrapper around [`Rga<char>`] that provides text-specific
46/// convenience methods like `insert_str`, `remove_range`, and `Display`.
47///
48/// # Example
49///
50/// ```
51/// use crdt_kit::prelude::*;
52///
53/// let mut t1 = TextCrdt::new(1);
54/// t1.insert_str(0, "hello").unwrap();
55///
56/// let mut t2 = TextCrdt::new(2);
57/// t2.insert_str(0, "world").unwrap();
58///
59/// t1.merge(&t2);
60/// t2.merge(&t1);
61///
62/// // Both replicas converge to the same text.
63/// assert_eq!(t1.to_string(), t2.to_string());
64/// ```
65#[derive(Debug, Clone, PartialEq, Eq)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
67pub struct TextCrdt(Rga<char>);
68
69/// Delta for [`TextCrdt`] — delegates to [`RgaDelta<char>`].
70pub type TextDelta = RgaDelta<char>;
71
72impl TextCrdt {
73    /// Create a new empty text CRDT for the given node.
74    pub fn new(actor: NodeId) -> Self {
75        Self(Rga::new(actor))
76    }
77
78    /// Create a fork of this replica with a different node ID.
79    pub fn fork(&self, new_actor: NodeId) -> Self {
80        Self(self.0.fork(new_actor))
81    }
82
83    /// Insert a character at the given visible index.
84    pub fn insert(&mut self, index: usize, ch: char) -> Result<(), TextError> {
85        self.0.insert_at(index, ch).map_err(|e| match e {
86            RgaError::IndexOutOfBounds { index, len } => {
87                TextError::IndexOutOfBounds { index, len }
88            }
89        })
90    }
91
92    /// Insert a string at the given visible index.
93    pub fn insert_str(&mut self, index: usize, s: &str) -> Result<(), TextError> {
94        if index > self.0.len() {
95            return Err(TextError::IndexOutOfBounds {
96                index,
97                len: self.0.len(),
98            });
99        }
100        for (i, ch) in s.chars().enumerate() {
101            self.insert(index + i, ch)?;
102        }
103        Ok(())
104    }
105
106    /// Remove (tombstone) the character at the given visible index.
107    pub fn remove(&mut self, index: usize) -> Result<(), TextError> {
108        self.0.remove(index).map(|_| ()).map_err(|e| match e {
109            RgaError::IndexOutOfBounds { index, len } => {
110                TextError::IndexOutOfBounds { index, len }
111            }
112        })
113    }
114
115    /// Remove a range of characters starting at `start` with the given `count`.
116    pub fn remove_range(&mut self, start: usize, count: usize) -> Result<(), TextError> {
117        let len = self.0.len();
118        if start + count > len {
119            return Err(TextError::RangeOutOfBounds {
120                start,
121                end: start + count,
122                len,
123            });
124        }
125        // Remove from back to front so indices stay valid
126        for _ in 0..count {
127            self.remove(start)?;
128        }
129        Ok(())
130    }
131
132    /// Return the number of visible (non-deleted) characters.
133    #[must_use]
134    pub fn len(&self) -> usize {
135        self.0.len()
136    }
137
138    /// Check whether the visible text is empty.
139    #[must_use]
140    pub fn is_empty(&self) -> bool {
141        self.0.is_empty()
142    }
143
144    /// Get this replica's node ID.
145    #[must_use]
146    pub fn actor(&self) -> NodeId {
147        self.0.actor()
148    }
149}
150
151impl Crdt for TextCrdt {
152    fn merge(&mut self, other: &Self) {
153        self.0.merge(&other.0);
154    }
155}
156
157impl DeltaCrdt for TextCrdt {
158    type Delta = TextDelta;
159
160    fn delta(&self, other: &Self) -> TextDelta {
161        self.0.delta(&other.0)
162    }
163
164    fn apply_delta(&mut self, delta: &TextDelta) {
165        self.0.apply_delta(delta);
166    }
167}
168
169impl fmt::Display for TextCrdt {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        for ch in self.0.iter() {
172            write!(f, "{}", ch)?;
173        }
174        Ok(())
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn new_text_is_empty() {
184        let t = TextCrdt::new(1);
185        assert!(t.is_empty());
186        assert_eq!(t.len(), 0);
187        assert_eq!(t.to_string(), "");
188    }
189
190    #[test]
191    fn insert_single_char() {
192        let mut t = TextCrdt::new(1);
193        t.insert(0, 'x').unwrap();
194        assert_eq!(t.to_string(), "x");
195        assert_eq!(t.len(), 1);
196    }
197
198    #[test]
199    fn insert_at_beginning_middle_end() {
200        let mut t = TextCrdt::new(1);
201        t.insert(0, 'a').unwrap();
202        t.insert(1, 'c').unwrap();
203        t.insert(1, 'b').unwrap();
204        t.insert(0, 'z').unwrap();
205        assert_eq!(t.to_string(), "zabc");
206    }
207
208    #[test]
209    fn delete_char() {
210        let mut t = TextCrdt::new(1);
211        t.insert_str(0, "hello").unwrap();
212        t.remove(1).unwrap();
213        assert_eq!(t.to_string(), "hllo");
214        assert_eq!(t.len(), 4);
215    }
216
217    #[test]
218    fn insert_str_basic() {
219        let mut t = TextCrdt::new(1);
220        t.insert_str(0, "hello").unwrap();
221        assert_eq!(t.to_string(), "hello");
222        assert_eq!(t.len(), 5);
223    }
224
225    #[test]
226    fn remove_range_basic() {
227        let mut t = TextCrdt::new(1);
228        t.insert_str(0, "hello world").unwrap();
229        t.remove_range(5, 6).unwrap();
230        assert_eq!(t.to_string(), "hello");
231    }
232
233    #[test]
234    fn merge_disjoint_inserts() {
235        let mut t1 = TextCrdt::new(1);
236        t1.insert_str(0, "hello").unwrap();
237
238        let mut t2 = TextCrdt::new(2);
239        t2.insert_str(0, "world").unwrap();
240
241        t1.merge(&t2);
242        assert_eq!(t1.len(), 10);
243    }
244
245    #[test]
246    fn merge_propagates_tombstones() {
247        let mut t1 = TextCrdt::new(1);
248        t1.insert_str(0, "abc").unwrap();
249
250        let mut t2 = t1.fork(2);
251        t2.remove(1).unwrap();
252
253        t1.merge(&t2);
254        assert_eq!(t1.to_string(), "ac");
255    }
256
257    #[test]
258    fn merge_commutativity() {
259        let mut t1 = TextCrdt::new(1);
260        t1.insert_str(0, "hello").unwrap();
261
262        let mut t2 = TextCrdt::new(2);
263        t2.insert_str(0, "world").unwrap();
264
265        let mut left = t1.clone();
266        left.merge(&t2);
267
268        let mut right = t2.clone();
269        right.merge(&t1);
270
271        assert_eq!(left.to_string(), right.to_string());
272    }
273
274    #[test]
275    fn merge_idempotency() {
276        let mut t1 = TextCrdt::new(1);
277        t1.insert_str(0, "hello").unwrap();
278
279        let mut t2 = TextCrdt::new(2);
280        t2.insert_str(0, "world").unwrap();
281
282        t1.merge(&t2);
283        let after_first = t1.clone();
284        t1.merge(&t2);
285
286        assert_eq!(t1.to_string(), after_first.to_string());
287    }
288
289    #[test]
290    fn concurrent_inserts_at_same_position() {
291        let mut t1 = TextCrdt::new(1);
292        t1.insert(0, 'a').unwrap();
293
294        let mut t2 = TextCrdt::new(2);
295        t2.insert(0, 'b').unwrap();
296
297        let mut left = t1.clone();
298        left.merge(&t2);
299
300        let mut right = t2.clone();
301        right.merge(&t1);
302
303        assert_eq!(left.to_string(), right.to_string());
304        assert_eq!(left.len(), 2);
305    }
306
307    #[test]
308    fn delta_apply_equivalent_to_merge() {
309        let mut t1 = TextCrdt::new(1);
310        t1.insert_str(0, "hello").unwrap();
311
312        let mut t2 = TextCrdt::new(2);
313        t2.insert_str(0, "world").unwrap();
314
315        let mut via_merge = t2.clone();
316        via_merge.merge(&t1);
317
318        let mut via_delta = t2.clone();
319        let d = t1.delta(&t2);
320        via_delta.apply_delta(&d);
321
322        assert_eq!(via_merge.to_string(), via_delta.to_string());
323    }
324
325    #[test]
326    fn triple_merge_convergence() {
327        let mut t1 = TextCrdt::new(1);
328        t1.insert_str(0, "base").unwrap();
329
330        let mut t2 = t1.fork(2);
331        let mut t3 = t1.fork(3);
332
333        t1.insert(4, '!').unwrap();
334        t2.insert(0, '>').unwrap();
335        t3.remove(2).unwrap();
336
337        let mut r1 = t1.clone();
338        r1.merge(&t2);
339        r1.merge(&t3);
340
341        let mut r2 = t2.clone();
342        r2.merge(&t3);
343        r2.merge(&t1);
344
345        let mut r3 = t3.clone();
346        r3.merge(&t1);
347        r3.merge(&t2);
348
349        assert_eq!(r1.to_string(), r2.to_string());
350        assert_eq!(r2.to_string(), r3.to_string());
351    }
352
353    #[test]
354    fn insert_out_of_bounds_returns_error() {
355        let mut t = TextCrdt::new(1);
356        let err = t.insert(1, 'x');
357        assert_eq!(err, Err(TextError::IndexOutOfBounds { index: 1, len: 0 }));
358    }
359
360    #[test]
361    fn remove_out_of_bounds_returns_error() {
362        let mut t = TextCrdt::new(1);
363        let err = t.remove(0);
364        assert_eq!(err, Err(TextError::IndexOutOfBounds { index: 0, len: 0 }));
365    }
366
367    #[test]
368    fn display_trait() {
369        let mut t = TextCrdt::new(1);
370        t.insert_str(0, "hello").unwrap();
371        assert_eq!(format!("{t}"), "hello");
372    }
373}