1use anyhow::{Context, Result};
60use yrs::updates::decoder::Decode;
61use yrs::{Doc, GetString, ReadTxn, Text, TextRef, Transact, Update};
62
63const TEXT_KEY: &str = "content";
64
65pub struct CrdtDoc {
67 doc: Doc,
68}
69
70impl CrdtDoc {
71 pub fn from_text(content: &str) -> Self {
73 let doc = Doc::new();
74 let text = doc.get_or_insert_text(TEXT_KEY);
75 let mut txn = doc.transact_mut();
76 text.insert(&mut txn, 0, content);
77 drop(txn);
78 CrdtDoc { doc }
79 }
80
81 pub fn to_text(&self) -> String {
83 let text = self.doc.get_or_insert_text(TEXT_KEY);
84 let txn = self.doc.transact();
85 text.get_string(&txn)
86 }
87
88 #[allow(dead_code)] pub fn apply_edit(&self, offset: u32, delete_len: u32, insert: &str) {
91 let text = self.doc.get_or_insert_text(TEXT_KEY);
92 let mut txn = self.doc.transact_mut();
93 if delete_len > 0 {
94 text.remove_range(&mut txn, offset, delete_len);
95 }
96 if !insert.is_empty() {
97 text.insert(&mut txn, offset, insert);
98 }
99 }
100
101 pub fn encode_state(&self) -> Vec<u8> {
103 let txn = self.doc.transact();
104 txn.encode_state_as_update_v1(&yrs::StateVector::default())
105 }
106
107 pub fn decode_state(bytes: &[u8]) -> Result<Self> {
109 let doc = Doc::new();
110 let update = Update::decode_v1(bytes)
111 .map_err(|e| anyhow::anyhow!("failed to decode CRDT state: {}", e))?;
112 let mut txn = doc.transact_mut();
113 txn.apply_update(update)
114 .map_err(|e| anyhow::anyhow!("failed to apply CRDT update: {}", e))?;
115 drop(txn);
116 Ok(CrdtDoc { doc })
117 }
118}
119
120pub fn merge(base_state: Option<&[u8]>, ours_text: &str, theirs_text: &str) -> Result<String> {
130 merge_inner(base_state, ours_text, theirs_text, None)
131}
132
133pub fn merge_with_editor_ops(
152 base_state: Option<&[u8]>,
153 ours_text: &str,
154 theirs_text: &str,
155 theirs_editor_ops: Option<&[EditorOp]>,
156) -> Result<String> {
157 merge_inner(base_state, ours_text, theirs_text, theirs_editor_ops)
158}
159
160fn merge_inner(
161 base_state: Option<&[u8]>,
162 ours_text: &str,
163 theirs_text: &str,
164 theirs_editor_ops: Option<&[EditorOp]>,
165) -> Result<String> {
166 if ours_text == theirs_text {
168 eprintln!("[crdt] ours == theirs, skipping merge");
169 return Ok(ours_text.to_string());
170 }
171
172 let base_doc = if let Some(bytes) = base_state {
174 CrdtDoc::decode_state(bytes).context("failed to decode base CRDT state")?
175 } else {
176 CrdtDoc::from_text("")
177 };
178 let mut base_text = base_doc.to_text();
179
180 eprintln!(
181 "[crdt] merge: base_len={} ours_len={} theirs_len={}",
182 base_text.len(),
183 ours_text.len(),
184 theirs_text.len()
185 );
186
187 let committed_response_headings = response_headings(&base_text);
193
194 let ours_prefix = common_prefix_len(&base_text, ours_text);
205 let ours_suffix = common_suffix_len(&base_text, ours_text);
206 let theirs_prefix = common_prefix_len(&base_text, theirs_text);
207 let theirs_suffix = common_suffix_len(&base_text, theirs_text);
208 let base_len = base_text.len();
209
210 let ours_shared = (ours_prefix + ours_suffix).min(base_len);
212 let theirs_shared = (theirs_prefix + theirs_suffix).min(base_len);
213
214 if base_len > 0
215 && (ours_shared as f64 / base_len as f64) < 0.5
216 && (theirs_shared as f64 / base_len as f64) < 0.5
217 {
218 eprintln!(
219 "[crdt] Stale CRDT base detected (shared: ours={}%, theirs={}%). Using ours as base.",
220 (ours_shared * 100) / base_len,
221 (theirs_shared * 100) / base_len
222 );
223 base_text = ours_text.to_string();
224 }
225
226 let mutual_prefix = common_prefix_len(ours_text, theirs_text);
250 let base_diverge =
251 common_prefix_len(&base_text, ours_text).max(common_prefix_len(&base_text, theirs_text));
252 if mutual_prefix > base_diverge {
253 let snap = &ours_text[..mutual_prefix];
260 let snapped = match snap.rfind('\n') {
261 Some(pos) if pos >= base_diverge => pos + 1,
262 _ => base_diverge, };
264 if snapped > base_diverge {
265 eprintln!(
266 "[crdt] Advancing base to shared prefix (diverge={} → {})",
267 base_diverge, snapped
268 );
269 base_text = ours_text[..snapped].to_string();
270 }
271 }
272
273 let theirs_replay_ops = match theirs_editor_ops {
279 Some(ops) if !ops.is_empty() && base_text.is_ascii() && theirs_text.is_ascii() => {
287 match replay_editor_ops(&base_text, ops) {
288 Some(replayed) if replayed == theirs_text => {
289 eprintln!(
290 "[crdt] editor_ops_replayed: {} captured op(s) reproduce theirs exactly (#qnodemerge4)",
291 ops.len()
292 );
293 Some(ops)
294 }
295 _ => {
296 eprintln!(
297 "[crdt] editor_ops_fallback: {} captured op(s) do not replay to theirs from the resolved base (stale/misaligned) — using diff-guess (#qnodemerge4)",
298 ops.len()
299 );
300 None
301 }
302 }
303 }
304 _ => None,
305 };
306
307 let ours_ops = compute_edit_ops(&base_text, ours_text);
309 let theirs_ops = compute_edit_ops(&base_text, theirs_text);
310
311 let base_encoded = if base_text == base_doc.to_text() {
314 base_doc.encode_state()
315 } else {
316 CrdtDoc::from_text(&base_text).encode_state()
317 };
318
319 let ours_doc = Doc::with_client_id(1);
323 {
324 let update =
325 Update::decode_v1(&base_encoded).map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
326 let mut txn = ours_doc.transact_mut();
327 txn.apply_update(update)
328 .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
329 }
330
331 let theirs_doc = Doc::with_client_id(2);
332 {
333 let update =
334 Update::decode_v1(&base_encoded).map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
335 let mut txn = theirs_doc.transact_mut();
336 txn.apply_update(update)
337 .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
338 }
339
340 {
342 let text = ours_doc.get_or_insert_text(TEXT_KEY);
343 let mut txn = ours_doc.transact_mut();
344 apply_ops(&text, &mut txn, &ours_ops);
345 }
346
347 {
350 let text = theirs_doc.get_or_insert_text(TEXT_KEY);
351 let mut txn = theirs_doc.transact_mut();
352 match theirs_replay_ops {
353 Some(ops) => apply_editor_ops(&text, &mut txn, ops),
354 None => apply_ops(&text, &mut txn, &theirs_ops),
355 }
356 }
357
358 let ours_sv = {
360 let txn = ours_doc.transact();
361 txn.state_vector()
362 };
363 let theirs_update = {
364 let txn = theirs_doc.transact();
365 txn.encode_state_as_update_v1(&ours_sv)
366 };
367 {
368 let update = Update::decode_v1(&theirs_update)
369 .map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
370 let mut txn = ours_doc.transact_mut();
371 txn.apply_update(update)
372 .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
373 }
374
375 let merged = {
381 let text = ours_doc.get_or_insert_text(TEXT_KEY);
382 let txn = ours_doc.transact();
383 text.get_string(&txn)
384 };
385
386 let merged =
395 guard_committed_responses(merged, &committed_response_headings, ours_text, theirs_text);
396
397 Ok(dedup_adjacent_blocks(&merged))
399}
400
401#[derive(Debug, Clone)]
409enum Node {
410 Interstitial(String),
413 Component { name: String, text: String },
415}
416
417impl Node {
418 fn component_name(&self) -> Option<&str> {
419 match self {
420 Node::Component { name, .. } => Some(name.as_str()),
421 Node::Interstitial(_) => None,
422 }
423 }
424
425 fn text(&self) -> &str {
426 match self {
427 Node::Interstitial(t) => t,
428 Node::Component { text, .. } => text,
429 }
430 }
431}
432
433fn segment_into_nodes(text: &str) -> Result<Vec<Node>> {
439 let comps = crate::component::parse(text)?;
440 let mut top: Vec<&crate::component::Component> = comps
443 .iter()
444 .filter(|c| {
445 !comps.iter().any(|o| {
446 !std::ptr::eq(o, *c) && o.open_start <= c.open_start && c.close_end <= o.close_end
447 })
448 })
449 .collect();
450 top.sort_by_key(|c| c.open_start);
451
452 let mut nodes = Vec::with_capacity(top.len() * 2 + 1);
453 let mut cursor = 0usize;
454 for c in top {
455 nodes.push(Node::Interstitial(text[cursor..c.open_start].to_string()));
457 nodes.push(Node::Component {
458 name: c.name.clone(),
459 text: text[c.open_start..c.close_end].to_string(),
460 });
461 cursor = c.close_end;
462 }
463 nodes.push(Node::Interstitial(text[cursor..].to_string()));
465 Ok(nodes)
466}
467
468pub fn merge_by_component(
484 base_state: Option<&[u8]>,
485 ours_text: &str,
486 theirs_text: &str,
487) -> Result<String> {
488 if ours_text == theirs_text {
489 return Ok(ours_text.to_string());
490 }
491
492 let ours_nodes = match segment_into_nodes(ours_text) {
493 Ok(c) => c,
494 Err(e) => {
495 eprintln!(
496 "[crdt] merge_by_component: failed to segment ours ({e}); falling back to whole-doc merge"
497 );
498 return merge(base_state, ours_text, theirs_text);
499 }
500 };
501 let theirs_nodes = match segment_into_nodes(theirs_text) {
502 Ok(c) => c,
503 Err(e) => {
504 eprintln!(
505 "[crdt] merge_by_component: failed to segment theirs ({e}); falling back to whole-doc merge"
506 );
507 return merge(base_state, ours_text, theirs_text);
508 }
509 };
510
511 let ours_names: Vec<&str> = ours_nodes.iter().filter_map(Node::component_name).collect();
512 let theirs_names: Vec<&str> = theirs_nodes
513 .iter()
514 .filter_map(Node::component_name)
515 .collect();
516
517 if ours_names.is_empty() && theirs_names.is_empty() {
520 return merge(base_state, ours_text, theirs_text);
521 }
522
523 if ours_names != theirs_names {
526 eprintln!(
527 "[crdt] merge_by_component: structural divergence (ours components {ours_names:?} != theirs {theirs_names:?}); falling back to whole-doc merge"
528 );
529 return merge(base_state, ours_text, theirs_text);
530 }
531
532 let base_text = match base_state {
534 Some(bytes) => match CrdtDoc::decode_state(bytes) {
535 Ok(doc) => doc.to_text(),
536 Err(e) => {
537 eprintln!(
538 "[crdt] merge_by_component: failed to decode base state ({e}); merging nodes without a base"
539 );
540 String::new()
541 }
542 },
543 None => String::new(),
544 };
545 let base_nodes = segment_into_nodes(&base_text).unwrap_or_default();
546 let mut base_by_name: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
547 for node in &base_nodes {
548 if let Node::Component { name, text } = node {
549 base_by_name.entry(name.as_str()).or_insert(text.as_str());
550 }
551 }
552 let base_interstitials: Vec<&str> = base_nodes
554 .iter()
555 .filter_map(|c| match c {
556 Node::Interstitial(t) => Some(t.as_str()),
557 Node::Component { .. } => None,
558 })
559 .collect();
560
561 let merged_nodes = merge_aligned_nodes(&ours_nodes, &theirs_nodes, |name, idx| match name {
562 Some(n) => base_by_name.get(n).map(|s| s.to_string()),
563 None => base_interstitials.get(idx).map(|s| s.to_string()),
564 })?;
565 let merged: String = merged_nodes.into_iter().map(|(_, text)| text).collect();
566
567 let merged_names: Vec<String> = match segment_into_nodes(&merged) {
577 Ok(nodes) => nodes
578 .iter()
579 .filter_map(|n| n.component_name().map(str::to_string))
580 .collect(),
581 Err(e) => {
582 eprintln!(
583 "[crdt] merge_by_component: merged result failed to re-segment ({e}); falling back to whole-doc merge"
584 );
585 return merge(base_state, ours_text, theirs_text);
586 }
587 };
588 let names_match = merged_names.len() == ours_names.len()
589 && merged_names
590 .iter()
591 .zip(ours_names.iter())
592 .all(|(m, o)| m.as_str() == *o);
593 if !names_match {
594 eprintln!(
595 "[crdt] merge_by_component: merged component framing diverged (ours {ours_names:?} != merged {merged_names:?}); falling back to whole-doc merge"
596 );
597 return merge(base_state, ours_text, theirs_text);
598 }
599
600 Ok(merged)
601}
602
603fn merge_aligned_nodes<F>(
614 ours_nodes: &[Node],
615 theirs_nodes: &[Node],
616 mut resolve_base: F,
617) -> Result<Vec<(Option<String>, String)>>
618where
619 F: FnMut(Option<&str>, usize) -> Option<String>,
620{
621 let mut merged = Vec::with_capacity(ours_nodes.len());
622 let mut interstitial_idx = 0usize;
623 for (ours_node, theirs_node) in ours_nodes.iter().zip(theirs_nodes.iter()) {
624 let name = ours_node.component_name();
625 let node_base = resolve_base(name, interstitial_idx);
626 if name.is_none() {
627 interstitial_idx += 1;
628 }
629
630 let ours_slice = ours_node.text();
631 let theirs_slice = theirs_node.text();
632
633 let merged_text = if ours_slice == theirs_slice {
634 ours_slice.to_string()
635 } else if let Some(component_name) = name
636 && let Some(merged) = reconcile_component(
637 component_name,
638 node_base.as_deref(),
639 ours_slice,
640 theirs_slice,
641 )
642 {
643 merged
649 } else {
650 let node_base_state = node_base
651 .as_deref()
652 .map(|t| CrdtDoc::from_text(t).encode_state());
653 merge(node_base_state.as_deref(), ours_slice, theirs_slice)?
654 };
655 merged.push((name.map(str::to_string), merged_text));
656 }
657 Ok(merged)
658}
659
660const PREAMBLE_KEY: &str = "\u{0}::preamble";
682
683fn is_list_component(name: &str) -> bool {
686 matches!(name, "queue" | "backlog" | "review" | "done")
687}
688
689struct KeyedChild {
693 key: String,
694 text: String,
695}
696
697fn first_hash_id(s: &str) -> Option<String> {
701 let bytes = s.as_bytes();
702 let mut i = 0;
703 while i + 1 < bytes.len() {
704 if bytes[i] == b'[' && bytes[i + 1] == b'#' {
706 let rest = &s[i + 2..];
707 if let Some(close) = rest.find(']') {
708 let id = &rest[..close];
709 if !id.is_empty()
710 && id
711 .chars()
712 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
713 {
714 return Some(id.to_string());
715 }
716 }
717 }
718 i += 1;
719 }
720 None
721}
722
723fn normalize_item_text(item: &str) -> String {
727 let first = item.lines().next().unwrap_or("").trim();
728 let mut s = first.strip_prefix("- ").unwrap_or(first).trim_start();
729 for cb in ["[ ]", "[x]", "[X]", "[/]"] {
730 if let Some(r) = s.strip_prefix(cb) {
731 s = r.trim_start();
732 break;
733 }
734 }
735 s.replace("~~", "")
736 .replace(":pushpin:", "")
737 .replace('📌', "")
738 .split_whitespace()
739 .collect::<Vec<_>>()
740 .join(" ")
741}
742
743fn list_item_key(item: &str) -> String {
746 match first_hash_id(item) {
747 Some(id) => format!("id:{id}"),
748 None => format!("txt:{}", normalize_item_text(item)),
749 }
750}
751
752fn exchange_heading_key(block: &str) -> String {
755 let heading = block.lines().next().unwrap_or("").trim();
756 heading
757 .strip_suffix(" (HEAD)")
758 .unwrap_or(heading)
759 .trim_end()
760 .to_string()
761}
762
763fn split_list_children(body: &str) -> Option<Vec<KeyedChild>> {
768 let mut starts = Vec::new();
769 let mut pos = 0usize;
770 for line in body.split_inclusive('\n') {
771 let trimmed = line.trim_start();
772 if trimmed.starts_with("- ") || trimmed.trim_end() == "-" {
773 starts.push(pos);
774 }
775 pos += line.len();
776 }
777 if starts.is_empty() {
778 return None;
779 }
780 Some(segment_by_starts(body, &starts, list_item_key))
781}
782
783fn split_exchange_children(body: &str) -> Option<Vec<KeyedChild>> {
787 let mut starts = Vec::new();
788 let mut pos = 0usize;
789 for line in body.split_inclusive('\n') {
790 if line.trim_start().starts_with("### Re:") {
791 starts.push(pos);
792 }
793 pos += line.len();
794 }
795 if starts.is_empty() {
796 return None;
797 }
798 Some(segment_by_starts(body, &starts, exchange_heading_key))
799}
800
801fn segment_by_starts(body: &str, starts: &[usize], key_of: fn(&str) -> String) -> Vec<KeyedChild> {
805 let mut children = Vec::with_capacity(starts.len() + 1);
806 if starts[0] > 0 {
807 children.push(KeyedChild {
808 key: PREAMBLE_KEY.to_string(),
809 text: body[..starts[0]].to_string(),
810 });
811 }
812 for (i, &s) in starts.iter().enumerate() {
813 let e = starts.get(i + 1).copied().unwrap_or(body.len());
814 let slice = &body[s..e];
815 children.push(KeyedChild {
816 key: key_of(slice),
817 text: slice.to_string(),
818 });
819 }
820 children
821}
822
823fn keys_unique(children: &[KeyedChild]) -> bool {
827 let mut seen = std::collections::HashSet::new();
828 children.iter().all(|c| seen.insert(c.key.as_str()))
829}
830
831fn component_framing(text: &str) -> Option<(&str, &str, &str)> {
835 let comps = crate::component::parse(text).ok()?;
836 let comp = comps.iter().min_by_key(|c| c.open_start)?;
837 Some((
838 &text[..comp.open_end],
839 &text[comp.open_end..comp.close_start],
840 &text[comp.close_start..],
841 ))
842}
843
844fn reconcile_component(
850 name: &str,
851 base_text: Option<&str>,
852 ours_text: &str,
853 theirs_text: &str,
854) -> Option<String> {
855 if name != "exchange" && !is_list_component(name) {
856 return None;
857 }
858 let (ours_open, ours_body, ours_close) = component_framing(ours_text)?;
859 let (_, theirs_body, _) = component_framing(theirs_text)?;
860 let base_body = base_text.and_then(|t| component_framing(t).map(|(_, b, _)| b));
861
862 let merged_body = reconcile_component_body(name, base_body, ours_body, theirs_body)?;
863 Some(format!("{ours_open}{merged_body}{ours_close}"))
864}
865
866fn reconcile_component_body(
869 name: &str,
870 base_body: Option<&str>,
871 ours_body: &str,
872 theirs_body: &str,
873) -> Option<String> {
874 let split = |b: &str| -> Option<Vec<KeyedChild>> {
875 if name == "exchange" {
876 split_exchange_children(b)
877 } else {
878 split_list_children(b)
879 }
880 };
881 let ours_children = split(ours_body)?;
882 let theirs_children = split(theirs_body)?;
883 let base_children = base_body.and_then(split).unwrap_or_default();
884
885 if !keys_unique(&ours_children)
886 || !keys_unique(&theirs_children)
887 || !keys_unique(&base_children)
888 {
889 return None;
890 }
891
892 let ours_map: std::collections::HashMap<&str, &KeyedChild> =
893 ours_children.iter().map(|c| (c.key.as_str(), c)).collect();
894 let theirs_map: std::collections::HashMap<&str, &KeyedChild> = theirs_children
895 .iter()
896 .map(|c| (c.key.as_str(), c))
897 .collect();
898 let base_map: std::collections::HashMap<&str, &KeyedChild> =
899 base_children.iter().map(|c| (c.key.as_str(), c)).collect();
900
901 let ours_keys: Vec<&str> = ours_children.iter().map(|c| c.key.as_str()).collect();
902 let theirs_keys: Vec<&str> = theirs_children.iter().map(|c| c.key.as_str()).collect();
903 let order = if name == "exchange" {
912 order_union(&ours_keys, &theirs_keys)
913 } else {
914 order_union(&theirs_keys, &ours_keys)
915 };
916
917 let protect_deletes = name == "exchange";
921
922 let mut out = String::new();
923 for key in &order {
924 let in_o = ours_map.get(key.as_str());
925 let in_t = theirs_map.get(key.as_str());
926 let in_b = base_map.get(key.as_str());
927 let resolved: Option<String> = match (in_o, in_t) {
928 (Some(o), Some(t)) => {
929 if o.text == t.text {
930 Some(o.text.clone())
931 } else {
932 let base_state = in_b.map(|b| CrdtDoc::from_text(&b.text).encode_state());
936 Some(merge(base_state.as_deref(), &o.text, &t.text).ok()?)
937 }
938 }
939 (Some(o), None) => match in_b {
941 None => Some(o.text.clone()),
942 Some(b) if protect_deletes || o.text != b.text => Some(o.text.clone()),
943 Some(_) => None,
944 },
945 (None, Some(t)) => match in_b {
947 None => Some(t.text.clone()),
948 Some(b) if protect_deletes || t.text != b.text => Some(t.text.clone()),
949 Some(_) => None,
950 },
951 (None, None) => None,
952 };
953 if let Some(text) = resolved {
954 out.push_str(&text);
955 }
956 }
957 Some(out)
958}
959
960fn order_union(ours_keys: &[&str], theirs_keys: &[&str]) -> Vec<String> {
965 let ours_set: std::collections::HashSet<&str> = ours_keys.iter().copied().collect();
966 let mut result: Vec<String> = ours_keys.iter().map(|s| s.to_string()).collect();
967 for (i, &k) in theirs_keys.iter().enumerate() {
968 if ours_set.contains(k) || result.iter().any(|r| r == k) {
969 continue;
970 }
971 let mut insert_pos = 0usize;
972 for j in (0..i).rev() {
973 if let Some(p) = result.iter().position(|r| r == theirs_keys[j]) {
974 insert_pos = p + 1;
975 break;
976 }
977 }
978 result.insert(insert_pos, k.to_string());
979 }
980 result
981}
982
983fn encode_text_deterministic(text: &str) -> Vec<u8> {
990 let doc = Doc::with_client_id(0);
991 let yrs_text = doc.get_or_insert_text(TEXT_KEY);
992 {
993 let mut txn = doc.transact_mut();
994 yrs_text.insert(&mut txn, 0, text);
995 }
996 let txn = doc.transact();
997 txn.encode_state_as_update_v1(&yrs::StateVector::default())
998}
999
1000const MULTINODE_MAGIC: &[u8; 4] = b"ADN1";
1005
1006#[derive(Debug, Clone, Default, PartialEq, Eq)]
1018pub struct MultiNodeState {
1019 nodes: Vec<PersistedNode>,
1020}
1021
1022#[derive(Debug, Clone, PartialEq, Eq)]
1024struct PersistedNode {
1025 name: Option<String>,
1027 state: Vec<u8>,
1029}
1030
1031impl MultiNodeState {
1032 pub fn from_text(text: &str) -> Result<Self> {
1035 let nodes = segment_into_nodes(text)?
1036 .iter()
1037 .map(|node| PersistedNode {
1038 name: node.component_name().map(str::to_string),
1039 state: encode_text_deterministic(node.text()),
1040 })
1041 .collect();
1042 Ok(MultiNodeState { nodes })
1043 }
1044
1045 fn from_merged_nodes(merged: &[(Option<String>, String)]) -> Self {
1047 MultiNodeState {
1048 nodes: merged
1049 .iter()
1050 .map(|(name, text)| PersistedNode {
1051 name: name.clone(),
1052 state: encode_text_deterministic(text),
1053 })
1054 .collect(),
1055 }
1056 }
1057
1058 fn node_text(node: &PersistedNode) -> Result<String> {
1060 Ok(CrdtDoc::decode_state(&node.state)?.to_text())
1061 }
1062
1063 pub fn to_text(&self) -> Result<String> {
1065 let mut text = String::new();
1066 for node in &self.nodes {
1067 text.push_str(&Self::node_text(node)?);
1068 }
1069 Ok(text)
1070 }
1071
1072 fn base_lookup(&self) -> Result<(std::collections::HashMap<String, String>, Vec<String>)> {
1076 let mut by_name = std::collections::HashMap::new();
1077 let mut interstitials = Vec::new();
1078 for node in &self.nodes {
1079 let text = Self::node_text(node)?;
1080 match &node.name {
1081 Some(name) => {
1082 by_name.entry(name.clone()).or_insert(text);
1083 }
1084 None => interstitials.push(text),
1085 }
1086 }
1087 Ok((by_name, interstitials))
1088 }
1089
1090 pub fn encode(&self) -> Vec<u8> {
1095 let mut out = Vec::new();
1096 out.extend_from_slice(MULTINODE_MAGIC);
1097 out.push(1u8); out.extend_from_slice(&(self.nodes.len() as u32).to_le_bytes());
1099 for node in &self.nodes {
1100 match &node.name {
1101 Some(name) => {
1102 let bytes = name.as_bytes();
1103 out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
1104 out.extend_from_slice(bytes);
1105 }
1106 None => out.extend_from_slice(&u32::MAX.to_le_bytes()),
1107 }
1108 out.extend_from_slice(&(node.state.len() as u32).to_le_bytes());
1109 out.extend_from_slice(&node.state);
1110 }
1111 out
1112 }
1113
1114 pub fn decode(bytes: &[u8]) -> Result<Self> {
1118 let mut cursor = 0usize;
1119 let take = |cursor: &mut usize, n: usize| -> Result<&[u8]> {
1120 let end = cursor
1121 .checked_add(n)
1122 .filter(|end| *end <= bytes.len())
1123 .ok_or_else(|| anyhow::anyhow!("multinode state truncated"))?;
1124 let slice = &bytes[*cursor..end];
1125 *cursor = end;
1126 Ok(slice)
1127 };
1128 let read_u32 = |cursor: &mut usize| -> Result<u32> {
1129 let b = take(cursor, 4)?;
1130 Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
1131 };
1132
1133 if take(&mut cursor, 4)? != MULTINODE_MAGIC {
1134 anyhow::bail!("not a multinode state container (magic mismatch)");
1135 }
1136 let version = take(&mut cursor, 1)?[0];
1137 if version != 1 {
1138 anyhow::bail!("unsupported multinode state version {version}");
1139 }
1140 let count = read_u32(&mut cursor)? as usize;
1141 let mut nodes = Vec::with_capacity(count);
1142 for _ in 0..count {
1143 let name_len = read_u32(&mut cursor)?;
1144 let name = if name_len == u32::MAX {
1145 None
1146 } else {
1147 let bytes = take(&mut cursor, name_len as usize)?;
1148 Some(
1149 std::str::from_utf8(bytes)
1150 .map_err(|e| anyhow::anyhow!("invalid node name UTF-8: {e}"))?
1151 .to_string(),
1152 )
1153 };
1154 let state_len = read_u32(&mut cursor)? as usize;
1155 let state = take(&mut cursor, state_len)?.to_vec();
1156 nodes.push(PersistedNode { name, state });
1157 }
1158 Ok(MultiNodeState { nodes })
1159 }
1160
1161 pub fn decode_or_migrate(bytes: &[u8], fallback_text: &str) -> Self {
1169 match Self::decode(bytes) {
1170 Ok(state) => return state,
1171 Err(_) => {
1172 if let Ok(doc) = CrdtDoc::decode_state(bytes)
1174 && let Ok(state) = Self::from_text(&doc.to_text())
1175 {
1176 eprintln!(
1177 "[crdt] MultiNodeState: migrated legacy whole-doc state into per-node state"
1178 );
1179 return state;
1180 }
1181 }
1182 }
1183 eprintln!(
1184 "[crdt] MultiNodeState: state unreadable; rebuilding per-node state from fallback text"
1185 );
1186 Self::from_text(fallback_text).unwrap_or_default()
1187 }
1188
1189 pub fn merge(
1201 base: Option<&MultiNodeState>,
1202 ours_text: &str,
1203 theirs_text: &str,
1204 ) -> Result<(String, MultiNodeState)> {
1205 let whole_doc_fallback = |reason: &str| -> Result<(String, MultiNodeState)> {
1206 let base_text = match base {
1207 Some(b) => b.to_text().unwrap_or_default(),
1208 None => String::new(),
1209 };
1210 let base_state = base.map(|_| CrdtDoc::from_text(&base_text).encode_state());
1211 if !reason.is_empty() {
1212 eprintln!(
1213 "[crdt] MultiNodeState::merge: {reason}; falling back to whole-doc merge"
1214 );
1215 }
1216 let merged = merge(base_state.as_deref(), ours_text, theirs_text)?;
1217 let state = MultiNodeState::from_text(&merged)?;
1218 Ok((merged, state))
1219 };
1220
1221 if ours_text == theirs_text {
1222 return Ok((ours_text.to_string(), MultiNodeState::from_text(ours_text)?));
1223 }
1224
1225 let ours_nodes = match segment_into_nodes(ours_text) {
1226 Ok(c) => c,
1227 Err(e) => return whole_doc_fallback(&format!("failed to segment ours ({e})")),
1228 };
1229 let theirs_nodes = match segment_into_nodes(theirs_text) {
1230 Ok(c) => c,
1231 Err(e) => return whole_doc_fallback(&format!("failed to segment theirs ({e})")),
1232 };
1233
1234 let ours_names: Vec<&str> = ours_nodes.iter().filter_map(Node::component_name).collect();
1235 let theirs_names: Vec<&str> = theirs_nodes
1236 .iter()
1237 .filter_map(Node::component_name)
1238 .collect();
1239
1240 if ours_names.is_empty() && theirs_names.is_empty() {
1241 return whole_doc_fallback("");
1242 }
1243 if ours_names != theirs_names {
1244 return whole_doc_fallback(&format!(
1245 "structural divergence (ours {ours_names:?} != theirs {theirs_names:?})"
1246 ));
1247 }
1248
1249 let (base_by_name, base_interstitials) = match base {
1250 Some(b) => b.base_lookup()?,
1251 None => (std::collections::HashMap::new(), Vec::new()),
1252 };
1253
1254 let merged_nodes =
1255 merge_aligned_nodes(&ours_nodes, &theirs_nodes, |name, idx| match name {
1256 Some(n) => base_by_name.get(n).cloned(),
1257 None => base_interstitials.get(idx).cloned(),
1258 })?;
1259
1260 let merged_text: String = merged_nodes.iter().map(|(_, text)| text.as_str()).collect();
1261 let new_state = MultiNodeState::from_merged_nodes(&merged_nodes);
1262 Ok((merged_text, new_state))
1263 }
1264}
1265
1266fn is_transient_merge_line(line: &str) -> bool {
1270 let t = line.trim();
1271 t.starts_with("<!-- agent:boundary:")
1272}
1273
1274fn response_headings(text: &str) -> Vec<String> {
1278 text.lines()
1279 .map(str::trim)
1280 .filter(|l| l.starts_with("### Re:"))
1281 .map(|l| {
1282 l.strip_suffix(" (HEAD)")
1283 .unwrap_or(l)
1284 .trim_end()
1285 .to_string()
1286 })
1287 .collect()
1288}
1289
1290fn guard_committed_responses(
1299 merged: String,
1300 committed_response_headings: &[String],
1301 ours_text: &str,
1302 theirs_text: &str,
1303) -> String {
1304 if committed_response_headings.is_empty() {
1305 return merged;
1306 }
1307 let ours_headings = response_headings(ours_text);
1308 let merged_headings = response_headings(&merged);
1309 let lost: Vec<&String> = committed_response_headings
1310 .iter()
1311 .filter(|h| ours_headings.contains(h) && !merged_headings.contains(h))
1312 .collect();
1313 if lost.is_empty() {
1314 return merged;
1315 }
1316
1317 let ours_lines: std::collections::HashSet<&str> = ours_text
1320 .lines()
1321 .map(str::trim)
1322 .filter(|l| !l.is_empty())
1323 .collect();
1324 let theirs_adds_new = theirs_text
1325 .lines()
1326 .map(str::trim)
1327 .any(|l| !l.is_empty() && !is_transient_merge_line(l) && !ours_lines.contains(l));
1328
1329 if theirs_adds_new {
1330 eprintln!(
1331 "[crdt] WARNING: merge dropped committed response heading(s) {:?} while theirs added new content; keeping merge (#ipc-crdt-response-drift)",
1332 lost
1333 );
1334 merged
1335 } else {
1336 eprintln!(
1337 "[crdt] stale theirs dropped committed response heading(s) {:?}; preferring ours (#ipc-crdt-response-drift)",
1338 lost
1339 );
1340 ours_text.to_string()
1341 }
1342}
1343
1344pub fn dedup_adjacent_blocks(text: &str) -> String {
1351 let blocks: Vec<&str> = text.split("\n\n").collect();
1352 if blocks.len() < 2 {
1353 return text.to_string();
1354 }
1355
1356 let mut result: Vec<&str> = Vec::with_capacity(blocks.len());
1357 for block in &blocks {
1358 let trimmed = block.trim();
1359 let non_empty_lines = trimmed.lines().filter(|l| !l.trim().is_empty()).count();
1360 if non_empty_lines >= 1
1361 && !is_structural_block(trimmed)
1362 && let Some(prev) = result.last()
1363 && prev.trim() == trimmed
1364 {
1365 eprintln!(
1366 "[crdt] dedup: removed duplicate block ({} lines)",
1367 non_empty_lines
1368 );
1369 continue;
1370 }
1371 result.push(*block);
1372 }
1373
1374 result.join("\n\n")
1375}
1376
1377fn is_structural_block(trimmed: &str) -> bool {
1380 let mut lines = trimmed.lines();
1381 let first = match lines.next() {
1382 Some(l) => l.trim(),
1383 None => return false,
1384 };
1385 if lines.next().is_some() {
1386 return false;
1387 }
1388 first.len() >= 3 && first.chars().all(|c| c == '-' || c == '*' || c == '_')
1389}
1390
1391pub fn compact(state: &[u8]) -> Result<Vec<u8>> {
1393 let doc = CrdtDoc::decode_state(state)?;
1394 Ok(doc.encode_state())
1395}
1396
1397fn common_prefix_len(a: &str, b: &str) -> usize {
1399 a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count()
1400}
1401
1402fn common_suffix_len(a: &str, b: &str) -> usize {
1404 a.bytes()
1405 .rev()
1406 .zip(b.bytes().rev())
1407 .take_while(|(x, y)| x == y)
1408 .count()
1409}
1410
1411#[derive(Debug)]
1413enum EditOp {
1414 Retain(u32),
1415 Delete(u32),
1416 Insert(String),
1417}
1418
1419fn compute_edit_ops(from: &str, to: &str) -> Vec<EditOp> {
1421 use similar::{ChangeTag, TextDiff};
1422
1423 let diff = TextDiff::from_lines(from, to);
1424 let mut ops = Vec::new();
1425
1426 for change in diff.iter_all_changes() {
1427 match change.tag() {
1428 ChangeTag::Equal => {
1429 let len = change.value().len() as u32;
1430 if let Some(EditOp::Retain(n)) = ops.last_mut() {
1431 *n += len;
1432 } else {
1433 ops.push(EditOp::Retain(len));
1434 }
1435 }
1436 ChangeTag::Delete => {
1437 let len = change.value().len() as u32;
1438 if let Some(EditOp::Delete(n)) = ops.last_mut() {
1439 *n += len;
1440 } else {
1441 ops.push(EditOp::Delete(len));
1442 }
1443 }
1444 ChangeTag::Insert => {
1445 let s = change.value().to_string();
1446 if let Some(EditOp::Insert(existing)) = ops.last_mut() {
1447 existing.push_str(&s);
1448 } else {
1449 ops.push(EditOp::Insert(s));
1450 }
1451 }
1452 }
1453 }
1454
1455 ops
1456}
1457
1458#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1472#[serde(tag = "kind", rename_all = "lowercase")]
1473pub enum EditorOp {
1474 Insert { offset: usize, text: String },
1476 Delete { offset: usize, len: usize },
1478}
1479
1480pub fn replay_editor_ops(base: &str, ops: &[EditorOp]) -> Option<String> {
1490 let mut buf = base.to_string();
1491 for op in ops {
1492 match op {
1493 EditorOp::Insert { offset, text } => {
1494 if *offset > buf.len() || !buf.is_char_boundary(*offset) {
1495 return None;
1496 }
1497 buf.insert_str(*offset, text);
1498 }
1499 EditorOp::Delete { offset, len } => {
1500 let end = offset.checked_add(*len)?;
1501 if end > buf.len() || !buf.is_char_boundary(*offset) || !buf.is_char_boundary(end) {
1502 return None;
1503 }
1504 buf.replace_range(*offset..end, "");
1505 }
1506 }
1507 }
1508 Some(buf)
1509}
1510
1511fn apply_editor_ops(text: &TextRef, txn: &mut yrs::TransactionMut<'_>, ops: &[EditorOp]) {
1519 for op in ops {
1520 match op {
1521 EditorOp::Insert { offset, text: s } => {
1522 text.insert(txn, *offset as u32, s);
1523 }
1524 EditorOp::Delete { offset, len } => {
1525 text.remove_range(txn, *offset as u32, *len as u32);
1526 }
1527 }
1528 }
1529}
1530
1531fn apply_ops(text: &TextRef, txn: &mut yrs::TransactionMut<'_>, ops: &[EditOp]) {
1533 let mut cursor: u32 = 0;
1534 for op in ops {
1535 match op {
1536 EditOp::Retain(n) => cursor += n,
1537 EditOp::Delete(n) => {
1538 text.remove_range(txn, cursor, *n);
1539 }
1541 EditOp::Insert(s) => {
1542 text.insert(txn, cursor, s);
1543 cursor += s.len() as u32;
1544 }
1545 }
1546 }
1547}
1548
1549#[cfg(test)]
1550mod tests {
1551 use super::*;
1552
1553 #[test]
1554 fn roundtrip_text() {
1555 let content = "Hello, world!\nLine two.\n";
1556 let doc = CrdtDoc::from_text(content);
1557 assert_eq!(doc.to_text(), content);
1558 }
1559
1560 #[test]
1563 fn replay_editor_ops_reconstructs_insert() {
1564 let base = "hello world\n";
1565 let ops = vec![EditorOp::Insert {
1567 offset: 5,
1568 text: "!".to_string(),
1569 }];
1570 assert_eq!(
1571 replay_editor_ops(base, &ops).as_deref(),
1572 Some("hello! world\n")
1573 );
1574 }
1575
1576 #[test]
1577 fn replay_editor_ops_reconstructs_delete_and_replace_sequence() {
1578 let base = "- do [#foo]\n";
1579 let ops = vec![
1581 EditorOp::Delete { offset: 7, len: 3 },
1582 EditorOp::Insert {
1583 offset: 7,
1584 text: "foobar".to_string(),
1585 },
1586 ];
1587 assert_eq!(
1588 replay_editor_ops(base, &ops).as_deref(),
1589 Some("- do [#foobar]\n")
1590 );
1591 }
1592
1593 #[test]
1594 fn replay_editor_ops_sequential_offsets_are_post_prior_op() {
1595 let base = "ab";
1598 let ops = vec![
1599 EditorOp::Insert {
1600 offset: 0,
1601 text: "X".to_string(),
1602 },
1603 EditorOp::Insert {
1604 offset: 2,
1605 text: "Y".to_string(),
1606 },
1607 ];
1608 assert_eq!(replay_editor_ops(base, &ops).as_deref(), Some("XaYb"));
1609 }
1610
1611 #[test]
1612 fn replay_editor_ops_out_of_bounds_is_none() {
1613 let base = "short";
1614 let insert = vec![EditorOp::Insert {
1615 offset: 99,
1616 text: "x".to_string(),
1617 }];
1618 assert_eq!(replay_editor_ops(base, &insert), None);
1619 let delete = vec![EditorOp::Delete { offset: 3, len: 99 }];
1620 assert_eq!(replay_editor_ops(base, &delete), None);
1621 }
1622
1623 #[test]
1624 fn replay_editor_ops_non_char_boundary_is_none() {
1625 let base = "é";
1627 let ops = vec![EditorOp::Insert {
1628 offset: 1,
1629 text: "x".to_string(),
1630 }];
1631 assert_eq!(replay_editor_ops(base, &ops), None);
1632 }
1633
1634 #[test]
1635 fn editor_op_json_roundtrip() {
1636 let ops = vec![
1637 EditorOp::Insert {
1638 offset: 3,
1639 text: "hi".to_string(),
1640 },
1641 EditorOp::Delete { offset: 0, len: 2 },
1642 ];
1643 let json = serde_json::to_string(&ops).unwrap();
1644 let back: Vec<EditorOp> = serde_json::from_str(&json).unwrap();
1645 assert_eq!(ops, back);
1646 }
1647
1648 #[test]
1649 fn merge_with_editor_ops_gate_passes_and_preserves_user_edit() {
1650 let base = "<!-- agent:queue -->\n- do [#foo]\n<!-- /agent:queue -->\n";
1653 let ours = "<!-- agent:queue -->\n- do [#foo]\n<!-- /agent:queue -->\nAGENT RESPONSE\n";
1654 let theirs = "<!-- agent:queue -->\n- do [#foobar]\n<!-- /agent:queue -->\n";
1655 let ops = vec![
1656 EditorOp::Delete { offset: 28, len: 3 },
1657 EditorOp::Insert {
1658 offset: 28,
1659 text: "foobar".to_string(),
1660 },
1661 ];
1662 assert_eq!(replay_editor_ops(base, &ops).as_deref(), Some(theirs));
1664
1665 let base_state = CrdtDoc::from_text(base).encode_state();
1666 let merged = merge_with_editor_ops(Some(&base_state), ours, theirs, Some(&ops)).unwrap();
1667 assert!(merged.contains("- do [#foobar]"), "merged: {merged}");
1669 assert!(merged.contains("AGENT RESPONSE"), "merged: {merged}");
1670 assert!(
1671 !merged.contains("- do [#foo]\n"),
1672 "stale item leaked: {merged}"
1673 );
1674 assert_eq!(
1675 merged.matches("do [#foobar]").count(),
1676 1,
1677 "duplicated: {merged}"
1678 );
1679 }
1680
1681 #[test]
1682 fn merge_with_editor_ops_stale_ops_fall_back_to_diff() {
1683 let base = "Line A\nLine B\n";
1687 let ours = "Line A\nLine B\nAgent line\n";
1688 let theirs = "Line A\nLine B edited\n";
1689 let bogus = vec![EditorOp::Insert {
1691 offset: 0,
1692 text: "ZZZ".to_string(),
1693 }];
1694 assert_ne!(replay_editor_ops(base, &bogus).as_deref(), Some(theirs));
1695
1696 let base_state = CrdtDoc::from_text(base).encode_state();
1697 let with_ops =
1698 merge_with_editor_ops(Some(&base_state), ours, theirs, Some(&bogus)).unwrap();
1699 let diff_only = merge(Some(&base_state), ours, theirs).unwrap();
1700 assert_eq!(
1701 with_ops, diff_only,
1702 "stale ops must fall back to the diff-guess result"
1703 );
1704 assert!(!with_ops.contains("ZZZ"), "stale op leaked: {with_ops}");
1706 }
1707
1708 #[test]
1709 fn merge_with_editor_ops_none_matches_plain_merge() {
1710 let base = "header\n\nbody\n";
1712 let ours = "header\n\nbody\nagent\n";
1713 let theirs = "header\n\nbody edited\n";
1714 let base_state = CrdtDoc::from_text(base).encode_state();
1715 assert_eq!(
1716 merge_with_editor_ops(Some(&base_state), ours, theirs, None).unwrap(),
1717 merge(Some(&base_state), ours, theirs).unwrap()
1718 );
1719 }
1720
1721 #[test]
1722 fn merge_with_editor_ops_empty_ops_fall_back() {
1723 let base = "x\ny\n";
1724 let ours = "x\ny\nagent\n";
1725 let theirs = "x\ny edited\n";
1726 let base_state = CrdtDoc::from_text(base).encode_state();
1727 let empty: Vec<EditorOp> = vec![];
1728 assert_eq!(
1729 merge_with_editor_ops(Some(&base_state), ours, theirs, Some(&empty)).unwrap(),
1730 merge(Some(&base_state), ours, theirs).unwrap()
1731 );
1732 }
1733
1734 #[test]
1735 fn roundtrip_encode_decode() {
1736 let content = "Some document content.\n";
1737 let doc = CrdtDoc::from_text(content);
1738 let encoded = doc.encode_state();
1739 let decoded = CrdtDoc::decode_state(&encoded).unwrap();
1740 assert_eq!(decoded.to_text(), content);
1741 }
1742
1743 #[test]
1744 fn apply_edit_insert() {
1745 let doc = CrdtDoc::from_text("Hello world");
1746 doc.apply_edit(5, 0, ",");
1747 assert_eq!(doc.to_text(), "Hello, world");
1748 }
1749
1750 #[test]
1751 fn apply_edit_delete() {
1752 let doc = CrdtDoc::from_text("Hello, world");
1753 doc.apply_edit(5, 1, "");
1754 assert_eq!(doc.to_text(), "Hello world");
1755 }
1756
1757 #[test]
1758 fn apply_edit_replace() {
1759 let doc = CrdtDoc::from_text("Hello world");
1760 doc.apply_edit(6, 5, "Rust");
1761 assert_eq!(doc.to_text(), "Hello Rust");
1762 }
1763
1764 #[test]
1765 fn concurrent_append_merge_no_conflict() {
1766 let base = "# Document\n\nBase content.\n";
1767 let base_doc = CrdtDoc::from_text(base);
1768 let base_state = base_doc.encode_state();
1769
1770 let ours = format!("{base}## Agent\n\nAgent response.\n");
1771 let theirs = format!("{base}## User\n\nUser addition.\n");
1772
1773 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
1774
1775 assert!(merged.contains("Agent response."), "missing agent text");
1777 assert!(merged.contains("User addition."), "missing user text");
1778 assert!(merged.contains("Base content."), "missing base text");
1779 assert!(!merged.contains("<<<<<<<"));
1781 assert!(!merged.contains(">>>>>>>"));
1782 }
1783
1784 #[test]
1785 fn concurrent_insert_same_position() {
1786 let base = "Line 1\nLine 3\n";
1787 let base_doc = CrdtDoc::from_text(base);
1788 let base_state = base_doc.encode_state();
1789
1790 let ours = "Line 1\nAgent line\nLine 3\n";
1791 let theirs = "Line 1\nUser line\nLine 3\n";
1792
1793 let merged = merge(Some(&base_state), ours, theirs).unwrap();
1794
1795 assert!(merged.contains("Agent line"), "missing agent insertion");
1797 assert!(merged.contains("User line"), "missing user insertion");
1798 assert!(merged.contains("Line 1"), "missing line 1");
1799 assert!(merged.contains("Line 3"), "missing line 3");
1800 }
1801
1802 #[test]
1803 fn merge_no_base_state() {
1804 let ours = "Agent wrote this.\n";
1806 let theirs = "User wrote this.\n";
1807
1808 let merged = merge(None, ours, theirs).unwrap();
1809
1810 assert!(merged.contains("Agent wrote this."));
1811 assert!(merged.contains("User wrote this."));
1812 }
1813
1814 #[test]
1815 fn compact_preserves_content() {
1816 let doc = CrdtDoc::from_text("Hello");
1817 doc.apply_edit(5, 0, " world");
1818 doc.apply_edit(11, 0, "!");
1819
1820 let state = doc.encode_state();
1821 let compacted = compact(&state).unwrap();
1822 let restored = CrdtDoc::decode_state(&compacted).unwrap();
1823
1824 assert_eq!(restored.to_text(), "Hello world!");
1825 assert!(compacted.len() <= state.len());
1826 }
1827
1828 #[test]
1829 fn compact_reduces_size_after_edits() {
1830 let doc = CrdtDoc::from_text("aaaa");
1831 for i in 0..20 {
1833 let c = ((b'a' + (i % 26)) as char).to_string();
1834 doc.apply_edit(0, 1, &c);
1835 }
1836 let state = doc.encode_state();
1837 let compacted = compact(&state).unwrap();
1838 let restored = CrdtDoc::decode_state(&compacted).unwrap();
1839 assert_eq!(restored.to_text(), doc.to_text());
1840 }
1841
1842 #[test]
1843 fn empty_document() {
1844 let doc = CrdtDoc::from_text("");
1845 assert_eq!(doc.to_text(), "");
1846
1847 let encoded = doc.encode_state();
1848 let decoded = CrdtDoc::decode_state(&encoded).unwrap();
1849 assert_eq!(decoded.to_text(), "");
1850 }
1851
1852 #[test]
1853 fn decode_invalid_bytes_errors() {
1854 let result = CrdtDoc::decode_state(&[0xff, 0xfe, 0xfd]);
1855 assert!(result.is_err());
1856 }
1857
1858 #[test]
1859 fn merge_identical_texts() {
1860 let base = "Same content.\n";
1861 let base_doc = CrdtDoc::from_text(base);
1862 let state = base_doc.encode_state();
1863
1864 let merged = merge(Some(&state), base, base).unwrap();
1865 assert_eq!(merged, base);
1866 }
1867
1868 #[test]
1869 fn merge_one_side_unchanged() {
1870 let base = "Original.\n";
1871 let base_doc = CrdtDoc::from_text(base);
1872 let state = base_doc.encode_state();
1873
1874 let ours = "Original.\nAgent added.\n";
1875 let merged = merge(Some(&state), ours, base).unwrap();
1876 assert_eq!(merged, ours);
1877 }
1878
1879 #[test]
1890 fn merge_stale_base_no_duplicate_user_prompt() {
1891 let base_content = "\
1893## Assistant
1894
1895Previous response content.
1896
1897Committed and pushed.
1898
1899";
1900 let base_doc = CrdtDoc::from_text(base_content);
1901 let base_state = base_doc.encode_state();
1902
1903 let user_prompt = "\
1905Opening a video a shows video a.
1906Closing video a then opening video b start video b but video b is hidden.
1907Closing video b then reopening video b starts and shows video b. video b is visible.
1908";
1909
1910 let ours = format!(
1912 "\
1913{}{}### Re: Close A → Open B still hidden
1914
1915Added explicit height and visibility reset.
1916
1917Committed and pushed.
1918
1919",
1920 base_content, user_prompt
1921 );
1922
1923 let theirs = format!(
1925 "\
1926{}{}
1927",
1928 base_content, user_prompt
1929 );
1930
1931 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
1932
1933 let prompt_count = merged.matches("Opening a video a shows video a.").count();
1935 assert_eq!(
1936 prompt_count, 1,
1937 "User prompt duplicated! Appeared {} times in:\n{}",
1938 prompt_count, merged
1939 );
1940
1941 assert!(
1943 merged.contains("### Re: Close A → Open B still hidden"),
1944 "Agent response missing from merge:\n{}",
1945 merged
1946 );
1947 }
1948
1949 #[test]
1952 fn merge_stale_base_same_insertion_both_sides() {
1953 let base_content = "Line 1\nLine 2\n";
1954 let base_doc = CrdtDoc::from_text(base_content);
1955 let base_state = base_doc.encode_state();
1956
1957 let shared_addition = "User typed this.\n";
1959 let ours = format!("{}{}Agent response.\n", base_content, shared_addition);
1960 let theirs = format!("{}{}", base_content, shared_addition);
1961
1962 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
1963
1964 let count = merged.matches("User typed this.").count();
1965 assert_eq!(
1966 count, 1,
1967 "Shared text duplicated! Appeared {} times in:\n{}",
1968 count, merged
1969 );
1970 assert!(
1971 merged.contains("Agent response."),
1972 "Agent text missing:\n{}",
1973 merged
1974 );
1975 }
1976
1977 #[test]
1993 fn merge_no_character_interleaving() {
1994 let base = "# Doc\n\nPrevious content.\n\n";
1996 let base_doc = CrdtDoc::from_text(base);
1997 let base_state = base_doc.encode_state();
1998
1999 let ours = "# Doc\n\nPrevious content.\n\n*Compacted. Content archived to*\n";
2001 let theirs = "# Doc\n\nPrevious content.\n\n**Soft-bristle brush only**\n";
2003
2004 let merged = merge(Some(&base_state), ours, theirs).unwrap();
2005
2006 assert!(
2008 merged.contains("*Compacted. Content archived to*"),
2009 "Agent text should be contiguous (not interleaved). Got:\n{}",
2010 merged
2011 );
2012 assert!(
2013 merged.contains("**Soft-bristle brush only**"),
2014 "User text should be contiguous (not interleaved). Got:\n{}",
2015 merged
2016 );
2017 }
2018
2019 #[test]
2022 fn merge_concurrent_same_line_no_garbling() {
2023 let base = "Some base text\n";
2024 let base_doc = CrdtDoc::from_text(base);
2025 let base_state = base_doc.encode_state();
2026
2027 let ours = "Agent wrote this line\n";
2029 let theirs = "User wrote different text\n";
2030
2031 let merged = merge(Some(&base_state), ours, theirs).unwrap();
2032
2033 let has_agent_contiguous = merged.contains("Agent wrote this line");
2035 let has_user_contiguous = merged.contains("User wrote different text");
2036
2037 assert!(
2038 has_agent_contiguous || has_user_contiguous,
2039 "At least one side should have contiguous text (no char interleaving). Got:\n{}",
2040 merged
2041 );
2042 }
2043
2044 #[test]
2060 fn merge_replace_vs_append_no_interleaving() {
2061 let header = "---\nagent_doc_format: template\n---\n\n# Title\n\n<!-- agent:exchange -->\n";
2063 let footer = "\n<!-- /agent:exchange -->\n";
2064
2065 let old_exchange = "\
2067### Committed, Pushed & Released
2068
2069**project (v0.1.0):**
2070- Committed initial implementation
2071- Tagged v0.1.0 and pushed
2072
2073Add a README.md to the project.
2074Also add AGENTS.md with a symlink CLAUDE.md
2075
2076**sub-project:**
2077- Committed fix + SPEC.md
2078- Pushed to remote
2079";
2080 let stale_base = format!("{header}{old_exchange}{footer}");
2081 let stale_state = CrdtDoc::from_text(&stale_base).encode_state();
2082
2083 let _baseline = stale_base.clone();
2086
2087 let agent_exchange = "\
2089### Done
2090
2091Added to project and pushed:
2092
2093- **README.md** — overview, usage, design notes
2094- **AGENTS.md** — architecture, key decisions, commands, related projects
2095- **CLAUDE.md** → symlink to AGENTS.md
2096
2097All committed and pushed.
2098";
2099 let ours = format!("{header}{agent_exchange}{footer}");
2100
2101 let theirs_exchange = "\
2105### Committed, Pushed & Released
2106
2107**project (v0.1.0):**
2108- Committed initial implementation
2109- Tagged v0.1.0 and pushed
2110
2111Add a README.md to the project.
2112Also add AGENTS.md with a symlink CLAUDE.md
2113
2114Please add tests.
2115Please comprehensively test adherence to the spec.
2116
2117**sub-project:**
2118- Committed fix + SPEC.md
2119- Pushed to remote
2120";
2121 let theirs = format!("{header}{theirs_exchange}{footer}");
2122
2123 let merged = merge(Some(&stale_state), &ours, &theirs).unwrap();
2125
2126 assert!(
2128 merged.contains(
2129 "- **AGENTS.md** — architecture, key decisions, commands, related projects"
2130 ),
2131 "Agent text garbled (mid-word split). Got:\n{}",
2132 merged
2133 );
2134
2135 assert!(
2137 merged.contains("Please add tests."),
2138 "User addition missing. Got:\n{}",
2139 merged
2140 );
2141
2142 assert!(
2144 !merged.contains("key deAdd") && !merged.contains("key de\n"),
2145 "Old content interleaved into agent text. Got:\n{}",
2146 merged
2147 );
2148 }
2149
2150 #[test]
2153 fn merge_replace_vs_append_with_baseline_base() {
2154 let header = "---\nagent_doc_format: template\n---\n\n# Title\n\n<!-- agent:exchange -->\n";
2155 let footer = "\n<!-- /agent:exchange -->\n";
2156
2157 let old_exchange = "\
2158### Previous Response
2159
2160Old content here.
2161
2162Add a README.md to the project.
2163Also add AGENTS.md with a symlink CLAUDE.md
2164";
2165 let baseline = format!("{header}{old_exchange}{footer}");
2166
2167 let agent_exchange = "\
2169### Done
2170
2171- **README.md** — overview, usage, design notes
2172- **AGENTS.md** — architecture, key decisions, commands, related projects
2173- **CLAUDE.md** → symlink to AGENTS.md
2174
2175All committed and pushed.
2176";
2177 let ours = format!("{header}{agent_exchange}{footer}");
2178
2179 let user_addition = "\nPlease add tests.\n";
2181 let theirs = format!("{header}{old_exchange}{user_addition}{footer}");
2182
2183 let baseline_state = CrdtDoc::from_text(&baseline).encode_state();
2185 let merged = merge(Some(&baseline_state), &ours, &theirs).unwrap();
2186
2187 assert!(
2189 merged.contains("key decisions, commands, related projects"),
2190 "Agent text garbled. Got:\n{}",
2191 merged
2192 );
2193
2194 assert!(
2196 merged.contains("Please add tests."),
2197 "User addition missing. Got:\n{}",
2198 merged
2199 );
2200 }
2201
2202 #[test]
2209 fn merge_streaming_concurrent_edit_preserves_formatting() {
2210 let base = "commit and push all rappstack packages.\n\n";
2212 let base_doc = CrdtDoc::from_text(base);
2213 let base_state = base_doc.encode_state();
2214
2215 let ours = "\
2217commit and push all rappstack packages.
2218
2219### Re: commit and push
2220
2221*Compacted. Content archived to `docs/`*
2222
2223Done — all packages pushed.
2224";
2225
2226 let theirs = "\
2228commit and push all rappstack packages.
2229
2230**Soft-bristle brush only**
2231";
2232
2233 let merged = merge(Some(&base_state), ours, theirs).unwrap();
2234
2235 assert!(
2237 merged.contains("*Compacted. Content archived to `docs/`*"),
2238 "Agent formatting broken. Got:\n{}",
2239 merged
2240 );
2241 assert!(
2243 merged.contains("**Soft-bristle brush only**"),
2244 "User formatting broken. Got:\n{}",
2245 merged
2246 );
2247 assert!(
2249 !merged.contains("*C*C") && !merged.contains("**Sot"),
2250 "Character interleaving detected. Got:\n{}",
2251 merged
2252 );
2253 }
2254
2255 #[test]
2262 fn merge_replace_vs_insert_no_interleaving() {
2263 let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n# Document Title\n\nSome preamble text that both sides share.\nThis provides enough common prefix to avoid stale detection.\n\n<!-- agent:exchange -->\n";
2264 let footer = "<!-- /agent:exchange -->\n";
2265
2266 let old_exchange =
2267 "Line one of old content\nLine two of old content\nLine three of old content\n";
2268 let baseline = format!("{header}{old_exchange}{footer}");
2269 let baseline_doc = CrdtDoc::from_text(&baseline);
2270 let baseline_state = baseline_doc.encode_state();
2271
2272 let agent_exchange = "Completely new line one\nCompletely new line two\nCompletely new line three\nCompletely new line four\n";
2274 let ours = format!("{header}{agent_exchange}{footer}");
2275
2276 let theirs = format!(
2278 "{header}Line one of old content\nUser inserted this line\nLine two of old content\nLine three of old content\n{footer}"
2279 );
2280
2281 let merged = merge(Some(&baseline_state), &ours, &theirs).unwrap();
2282
2283 assert!(
2285 merged.contains("Completely new line one"),
2286 "Agent line 1 missing or garbled. Got:\n{}",
2287 merged
2288 );
2289 assert!(
2290 merged.contains("Completely new line two"),
2291 "Agent line 2 missing or garbled. Got:\n{}",
2292 merged
2293 );
2294
2295 assert!(
2297 merged.contains("User inserted this line"),
2298 "User insertion missing. Got:\n{}",
2299 merged
2300 );
2301
2302 assert!(
2304 !merged.contains("CompleteUser") && !merged.contains("Complete\nUser"),
2305 "Character interleaving detected. Got:\n{}",
2306 merged
2307 );
2308 }
2309
2310 #[test]
2313 fn reorder_agent_before_human_at_append_boundary() {
2314 let base = "# Document\n\nBase content.\n";
2315 let base_doc = CrdtDoc::from_text(base);
2316 let base_state = base_doc.encode_state();
2317
2318 let ours = format!("{base}### Agent Response\n\nAgent wrote this.\n");
2320 let theirs = format!("{base}User added this line.\n");
2322
2323 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2324
2325 assert!(merged.contains("Agent wrote this."), "missing agent text");
2327 assert!(
2328 merged.contains("User added this line."),
2329 "missing user text"
2330 );
2331 assert!(merged.contains("Base content."), "missing base text");
2332
2333 let agent_pos = merged.find("Agent wrote this.").unwrap();
2335 let human_pos = merged.find("User added this line.").unwrap();
2336 assert!(
2337 agent_pos < human_pos,
2338 "Agent content should appear before human content.\nAgent pos: {}, Human pos: {}\nMerged:\n{}",
2339 agent_pos,
2340 human_pos,
2341 merged
2342 );
2343 }
2344
2345 #[test]
2350 fn dedup_removes_identical_adjacent_blocks() {
2351 let text =
2352 "### Re: Question\nAnswer here.\n\n### Re: Question\nAnswer here.\n\nDifferent block.";
2353 let result = dedup_adjacent_blocks(text);
2354 assert_eq!(result.matches("### Re: Question").count(), 1);
2355 assert!(result.contains("Different block."));
2356 }
2357
2358 #[test]
2359 fn dedup_preserves_different_adjacent_blocks() {
2360 let text = "### Re: First\nAnswer one.\n\n### Re: Second\nAnswer two.";
2361 let result = dedup_adjacent_blocks(text);
2362 assert!(result.contains("### Re: First"));
2363 assert!(result.contains("### Re: Second"));
2364 }
2365
2366 #[test]
2367 fn dedup_preserves_structural_thematic_breaks() {
2368 let text = "---\n\n---\n\nContent.";
2369 let result = dedup_adjacent_blocks(text);
2370 assert_eq!(result, text);
2371
2372 let text2 = "***\n\n***\n\nContent.";
2373 assert_eq!(dedup_adjacent_blocks(text2), text2);
2374
2375 let text3 = "___\n\n___\n\nContent.";
2376 assert_eq!(dedup_adjacent_blocks(text3), text3);
2377 }
2378
2379 #[test]
2380 fn dedup_removes_single_line_content_duplicates() {
2381 let text = "### Re: topic — opus-4-6\n\n### Re: topic — opus-4-6\n\nDifferent.";
2382 let result = dedup_adjacent_blocks(text);
2383 assert_eq!(result.matches("### Re: topic").count(), 1);
2384 assert!(result.contains("Different."));
2385 }
2386
2387 #[test]
2388 fn dedup_removes_single_line_text_duplicates() {
2389 let text = "Some response text.\n\nSome response text.\n\nNext section.";
2390 let result = dedup_adjacent_blocks(text);
2391 assert_eq!(result.matches("Some response text.").count(), 1);
2392 assert!(result.contains("Next section."));
2393 }
2394
2395 #[test]
2396 fn dedup_preserves_different_single_line_blocks() {
2397 let text = "Line A.\n\nLine B.\n\nLine C.";
2398 let result = dedup_adjacent_blocks(text);
2399 assert_eq!(result, text);
2400 }
2401
2402 #[test]
2403 fn dedup_handles_empty_text() {
2404 assert_eq!(dedup_adjacent_blocks(""), "");
2405 }
2406
2407 #[test]
2408 fn dedup_no_change_when_no_duplicates() {
2409 let text = "Block A\nLine 2.\n\nBlock B\nLine 2.";
2410 let result = dedup_adjacent_blocks(text);
2411 assert_eq!(result, text);
2412 }
2413
2414 #[test]
2415 fn is_structural_block_detects_thematic_breaks() {
2416 assert!(is_structural_block("---"));
2417 assert!(is_structural_block("***"));
2418 assert!(is_structural_block("___"));
2419 assert!(is_structural_block("-----"));
2420 assert!(!is_structural_block("--"));
2421 assert!(!is_structural_block("### Heading"));
2422 assert!(!is_structural_block("some text"));
2423 assert!(!is_structural_block("---\nmore"));
2424 }
2425
2426 #[test]
2435 fn merge_empty_exchange_no_duplicate_user_line() {
2436 let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2437 let footer = "<!-- /agent:exchange -->\n\n## Pending\n\n<!-- agent:pending -->\n<!-- /agent:pending -->\n";
2438
2439 let base = format!("{header}\n<!-- agent:boundary:base-id -->\n{footer}");
2441
2442 let ours = format!(
2444 "{header}\n### Re: topic — opus-4-6\n\nResponse content here.\n\nMore response text.\n\n<!-- agent:boundary:new-id -->\n{footer}"
2445 );
2446
2447 let theirs = format!(
2449 "{header}User prompt line one.\nUser prompt line two.\nLast line of user prompt.\n<!-- agent:boundary:base-id -->\n{footer}"
2450 );
2451
2452 let base_doc = CrdtDoc::from_text(&base);
2453 let base_state = base_doc.encode_state();
2454
2455 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2456
2457 let count1 = merged.matches("User prompt line one.").count();
2459 assert_eq!(
2460 count1, 1,
2461 "Line one duplicated (count={}). Merged:\n{}",
2462 count1, merged
2463 );
2464
2465 let count2 = merged.matches("User prompt line two.").count();
2466 assert_eq!(
2467 count2, 1,
2468 "Line two duplicated (count={}). Merged:\n{}",
2469 count2, merged
2470 );
2471
2472 let count3 = merged.matches("Last line of user prompt.").count();
2473 assert_eq!(
2474 count3, 1,
2475 "Last line duplicated (count={}). Merged:\n{}",
2476 count3, merged
2477 );
2478
2479 assert!(
2481 merged.contains("### Re: topic — opus-4-6"),
2482 "Response heading missing:\n{}",
2483 merged
2484 );
2485 assert!(
2486 merged.contains("Response content here."),
2487 "Response content missing:\n{}",
2488 merged
2489 );
2490
2491 let prompt_pos = merged.find("User prompt line one.").unwrap();
2493 let response_pos = merged.find("### Re: topic").unwrap();
2494 assert!(
2495 prompt_pos < response_pos,
2496 "User prompt should precede response. prompt={} response={}\nMerged:\n{}",
2497 prompt_pos,
2498 response_pos,
2499 merged
2500 );
2501 }
2502
2503 #[test]
2505 fn merge_empty_exchange_prefixed_prompts_no_duplicate() {
2506 let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2507 let footer = "<!-- /agent:exchange -->\n";
2508
2509 let base = format!("{header}\n<!-- agent:boundary:base-id -->\n{footer}");
2510
2511 let ours = format!(
2513 "{header}❯ Review the code\n❯ What does it do?\n\n### Re: code review — opus-4-6\n\nThe code does X.\n\n<!-- agent:boundary:new-id -->\n{footer}"
2514 );
2515
2516 let theirs = format!(
2518 "{header}❯ Review the code\n❯ What does it do?\n<!-- agent:boundary:base-id -->\n{footer}"
2519 );
2520
2521 let base_doc = CrdtDoc::from_text(&base);
2522 let base_state = base_doc.encode_state();
2523
2524 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2525
2526 let count = merged.matches("❯ Review the code").count();
2527 assert_eq!(
2528 count, 1,
2529 "Prompt duplicated (count={}). Merged:\n{}",
2530 count, merged
2531 );
2532
2533 assert!(
2534 merged.contains("The code does X."),
2535 "Response missing:\n{}",
2536 merged
2537 );
2538 }
2539
2540 #[test]
2550 fn merge_foreign_tail_append_preserves_response_order() {
2551 let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2552 let exchange_committed = "\
2553❯ first question
2554
2555### Re: first question — opus-4-8
2556
2557First answer here. Already committed to HEAD.
2558
2559❯ second question
2560";
2561 let queue_open = "<!-- /agent:exchange -->\n\n## Queue\n\n<!-- agent:queue -->\n";
2562 let queue_close = "<!-- /agent:queue -->\n";
2563
2564 let base = format!(
2567 "{header}{exchange_committed}<!-- agent:boundary:base-id -->\n{queue_open}{queue_close}"
2568 );
2569
2570 let agent_response = "\
2572### Re: second question — opus-4-8
2573
2574Second answer line one.
2575Second answer line two.
2576Second answer line three.
2577
2578";
2579 let ours = format!(
2580 "{header}{exchange_committed}{agent_response}<!-- agent:boundary:new-id -->\n{queue_open}{queue_close}"
2581 );
2582
2583 let theirs = format!(
2586 "{header}{exchange_committed}<!-- agent:boundary:base-id -->\n{queue_open}do [#foreign-task]\n{queue_close}"
2587 );
2588
2589 let base_state = CrdtDoc::from_text(&base).encode_state();
2590 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2591
2592 assert!(
2594 merged.contains("### Re: second question — opus-4-8"),
2595 "new response heading dropped:\n{}",
2596 merged
2597 );
2598 let l1 = merged
2600 .find("Second answer line one.")
2601 .expect("line one missing");
2602 let l2 = merged
2603 .find("Second answer line two.")
2604 .expect("line two missing");
2605 let l3 = merged
2606 .find("Second answer line three.")
2607 .expect("line three missing");
2608 assert!(
2609 l1 < l2 && l2 < l3,
2610 "response lines reversed/reordered (l1={l1} l2={l2} l3={l3}):\n{merged}"
2611 );
2612 let prior = merged
2614 .find("### Re: first question")
2615 .expect("prior response missing");
2616 let current = merged.find("### Re: second question").unwrap();
2617 assert!(
2618 prior < current,
2619 "new response hoisted above prior HEAD response (prior={prior} current={current}):\n{merged}"
2620 );
2621 assert!(
2623 merged.contains("do [#foreign-task]"),
2624 "foreign queue append lost:\n{}",
2625 merged
2626 );
2627 }
2628
2629 #[test]
2635 fn merge_stale_theirs_cannot_delete_committed_response() {
2636 let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2637 let committed = "❯ first question\n\n### Re: first question — opus-4-8\n\nFirst answer. Committed to HEAD.\n\n❯ second question\n";
2638 let foot = "<!-- /agent:exchange -->\n";
2639 let base = format!("{header}{committed}<!-- agent:boundary:base-id -->\n{foot}");
2640 let resp = "### Re: second question — opus-4-8\n\nLine one.\nLine two.\nLine three.\n\n";
2641 let ours = format!("{header}{committed}{resp}<!-- agent:boundary:new-id -->\n{foot}");
2642
2643 let theirs = format!("{header}❯ second question\n<!-- agent:boundary:base-id -->\n{foot}");
2646
2647 let base_state = CrdtDoc::from_text(&base).encode_state();
2648 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2649
2650 assert!(
2651 merged.contains("### Re: first question — opus-4-8"),
2652 "committed prior response heading deleted by stale theirs:\n{merged}"
2653 );
2654 assert!(
2655 merged.contains("First answer. Committed to HEAD."),
2656 "committed prior response body deleted by stale theirs:\n{merged}"
2657 );
2658 assert!(
2659 merged.contains("### Re: second question — opus-4-8"),
2660 "new response heading missing:\n{merged}"
2661 );
2662 let prior = merged.find("### Re: first question").unwrap();
2664 let current = merged.find("### Re: second question").unwrap();
2665 assert!(prior < current, "response order inverted:\n{merged}");
2666 }
2667
2668 #[test]
2673 fn merge_committed_guard_keeps_new_user_input() {
2674 let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n## Exchange\n\n<!-- agent:exchange patch=append -->\n";
2675 let committed = "❯ first question\n\n### Re: first question — opus-4-8\n\nFirst answer. Committed to HEAD.\n\n❯ second question\n";
2676 let foot = "<!-- /agent:exchange -->\n";
2677 let base = format!("{header}{committed}<!-- agent:boundary:base-id -->\n{foot}");
2678 let resp = "### Re: second question — opus-4-8\n\nAnswer body.\n\n";
2679 let ours = format!("{header}{committed}{resp}<!-- agent:boundary:new-id -->\n{foot}");
2680
2681 let theirs = format!(
2683 "{header}{committed}❯ a genuinely new third prompt\n<!-- agent:boundary:base-id -->\n{foot}"
2684 );
2685
2686 let base_state = CrdtDoc::from_text(&base).encode_state();
2687 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
2688
2689 assert!(
2690 merged.contains("❯ a genuinely new third prompt"),
2691 "new user prompt clobbered by committed-response guard:\n{merged}"
2692 );
2693 assert!(
2694 merged.contains("### Re: first question"),
2695 "committed prior response lost:\n{merged}"
2696 );
2697 assert!(
2698 merged.contains("### Re: second question"),
2699 "new response lost:\n{merged}"
2700 );
2701 }
2702
2703 fn doc_with_exchange_queue(exchange: &str, queue: &str) -> String {
2707 format!(
2708 "---\nagent_doc_format: template\n---\n\n## Exchange\n\n<!-- agent:exchange -->\n{exchange}\n<!-- /agent:exchange -->\n\n## Queue\n\n<!-- agent:queue -->\n{queue}\n<!-- /agent:queue -->\n"
2709 )
2710 }
2711
2712 #[test]
2713 fn merge_by_component_isolates_concurrent_cross_component_edits() {
2714 let base = doc_with_exchange_queue("Existing prompt.", "- do [#a1]");
2717 let base_state = CrdtDoc::from_text(&base).encode_state();
2718
2719 let ours = doc_with_exchange_queue(
2720 "Existing prompt.\n\n### Re: existing — opus\n\nAgent response body.",
2721 "- do [#a1]",
2722 );
2723 let theirs = doc_with_exchange_queue("Existing prompt.", "- do [#a1]\n- do [#b2]");
2724
2725 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2726
2727 assert!(
2729 merged.contains("### Re: existing"),
2730 "agent exchange edit lost:\n{merged}"
2731 );
2732 assert!(
2733 merged.contains("[#b2]"),
2734 "operator queue edit lost:\n{merged}"
2735 );
2736
2737 let queue_body = merged
2741 .split("<!-- agent:queue -->")
2742 .nth(1)
2743 .and_then(|s| s.split("<!-- /agent:queue -->").next())
2744 .unwrap_or("");
2745 let exchange_body = merged
2746 .split("<!-- agent:exchange -->")
2747 .nth(1)
2748 .and_then(|s| s.split("<!-- /agent:exchange -->").next())
2749 .unwrap_or("");
2750 assert!(
2751 !queue_body.contains("Agent response body"),
2752 "exchange content spliced into queue:\n{queue_body}"
2753 );
2754 assert!(
2755 !exchange_body.contains("[#b2]"),
2756 "queue content spliced into exchange:\n{exchange_body}"
2757 );
2758 }
2759
2760 #[test]
2761 fn merge_by_component_blocks_console_output_in_queue_corruption() {
2762 let console = "```\n● Supervisor is now fresh (recycled to 0.34.35).\n```";
2768 let base = doc_with_exchange_queue("Q.", "- :pushpin: typing here");
2769 let base_state = CrdtDoc::from_text(&base).encode_state();
2770
2771 let ours = doc_with_exchange_queue(
2772 &format!("Q.\n\n### Re: q\n\n{console}"),
2773 "- :pushpin: typing here",
2774 );
2775 let theirs = doc_with_exchange_queue("Q.", "- :pushpin: typing here now extended");
2776
2777 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2778
2779 let queue_body = merged
2780 .split("<!-- agent:queue -->")
2781 .nth(1)
2782 .and_then(|s| s.split("<!-- /agent:queue -->").next())
2783 .unwrap_or("");
2784 assert!(
2785 !queue_body.contains("Supervisor is now fresh"),
2786 "console output corrupted the queue node:\n{queue_body}"
2787 );
2788 assert!(
2789 queue_body.contains("extended"),
2790 "operator queue edit lost:\n{queue_body}"
2791 );
2792 assert!(
2793 merged.contains("Supervisor is now fresh"),
2794 "console output must remain in its own (exchange) node:\n{merged}"
2795 );
2796 }
2797
2798 #[test]
2799 fn merge_by_component_degenerates_to_whole_doc_without_components() {
2800 let base = "# Document\n\nBase content.\n";
2803 let base_state = CrdtDoc::from_text(base).encode_state();
2804 let ours = format!("{base}## Agent\n\nAgent response.\n");
2805 let theirs = format!("{base}## User\n\nUser addition.\n");
2806
2807 let by_component = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2808 let whole_doc = merge(Some(&base_state), &ours, &theirs).unwrap();
2809 assert_eq!(
2810 by_component, whole_doc,
2811 "component merge must equal whole-doc merge for a component-less doc"
2812 );
2813 }
2814
2815 #[test]
2816 fn merge_by_component_falls_back_on_structural_divergence() {
2817 let base = doc_with_exchange_queue("Q.", "- do [#a1]");
2820 let base_state = CrdtDoc::from_text(&base).encode_state();
2821 let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nResponse.", "- do [#a1]");
2822 let theirs = "---\nagent_doc_format: template\n---\n\n## Exchange\n\n<!-- agent:exchange -->\nQ.\n<!-- /agent:exchange -->\n".to_string();
2824
2825 let by_component = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2827 let whole_doc = merge(Some(&base_state), &ours, &theirs).unwrap();
2828 assert_eq!(
2829 by_component, whole_doc,
2830 "structural divergence must use whole-doc fallback"
2831 );
2832 }
2833
2834 #[test]
2837 fn multinode_state_roundtrip_encode_decode() {
2838 let doc =
2841 doc_with_exchange_queue("Prompt.\n\n### Re: q\n\nBody.", "- do [#a1]\n- do [#b2]");
2842 let state = MultiNodeState::from_text(&doc).unwrap();
2843 let encoded = state.encode();
2844 let decoded = MultiNodeState::decode(&encoded).unwrap();
2845 assert_eq!(
2846 decoded, state,
2847 "decode must reproduce the encoded per-node state"
2848 );
2849 assert_eq!(
2850 decoded.to_text().unwrap(),
2851 doc,
2852 "per-node state must reconstruct the original document text"
2853 );
2854 assert!(
2856 decoded.nodes.len() >= 4,
2857 "expected ≥4 nodes, got {}",
2858 decoded.nodes.len()
2859 );
2860 assert!(
2861 decoded
2862 .nodes
2863 .iter()
2864 .any(|n| n.name.as_deref() == Some("exchange"))
2865 );
2866 assert!(
2867 decoded
2868 .nodes
2869 .iter()
2870 .any(|n| n.name.as_deref() == Some("queue"))
2871 );
2872 }
2873
2874 #[test]
2875 fn multinode_state_decode_rejects_non_container() {
2876 let legacy = CrdtDoc::from_text("# Doc\n\nlegacy whole-doc state\n").encode_state();
2879 assert!(
2880 MultiNodeState::decode(&legacy).is_err(),
2881 "legacy whole-doc state must not decode as a per-node container"
2882 );
2883 }
2884
2885 #[test]
2886 fn multinode_state_migrates_legacy_whole_doc() {
2887 let doc = doc_with_exchange_queue("Q.", "- do [#a1]");
2889 let legacy = CrdtDoc::from_text(&doc).encode_state();
2890 let migrated = MultiNodeState::decode_or_migrate(&legacy, "");
2891 assert_eq!(
2892 migrated.to_text().unwrap(),
2893 doc,
2894 "legacy migration must preserve the full document text"
2895 );
2896 assert!(
2897 migrated
2898 .nodes
2899 .iter()
2900 .any(|n| n.name.as_deref() == Some("queue")),
2901 "legacy migration must recover component nodes"
2902 );
2903 }
2904
2905 #[test]
2906 fn multinode_merge_advances_node_base_independently() {
2907 let base_doc = doc_with_exchange_queue("Q.", "- do [#a1]");
2911 let base = MultiNodeState::from_text(&base_doc).unwrap();
2912
2913 let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nAgent body.", "- do [#a1]");
2915 let theirs = base_doc.clone();
2916
2917 let (merged, advanced) = MultiNodeState::merge(Some(&base), &ours, &theirs).unwrap();
2918 assert!(
2919 merged.contains("Agent body."),
2920 "exchange edit lost:\n{merged}"
2921 );
2922
2923 let queue_before = base
2924 .nodes
2925 .iter()
2926 .find(|n| n.name.as_deref() == Some("queue"))
2927 .map(|n| &n.state);
2928 let queue_after = advanced
2929 .nodes
2930 .iter()
2931 .find(|n| n.name.as_deref() == Some("queue"))
2932 .map(|n| &n.state);
2933 assert_eq!(
2934 queue_before, queue_after,
2935 "untouched queue node base must be byte-identical across the cycle"
2936 );
2937
2938 let exchange_before = base
2939 .nodes
2940 .iter()
2941 .find(|n| n.name.as_deref() == Some("exchange"))
2942 .map(|n| &n.state);
2943 let exchange_after = advanced
2944 .nodes
2945 .iter()
2946 .find(|n| n.name.as_deref() == Some("exchange"))
2947 .map(|n| &n.state);
2948 assert_ne!(
2949 exchange_before, exchange_after,
2950 "changed exchange node base must advance"
2951 );
2952 }
2953
2954 #[test]
2955 fn multinode_merge_isolates_concurrent_cross_component_edits() {
2956 let base_doc = doc_with_exchange_queue("Existing prompt.", "- do [#a1]");
2959 let base = MultiNodeState::from_text(&base_doc).unwrap();
2960
2961 let ours = doc_with_exchange_queue(
2962 "Existing prompt.\n\n### Re: existing — opus\n\nAgent response body.",
2963 "- do [#a1]",
2964 );
2965 let theirs = doc_with_exchange_queue("Existing prompt.", "- do [#a1]\n- do [#b2]");
2966
2967 let (merged, _advanced) = MultiNodeState::merge(Some(&base), &ours, &theirs).unwrap();
2968 assert!(
2969 merged.contains("### Re: existing"),
2970 "exchange edit lost:\n{merged}"
2971 );
2972 assert!(merged.contains("[#b2]"), "queue edit lost:\n{merged}");
2973
2974 let queue_body = merged
2975 .split("<!-- agent:queue -->")
2976 .nth(1)
2977 .and_then(|s| s.split("<!-- /agent:queue -->").next())
2978 .unwrap_or("");
2979 assert!(
2980 !queue_body.contains("Agent response body"),
2981 "exchange content spliced into queue:\n{queue_body}"
2982 );
2983 }
2984
2985 #[test]
2986 fn multinode_merge_falls_back_on_structural_divergence() {
2987 let base_doc = doc_with_exchange_queue("Q.", "- do [#a1]");
2990 let base = MultiNodeState::from_text(&base_doc).unwrap();
2991 let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nResponse.", "- do [#a1]");
2992 let theirs = "---\nagent_doc_format: template\n---\n\n## Exchange\n\n<!-- agent:exchange -->\nQ.\n<!-- /agent:exchange -->\n".to_string();
2993
2994 let (merged, advanced) = MultiNodeState::merge(Some(&base), &ours, &theirs).unwrap();
2995 let whole_doc = {
2996 let base_state = CrdtDoc::from_text(&base_doc).encode_state();
2997 merge(Some(&base_state), &ours, &theirs).unwrap()
2998 };
2999 assert_eq!(
3000 merged, whole_doc,
3001 "structural divergence must use whole-doc fallback"
3002 );
3003 assert_eq!(
3004 advanced.to_text().unwrap(),
3005 merged,
3006 "fallback must still yield a per-node state round-tripping the merged text"
3007 );
3008 }
3009
3010 #[test]
3011 fn multinode_merge_bootstraps_without_base() {
3012 let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nBody.", "- do [#a1]");
3014 let theirs = doc_with_exchange_queue("Q.", "- do [#a1]\n- do [#b2]");
3015 let (merged, state) = MultiNodeState::merge(None, &ours, &theirs).unwrap();
3016 assert!(merged.contains("Body."));
3017 assert!(merged.contains("[#b2]"));
3018 assert_eq!(state.to_text().unwrap(), merged);
3019 }
3020
3021 #[test]
3024 fn reconcile_queue_item_edit_does_not_touch_sibling_item() {
3025 let base = doc_with_exchange_queue("Q.", "- :pushpin: do [#a1]\n- :pushpin: do [#b2]");
3031 let base_state = CrdtDoc::from_text(&base).encode_state();
3032 let ours = doc_with_exchange_queue(
3033 "Q.\n\n### Re: q — opus\n\nBody.",
3034 "- :pushpin: do [#a1]\n- :pushpin: do [#b2]",
3035 );
3036 let theirs = doc_with_exchange_queue(
3037 "Q.",
3038 "- :pushpin: do [#a1]\n- :pushpin: do [#b2] with operator note",
3039 );
3040
3041 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3042 assert!(
3043 merged.contains("### Re: q"),
3044 "exchange edit lost:\n{merged}"
3045 );
3046 assert_eq!(
3047 merged.matches("do [#a1]").count(),
3048 1,
3049 "sibling item A drifted/duplicated:\n{merged}"
3050 );
3051 assert!(
3052 merged.contains("- :pushpin: do [#a1]\n"),
3053 "sibling item A text drifted:\n{merged}"
3054 );
3055 assert!(
3056 merged.contains("operator note"),
3057 "item B operator edit lost:\n{merged}"
3058 );
3059 }
3060
3061 #[test]
3062 fn reconcile_exchange_prompt_between_blocks_converges_with_new_block() {
3063 let r1 = "### Re: first — opus\n\nFirst answer.\n\n";
3067 let r2 = "### Re: second — opus\n\nSecond answer.\n\n";
3068 let base = doc_with_exchange_queue(&format!("{r1}{r2}"), "- do [#a1]");
3069 let base_state = CrdtDoc::from_text(&base).encode_state();
3070
3071 let r3 = "### Re: third — opus\n\nThird answer.\n\n";
3072 let ours = doc_with_exchange_queue(&format!("{r1}{r2}{r3}"), "- do [#a1]");
3073 let theirs = doc_with_exchange_queue(
3074 &format!("{r1}❯ a prompt typed between blocks\n\n{r2}"),
3075 "- do [#a1]",
3076 );
3077
3078 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3079 assert!(merged.contains("### Re: first"), "block 1 lost:\n{merged}");
3080 assert!(merged.contains("### Re: second"), "block 2 lost:\n{merged}");
3081 assert!(
3082 merged.contains("### Re: third"),
3083 "appended block 3 lost:\n{merged}"
3084 );
3085 assert!(
3086 merged.contains("a prompt typed between blocks"),
3087 "interleaved operator prompt lost:\n{merged}"
3088 );
3089 assert_eq!(
3090 merged.matches("### Re: first").count(),
3091 1,
3092 "block 1 cross-spliced/duplicated:\n{merged}"
3093 );
3094 assert_eq!(
3095 merged.matches("### Re: third").count(),
3096 1,
3097 "block 3 cross-spliced/duplicated:\n{merged}"
3098 );
3099 }
3100
3101 #[test]
3102 fn reconcile_queue_preserves_concurrent_user_item_addition() {
3103 let base = doc_with_exchange_queue("Q.", "- do [#a1]");
3107 let base_state = CrdtDoc::from_text(&base).encode_state();
3108 let ours = doc_with_exchange_queue("Q.", "- do [#a1] agent touched");
3109 let theirs = doc_with_exchange_queue("Q.", "- do [#a1]\n- do [#user-added]");
3110
3111 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3112 assert!(
3113 merged.contains("[#user-added]"),
3114 "concurrent user queue addition dropped:\n{merged}"
3115 );
3116 assert!(
3117 merged.contains("agent touched"),
3118 "concurrent agent item edit lost:\n{merged}"
3119 );
3120 }
3121
3122 #[test]
3123 fn reconcile_queue_honors_clean_item_deletion() {
3124 let base = doc_with_exchange_queue("Q.", "- do [#a1]\n- do [#b2]");
3127 let base_state = CrdtDoc::from_text(&base).encode_state();
3128 let ours = doc_with_exchange_queue("Q.\n\n### Re: q\n\nBody.", "- do [#a1]\n- do [#b2]");
3129 let theirs = doc_with_exchange_queue("Q.", "- do [#a1]");
3130
3131 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3132 assert!(merged.contains("Body."), "exchange edit lost:\n{merged}");
3133 let queue_body = merged
3134 .split("<!-- agent:queue -->")
3135 .nth(1)
3136 .and_then(|s| s.split("<!-- /agent:queue -->").next())
3137 .unwrap_or("");
3138 assert!(queue_body.contains("[#a1]"), "item A lost:\n{queue_body}");
3139 assert!(
3140 !queue_body.contains("[#b2]"),
3141 "clean deletion of item B not honored:\n{queue_body}"
3142 );
3143 }
3144
3145 #[test]
3146 fn reconcile_exchange_never_drops_committed_block_on_stale_side() {
3147 let r1 = "### Re: first — opus\n\nFirst answer.\n\n";
3151 let r2 = "### Re: second — opus\n\nSecond answer.\n\n";
3152 let base = doc_with_exchange_queue(&format!("{r1}{r2}"), "- do [#a1]");
3153 let base_state = CrdtDoc::from_text(&base).encode_state();
3154 let ours = doc_with_exchange_queue(&format!("{r1}{r2}"), "- do [#a1]\n- do [#new]");
3156 let theirs = doc_with_exchange_queue(&r1.to_string(), "- do [#a1]");
3157
3158 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3159 assert!(
3160 merged.contains("### Re: second"),
3161 "stale side deleted a committed block:\n{merged}"
3162 );
3163 assert!(
3164 merged.contains("[#new]"),
3165 "concurrent queue add lost:\n{merged}"
3166 );
3167 }
3168
3169 #[test]
3170 fn reconcile_falls_back_on_duplicate_item_keys() {
3171 let base = doc_with_exchange_queue("Q.", "- repeated item\n- repeated item");
3174 let base_state = CrdtDoc::from_text(&base).encode_state();
3175 let ours = doc_with_exchange_queue("Q.", "- repeated item\n- repeated item\n- new one");
3176 let theirs = doc_with_exchange_queue("Q.", "- repeated item\n- repeated item");
3177
3178 let merged = merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
3179 assert!(
3180 merged.contains("new one"),
3181 "duplicate-key fallback dropped the new item:\n{merged}"
3182 );
3183 }
3184
3185 #[test]
3186 fn first_hash_id_extracts_durable_id() {
3187 assert_eq!(
3188 first_hash_id("- :pushpin: do [#qnodemerge3]"),
3189 Some("qnodemerge3".to_string())
3190 );
3191 assert_eq!(
3192 first_hash_id("- [ ] [#6b5hwire] some text"),
3193 Some("6b5hwire".to_string())
3194 );
3195 assert_eq!(first_hash_id("- free text no id"), None);
3196 }
3197
3198 #[test]
3199 fn list_item_key_stable_across_strike_and_glyphs() {
3200 assert_eq!(list_item_key("- do [#a1]"), list_item_key("- ~~do [#a1]~~"));
3203 assert_eq!(
3204 list_item_key("- :pushpin: hello world"),
3205 list_item_key("- ~~hello world~~")
3206 );
3207 }
3208
3209 #[test]
3210 fn order_union_weaves_theirs_only_inserts() {
3211 let ours = ["a", "b", "c"];
3212 let theirs = ["a", "x", "b", "c", "y"];
3213 assert_eq!(
3214 order_union(&ours, &theirs),
3215 vec![
3216 "a".to_string(),
3217 "x".into(),
3218 "b".into(),
3219 "c".into(),
3220 "y".into()
3221 ]
3222 );
3223 }
3224
3225 #[test]
3226 fn split_list_children_is_lossless() {
3227 let body = "\n- do [#a1]\n continuation line\n- :pushpin: do [#b2]\n\n";
3230 let children = split_list_children(body).unwrap();
3231 let joined: String = children.iter().map(|c| c.text.as_str()).collect();
3232 assert_eq!(joined, body, "list segmentation is not lossless");
3233 }
3234
3235 #[test]
3236 fn split_exchange_children_is_lossless() {
3237 let body = "\n### Re: a — opus\n\nbody a\n\n### Re: b — opus\n\nbody b\n";
3238 let children = split_exchange_children(body).unwrap();
3239 let joined: String = children.iter().map(|c| c.text.as_str()).collect();
3240 assert_eq!(joined, body, "exchange segmentation is not lossless");
3241 }
3242}