1use lazily::{
42 CellMap, CellTree, Context, DiffOp, SemTree, TextCrdt, apply_to_map, apply_to_tree, reconcile,
43};
44
45use crate::component::{self, Component};
46use crate::crdt::{
47 EditorOp, PREAMBLE_KEY, is_list_component, replay_editor_ops, split_exchange_children,
48 split_list_children,
49};
50use crate::queue_item_lifecycle::QueueItemLifecycle;
51
52pub const CELL_MERGE_ENV: &str = "AGENT_DOC_CELL_MERGE";
58
59#[cfg(test)]
64pub(crate) static CELL_MERGE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
65
66pub const CELL_MERGE_OPCAPTURE_ENV: &str = "AGENT_DOC_CELL_MERGE_OPCAPTURE";
78
79pub const CELL_MERGE_CONFLICT_MARKERS_ENV: &str = "AGENT_DOC_CELL_MERGE_CONFLICT_MARKERS";
94
95fn env_falsy(name: &str) -> bool {
100 match std::env::var(name) {
101 Ok(v) => {
102 let v = v.trim().to_ascii_lowercase();
103 matches!(v.as_str(), "0" | "false" | "off" | "no")
104 }
105 Err(_) => false,
106 }
107}
108
109fn env_truthy(name: &str) -> bool {
110 match std::env::var(name) {
111 Ok(v) => {
112 let v = v.trim().to_ascii_lowercase();
113 matches!(v.as_str(), "1" | "true" | "on" | "yes")
114 }
115 Err(_) => false,
116 }
117}
118
119pub fn cell_merge_enabled() -> bool {
124 !env_falsy(CELL_MERGE_ENV)
125}
126
127pub fn cell_merge_opcapture_enabled() -> bool {
134 cell_merge_enabled() && !env_falsy(CELL_MERGE_OPCAPTURE_ENV)
135}
136
137pub fn cell_merge_conflict_markers_enabled() -> bool {
148 cell_merge_enabled() && env_truthy(CELL_MERGE_CONFLICT_MARKERS_ENV)
149}
150
151pub type NodeKey = String;
160
161pub type ItemValue = String;
164
165fn make_node_key(component: &str, occurrence: usize, item_id: &str, index: usize) -> NodeKey {
167 format!("{component}:{occurrence}:{item_id}:{index}")
168}
169
170fn is_body_only(occ: &ComponentOccurrence) -> bool {
177 occ.items.len() == 1
178 && occ.items[0].0 == make_node_key(&occ.component, occ.occurrence, "body", 0)
179}
180
181pub fn node_key_identity(key: &str) -> &str {
185 match key.rfind(':') {
186 Some(i) => &key[..i],
187 None => key,
188 }
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct ComponentOccurrence {
194 pub component: String,
196 pub occurrence: usize,
198 pub items: Vec<(NodeKey, ItemValue)>,
201}
202
203impl ComponentOccurrence {
204 fn keyed_for_diff(&self) -> Vec<(String, ItemValue)> {
208 self.items
209 .iter()
210 .map(|(k, v)| (node_key_identity(k).to_string(), v.clone()))
211 .collect()
212 }
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct ComponentDiff {
219 pub component: String,
221 pub occurrence: usize,
223 pub ops: Vec<DiffOp<String, ItemValue>>,
226}
227
228impl ComponentDiff {
229 pub fn is_empty(&self) -> bool {
232 self.ops.is_empty()
233 }
234}
235
236pub fn project_document(doc: &str) -> Vec<ComponentOccurrence> {
246 let components = match component::parse(doc) {
247 Ok(c) => c,
248 Err(_) => return Vec::new(),
249 };
250 let mut occurrence_counts: std::collections::HashMap<String, usize> =
251 std::collections::HashMap::new();
252 let mut out = Vec::with_capacity(components.len());
253 for comp in &components {
254 let occ = occurrence_counts.entry(comp.name.clone()).or_insert(0);
255 let occurrence = *occ;
256 *occ += 1;
257 out.push(project_component(doc, comp, occurrence));
258 }
259 out
260}
261
262fn project_component(doc: &str, comp: &Component, occurrence: usize) -> ComponentOccurrence {
264 let body = comp.content(doc);
265 let children = split_keyed_children(&comp.name, body);
266 let items = match children {
267 Some(children) => children
268 .into_iter()
269 .enumerate()
270 .map(|(index, (key, text))| (make_node_key(&comp.name, occurrence, &key, index), text))
271 .collect(),
272 None => vec![(
276 make_node_key(&comp.name, occurrence, "body", 0),
277 body.to_string(),
278 )],
279 };
280 ComponentOccurrence {
281 component: comp.name.clone(),
282 occurrence,
283 items,
284 }
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub struct ByteSpan {
305 pub start: usize,
307 pub end: usize,
309}
310
311impl ByteSpan {
312 fn contains_offset(&self, offset: usize) -> bool {
314 offset >= self.start && offset < self.end
315 }
316
317 fn contains_range(&self, lo: usize, hi: usize) -> bool {
320 lo >= self.start && hi <= self.end && lo <= hi
321 }
322}
323
324#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct ItemSpan {
330 pub component: String,
332 pub occurrence: usize,
334 pub node_key: NodeKey,
336 pub identity: String,
338 pub value: ItemValue,
340 pub span: ByteSpan,
342}
343
344pub fn project_document_spans(doc: &str) -> Option<Vec<ItemSpan>> {
355 let components = component::parse(doc).ok()?;
356 let top: Vec<&Component> = components
358 .iter()
359 .filter(|c| {
360 !components.iter().any(|o| {
361 !std::ptr::eq(o, *c) && o.open_start <= c.open_start && c.close_end <= o.close_end
362 })
363 })
364 .collect();
365 let mut top = top;
366 top.sort_by_key(|c| c.open_start);
367
368 let mut occurrence_counts: std::collections::HashMap<String, usize> =
369 std::collections::HashMap::new();
370 let mut out = Vec::new();
371 for comp in top {
372 let occ = occurrence_counts.entry(comp.name.clone()).or_insert(0);
373 let occurrence = *occ;
374 *occ += 1;
375 let body_start = comp.open_end;
378 let body = comp.content(doc);
379 let occ_proj = project_component(doc, comp, occurrence);
380 let mut cursor = body_start;
381 for (node_key, value) in occ_proj.items {
382 let start = cursor;
383 let end = start + value.len();
384 cursor = end;
385 out.push(ItemSpan {
386 component: comp.name.clone(),
387 occurrence,
388 identity: node_key_identity(&node_key).to_string(),
389 node_key,
390 value,
391 span: ByteSpan { start, end },
392 });
393 }
394 debug_assert_eq!(cursor, body_start + body.len(), "item spans must tile body");
398 if cursor != body_start + body.len() {
399 return None;
400 }
401 }
402 Some(out)
403}
404
405fn split_keyed_children(name: &str, body: &str) -> Option<Vec<(String, String)>> {
410 let children = if name == "exchange" {
411 split_exchange_children(body)
412 } else if is_list_component(name) {
413 split_list_children(body)
414 } else {
415 None
416 }?;
417 let mut seen = std::collections::HashSet::new();
420 let mut pairs = Vec::with_capacity(children.len());
421 for child in &children {
422 let key = if child.key == PREAMBLE_KEY {
423 "preamble".to_string()
424 } else {
425 child.key.clone()
426 };
427 if !seen.insert(key.clone()) {
428 return None;
429 }
430 pairs.push((key, child.text.clone()));
431 }
432 Some(pairs)
433}
434
435fn diff_occurrence(old: &ComponentOccurrence, new: &ComponentOccurrence) -> ComponentDiff {
439 let ops = reconcile(&old.keyed_for_diff(), &new.keyed_for_diff());
440 ComponentDiff {
441 component: new.component.clone(),
442 occurrence: new.occurrence,
443 ops,
444 }
445}
446
447pub fn diff_document(old_doc: &str, new_doc: &str) -> Vec<ComponentDiff> {
455 let old = project_document(old_doc);
456 let new = project_document(new_doc);
457
458 let mut old_by_key: std::collections::HashMap<(String, usize), &ComponentOccurrence> =
459 std::collections::HashMap::new();
460 for occ in &old {
461 old_by_key.insert((occ.component.clone(), occ.occurrence), occ);
462 }
463 let mut new_keys: std::collections::HashSet<(String, usize)> = std::collections::HashSet::new();
464
465 let mut diffs = Vec::new();
466
467 for occ in &new {
469 new_keys.insert((occ.component.clone(), occ.occurrence));
470 let diff = match old_by_key.get(&(occ.component.clone(), occ.occurrence)) {
471 Some(old_occ) => diff_occurrence(old_occ, occ),
472 None => {
473 let empty = ComponentOccurrence {
475 component: occ.component.clone(),
476 occurrence: occ.occurrence,
477 items: Vec::new(),
478 };
479 diff_occurrence(&empty, occ)
480 }
481 };
482 if !diff.is_empty() {
483 diffs.push(diff);
484 }
485 }
486
487 for occ in &old {
489 if !new_keys.contains(&(occ.component.clone(), occ.occurrence)) {
490 let empty = ComponentOccurrence {
491 component: occ.component.clone(),
492 occurrence: occ.occurrence,
493 items: Vec::new(),
494 };
495 let diff = diff_occurrence(occ, &empty);
496 if !diff.is_empty() {
497 diffs.push(diff);
498 }
499 }
500 }
501
502 diffs
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
524pub enum ConflictKind {
525 #[default]
527 Content,
528}
529
530#[derive(Debug, Clone, PartialEq, Eq)]
539pub struct CellConflict {
540 pub component: String,
542 pub occurrence: usize,
544 pub identity: String,
546 pub kind: ConflictKind,
549 pub chosen: ItemValue,
551 pub ours: ItemValue,
553 pub theirs: ItemValue,
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
560pub enum ConflictPolicy {
561 #[default]
564 OursWins,
565 TheirsWins,
567}
568
569pub fn component_conflict_policy(component: &str) -> ConflictPolicy {
588 match component {
589 "exchange" | "status" | "log" => ConflictPolicy::OursWins,
590 _ => ConflictPolicy::TheirsWins,
591 }
592}
593
594#[derive(Debug, Clone, PartialEq, Eq)]
596pub struct CellMergeOutcome {
597 pub merged_text: String,
600 pub conflicts: Vec<CellConflict>,
602 pub fell_back: bool,
605}
606
607impl CellMergeOutcome {
608 fn fallback() -> Self {
609 CellMergeOutcome {
610 merged_text: String::new(),
611 conflicts: Vec::new(),
612 fell_back: true,
613 }
614 }
615}
616
617enum DocNode {
620 Interstitial(String),
621 Component {
622 framing: ComponentFraming,
623 occurrence: ComponentOccurrence,
624 },
625}
626
627struct ComponentFraming {
630 open: String,
631 close: String,
632}
633
634fn parse_doc_nodes(doc: &str) -> Option<Vec<DocNode>> {
638 let comps = component::parse(doc).ok()?;
639 let mut top: Vec<&Component> = comps
641 .iter()
642 .filter(|c| {
643 !comps.iter().any(|o| {
644 !std::ptr::eq(o, *c) && o.open_start <= c.open_start && c.close_end <= o.close_end
645 })
646 })
647 .collect();
648 top.sort_by_key(|c| c.open_start);
649
650 let mut occurrence_counts: std::collections::HashMap<String, usize> =
651 std::collections::HashMap::new();
652 let mut nodes = Vec::with_capacity(top.len() * 2 + 1);
653 let mut cursor = 0usize;
654 for c in top {
655 nodes.push(DocNode::Interstitial(doc[cursor..c.open_start].to_string()));
656 let occ_idx = occurrence_counts.entry(c.name.clone()).or_insert(0);
657 let occurrence = *occ_idx;
658 *occ_idx += 1;
659 let framing = ComponentFraming {
660 open: doc[c.open_start..c.open_end].to_string(),
661 close: doc[c.close_start..c.close_end].to_string(),
662 };
663 nodes.push(DocNode::Component {
664 framing,
665 occurrence: project_component(doc, c, occurrence),
666 });
667 cursor = c.close_end;
668 }
669 nodes.push(DocNode::Interstitial(doc[cursor..].to_string()));
670 Some(nodes)
671}
672
673fn reconcile_marker_operator_authoritative<'a>(
683 base: Option<&str>,
684 ours: &'a str,
685 theirs: &'a str,
686) -> &'a str {
687 if ours == theirs {
688 return ours;
689 }
690 match base {
691 Some(b) if theirs != b => theirs,
692 Some(_) => ours,
693 None => theirs,
694 }
695}
696
697fn component_name_sequence(nodes: &[DocNode]) -> Vec<&str> {
699 nodes
700 .iter()
701 .filter_map(|n| match n {
702 DocNode::Component { occurrence, .. } => Some(occurrence.component.as_str()),
703 DocNode::Interstitial(_) => None,
704 })
705 .collect()
706}
707
708fn keys_unique(occ: &ComponentOccurrence) -> bool {
712 let mut seen = std::collections::HashSet::new();
713 occ.items
714 .iter()
715 .all(|(k, _)| seen.insert(node_key_identity(k).to_string()))
716}
717
718type ComposedOccurrence = (Vec<(String, ItemValue)>, Vec<CellConflict>);
721
722fn compose_occurrence(
732 base: &ComponentOccurrence,
733 ours: &ComponentOccurrence,
734 theirs: &ComponentOccurrence,
735 policy: ConflictPolicy,
736) -> Option<ComposedOccurrence> {
737 let base_to_ours = reconcile(&base.keyed_for_diff(), &ours.keyed_for_diff());
746 let base_to_theirs = reconcile(&base.keyed_for_diff(), &theirs.keyed_for_diff());
747 let ours_removed = removed_keys(&base_to_ours);
748 let theirs_removed = removed_keys(&base_to_theirs);
749 let ours_updated = updated_keys(&base_to_ours);
750 let theirs_updated = updated_keys(&base_to_theirs);
751
752 let to_map = |occ: &ComponentOccurrence| -> std::collections::HashMap<String, ItemValue> {
754 occ.items
755 .iter()
756 .map(|(k, v)| (node_key_identity(k).to_string(), v.clone()))
757 .collect()
758 };
759 let base_map = to_map(base);
760 let ours_map = to_map(ours);
761 let theirs_map = to_map(theirs);
762
763 let identity_seq = |occ: &ComponentOccurrence| -> Vec<String> {
764 occ.items
765 .iter()
766 .map(|(k, _)| node_key_identity(k).to_string())
767 .collect()
768 };
769
770 let is_exchange = ours.component == "exchange";
771 let lifecycle_governed = is_list_component(&ours.component);
779 let (spine, weave) = if is_exchange {
781 (identity_seq(ours), identity_seq(theirs))
782 } else {
783 (identity_seq(theirs), identity_seq(ours))
784 };
785 let order = weave_order(&spine, &weave);
786
787 let mut merged: Vec<(String, ItemValue)> = Vec::with_capacity(order.len());
788 let mut conflicts = Vec::new();
789 for id in &order {
790 let in_b = base_map.contains_key(id);
791 let in_o = ours_map.get(id);
792 let in_t = theirs_map.get(id);
793 let resolved: Option<ItemValue> = match (in_o, in_t) {
794 (Some(o), Some(t)) => {
795 if o == t {
796 Some(o.clone())
797 } else if lifecycle_governed {
798 let o_life = QueueItemLifecycle::classify(o);
804 let t_life = QueueItemLifecycle::classify(t);
805 let joined = o_life.join(t_life);
806 match (o_life == joined, t_life == joined) {
807 (true, false) => Some(o.clone()),
809 (false, true) => Some(t.clone()),
811 _ => Some(resolve_content_divergence(
814 id,
815 o,
816 t,
817 in_b,
818 base_map.get(id),
819 &ours_updated,
820 &theirs_updated,
821 ours,
822 policy,
823 &mut conflicts,
824 )),
825 }
826 } else {
827 Some(resolve_content_divergence(
829 id,
830 o,
831 t,
832 in_b,
833 base_map.get(id),
834 &ours_updated,
835 &theirs_updated,
836 ours,
837 policy,
838 &mut conflicts,
839 ))
840 }
841 }
842 (Some(o), None) => {
849 let authoritative_theirs_delete =
850 policy == ConflictPolicy::TheirsWins || !ours_updated.contains(id);
851 if theirs_removed.contains(id) && authoritative_theirs_delete && !is_exchange {
852 None
853 } else {
854 Some(o.clone())
855 }
856 }
857 (None, Some(t)) => {
859 if ours_removed.contains(id) && !theirs_updated.contains(id) && !is_exchange {
860 None
861 } else {
862 Some(t.clone())
863 }
864 }
865 (None, None) => None,
866 };
867 if let Some(v) = resolved {
868 merged.push((id.clone(), v));
869 }
870 }
871 Some((merged, conflicts))
872}
873
874#[allow(clippy::too_many_arguments)]
880fn resolve_content_divergence(
881 id: &str,
882 o: &ItemValue,
883 t: &ItemValue,
884 in_b: bool,
885 base_value: Option<&ItemValue>,
886 ours_updated: &std::collections::HashSet<String>,
887 theirs_updated: &std::collections::HashSet<String>,
888 occ: &ComponentOccurrence,
889 policy: ConflictPolicy,
890 conflicts: &mut Vec<CellConflict>,
891) -> ItemValue {
892 let o_changed = ours_updated.contains(id) || !in_b;
894 let t_changed = theirs_updated.contains(id) || !in_b;
895 match (o_changed, t_changed) {
896 (true, false) => o.clone(),
898 (false, true) => t.clone(),
900 (true, true) => {
908 if cell_merge_opcapture_enabled()
909 && let Some(base) = base_value
910 && let Some(merged) = op_level_merge(base, o, t)
911 {
912 return merged;
915 }
916 let chosen = match policy {
917 ConflictPolicy::OursWins => o.clone(),
918 ConflictPolicy::TheirsWins => t.clone(),
919 };
920 conflicts.push(CellConflict {
921 component: occ.component.clone(),
922 occurrence: occ.occurrence,
923 identity: id.to_string(),
924 kind: ConflictKind::Content,
925 chosen: chosen.clone(),
926 ours: o.clone(),
927 theirs: t.clone(),
928 });
929 chosen
930 }
931 (false, false) => o.clone(),
934 }
935}
936
937#[derive(Debug, Clone, PartialEq, Eq)]
943struct CharSpanEdit {
944 start: usize,
946 end: usize,
948 replacement: Vec<char>,
950}
951
952fn char_span_edit(base: &[char], side: &[char]) -> Option<CharSpanEdit> {
959 if base == side {
960 return None;
961 }
962 let mut start = 0usize;
964 let max_pre = base.len().min(side.len());
965 while start < max_pre && base[start] == side[start] {
966 start += 1;
967 }
968 let mut suffix = 0usize;
970 let max_suf = (base.len() - start).min(side.len() - start);
971 while suffix < max_suf && base[base.len() - 1 - suffix] == side[side.len() - 1 - suffix] {
972 suffix += 1;
973 }
974 let end = base.len() - suffix;
975 let replacement = side[start..side.len() - suffix].to_vec();
976 Some(CharSpanEdit {
977 start,
978 end,
979 replacement,
980 })
981}
982
983fn ranges_overlap(a: &CharSpanEdit, b: &CharSpanEdit) -> bool {
989 if a.start == a.end && b.start == b.end {
991 return a.start == b.start;
992 }
993 let (a_lo, a_hi) = (a.start, a.end.max(a.start));
996 let (b_lo, b_hi) = (b.start, b.end.max(b.start));
997 a_lo < b_hi && b_lo < a_hi
998 || (a.start == a.end && a.start > b_lo && a.start < b_hi)
1000 || (b.start == b.end && b.start > a_lo && b.start < a_hi)
1001}
1002
1003fn apply_span_edit(crdt: &mut TextCrdt, edit: &CharSpanEdit) {
1007 for idx in (edit.start..edit.end).rev() {
1009 crdt.delete(idx);
1010 }
1011 for (i, ch) in edit.replacement.iter().enumerate() {
1013 crdt.insert(edit.start + i, *ch);
1014 }
1015}
1016
1017fn op_level_merge(base: &str, ours: &str, theirs: &str) -> Option<String> {
1027 let base_chars: Vec<char> = base.chars().collect();
1028 let ours_edit = char_span_edit(&base_chars, &ours.chars().collect::<Vec<_>>())?;
1029 let theirs_edit = char_span_edit(&base_chars, &theirs.chars().collect::<Vec<_>>())?;
1030
1031 if ranges_overlap(&ours_edit, &theirs_edit) {
1034 return None;
1035 }
1036
1037 let base_crdt = TextCrdt::from_str(1, base);
1039 let mut ours_fork = base_crdt.fork(1);
1040 let mut theirs_fork = base_crdt.fork(2);
1041 apply_span_edit(&mut ours_fork, &ours_edit);
1042 apply_span_edit(&mut theirs_fork, &theirs_edit);
1043 ours_fork.merge(&theirs_fork);
1044 Some(ours_fork.text())
1045}
1046
1047fn removed_keys(ops: &[DiffOp<String, ItemValue>]) -> std::collections::HashSet<String> {
1049 ops.iter()
1050 .filter_map(|op| match op {
1051 DiffOp::Remove { key } => Some(key.clone()),
1052 _ => None,
1053 })
1054 .collect()
1055}
1056
1057fn updated_keys(ops: &[DiffOp<String, ItemValue>]) -> std::collections::HashSet<String> {
1059 ops.iter()
1060 .filter_map(|op| match op {
1061 DiffOp::Update { key, .. } => Some(key.clone()),
1062 _ => None,
1063 })
1064 .collect()
1065}
1066
1067fn weave_order(spine: &[String], weave: &[String]) -> Vec<String> {
1071 let spine_set: std::collections::HashSet<&str> = spine.iter().map(|s| s.as_str()).collect();
1072 let mut result: Vec<String> = spine.to_vec();
1073 for (i, k) in weave.iter().enumerate() {
1074 if spine_set.contains(k.as_str()) || result.iter().any(|r| r == k) {
1075 continue;
1076 }
1077 let mut insert_pos = 0usize;
1078 for j in (0..i).rev() {
1079 if let Some(p) = result.iter().position(|r| r == &weave[j]) {
1080 insert_pos = p + 1;
1081 break;
1082 }
1083 }
1084 result.insert(insert_pos, k.clone());
1085 }
1086 result
1087}
1088
1089pub fn merge_3way(base_doc: &str, ours_doc: &str, theirs_doc: &str) -> CellMergeOutcome {
1111 if ours_doc == theirs_doc {
1112 return CellMergeOutcome {
1113 merged_text: ours_doc.to_string(),
1114 conflicts: Vec::new(),
1115 fell_back: false,
1116 };
1117 }
1118
1119 let ours_nodes = match parse_doc_nodes(ours_doc) {
1120 Some(n) => n,
1121 None => return CellMergeOutcome::fallback(),
1122 };
1123 let theirs_nodes = match parse_doc_nodes(theirs_doc) {
1124 Some(n) => n,
1125 None => return CellMergeOutcome::fallback(),
1126 };
1127 let base_nodes = parse_doc_nodes(base_doc).unwrap_or_default();
1128
1129 let ours_names = component_name_sequence(&ours_nodes);
1130 let theirs_names = component_name_sequence(&theirs_nodes);
1131 if ours_names != theirs_names {
1133 return CellMergeOutcome::fallback();
1134 }
1135 if ours_names.is_empty() {
1137 return CellMergeOutcome::fallback();
1138 }
1139
1140 let mut base_by_key: std::collections::HashMap<(String, usize), &ComponentOccurrence> =
1145 std::collections::HashMap::new();
1146 let mut base_framing_by_key: std::collections::HashMap<(String, usize), &ComponentFraming> =
1147 std::collections::HashMap::new();
1148 for n in &base_nodes {
1149 if let DocNode::Component {
1150 framing,
1151 occurrence,
1152 } = n
1153 {
1154 base_by_key.insert(
1155 (occurrence.component.clone(), occurrence.occurrence),
1156 occurrence,
1157 );
1158 base_framing_by_key.insert(
1159 (occurrence.component.clone(), occurrence.occurrence),
1160 framing,
1161 );
1162 }
1163 }
1164
1165 let base_interstitials: Vec<&str> = if component_name_sequence(&base_nodes) == ours_names {
1171 base_nodes
1172 .iter()
1173 .filter_map(|n| match n {
1174 DocNode::Interstitial(s) => Some(s.as_str()),
1175 DocNode::Component { .. } => None,
1176 })
1177 .collect()
1178 } else {
1179 Vec::new()
1180 };
1181
1182 let mut out = String::new();
1186 let mut all_conflicts = Vec::new();
1187 let empty_occurrence = |o: &ComponentOccurrence| ComponentOccurrence {
1188 component: o.component.clone(),
1189 occurrence: o.occurrence,
1190 items: Vec::new(),
1191 };
1192
1193 let mut theirs_iter = theirs_nodes.iter();
1194 let mut interstitial_index = 0usize;
1195 for ours_node in &ours_nodes {
1196 let theirs_node = theirs_iter.next().expect("node sequences aligned");
1197 match (ours_node, theirs_node) {
1198 (DocNode::Interstitial(o), DocNode::Interstitial(t)) => {
1199 let base = base_interstitials.get(interstitial_index).copied();
1205 let chosen = match base {
1206 Some(b) if *o == b => t.as_str(), Some(b) if t == b => o.as_str(), _ => o.as_str(), };
1210 out.push_str(chosen);
1211 interstitial_index += 1;
1212 }
1213 (
1214 DocNode::Component {
1215 framing: o_framing,
1216 occurrence: o_occ,
1217 },
1218 DocNode::Component {
1219 framing: t_framing,
1220 occurrence: t_occ,
1221 },
1222 ) => {
1223 if is_body_only(o_occ) != is_body_only(t_occ) {
1241 let body_only_side = if is_body_only(o_occ) { o_occ } else { t_occ };
1242 let body_only_has_content = body_only_side
1243 .items
1244 .iter()
1245 .any(|(_, value)| !value.trim().is_empty());
1246 if body_only_has_content {
1247 return CellMergeOutcome::fallback();
1248 }
1249 }
1250 let base_occ = base_by_key
1251 .get(&(o_occ.component.clone(), o_occ.occurrence))
1252 .copied();
1253 let base_holder;
1254 let base_ref = match base_occ {
1255 Some(b) => b,
1256 None => {
1257 base_holder = empty_occurrence(o_occ);
1258 &base_holder
1259 }
1260 };
1261 if !keys_unique(o_occ) || !keys_unique(t_occ) || !keys_unique(base_ref) {
1263 return CellMergeOutcome::fallback();
1264 }
1265 let comp_policy = component_conflict_policy(&o_occ.component);
1273 let (mut merged_items, conflicts) =
1274 match compose_occurrence(base_ref, o_occ, t_occ, comp_policy) {
1275 Some(r) => r,
1276 None => return CellMergeOutcome::fallback(),
1277 };
1278 if cell_merge_conflict_markers_enabled() {
1283 surface_conflict_markers(&mut merged_items, &conflicts);
1284 }
1285 all_conflicts.extend(conflicts);
1286 let body: String = merged_items.iter().map(|(_, v)| v.as_str()).collect();
1294 let base_framing = base_framing_by_key
1295 .get(&(o_occ.component.clone(), o_occ.occurrence))
1296 .copied();
1297 let open = reconcile_marker_operator_authoritative(
1298 base_framing.map(|f| f.open.as_str()),
1299 &o_framing.open,
1300 &t_framing.open,
1301 );
1302 let close = reconcile_marker_operator_authoritative(
1303 base_framing.map(|f| f.close.as_str()),
1304 &o_framing.close,
1305 &t_framing.close,
1306 );
1307 out.push_str(open);
1308 out.push_str(&body);
1309 out.push_str(close);
1310 }
1311 _ => return CellMergeOutcome::fallback(),
1314 }
1315 }
1316
1317 match parse_doc_nodes(&out) {
1320 Some(merged_nodes) if component_name_sequence(&merged_nodes) == ours_names => {
1321 log_conflicts(&all_conflicts);
1322 CellMergeOutcome {
1323 merged_text: out,
1324 conflicts: all_conflicts,
1325 fell_back: false,
1326 }
1327 }
1328 _ => CellMergeOutcome::fallback(),
1329 }
1330}
1331
1332fn conflict_marker(conflict: &CellConflict) -> String {
1353 let policy_chose_ours = conflict.chosen == conflict.ours;
1354 let chosen_side = if policy_chose_ours { "ours" } else { "theirs" };
1355 let ours = conflict.ours.strip_suffix('\n').unwrap_or(&conflict.ours);
1359 let theirs = conflict
1360 .theirs
1361 .strip_suffix('\n')
1362 .unwrap_or(&conflict.theirs);
1363 format!(
1364 "<!-- cell-merge-conflict component={} occurrence={} identity={} kind={:?} chosen={} (operator: resolve; ours kept active)\n\
1365 <<<<<<< ours\n{ours}\n=======\n{theirs}\n>>>>>>> theirs\n-->\n",
1366 conflict.component, conflict.occurrence, conflict.identity, conflict.kind, chosen_side,
1367 )
1368}
1369
1370fn marker_is_framing_safe(conflict: &CellConflict) -> bool {
1377 let carries_framing =
1381 |s: &str| s.contains("<!-- agent:") || s.contains("<!-- /agent:") || s.contains("-->");
1382 !(carries_framing(&conflict.ours) || carries_framing(&conflict.theirs))
1383}
1384
1385fn surface_conflict_markers(merged_items: &mut [(String, ItemValue)], conflicts: &[CellConflict]) {
1392 for conflict in conflicts {
1393 if !marker_is_framing_safe(conflict) {
1394 eprintln!(
1395 "cell_merge conflict identity={} not surfaced in-band (side text carries framing); stderr-only",
1396 conflict.identity,
1397 );
1398 continue;
1399 }
1400 if let Some((_, value)) = merged_items
1404 .iter_mut()
1405 .find(|(id, _)| id == &conflict.identity)
1406 {
1407 if !value.ends_with('\n') {
1411 value.push('\n');
1412 }
1413 value.push_str(&conflict_marker(conflict));
1414 }
1415 }
1416}
1417
1418fn log_conflicts(conflicts: &[CellConflict]) {
1422 for c in conflicts {
1423 eprintln!(
1424 "cell_merge conflict kind={:?} component={} occurrence={} identity={} policy_chose_ours={}",
1425 c.kind,
1426 c.component,
1427 c.occurrence,
1428 c.identity,
1429 c.chosen == c.ours,
1430 );
1431 }
1432}
1433
1434pub fn route_ops_to_cells(
1471 doc: &str,
1472 ops: &[EditorOp],
1473) -> Option<std::collections::HashMap<NodeKey, Vec<EditorOp>>> {
1474 if ops.is_empty() {
1475 return Some(std::collections::HashMap::new());
1476 }
1477 let spans = project_document_spans(doc)?;
1478 let mut routed: std::collections::HashMap<NodeKey, Vec<EditorOp>> =
1479 std::collections::HashMap::new();
1480 for op in ops {
1481 let (node_key, span) = match op {
1482 EditorOp::Insert { offset, .. } => {
1483 let found = spans
1487 .iter()
1488 .find(|s| s.span.contains_offset(*offset))
1489 .or_else(|| {
1490 spans.iter().find(|s| {
1495 s.span.end == *offset && !spans.iter().any(|o| o.span.start == *offset)
1496 })
1497 });
1498 match found {
1499 Some(s) => (s.node_key.clone(), s.span),
1500 None => return None,
1501 }
1502 }
1503 EditorOp::Delete { offset, len } => {
1504 let hi = offset.checked_add(*len)?;
1505 let found = spans.iter().find(|s| s.span.contains_range(*offset, hi));
1507 match found {
1508 Some(s) => (s.node_key.clone(), s.span),
1509 None => return None,
1510 }
1511 }
1512 };
1513 let local = translate_to_cell_local(op, span.start)?;
1514 routed.entry(node_key).or_default().push(local);
1515 }
1516 Some(routed)
1517}
1518
1519fn translate_to_cell_local(op: &EditorOp, span_start: usize) -> Option<EditorOp> {
1523 match op {
1524 EditorOp::Insert { offset, text } => Some(EditorOp::Insert {
1525 offset: offset.checked_sub(span_start)?,
1526 text: text.clone(),
1527 }),
1528 EditorOp::Delete { offset, len } => Some(EditorOp::Delete {
1529 offset: offset.checked_sub(span_start)?,
1530 len: *len,
1531 }),
1532 }
1533}
1534
1535pub fn merge_3way_with_ops(
1553 base: &str,
1554 ours: &str,
1555 theirs: &str,
1556 theirs_ops: &[EditorOp],
1557) -> CellMergeOutcome {
1558 let routed = match route_ops_to_cells(base, theirs_ops) {
1562 Some(r) => r,
1563 None => return CellMergeOutcome::fallback(),
1564 };
1565
1566 let reconstructed_theirs = match reconstruct_theirs_from_ops(base, &routed) {
1570 Some(t) => t,
1571 None => return CellMergeOutcome::fallback(),
1572 };
1573
1574 if reconstructed_theirs != theirs {
1579 eprintln!(
1580 "[cell_merge] op-routing replay != theirs (stale/misaligned ops) — falling back to text-diff path"
1581 );
1582 return CellMergeOutcome::fallback();
1583 }
1584
1585 merge_3way(base, ours, &reconstructed_theirs)
1586}
1587
1588fn reconstruct_theirs_from_ops(
1593 base: &str,
1594 routed: &std::collections::HashMap<NodeKey, Vec<EditorOp>>,
1595) -> Option<String> {
1596 let spans = project_document_spans(base)?;
1597 let mut out = String::new();
1601 let mut cursor = 0usize;
1602 for item in &spans {
1603 out.push_str(&base[cursor..item.span.start]);
1605 let cell_text = match routed.get(&item.node_key) {
1606 Some(ops) => replay_editor_ops(&item.value, ops)?,
1607 None => item.value.clone(),
1608 };
1609 out.push_str(&cell_text);
1610 cursor = item.span.end;
1611 }
1612 out.push_str(&base[cursor..]);
1613 Some(out)
1614}
1615
1616pub struct CellDocTree {
1627 root: CellTree<String, String>,
1628 occurrences: std::collections::HashMap<(String, usize), CellMap<String, String>>,
1631}
1632
1633impl CellDocTree {
1634 pub fn from_document(ctx: &Context, doc: &str) -> Self {
1636 let root: CellTree<String, String> = CellTree::leaf(ctx, String::new(), String::new());
1637 let mut occurrences = std::collections::HashMap::new();
1638 for occ in project_document(doc) {
1639 let occ_id = format!("{}:{}", occ.component, occ.occurrence);
1640 let occ_node = root.insert_child(ctx, occ_id.clone(), String::new());
1641 let map: CellMap<String, String> = CellMap::new(ctx);
1642 for (key, value) in &occ.items {
1643 let identity = node_key_identity(key).to_string();
1644 occ_node.insert_child(ctx, identity.clone(), value.clone());
1645 map.entry(ctx, identity, value.clone());
1646 }
1647 occurrences.insert((occ.component.clone(), occ.occurrence), map);
1648 }
1649 Self { root, occurrences }
1650 }
1651
1652 pub fn root(&self) -> &CellTree<String, String> {
1654 &self.root
1655 }
1656
1657 pub fn item_value(
1660 &self,
1661 ctx: &Context,
1662 component: &str,
1663 occurrence: usize,
1664 identity: &str,
1665 ) -> Option<String> {
1666 self.occurrences
1667 .get(&(component.to_string(), occurrence))
1668 .and_then(|m| m.get(ctx, &identity.to_string()))
1669 }
1670
1671 pub fn item_ids(&self, ctx: &Context, component: &str, occurrence: usize) -> Vec<String> {
1673 self.occurrences
1674 .get(&(component.to_string(), occurrence))
1675 .map(|m| m.keys(ctx))
1676 .unwrap_or_default()
1677 }
1678
1679 pub fn update_to(&mut self, ctx: &Context, old_doc: &str, new_doc: &str) {
1682 for diff in diff_document(old_doc, new_doc) {
1683 self.apply(ctx, &diff);
1684 }
1685
1686 let new_occurrences: std::collections::HashSet<(String, usize)> = project_document(new_doc)
1687 .into_iter()
1688 .map(|occ| (occ.component, occ.occurrence))
1689 .collect();
1690 let existing: Vec<(String, usize)> = self.occurrences.keys().cloned().collect();
1691 for (component, occurrence) in existing {
1692 if !new_occurrences.contains(&(component.clone(), occurrence)) {
1693 self.occurrences.remove(&(component.clone(), occurrence));
1694 self.root
1695 .remove_child(ctx, &format!("{component}:{occurrence}"));
1696 }
1697 }
1698 }
1699
1700 pub fn unresolved_prompt_counts(&self, ctx: &Context) -> SemTree<String, usize> {
1703 SemTree::build(ctx, &self.root, |value: &String, kids: &[usize]| {
1704 unresolved_prompt_count(value) + kids.iter().sum::<usize>()
1705 })
1706 }
1707
1708 pub fn apply(&mut self, ctx: &Context, diff: &ComponentDiff) {
1713 let occ_key = (diff.component.clone(), diff.occurrence);
1714 let occ_id = format!("{}:{}", diff.component, diff.occurrence);
1715 let occ_node = self
1716 .root
1717 .child(&occ_id)
1718 .unwrap_or_else(|| self.root.insert_child(ctx, occ_id.clone(), String::new()));
1719 let map = self
1720 .occurrences
1721 .entry(occ_key)
1722 .or_insert_with(|| CellMap::new(ctx));
1723 apply_to_map(ctx, map, &diff.ops);
1724 apply_to_tree(ctx, &occ_node, &diff.ops);
1725 }
1726}
1727
1728fn unresolved_prompt_count(value: &str) -> usize {
1729 value
1730 .lines()
1731 .filter(|line| line.contains("[#") && !line.contains("~~"))
1732 .count()
1733}
1734
1735#[cfg(test)]
1736mod tests {
1737 use super::*;
1738
1739 const DOC: &str = "\
1740---
1741agent_doc_format: template
1742---
1743<!-- agent:queue -->
1744- do [#alpha] first task
1745- do [#beta] second task
1746- do [#gamma] third task
1747<!-- /agent:queue -->
1748<!-- agent:backlog -->
1749- [#one] backlog item one
1750- [#two] backlog item two
1751<!-- /agent:backlog -->
1752";
1753
1754 fn queue_diff(diffs: &[ComponentDiff]) -> &ComponentDiff {
1755 diffs
1756 .iter()
1757 .find(|d| d.component == "queue")
1758 .expect("queue diff present")
1759 }
1760
1761 #[test]
1762 fn project_keys_use_component_occurrence_item_index_scheme() {
1763 let occs = project_document(DOC);
1764 let queue = occs
1765 .iter()
1766 .find(|o| o.component == "queue")
1767 .expect("queue projected");
1768 assert_eq!(queue.occurrence, 0);
1769 let keys: Vec<&str> = queue.items.iter().map(|(k, _)| k.as_str()).collect();
1770 assert_eq!(keys[0], "queue:0:id:alpha:0");
1772 assert_eq!(keys[1], "queue:0:id:beta:1");
1773 assert_eq!(keys[2], "queue:0:id:gamma:2");
1774 }
1775
1776 #[test]
1777 fn reorder_yields_moves_not_replace() {
1778 let reordered = DOC.replace(
1780 "- do [#alpha] first task\n- do [#beta] second task\n- do [#gamma] third task\n",
1781 "- do [#gamma] third task\n- do [#alpha] first task\n- do [#beta] second task\n",
1782 );
1783 let diffs = diff_document(DOC, &reordered);
1784 let q = queue_diff(&diffs);
1785 assert!(
1786 q.ops.iter().all(|op| matches!(op, DiffOp::Move { .. })),
1787 "pure reorder must be moves only, not remove+insert: {:?}",
1788 q.ops
1789 );
1790 assert!(!q.ops.is_empty(), "reorder must produce at least one move");
1791 assert!(q.ops.iter().all(|op| matches!(op, DiffOp::Move { .. })),);
1792 }
1793
1794 #[test]
1795 fn insert_one_item_yields_single_insert() {
1796 let added = DOC.replace(
1797 "- do [#gamma] third task\n",
1798 "- do [#gamma] third task\n- do [#delta] fourth task\n",
1799 );
1800 let diffs = diff_document(DOC, &added);
1801 let q = queue_diff(&diffs);
1802 let inserts: Vec<_> = q
1803 .ops
1804 .iter()
1805 .filter(|op| matches!(op, DiffOp::Insert { .. }))
1806 .collect();
1807 assert_eq!(inserts.len(), 1, "exactly one insert: {:?}", q.ops);
1808 assert!(matches!(
1809 inserts[0],
1810 DiffOp::Insert { key, .. } if key == "queue:0:id:delta"
1811 ));
1812 assert!(
1814 !q.ops
1815 .iter()
1816 .any(|op| matches!(op, DiffOp::Remove { .. } | DiffOp::Update { .. })),
1817 "insert must not touch siblings: {:?}",
1818 q.ops
1819 );
1820 }
1821
1822 #[test]
1823 fn remove_one_item_yields_single_remove() {
1824 let removed = DOC.replace("- do [#beta] second task\n", "");
1825 let diffs = diff_document(DOC, &removed);
1826 let q = queue_diff(&diffs);
1827 let removes: Vec<_> = q
1828 .ops
1829 .iter()
1830 .filter(|op| matches!(op, DiffOp::Remove { .. }))
1831 .collect();
1832 assert_eq!(removes.len(), 1, "exactly one remove: {:?}", q.ops);
1833 assert!(matches!(
1834 removes[0],
1835 DiffOp::Remove { key } if key == "queue:0:id:beta"
1836 ));
1837 }
1838
1839 #[test]
1840 fn operator_queue_delete_wins_over_agent_update() {
1841 let ours = DOC.replace(
1842 "- do [#beta] second task\n",
1843 "- ~~do [#beta] second task~~\n",
1844 );
1845 let theirs = DOC.replace("- do [#beta] second task\n", "");
1846
1847 let outcome = merge_3way(DOC, &ours, &theirs);
1848
1849 assert!(!outcome.fell_back, "cell merge should handle queue delete");
1850 assert!(
1851 outcome.conflicts.is_empty(),
1852 "authoritative operator delete is not a conflict: {:?}",
1853 outcome.conflicts
1854 );
1855 assert!(
1856 !outcome.merged_text.contains("[#beta]"),
1857 "operator-deleted queue item must not be resurrected:\n{}",
1858 outcome.merged_text
1859 );
1860 assert!(outcome.merged_text.contains("[#alpha]"));
1861 assert!(outcome.merged_text.contains("[#gamma]"));
1862 }
1863
1864 #[test]
1865 fn single_item_edit_yields_one_update_isolated() {
1866 let edited = DOC.replace(
1868 "- do [#beta] second task\n",
1869 "- do [#beta] second task EDITED\n",
1870 );
1871 let diffs = diff_document(DOC, &edited);
1872 let q = queue_diff(&diffs);
1873 assert_eq!(
1874 q.ops.len(),
1875 1,
1876 "exactly one op for a single-item edit: {:?}",
1877 q.ops
1878 );
1879 match &q.ops[0] {
1880 DiffOp::Update { key, value } => {
1881 assert_eq!(key, "queue:0:id:beta");
1882 assert!(value.contains("EDITED"));
1883 }
1884 other => panic!("expected a single Update, got {other:?}"),
1885 }
1886 assert!(
1888 !q.ops.iter().any(|op| {
1889 let k = match op {
1890 DiffOp::Update { key, .. }
1891 | DiffOp::Move { key, .. }
1892 | DiffOp::Remove { key } => key.as_str(),
1893 DiffOp::Insert { key, .. } => key.as_str(),
1894 };
1895 k.contains("alpha") || k.contains("gamma")
1896 }),
1897 "sibling items must be untouched: {:?}",
1898 q.ops
1899 );
1900 }
1901
1902 #[test]
1903 fn cross_component_isolation() {
1904 let edited = DOC.replace(
1906 "- do [#alpha] first task\n",
1907 "- do [#alpha] first task CHANGED\n",
1908 );
1909 let diffs = diff_document(DOC, &edited);
1910 assert!(
1911 diffs.iter().all(|d| d.component != "backlog"),
1912 "an edit in queue must produce no ops for backlog: {diffs:?}"
1913 );
1914 let q = queue_diff(&diffs);
1916 assert_eq!(q.ops.len(), 1);
1917 assert!(matches!(&q.ops[0], DiffOp::Update { key, .. } if key == "queue:0:id:alpha"));
1918 }
1919
1920 #[test]
1921 fn reactive_tree_single_edit_spares_sibling_cell() {
1922 let ctx = Context::new();
1923 let mut tree = CellDocTree::from_document(&ctx, DOC);
1924
1925 let alpha_view = {
1927 let tree_ids = tree.item_value(&ctx, "queue", 0, "queue:0:id:alpha");
1928 assert!(tree_ids.is_some());
1929 ctx.computed({
1930 let map = tree
1931 .occurrences
1932 .get(&("queue".to_string(), 0))
1933 .unwrap()
1934 .clone();
1935 move |ctx| {
1936 map.get(ctx, &"queue:0:id:alpha".to_string())
1937 .unwrap_or_default()
1938 }
1939 })
1940 };
1941 let _ = ctx.get(&alpha_view);
1942 assert!(ctx.is_set(&alpha_view));
1943
1944 let edited = DOC.replace(
1946 "- do [#beta] second task\n",
1947 "- do [#beta] second task EDITED\n",
1948 );
1949 let diffs = diff_document(DOC, &edited);
1950 for d in &diffs {
1951 tree.apply(&ctx, d);
1952 }
1953
1954 assert!(
1956 tree.item_value(&ctx, "queue", 0, "queue:0:id:beta")
1957 .unwrap()
1958 .contains("EDITED")
1959 );
1960 assert!(
1962 ctx.is_set(&alpha_view),
1963 "sibling value cell must not be invalidated by a per-item edit"
1964 );
1965 }
1966
1967 #[test]
1968 fn reactive_tree_reorder_keeps_membership_cached() {
1969 let ctx = Context::new();
1970 let mut tree = CellDocTree::from_document(&ctx, DOC);
1971 let occ_node = tree.root().child(&"queue:0".to_string()).unwrap();
1972 let len_view = {
1973 let node = occ_node.clone();
1974 ctx.computed(move |ctx| node.len(ctx))
1975 };
1976 assert_eq!(ctx.get(&len_view), 3);
1977
1978 let reordered = DOC.replace(
1979 "- do [#alpha] first task\n- do [#beta] second task\n- do [#gamma] third task\n",
1980 "- do [#gamma] third task\n- do [#alpha] first task\n- do [#beta] second task\n",
1981 );
1982 let diffs = diff_document(DOC, &reordered);
1983 for d in &diffs {
1984 tree.apply(&ctx, d);
1985 }
1986 assert_eq!(
1988 tree.item_ids(&ctx, "queue", 0),
1989 vec![
1990 "queue:0:id:gamma".to_string(),
1991 "queue:0:id:alpha".to_string(),
1992 "queue:0:id:beta".to_string(),
1993 ]
1994 );
1995 assert!(
1997 ctx.is_set(&len_view),
1998 "a pure reorder must not invalidate the membership-count reader"
1999 );
2000 assert_eq!(ctx.get(&len_view), 3);
2001 }
2002
2003 fn tree_snapshot(
2004 ctx: &Context,
2005 tree: &CellDocTree,
2006 ) -> Vec<(String, usize, Vec<(String, String)>)> {
2007 let mut occurrences: Vec<(String, usize)> = tree.occurrences.keys().cloned().collect();
2008 occurrences.sort();
2009 occurrences
2010 .into_iter()
2011 .map(|(component, occurrence)| {
2012 let map = tree
2013 .occurrences
2014 .get(&(component.clone(), occurrence))
2015 .expect("occurrence map present");
2016 let map_items: Vec<(String, String)> = map
2017 .keys(ctx)
2018 .into_iter()
2019 .map(|id| {
2020 let value = map.get(ctx, &id).expect("mapped value present");
2021 (id, value)
2022 })
2023 .collect();
2024 let occ_id = format!("{component}:{occurrence}");
2025 let occ_node = tree.root().child(&occ_id).expect("occurrence node present");
2026 assert_eq!(
2027 occ_node.child_ids(ctx),
2028 map_items
2029 .iter()
2030 .map(|(id, _)| id.clone())
2031 .collect::<Vec<_>>(),
2032 "CellTree child order must match CellMap order for {occ_id}"
2033 );
2034 for (id, value) in &map_items {
2035 assert_eq!(
2036 occ_node.child(id).expect("child present").get(ctx),
2037 *value,
2038 "CellTree child value must match CellMap value for {id}"
2039 );
2040 }
2041 (component, occurrence, map_items)
2042 })
2043 .collect()
2044 }
2045
2046 fn assert_incremental_matches_rebuild(ctx: &Context, tree: &CellDocTree, doc: &str) {
2047 let rebuilt = CellDocTree::from_document(ctx, doc);
2048 assert_eq!(tree_snapshot(ctx, tree), tree_snapshot(ctx, &rebuilt));
2049 }
2050
2051 fn eager_unresolved_prompt_count(doc: &str) -> usize {
2052 project_document(doc)
2053 .into_iter()
2054 .flat_map(|occ| occ.items.into_iter().map(|(_, value)| value))
2055 .map(|value| unresolved_prompt_count(&value))
2056 .sum()
2057 }
2058
2059 #[test]
2060 fn update_to_matches_rebuild_across_revisions() {
2061 let ctx = Context::new();
2062 let mut tree = CellDocTree::from_document(&ctx, DOC);
2063 let mut old = DOC.to_string();
2064
2065 let rev1 = old.replace(
2066 "- do [#beta] second task\n",
2067 "- do [#beta] second task EDITED\n",
2068 );
2069 let rev2 = rev1.replace(
2070 "- do [#alpha] first task\n- do [#beta] second task EDITED\n- do [#gamma] third task\n",
2071 "- do [#gamma] third task\n- do [#alpha] first task\n- do [#beta] second task EDITED\n",
2072 );
2073 let rev3 = format!(
2074 "{rev2}<!-- agent:review -->\n- do [#review] inspect\n<!-- /agent:review -->\n"
2075 );
2076 let rev4 = rev3.replace(
2077 "<!-- agent:backlog -->\n- [#one] backlog item one\n- [#two] backlog item two\n<!-- /agent:backlog -->\n",
2078 "",
2079 );
2080 let rev5 = rev4
2081 .replace(
2082 "- do [#alpha] first task\n",
2083 "- do [#alpha] first task CHANGED\n",
2084 )
2085 .replace(
2086 "- do [#review] inspect\n",
2087 "- do [#review] inspect EDITED\n",
2088 );
2089
2090 for new_doc in [rev1, rev2, rev3, rev4, rev5] {
2091 tree.update_to(&ctx, &old, &new_doc);
2092 assert_incremental_matches_rebuild(&ctx, &tree, &new_doc);
2093 old = new_doc;
2094 }
2095 }
2096
2097 #[test]
2098 fn sem_tree_unresolved_prompt_counts_are_subtree_scoped() {
2099 use std::cell::Cell as StdCell;
2100 use std::rc::Rc;
2101
2102 let ctx = Context::new();
2103 let mut tree = CellDocTree::from_document(&ctx, DOC);
2104 let counts = tree.unresolved_prompt_counts(&ctx);
2105 assert_eq!(counts.value(&ctx), eager_unresolved_prompt_count(DOC));
2106 assert_eq!(counts.node_value(&ctx, &"queue:0".to_string()), Some(3));
2107 assert_eq!(counts.node_value(&ctx, &"backlog:0".to_string()), Some(2));
2108
2109 let queue_alpha_calls = Rc::new(StdCell::new(0usize));
2110 let backlog_one_calls = Rc::new(StdCell::new(0usize));
2111 let probe = SemTree::build(&ctx, tree.root(), {
2112 let queue_alpha_calls = Rc::clone(&queue_alpha_calls);
2113 let backlog_one_calls = Rc::clone(&backlog_one_calls);
2114 move |value: &String, kids: &[usize]| {
2115 if value.contains("#alpha") {
2116 queue_alpha_calls.set(queue_alpha_calls.get() + 1);
2117 }
2118 if value.contains("#one") {
2119 backlog_one_calls.set(backlog_one_calls.get() + 1);
2120 }
2121 unresolved_prompt_count(value) + kids.iter().sum::<usize>()
2122 }
2123 });
2124
2125 assert_eq!(probe.value(&ctx), 5);
2126 let queue_alpha_baseline = queue_alpha_calls.get();
2127 let backlog_one_baseline = backlog_one_calls.get();
2128 assert_eq!(probe.value(&ctx), 5);
2129 assert_eq!(
2130 queue_alpha_calls.get(),
2131 queue_alpha_baseline,
2132 "memoized re-read must not recompute queue leaf"
2133 );
2134 assert_eq!(
2135 backlog_one_calls.get(),
2136 backlog_one_baseline,
2137 "memoized re-read must not recompute backlog leaf"
2138 );
2139
2140 let backlog_slot = probe.node(&"backlog:0".to_string()).unwrap();
2141 let edited = DOC.replace(
2142 "- do [#alpha] first task\n",
2143 "- ~~do [#alpha] first task~~\n",
2144 );
2145 tree.update_to(&ctx, DOC, &edited);
2146 assert!(
2147 ctx.is_set(&backlog_slot),
2148 "editing queue must not invalidate the backlog semantic subtree"
2149 );
2150 assert_eq!(probe.node_value(&ctx, &"queue:0".to_string()), Some(2));
2151 assert_eq!(probe.node_value(&ctx, &"backlog:0".to_string()), Some(2));
2152 assert_eq!(queue_alpha_calls.get(), queue_alpha_baseline + 1);
2153 assert_eq!(
2154 backlog_one_calls.get(),
2155 backlog_one_baseline,
2156 "backlog semantic leaf must stay cached after a queue edit"
2157 );
2158
2159 let updated_counts = tree.unresolved_prompt_counts(&ctx);
2160 assert_eq!(
2161 updated_counts.value(&ctx),
2162 eager_unresolved_prompt_count(&edited)
2163 );
2164 }
2165
2166 use super::CELL_MERGE_ENV_LOCK as ENV_LOCK;
2172
2173 const BASE3: &str = "\
2174---
2175agent_doc_format: template
2176---
2177<!-- agent:queue -->
2178- do [#alpha] first task
2179- do [#beta] second task
2180- do [#gamma] third task
2181<!-- /agent:queue -->
2182<!-- agent:exchange -->
2183### Re: earlier
2184prior response.
2185<!-- /agent:exchange -->
2186";
2187
2188 #[test]
2189 fn concurrent_edits_to_different_items_both_land_no_conflict() {
2190 let ours = BASE3.replace(
2192 "- do [#alpha] first task\n",
2193 "- do [#alpha] first task OURS\n",
2194 );
2195 let theirs = BASE3.replace(
2196 "- do [#gamma] third task\n",
2197 "- do [#gamma] third task THEIRS\n",
2198 );
2199 let out = merge_3way(BASE3, &ours, &theirs);
2200 assert!(!out.fell_back, "should not fall back");
2201 assert!(out.conflicts.is_empty(), "no conflict: {:?}", out.conflicts);
2202 assert!(out.merged_text.contains("first task OURS"));
2203 assert!(out.merged_text.contains("third task THEIRS"));
2204 assert!(out.merged_text.contains("- do [#beta] second task\n"));
2206 }
2207
2208 #[test]
2209 fn concurrent_edits_to_different_components_both_land() {
2210 let ours = BASE3.replace(
2212 "- do [#beta] second task\n",
2213 "- do [#beta] second task OURS\n",
2214 );
2215 let theirs = BASE3.replace("prior response.", "prior response. THEIRS");
2216 let out = merge_3way(BASE3, &ours, &theirs);
2217 assert!(!out.fell_back);
2218 assert!(out.conflicts.is_empty());
2219 assert!(out.merged_text.contains("second task OURS"));
2220 assert!(out.merged_text.contains("prior response. THEIRS"));
2221 }
2222
2223 #[test]
2224 fn exchange_response_does_not_splice_into_queue() {
2225 let with_response = BASE3.replace(
2228 "### Re: earlier\nprior response.\n",
2229 "### Re: earlier\nprior response.\n### Re: new prompt\nbrand new agent response.\n",
2230 );
2231 let edited_queue = BASE3.replace(
2232 "- do [#beta] second task\n",
2233 "- do [#beta] second task EDITED\n",
2234 );
2235 let out = merge_3way(BASE3, &with_response, &edited_queue);
2236 assert!(!out.fell_back, "should per-cell merge");
2237 let q_open = out.merged_text.find("<!-- agent:queue -->").unwrap();
2239 let q_close = out.merged_text.find("<!-- /agent:queue -->").unwrap();
2240 let queue_body = &out.merged_text[q_open..q_close];
2241 assert!(
2242 !queue_body.contains("### Re:"),
2243 "exchange response spliced into queue body: {queue_body}"
2244 );
2245 assert!(out.merged_text.contains("brand new agent response."));
2247 assert!(out.merged_text.contains("second task EDITED"));
2248 }
2249
2250 #[test]
2251 fn same_item_edited_both_sides_surfaces_conflict() {
2252 let _guard = ENV_LOCK.lock().unwrap();
2257 let ours = BASE3.replace(
2258 "- do [#beta] second task\n",
2259 "- do [#beta] second task OURS-VERSION\n",
2260 );
2261 let theirs = BASE3.replace(
2262 "- do [#beta] second task\n",
2263 "- do [#beta] second task THEIRS-VERSION\n",
2264 );
2265 let out = merge_3way(BASE3, &ours, &theirs);
2266 assert!(!out.fell_back);
2267 assert_eq!(out.conflicts.len(), 1, "one conflict: {:?}", out.conflicts);
2268 let c = &out.conflicts[0];
2269 assert_eq!(c.component, "queue");
2270 assert_eq!(c.identity, "queue:0:id:beta");
2271 assert!(c.ours.contains("OURS-VERSION"));
2272 assert!(c.theirs.contains("THEIRS-VERSION"));
2273 assert!(c.chosen.contains("THEIRS-VERSION"));
2275 assert!(out.merged_text.contains("THEIRS-VERSION"));
2276 assert!(
2277 !out.merged_text.contains("OURS-VERSION"),
2278 "no fabricated blend: the losing ours value must not also appear"
2279 );
2280 }
2281
2282 #[test]
2283 fn reorder_one_side_edit_other_side_compose() {
2284 let ours = BASE3.replace(
2286 "- do [#alpha] first task\n- do [#beta] second task\n- do [#gamma] third task\n",
2287 "- do [#gamma] third task\n- do [#alpha] first task\n- do [#beta] second task\n",
2288 );
2289 let theirs = BASE3.replace(
2290 "- do [#alpha] first task\n",
2291 "- do [#alpha] first task ALPHA-EDIT\n",
2292 );
2293 let out = merge_3way(BASE3, &ours, &theirs);
2294 assert!(!out.fell_back);
2295 assert!(out.conflicts.is_empty(), "reorder+edit compose cleanly");
2296 assert!(out.merged_text.contains("first task ALPHA-EDIT"));
2298 let q_open = out.merged_text.find("<!-- agent:queue -->").unwrap();
2301 let q_close = out.merged_text.find("<!-- /agent:queue -->").unwrap();
2302 let body = &out.merged_text[q_open..q_close];
2303 let pa = body.find("ALPHA-EDIT").unwrap();
2304 let pg = body.find("[#gamma]").unwrap();
2305 assert!(
2306 pa < pg,
2307 "theirs (live) order is the spine: alpha before gamma"
2308 );
2309 }
2310
2311 #[test]
2312 fn structural_divergence_falls_back() {
2313 let theirs = "\
2315---
2316agent_doc_format: template
2317---
2318<!-- agent:queue -->
2319- do [#alpha] first task
2320- do [#beta] second task
2321- do [#gamma] third task
2322<!-- /agent:queue -->
2323";
2324 let ours = BASE3.replace("- do [#alpha] first task\n", "- do [#alpha] first task X\n");
2325 let out = merge_3way(BASE3, &ours, theirs);
2326 assert!(out.fell_back, "structural divergence must fall back");
2327 assert!(out.merged_text.is_empty());
2328
2329 let _guard = ENV_LOCK.lock().unwrap();
2330 unsafe {
2334 std::env::set_var(CELL_MERGE_ENV, "1");
2335 }
2336 let base_state = crate::crdt::CrdtDoc::from_text(BASE3).encode_state();
2337 let legacy = crate::crdt::merge_by_component(Some(&base_state), &ours, theirs).unwrap();
2338 unsafe {
2339 std::env::remove_var(CELL_MERGE_ENV);
2340 }
2341 assert!(legacy.contains("<!-- agent:queue -->"));
2342 assert!(legacy.contains("first task X"));
2343 }
2344
2345 #[test]
2354 fn keyed_exchange_vs_body_only_with_content_falls_back() {
2355 let base = "<!-- agent:exchange -->\n\
2356❯ Please reply\n\
2357<!-- agent:boundary:base1234 -->\n\
2358<!-- /agent:exchange -->\n";
2359 let ours = "<!-- agent:exchange -->\n\
2362❯ Please reply\n\
2363### Re: answer\n\nDone.\n\n\
2364<!-- agent:boundary:fresh5678 -->\n\
2365<!-- /agent:exchange -->\n";
2366 let theirs = "<!-- agent:exchange -->\n\
2369❯ Please reply\n\
2370while I was typing the next queue item\n\
2371<!-- agent:boundary:base1234 -->\n\
2372<!-- /agent:exchange -->\n";
2373
2374 let out = merge_3way(base, ours, theirs);
2375 assert!(
2376 out.fell_back,
2377 "keyed-vs-body-only-with-content exchange must fall back to the legacy leaf merge"
2378 );
2379 assert!(out.merged_text.is_empty());
2380 }
2381
2382 #[test]
2386 fn keyed_exchange_vs_empty_body_only_does_not_fall_back() {
2387 let base = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n";
2388 let ours =
2389 "<!-- agent:exchange -->\n### Re: answer\nResponse body.\n<!-- /agent:exchange -->\n";
2390 let theirs = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n";
2391
2392 let out = merge_3way(base, ours, theirs);
2393 assert!(
2394 !out.fell_back,
2395 "an empty body-only exchange must stay on the per-cell path"
2396 );
2397 assert!(out.merged_text.contains("Response body."));
2398 }
2399
2400 #[test]
2401 fn flag_off_byte_identical_to_legacy() {
2402 let ours = BASE3.replace(
2407 "- do [#alpha] first task\n",
2408 "- do [#alpha] first task OURS\n",
2409 );
2410 let theirs = BASE3.replace(
2411 "- do [#gamma] third task\n",
2412 "- do [#gamma] third task THEIRS\n",
2413 );
2414 let base_state = crate::crdt::CrdtDoc::from_text(BASE3).encode_state();
2415
2416 let _guard = ENV_LOCK.lock().unwrap();
2417 unsafe {
2419 std::env::set_var(CELL_MERGE_ENV, "0");
2420 }
2421 let off = crate::crdt::merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2422
2423 let off2 = crate::crdt::merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
2425 assert_eq!(off, off2, "legacy path is deterministic with the flag off");
2426 assert!(!cell_merge_enabled(), "kill-switch must disable");
2427 assert!(off.contains("first task OURS"));
2429 assert!(off.contains("third task THEIRS"));
2430 unsafe {
2432 std::env::remove_var(CELL_MERGE_ENV);
2433 }
2434 }
2435
2436 #[test]
2437 fn cell_merge_enabled_default_on_with_kill_switch() {
2438 let _guard = ENV_LOCK.lock().unwrap();
2439 unsafe {
2441 std::env::remove_var(CELL_MERGE_ENV);
2442 }
2443 assert!(cell_merge_enabled(), "absent ⇒ default ON");
2444 for v in ["1", "true", "on", "yes", "TRUE", " On ", "", "maybe"] {
2446 unsafe {
2447 std::env::set_var(CELL_MERGE_ENV, v);
2448 }
2449 assert!(cell_merge_enabled(), "{v:?} should stay ON");
2450 }
2451 for v in ["0", "false", "off", "no", "FALSE", " Off "] {
2453 unsafe {
2454 std::env::set_var(CELL_MERGE_ENV, v);
2455 }
2456 assert!(!cell_merge_enabled(), "{v:?} should kill (OFF)");
2457 }
2458 unsafe {
2459 std::env::remove_var(CELL_MERGE_ENV);
2460 }
2461 }
2462
2463 #[test]
2464 fn cell_merge_opcapture_default_on_and_master_kill_switch_gates_it() {
2465 let _guard = ENV_LOCK.lock().unwrap();
2466 unsafe {
2468 std::env::remove_var(CELL_MERGE_ENV);
2469 std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
2470 }
2471 assert!(cell_merge_opcapture_enabled(), "absent ⇒ default ON");
2472 for v in ["0", "false", "off", "no"] {
2474 unsafe {
2475 std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, v);
2476 }
2477 assert!(!cell_merge_opcapture_enabled(), "{v:?} should kill");
2478 }
2479 unsafe {
2481 std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "1");
2482 std::env::set_var(CELL_MERGE_ENV, "0");
2483 }
2484 assert!(
2485 !cell_merge_opcapture_enabled(),
2486 "master kill-switch must force the sub-feature OFF"
2487 );
2488 unsafe {
2489 std::env::remove_var(CELL_MERGE_ENV);
2490 std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
2491 }
2492 }
2493
2494 #[test]
2495 fn unsplittable_component_diffs_as_whole_body_update() {
2496 let doc = "\
2498<!-- agent:status -->
2499working on it
2500<!-- /agent:status -->
2501";
2502 let edited = doc.replace("working on it", "done now");
2503 let diffs = diff_document(doc, &edited);
2504 let s = diffs
2505 .iter()
2506 .find(|d| d.component == "status")
2507 .expect("status diff");
2508 assert_eq!(s.ops.len(), 1);
2509 assert!(matches!(&s.ops[0], DiffOp::Update { key, .. } if key == "status:0:body"));
2510 }
2511
2512 fn component_body<'a>(merged: &'a str, name: &str) -> &'a str {
2516 let open = format!("<!-- agent:{name} -->");
2517 let close = format!("<!-- /agent:{name} -->");
2518 merged
2519 .split(&open)
2520 .nth(1)
2521 .and_then(|s| s.split(&close).next())
2522 .unwrap_or("")
2523 }
2524
2525 fn live_count(body: &str, needle: &str) -> usize {
2526 body.lines()
2527 .filter(|l| l.contains(needle) && !l.contains("~~"))
2528 .count()
2529 }
2530
2531 #[test]
2535 fn struck_id_head_not_resurrected_by_stale_unstrike_cell_path() {
2536 let base = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
2537<!-- agent:queue -->\n- ~~do [#abcd] fix the thing~~\n<!-- /agent:queue -->\n";
2538 let ours = "<!-- agent:exchange -->\n### Re: topic\nAgent response body.\n<!-- /agent:exchange -->\n\n\
2540<!-- agent:queue -->\n- do [#abcd] fix the thing\n<!-- /agent:queue -->\n";
2541 let theirs = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
2543<!-- agent:queue -->\n- ~~do [#abcd] fix the thing~~\n<!-- /agent:queue -->\n";
2544
2545 let out = merge_3way(base, ours, theirs);
2546 assert!(!out.fell_back, "should per-cell merge");
2547 let body = component_body(&out.merged_text, "queue");
2548 assert!(
2549 body.contains("~~do [#abcd] fix the thing~~"),
2550 "struck head must stay struck:\n{body}"
2551 );
2552 assert_eq!(
2553 live_count(body, "do [#abcd]"),
2554 0,
2555 "stale un-strike resurrected the struck head LIVE:\n{body}"
2556 );
2557 assert!(
2559 out.conflicts.is_empty(),
2560 "lifecycle join must not be a conflict: {:?}",
2561 out.conflicts
2562 );
2563 assert!(out.merged_text.contains("Agent response body."));
2565 }
2566
2567 #[test]
2569 fn struck_free_text_head_not_resurrected_by_stale_unstrike_cell_path() {
2570 let base = "<!-- agent:queue -->\n- ~~Still getting JB File Cache Conflict dialogs.~~\n<!-- /agent:queue -->\n";
2571 let ours = "<!-- agent:queue -->\n- Still getting JB File Cache Conflict dialogs.\n<!-- /agent:queue -->\n";
2572 let theirs = "<!-- agent:queue -->\n- ~~Still getting JB File Cache Conflict dialogs.~~\n<!-- /agent:queue -->\n";
2573
2574 let out = merge_3way(base, ours, theirs);
2575 assert!(!out.fell_back);
2576 let body = component_body(&out.merged_text, "queue");
2577 assert!(
2578 body.contains("~~Still getting JB File Cache Conflict dialogs.~~"),
2579 "struck free-text head must stay struck:\n{body}"
2580 );
2581 assert_eq!(
2582 live_count(body, "Still getting JB File Cache Conflict dialogs."),
2583 0,
2584 "stale un-strike resurrected the struck free-text head LIVE:\n{body}"
2585 );
2586 assert!(out.conflicts.is_empty());
2587 }
2588
2589 #[test]
2592 fn struck_head_stable_across_cycles_no_churn_cell_path() {
2593 let mut base =
2594 "<!-- agent:queue -->\n- ~~do [#abcd] answered~~\n<!-- /agent:queue -->\n".to_string();
2595 for cycle in 0..3 {
2596 let ours = "<!-- agent:queue -->\n- do [#abcd] answered\n<!-- /agent:queue -->\n";
2597 let theirs = "<!-- agent:queue -->\n- ~~do [#abcd] answered~~\n<!-- /agent:queue -->\n";
2598 let out = merge_3way(&base, ours, theirs);
2599 assert!(!out.fell_back, "cycle {cycle}: fell back");
2600 let body = component_body(&out.merged_text, "queue");
2601 assert_eq!(
2602 live_count(body, "do [#abcd]"),
2603 0,
2604 "cycle {cycle}: head resurrected LIVE (churn):\n{body}"
2605 );
2606 assert!(
2607 body.contains("~~do [#abcd] answered~~"),
2608 "cycle {cycle}: struck head lost:\n{body}"
2609 );
2610 assert_eq!(
2611 body.matches("do [#abcd] answered").count(),
2612 1,
2613 "cycle {cycle}: head duplicated:\n{body}"
2614 );
2615 assert!(out.conflicts.is_empty(), "cycle {cycle}: spurious conflict");
2616 base = out.merged_text;
2618 }
2619 }
2620
2621 #[test]
2623 fn struck_backlog_head_not_resurrected_cell_path() {
2624 let base =
2625 "<!-- agent:backlog -->\n- ~~[#bk1] backlog answered~~\n<!-- /agent:backlog -->\n";
2626 let ours = "<!-- agent:backlog -->\n- [#bk1] backlog answered\n<!-- /agent:backlog -->\n";
2627 let theirs =
2628 "<!-- agent:backlog -->\n- ~~[#bk1] backlog answered~~\n<!-- /agent:backlog -->\n";
2629
2630 let out = merge_3way(base, ours, theirs);
2631 assert!(!out.fell_back);
2632 let body = component_body(&out.merged_text, "backlog");
2633 assert!(
2634 body.contains("~~[#bk1] backlog answered~~"),
2635 "struck backlog head must stay struck:\n{body}"
2636 );
2637 assert_eq!(
2638 live_count(body, "[#bk1]"),
2639 0,
2640 "backlog head resurrected:\n{body}"
2641 );
2642 assert!(out.conflicts.is_empty());
2643 }
2644
2645 #[test]
2647 fn struck_review_head_not_resurrected_cell_path() {
2648 let base = "<!-- agent:review -->\n- ~~[#rv1] review item~~\n<!-- /agent:review -->\n";
2649 let ours = "<!-- agent:review -->\n- [#rv1] review item\n<!-- /agent:review -->\n";
2650 let theirs = "<!-- agent:review -->\n- ~~[#rv1] review item~~\n<!-- /agent:review -->\n";
2651
2652 let out = merge_3way(base, ours, theirs);
2653 assert!(!out.fell_back);
2654 let body = component_body(&out.merged_text, "review");
2655 assert!(body.contains("~~[#rv1] review item~~"));
2656 assert_eq!(live_count(body, "[#rv1]"), 0);
2657 assert!(out.conflicts.is_empty());
2658 }
2659
2660 #[test]
2663 fn struck_head_holds_when_theirs_is_the_stale_unstrike() {
2664 let base = "<!-- agent:queue -->\n- ~~do [#zz] q~~\n<!-- /agent:queue -->\n";
2665 let ours = "<!-- agent:queue -->\n- ~~do [#zz] q~~\n<!-- /agent:queue -->\n";
2666 let theirs = "<!-- agent:queue -->\n- do [#zz] q\n<!-- /agent:queue -->\n";
2667
2668 let out = merge_3way(base, ours, theirs);
2669 assert!(!out.fell_back);
2670 let body = component_body(&out.merged_text, "queue");
2671 assert!(
2672 body.contains("~~do [#zz] q~~"),
2673 "struck side must win:\n{body}"
2674 );
2675 assert_eq!(live_count(body, "do [#zz]"), 0);
2676 assert!(out.conflicts.is_empty());
2677 }
2678
2679 #[test]
2681 fn strike_vs_unstrike_is_not_a_conflict() {
2682 let base = "<!-- agent:queue -->\n- ~~do [#abcd] x~~\n<!-- /agent:queue -->\n";
2683 let ours = "<!-- agent:queue -->\n- do [#abcd] x\n<!-- /agent:queue -->\n";
2684 let theirs = "<!-- agent:queue -->\n- ~~do [#abcd] x~~\n<!-- /agent:queue -->\n";
2685 let out = merge_3way(base, ours, theirs);
2686 assert!(!out.fell_back);
2687 assert!(
2688 out.conflicts.is_empty(),
2689 "a lawful lifecycle join must never be a conflict: {:?}",
2690 out.conflicts
2691 );
2692 }
2693
2694 #[test]
2697 fn genuine_same_level_content_conflict_is_recorded() {
2698 let _guard = ENV_LOCK.lock().unwrap();
2699 let base = "<!-- agent:queue -->\n- do [#beta] orig\n<!-- /agent:queue -->\n";
2700 let ours = "<!-- agent:queue -->\n- do [#beta] OURS-VERSION\n<!-- /agent:queue -->\n";
2701 let theirs = "<!-- agent:queue -->\n- do [#beta] THEIRS-VERSION\n<!-- /agent:queue -->\n";
2702
2703 let out = merge_3way(base, ours, theirs);
2704 assert!(!out.fell_back);
2705 assert_eq!(out.conflicts.len(), 1, "one conflict: {:?}", out.conflicts);
2706 let c = &out.conflicts[0];
2707 assert_eq!(c.component, "queue");
2708 assert_eq!(c.identity, "queue:0:id:beta");
2709 assert_eq!(c.kind, ConflictKind::Content);
2710 assert!(c.ours.contains("OURS-VERSION"));
2711 assert!(c.theirs.contains("THEIRS-VERSION"));
2712 assert!(
2714 c.chosen.contains("THEIRS-VERSION"),
2715 "operator-owned theirs-wins"
2716 );
2717 assert!(out.merged_text.contains("THEIRS-VERSION"));
2718 assert!(
2719 !out.merged_text.contains("OURS-VERSION"),
2720 "no fabricated blend: losing ours value must not also appear:\n{}",
2721 out.merged_text
2722 );
2723 }
2724
2725 #[test]
2729 fn both_sides_struck_different_text_is_a_content_conflict() {
2730 let _guard = ENV_LOCK.lock().unwrap();
2731 let base = "<!-- agent:queue -->\n- do [#k] live\n<!-- /agent:queue -->\n";
2732 let ours = "<!-- agent:queue -->\n- ~~do [#k] struck OURS~~\n<!-- /agent:queue -->\n";
2733 let theirs = "<!-- agent:queue -->\n- ~~do [#k] struck THEIRS~~\n<!-- /agent:queue -->\n";
2734
2735 let out = merge_3way(base, ours, theirs);
2736 assert!(!out.fell_back);
2737 assert_eq!(
2738 out.conflicts.len(),
2739 1,
2740 "both-struck-differ is a conflict: {:?}",
2741 out.conflicts
2742 );
2743 assert_eq!(out.conflicts[0].kind, ConflictKind::Content);
2744 assert!(out.merged_text.contains("struck THEIRS"));
2746 assert!(!out.merged_text.contains("struck OURS"));
2747 }
2748
2749 #[test]
2755 fn op_level_merge_disjoint_edits_converge_both_present() {
2756 let base = "do [#beta] the original task body";
2757 let ours = "do [#beta] the EDITED original task body";
2760 let theirs = "do [#beta] the original task BODY!!";
2761 let merged = op_level_merge(base, ours, theirs).expect("disjoint edits merge");
2762 assert!(merged.contains("EDITED"), "ours edit present: {merged}");
2763 assert!(merged.contains("BODY!!"), "theirs edit present: {merged}");
2764 }
2765
2766 #[test]
2769 fn op_level_merge_same_region_overlap_is_none() {
2770 let base = "do [#beta] original";
2771 let ours = "do [#beta] OURS";
2773 let theirs = "do [#beta] THEIRS";
2774 assert!(
2775 op_level_merge(base, ours, theirs).is_none(),
2776 "overlapping same-region edits must NOT op-merge"
2777 );
2778 }
2779
2780 #[test]
2782 fn char_span_edit_and_overlap_detection() {
2783 let base: Vec<char> = "abcdef".chars().collect();
2784 let e = char_span_edit(&base, &"abXYef".chars().collect::<Vec<_>>()).unwrap();
2786 assert_eq!((e.start, e.end), (2, 4));
2787 assert_eq!(e.replacement, vec!['X', 'Y']);
2788 assert!(char_span_edit(&base, &base).is_none());
2790 let front = char_span_edit(&base, &"aZcdef".chars().collect::<Vec<_>>()).unwrap();
2792 let back = char_span_edit(&base, &"abcdeZ".chars().collect::<Vec<_>>()).unwrap();
2793 assert!(!ranges_overlap(&front, &back), "front vs back disjoint");
2794 let o = char_span_edit(&base, &"abXXef".chars().collect::<Vec<_>>()).unwrap();
2796 let t = char_span_edit(&base, &"abYYef".chars().collect::<Vec<_>>()).unwrap();
2797 assert!(ranges_overlap(&o, &t), "same middle region overlaps");
2798 }
2799
2800 #[test]
2801 fn merge_3way_preserves_operator_marker_attribute_qmarkerauth() {
2802 let base = "<!-- agent:queue -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
2808 let ours = "<!-- agent:queue -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
2809 let theirs = "<!-- agent:queue priority go -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
2810 let out = merge_3way(base, ours, theirs);
2811 assert!(!out.fell_back, "should not fall back: {out:?}");
2812 assert!(
2813 out.merged_text.contains("<!-- agent:queue priority go -->"),
2814 "operator marker attribute lost (reverted to ours' marker): {}",
2815 out.merged_text
2816 );
2817 }
2818
2819 #[test]
2820 fn merge_3way_keeps_agent_marker_when_operator_left_it_at_base_qmarkerauth() {
2821 let base = "<!-- agent:queue -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
2824 let ours = "<!-- agent:queue go -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
2825 let theirs = "<!-- agent:queue -->\n- do [#beta] task EDITED\n<!-- /agent:queue -->\n";
2826 let out = merge_3way(base, ours, theirs);
2827 assert!(!out.fell_back, "should not fall back: {out:?}");
2828 assert!(
2829 out.merged_text.contains("<!-- agent:queue go -->"),
2830 "agent marker change lost: {}",
2831 out.merged_text
2832 );
2833 assert!(
2834 out.merged_text.contains("EDITED"),
2835 "operator body edit lost: {}",
2836 out.merged_text
2837 );
2838 }
2839
2840 #[test]
2843 fn opcapture_on_disjoint_same_cell_edits_both_land_no_conflict() {
2844 let _guard = ENV_LOCK.lock().unwrap();
2845 let base = "<!-- agent:queue -->\n- do [#beta] the original answer body here\n<!-- /agent:queue -->\n";
2849 let ours = "<!-- agent:queue -->\n- do [#beta] the EDITED answer body here\n<!-- /agent:queue -->\n";
2850 let theirs = "<!-- agent:queue -->\n- do [#beta] the original answer body THERE!\n<!-- /agent:queue -->\n";
2851
2852 unsafe {
2854 std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "1");
2855 }
2856 let out = merge_3way(base, ours, theirs);
2857 unsafe {
2858 std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
2859 }
2860
2861 assert!(!out.fell_back, "should not fall back: {out:?}");
2862 assert!(
2863 out.conflicts.is_empty(),
2864 "disjoint op-merge records NO conflict: {:?}",
2865 out.conflicts
2866 );
2867 assert!(
2868 out.merged_text.contains("EDITED"),
2869 "ours edit: {}",
2870 out.merged_text
2871 );
2872 assert!(
2873 out.merged_text.contains("THERE!"),
2874 "theirs edit: {}",
2875 out.merged_text
2876 );
2877 }
2878
2879 #[test]
2882 fn opcapture_on_same_region_overlap_still_conflicts() {
2883 let _guard = ENV_LOCK.lock().unwrap();
2884 let base = "<!-- agent:queue -->\n- do [#beta] original\n<!-- /agent:queue -->\n";
2885 let ours = "<!-- agent:queue -->\n- do [#beta] OURS-ONLY\n<!-- /agent:queue -->\n";
2886 let theirs = "<!-- agent:queue -->\n- do [#beta] THEIRS-ONLY\n<!-- /agent:queue -->\n";
2887
2888 unsafe {
2892 std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "1");
2893 std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, "0");
2894 }
2895 let out = merge_3way(base, ours, theirs);
2896 unsafe {
2897 std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
2898 std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
2899 }
2900
2901 assert!(!out.fell_back);
2902 assert_eq!(
2903 out.conflicts.len(),
2904 1,
2905 "overlap still conflicts even with opcapture ON: {:?}",
2906 out.conflicts
2907 );
2908 assert_eq!(out.conflicts[0].kind, ConflictKind::Content);
2909 assert!(
2911 out.merged_text.contains("THEIRS-ONLY"),
2912 "operator-owned theirs-wins"
2913 );
2914 assert!(
2915 !out.merged_text.contains("OURS-ONLY"),
2916 "no blend on a real overlap: {}",
2917 out.merged_text
2918 );
2919 }
2920
2921 #[test]
2924 fn opcapture_off_legacy_owner_wins_unchanged() {
2925 let _guard = ENV_LOCK.lock().unwrap();
2926 let base = "<!-- agent:queue -->\n- do [#beta] the original answer body here\n<!-- /agent:queue -->\n";
2927 let ours = "<!-- agent:queue -->\n- do [#beta] the EDITED answer body here\n<!-- /agent:queue -->\n";
2928 let theirs = "<!-- agent:queue -->\n- do [#beta] the original answer body THERE!\n<!-- /agent:queue -->\n";
2929
2930 unsafe {
2933 std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "0");
2934 }
2935 assert!(!cell_merge_opcapture_enabled(), "opcapture kill-switch off");
2936
2937 let out = merge_3way(base, ours, theirs);
2938 assert!(!out.fell_back);
2939 assert_eq!(
2940 out.conflicts.len(),
2941 1,
2942 "OFF: disjoint same-cell edits are a conflict (no op-merge): {:?}",
2943 out.conflicts
2944 );
2945 assert!(
2948 out.merged_text.contains("THERE!"),
2949 "operator-owned theirs-wins"
2950 );
2951 assert!(
2952 !out.merged_text.contains("EDITED"),
2953 "OFF: losing ours disjoint edit is dropped (no op-merge): {}",
2954 out.merged_text
2955 );
2956 unsafe {
2958 std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
2959 }
2960 }
2961
2962 fn merge_with_markers(
2967 value: Option<&str>,
2968 base: &str,
2969 ours: &str,
2970 theirs: &str,
2971 ) -> CellMergeOutcome {
2972 let _guard = ENV_LOCK.lock().unwrap();
2973 unsafe {
2975 match value {
2976 Some(v) => std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, v),
2977 None => std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV),
2978 }
2979 }
2980 let out = merge_3way(base, ours, theirs);
2981 unsafe {
2982 std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
2983 }
2984 out
2985 }
2986
2987 #[test]
2991 fn conflict_markers_on_surfaces_both_versions_and_round_trips() {
2992 let base = "<!-- agent:queue -->\n- do [#beta] original\n<!-- /agent:queue -->\n";
2993 let ours = "<!-- agent:queue -->\n- do [#beta] OURS-VERSION\n<!-- /agent:queue -->\n";
2994 let theirs = "<!-- agent:queue -->\n- do [#beta] THEIRS-VERSION\n<!-- /agent:queue -->\n";
2995
2996 let out = merge_with_markers(Some("1"), base, ours, theirs);
2997 assert!(!out.fell_back, "marker must not break round-trip: {out:?}");
2998 assert_eq!(out.conflicts.len(), 1, "one conflict: {:?}", out.conflicts);
2999
3000 assert!(
3002 out.merged_text.contains("OURS-VERSION"),
3003 "ours verbatim: {}",
3004 out.merged_text
3005 );
3006 assert!(
3007 out.merged_text.contains("THEIRS-VERSION"),
3008 "theirs surfaced for operator resolution: {}",
3009 out.merged_text
3010 );
3011 assert!(
3013 out.merged_text.contains("<<<<<<< ours"),
3014 "{}",
3015 out.merged_text
3016 );
3017 assert!(out.merged_text.contains("======="), "{}", out.merged_text);
3018 assert!(
3019 out.merged_text.contains(">>>>>>> theirs"),
3020 "{}",
3021 out.merged_text
3022 );
3023 assert!(
3027 out.merged_text.contains("- do [#beta] THEIRS-VERSION\n"),
3028 "theirs stays the active list line: {}",
3029 out.merged_text
3030 );
3031 let parsed = component::parse(&out.merged_text).expect("merged doc parses");
3034 let top: Vec<&str> = parsed.iter().map(|c| c.name.as_str()).collect();
3035 assert_eq!(top, vec!["queue"], "component sequence unchanged: {top:?}");
3036 }
3037
3038 #[test]
3041 fn conflict_markers_on_no_marker_for_nonconflicting_edits() {
3042 let base = "<!-- agent:queue -->\n- do [#a] one\n- do [#b] two\n- do [#c] three\n<!-- /agent:queue -->\n";
3044 let ours = "<!-- agent:queue -->\n- do [#a] one\n- do [#b] two EDITED\n- do [#c] three\n<!-- /agent:queue -->\n";
3045 let out = merge_with_markers(Some("1"), base, ours, base);
3046 assert!(!out.fell_back);
3047 assert!(out.conflicts.is_empty(), "single-side edit is no conflict");
3048 assert!(
3049 !out.merged_text.contains("<<<<<<<"),
3050 "no conflict marker for a single-side edit: {}",
3051 out.merged_text
3052 );
3053
3054 let theirs = "<!-- agent:queue -->\n- do [#a] one\n- do [#b] two\n- do [#c] three EDITED-T\n<!-- /agent:queue -->\n";
3056 let ours2 = "<!-- agent:queue -->\n- do [#a] one EDITED-O\n- do [#b] two\n- do [#c] three\n<!-- /agent:queue -->\n";
3057 let out2 = merge_with_markers(Some("1"), base, &ours2, theirs);
3058 assert!(!out2.fell_back);
3059 assert!(
3060 out2.conflicts.is_empty(),
3061 "different-item edits never conflict"
3062 );
3063 assert!(
3064 !out2.merged_text.contains("<<<<<<<"),
3065 "no marker for disjoint different-item edits: {}",
3066 out2.merged_text
3067 );
3068 assert!(out2.merged_text.contains("one EDITED-O"));
3069 assert!(out2.merged_text.contains("three EDITED-T"));
3070
3071 {
3074 let _g = ENV_LOCK.lock().unwrap();
3075 let dbase = "<!-- agent:queue -->\n- do [#beta] the original answer body here\n<!-- /agent:queue -->\n";
3076 let dours = "<!-- agent:queue -->\n- do [#beta] the EDITED answer body here\n<!-- /agent:queue -->\n";
3077 let dtheirs = "<!-- agent:queue -->\n- do [#beta] the original answer body THERE!\n<!-- /agent:queue -->\n";
3078 unsafe {
3080 std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "1");
3081 std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, "1");
3082 }
3083 let out3 = merge_3way(dbase, dours, dtheirs);
3084 unsafe {
3085 std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
3086 std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
3087 }
3088 assert!(!out3.fell_back);
3089 assert!(
3090 out3.conflicts.is_empty(),
3091 "opcapture-clean disjoint edits are no conflict: {:?}",
3092 out3.conflicts
3093 );
3094 assert!(
3095 !out3.merged_text.contains("<<<<<<<"),
3096 "no marker when opcapture cleanly merges: {}",
3097 out3.merged_text
3098 );
3099 }
3100 }
3101
3102 #[test]
3105 fn conflict_markers_on_no_marker_for_lifecycle_join() {
3106 let base = "<!-- agent:queue -->\n- ~~do [#abcd] x~~\n<!-- /agent:queue -->\n";
3107 let ours = "<!-- agent:queue -->\n- do [#abcd] x\n<!-- /agent:queue -->\n";
3108 let theirs = "<!-- agent:queue -->\n- ~~do [#abcd] x~~\n<!-- /agent:queue -->\n";
3109
3110 let out = merge_with_markers(Some("1"), base, ours, theirs);
3111 assert!(!out.fell_back);
3112 assert!(
3113 out.conflicts.is_empty(),
3114 "lifecycle join is never a conflict: {:?}",
3115 out.conflicts
3116 );
3117 assert!(
3118 !out.merged_text.contains("<<<<<<<"),
3119 "no conflict marker for a lawful lifecycle join: {}",
3120 out.merged_text
3121 );
3122 let body = component_body(&out.merged_text, "queue");
3124 assert!(body.contains("~~do [#abcd] x~~"), "{body}");
3125 }
3126
3127 #[test]
3132 fn conflict_markers_default_off_optin_surfaces() {
3133 let base = "<!-- agent:queue -->\n- do [#beta] original\n<!-- /agent:queue -->\n";
3134 let ours = "<!-- agent:queue -->\n- do [#beta] OURS-VERSION\n<!-- /agent:queue -->\n";
3135 let theirs = "<!-- agent:queue -->\n- do [#beta] THEIRS-VERSION\n<!-- /agent:queue -->\n";
3136
3137 let absent = merge_with_markers(None, base, ours, theirs);
3140 assert!(absent.merged_text.contains("THEIRS-VERSION"));
3141 assert!(
3142 !absent.merged_text.contains("OURS-VERSION"),
3143 "default OFF: losing ours not surfaced: {}",
3144 absent.merged_text
3145 );
3146 assert!(
3147 !absent.merged_text.contains("<<<<<<<"),
3148 "default OFF: no conflict marker: {}",
3149 absent.merged_text
3150 );
3151 assert_eq!(absent.conflicts.len(), 1, "conflict still recorded");
3153
3154 let on = merge_with_markers(Some("1"), base, ours, theirs);
3156 assert!(
3157 on.merged_text.contains("THEIRS-VERSION")
3158 && on.merged_text.contains("OURS-VERSION")
3159 && on.merged_text.contains("<<<<<<<"),
3160 "opt-in ON surfaces both versions in-band: {}",
3161 on.merged_text
3162 );
3163 assert_ne!(
3165 absent.merged_text, on.merged_text,
3166 "opt-in must diverge from the default-OFF clean output"
3167 );
3168 }
3169
3170 #[test]
3173 fn conflict_markers_gate_default_off_with_optin() {
3174 let _guard = ENV_LOCK.lock().unwrap();
3175 unsafe {
3177 std::env::remove_var(CELL_MERGE_ENV);
3178 std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
3179 }
3180 assert!(
3181 !cell_merge_conflict_markers_enabled(),
3182 "absent ⇒ default OFF"
3183 );
3184 for v in ["1", "true", "on", "yes", "TRUE", " On "] {
3186 unsafe {
3187 std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, v);
3188 }
3189 assert!(
3190 cell_merge_conflict_markers_enabled(),
3191 "{v:?} should opt-in ON"
3192 );
3193 }
3194 for v in ["0", "false", "off", "no", "FALSE", " Off ", "", "maybe"] {
3196 unsafe {
3197 std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, v);
3198 }
3199 assert!(
3200 !cell_merge_conflict_markers_enabled(),
3201 "{v:?} should stay OFF"
3202 );
3203 }
3204 unsafe {
3206 std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, "1");
3207 std::env::set_var(CELL_MERGE_ENV, "0");
3208 }
3209 assert!(
3210 !cell_merge_conflict_markers_enabled(),
3211 "master kill-switch must force the sub-feature OFF"
3212 );
3213 unsafe {
3214 std::env::remove_var(CELL_MERGE_ENV);
3215 std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
3216 }
3217 }
3218
3219 #[test]
3222 fn conflict_markers_on_skips_framing_unsafe_side_text() {
3223 let base = "<!-- agent:queue -->\n- do [#beta] original\n<!-- /agent:queue -->\n";
3226 let ours = "<!-- agent:queue -->\n- do [#beta] OURS-VERSION\n<!-- /agent:queue -->\n";
3227 let theirs =
3228 "<!-- agent:queue -->\n- do [#beta] THEIRS with --> inside\n<!-- /agent:queue -->\n";
3229
3230 let out = merge_with_markers(Some("1"), base, ours, theirs);
3231 assert!(!out.fell_back, "must still round-trip: {out:?}");
3232 assert_eq!(out.conflicts.len(), 1, "conflict still recorded");
3233 assert!(
3234 !out.merged_text.contains("<<<<<<<"),
3235 "framing-unsafe side must not be surfaced in-band: {}",
3236 out.merged_text
3237 );
3238 assert!(out.merged_text.contains("THEIRS with --> inside"));
3241 }
3242
3243 const OPDOC: &str = "\
3252<!-- agent:queue -->
3253- do [#alpha] first task
3254- do [#beta] second task
3255- do [#gamma] third task
3256<!-- /agent:queue -->
3257";
3258
3259 #[test]
3263 fn project_document_spans_tiles_body_losslessly() {
3264 let spans = project_document_spans(OPDOC).expect("spans project");
3265 assert_eq!(spans.len(), 3, "three queue items: {spans:?}");
3266 for s in &spans {
3268 assert_eq!(&OPDOC[s.span.start..s.span.end], s.value.as_str());
3269 }
3270 for w in spans.windows(2) {
3272 assert_eq!(w[0].span.end, w[1].span.start, "spans must be contiguous");
3273 }
3274 let body_start = OPDOC.find("first task").unwrap() - "- do [#alpha] ".len();
3276 let body_end = OPDOC.find("<!-- /agent:queue -->").unwrap();
3277 let reassembled: String = spans.iter().map(|s| s.value.clone()).collect();
3278 assert_eq!(reassembled, &OPDOC[body_start..body_end]);
3279 assert_eq!(spans[0].identity, "queue:0:id:alpha");
3281 assert_eq!(spans[2].identity, "queue:0:id:gamma");
3282 }
3283
3284 #[test]
3288 fn route_ops_to_cells_places_by_span_and_bails_on_boundary_cross() {
3289 let spans = project_document_spans(OPDOC).unwrap();
3290 let beta = spans
3291 .iter()
3292 .find(|s| s.identity == "queue:0:id:beta")
3293 .unwrap();
3294 let abs = OPDOC.find("second task").unwrap() + "second task".len();
3296 assert!(beta.span.contains_offset(abs), "offset must be in beta");
3297 let ops = vec![EditorOp::Insert {
3298 offset: abs,
3299 text: " THEIRS".to_string(),
3300 }];
3301 let routed = route_ops_to_cells(OPDOC, &ops).expect("routes");
3302 assert_eq!(routed.len(), 1, "exactly one cell touched");
3303 let (key, cell_ops) = routed.iter().next().unwrap();
3304 assert_eq!(node_key_identity(key), "queue:0:id:beta");
3305 match &cell_ops[0] {
3307 EditorOp::Insert { offset, text } => {
3308 assert_eq!(*offset, abs - beta.span.start, "cell-local offset");
3309 assert_eq!(text, " THEIRS");
3310 }
3311 other => panic!("expected insert, got {other:?}"),
3312 }
3313
3314 let gamma = spans
3316 .iter()
3317 .find(|s| s.identity == "queue:0:id:gamma")
3318 .unwrap();
3319 let cross_len = gamma.span.start + 1 - beta.span.start;
3320 let crossing = vec![EditorOp::Delete {
3321 offset: beta.span.start,
3322 len: cross_len,
3323 }];
3324 assert!(
3325 route_ops_to_cells(OPDOC, &crossing).is_none(),
3326 "boundary-crossing delete must bail to None"
3327 );
3328
3329 let framing = vec![EditorOp::Insert {
3332 offset: 1,
3333 text: "x".to_string(),
3334 }];
3335 assert!(
3336 route_ops_to_cells(OPDOC, &framing).is_none(),
3337 "framing op must bail to None"
3338 );
3339 }
3340
3341 #[test]
3344 fn merge_3way_with_ops_replays_real_ops_and_merges() {
3345 let abs = OPDOC.find("second task").unwrap() + "second task".len();
3347 let ops = vec![EditorOp::Insert {
3348 offset: abs,
3349 text: " THEIRS".to_string(),
3350 }];
3351 let theirs = OPDOC.replace(
3352 "- do [#beta] second task\n",
3353 "- do [#beta] second task THEIRS\n",
3354 );
3355 let out = merge_3way_with_ops(OPDOC, OPDOC, &theirs, &ops);
3356 assert!(!out.fell_back, "real-op path should merge, not fall back");
3357 assert!(out.conflicts.is_empty(), "single-sided: no conflict");
3358 assert!(out.merged_text.contains("second task THEIRS"));
3359 assert!(out.merged_text.contains("- do [#alpha] first task\n"));
3361 assert!(out.merged_text.contains("- do [#gamma] third task\n"));
3362 }
3363
3364 #[test]
3368 fn merge_3way_with_ops_safety_gate_falls_back_on_stale_ops() {
3369 let abs = OPDOC.find("second task").unwrap() + "second task".len();
3372 let ops = vec![EditorOp::Insert {
3373 offset: abs,
3374 text: " THEIRS".to_string(),
3375 }];
3376 let divergent_theirs = OPDOC.replace(
3377 "- do [#beta] second task\n",
3378 "- do [#beta] second task DIVERGENT\n",
3379 );
3380 let out = merge_3way_with_ops(OPDOC, OPDOC, &divergent_theirs, &ops);
3381 assert!(
3382 out.fell_back,
3383 "stale/misaligned ops must trip the byte-for-byte safety gate"
3384 );
3385 }
3386
3387 #[test]
3390 fn merge_3way_with_ops_falls_back_on_framing_op() {
3391 let ops = vec![EditorOp::Insert {
3393 offset: 1,
3394 text: "x".to_string(),
3395 }];
3396 let out = merge_3way_with_ops(OPDOC, OPDOC, OPDOC, &ops);
3398 assert!(out.fell_back, "framing op must fall back");
3399 }
3400
3401 #[test]
3406 fn op_routed_same_cell_inherits_conflict_surfacing() {
3407 let _guard = ENV_LOCK.lock().unwrap();
3408 unsafe {
3410 std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, "1");
3411 }
3412 let ours = OPDOC.replace(
3415 "- do [#beta] second task\n",
3416 "- do [#beta] second task OURS-VERSION\n",
3417 );
3418 let abs = OPDOC.find("second task").unwrap() + "second task".len();
3419 let ops = vec![EditorOp::Insert {
3420 offset: abs,
3421 text: " THEIRS-VERSION".to_string(),
3422 }];
3423 let theirs = OPDOC.replace(
3424 "- do [#beta] second task\n",
3425 "- do [#beta] second task THEIRS-VERSION\n",
3426 );
3427 let out = merge_3way_with_ops(OPDOC, &ours, &theirs, &ops);
3428 unsafe {
3429 std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
3430 }
3431 assert!(!out.fell_back, "real-op path should merge: {out:?}");
3432 assert_eq!(
3433 out.conflicts.len(),
3434 1,
3435 "same-cell divergence surfaces a conflict via merge_3way: {:?}",
3436 out.conflicts
3437 );
3438 let c = &out.conflicts[0];
3439 assert_eq!(c.identity, "queue:0:id:beta");
3440 assert!(c.ours.contains("OURS-VERSION"));
3441 assert!(c.theirs.contains("THEIRS-VERSION"));
3442 assert!(
3445 out.merged_text.contains("OURS-VERSION") && out.merged_text.contains("THEIRS-VERSION"),
3446 "op-routed path must inherit in-band conflict-surfacing: {}",
3447 out.merged_text
3448 );
3449 }
3450
3451 #[test]
3455 fn op_routed_disjoint_same_cell_edits_inherit_op_level_merge() {
3456 let _guard = ENV_LOCK.lock().unwrap();
3457 unsafe {
3459 std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
3460 }
3461 let ours = OPDOC.replace(
3464 "- do [#beta] second task\n",
3465 "- do [#beta] second task OURS_TAIL\n",
3466 );
3467 let head_abs = OPDOC.find("second task").unwrap();
3469 let ops = vec![EditorOp::Insert {
3470 offset: head_abs,
3471 text: "THEIRS_HEAD ".to_string(),
3472 }];
3473 let theirs = OPDOC.replace(
3474 "- do [#beta] second task\n",
3475 "- do [#beta] THEIRS_HEAD second task\n",
3476 );
3477 let out = merge_3way_with_ops(OPDOC, &ours, &theirs, &ops);
3478 assert!(!out.fell_back, "real-op path should merge: {out:?}");
3479 assert!(
3480 out.conflicts.is_empty(),
3481 "disjoint same-cell edits must converge with no conflict: {:?}",
3482 out.conflicts
3483 );
3484 assert!(
3485 out.merged_text.contains("THEIRS_HEAD") && out.merged_text.contains("OURS_TAIL"),
3486 "both disjoint edits must survive (op_level_merge): {}",
3487 out.merged_text
3488 );
3489 }
3490
3491 #[test]
3495 fn ops_absent_uses_text_diff_path() {
3496 let ours = OPDOC.replace(
3498 "- do [#alpha] first task\n",
3499 "- do [#alpha] first task OURS\n",
3500 );
3501 let theirs = OPDOC.replace(
3502 "- do [#gamma] third task\n",
3503 "- do [#gamma] third task THEIRS\n",
3504 );
3505 let out = merge_3way(OPDOC, &ours, &theirs);
3507 assert!(!out.fell_back);
3508 assert!(out.conflicts.is_empty());
3509 assert!(out.merged_text.contains("first task OURS"));
3510 assert!(out.merged_text.contains("third task THEIRS"));
3511 }
3512}