use lazily::{
CellMap, CellTree, Context, DiffOp, SemTree, TextCrdt, apply_to_map, apply_to_tree, reconcile,
};
use crate::component::{self, Component};
use crate::crdt::{
EditorOp, PREAMBLE_KEY, is_list_component, replay_editor_ops, split_exchange_children,
split_list_children,
};
use crate::queue_item_lifecycle::QueueItemLifecycle;
pub const CELL_MERGE_ENV: &str = "AGENT_DOC_CELL_MERGE";
#[cfg(test)]
pub(crate) static CELL_MERGE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub const CELL_MERGE_OPCAPTURE_ENV: &str = "AGENT_DOC_CELL_MERGE_OPCAPTURE";
pub const CELL_MERGE_CONFLICT_MARKERS_ENV: &str = "AGENT_DOC_CELL_MERGE_CONFLICT_MARKERS";
fn env_falsy(name: &str) -> bool {
match std::env::var(name) {
Ok(v) => {
let v = v.trim().to_ascii_lowercase();
matches!(v.as_str(), "0" | "false" | "off" | "no")
}
Err(_) => false,
}
}
fn env_truthy(name: &str) -> bool {
match std::env::var(name) {
Ok(v) => {
let v = v.trim().to_ascii_lowercase();
matches!(v.as_str(), "1" | "true" | "on" | "yes")
}
Err(_) => false,
}
}
pub fn cell_merge_enabled() -> bool {
!env_falsy(CELL_MERGE_ENV)
}
pub fn cell_merge_opcapture_enabled() -> bool {
cell_merge_enabled() && !env_falsy(CELL_MERGE_OPCAPTURE_ENV)
}
pub fn cell_merge_conflict_markers_enabled() -> bool {
cell_merge_enabled() && env_truthy(CELL_MERGE_CONFLICT_MARKERS_ENV)
}
pub type NodeKey = String;
pub type ItemValue = String;
fn make_node_key(component: &str, occurrence: usize, item_id: &str, index: usize) -> NodeKey {
format!("{component}:{occurrence}:{item_id}:{index}")
}
fn is_body_only(occ: &ComponentOccurrence) -> bool {
occ.items.len() == 1
&& occ.items[0].0 == make_node_key(&occ.component, occ.occurrence, "body", 0)
}
pub fn node_key_identity(key: &str) -> &str {
match key.rfind(':') {
Some(i) => &key[..i],
None => key,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentOccurrence {
pub component: String,
pub occurrence: usize,
pub items: Vec<(NodeKey, ItemValue)>,
}
impl ComponentOccurrence {
fn keyed_for_diff(&self) -> Vec<(String, ItemValue)> {
self.items
.iter()
.map(|(k, v)| (node_key_identity(k).to_string(), v.clone()))
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentDiff {
pub component: String,
pub occurrence: usize,
pub ops: Vec<DiffOp<String, ItemValue>>,
}
impl ComponentDiff {
pub fn is_empty(&self) -> bool {
self.ops.is_empty()
}
}
pub fn project_document(doc: &str) -> Vec<ComponentOccurrence> {
let components = match component::parse(doc) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let mut occurrence_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut out = Vec::with_capacity(components.len());
for comp in &components {
let occ = occurrence_counts.entry(comp.name.clone()).or_insert(0);
let occurrence = *occ;
*occ += 1;
out.push(project_component(doc, comp, occurrence));
}
out
}
fn project_component(doc: &str, comp: &Component, occurrence: usize) -> ComponentOccurrence {
let body = comp.content(doc);
let children = split_keyed_children(&comp.name, body);
let items = match children {
Some(children) => children
.into_iter()
.enumerate()
.map(|(index, (key, text))| (make_node_key(&comp.name, occurrence, &key, index), text))
.collect(),
None => vec![(
make_node_key(&comp.name, occurrence, "body", 0),
body.to_string(),
)],
};
ComponentOccurrence {
component: comp.name.clone(),
occurrence,
items,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ByteSpan {
pub start: usize,
pub end: usize,
}
impl ByteSpan {
fn contains_offset(&self, offset: usize) -> bool {
offset >= self.start && offset < self.end
}
fn contains_range(&self, lo: usize, hi: usize) -> bool {
lo >= self.start && hi <= self.end && lo <= hi
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemSpan {
pub component: String,
pub occurrence: usize,
pub node_key: NodeKey,
pub identity: String,
pub value: ItemValue,
pub span: ByteSpan,
}
pub fn project_document_spans(doc: &str) -> Option<Vec<ItemSpan>> {
let components = component::parse(doc).ok()?;
let top: Vec<&Component> = components
.iter()
.filter(|c| {
!components.iter().any(|o| {
!std::ptr::eq(o, *c) && o.open_start <= c.open_start && c.close_end <= o.close_end
})
})
.collect();
let mut top = top;
top.sort_by_key(|c| c.open_start);
let mut occurrence_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut out = Vec::new();
for comp in top {
let occ = occurrence_counts.entry(comp.name.clone()).or_insert(0);
let occurrence = *occ;
*occ += 1;
let body_start = comp.open_end;
let body = comp.content(doc);
let occ_proj = project_component(doc, comp, occurrence);
let mut cursor = body_start;
for (node_key, value) in occ_proj.items {
let start = cursor;
let end = start + value.len();
cursor = end;
out.push(ItemSpan {
component: comp.name.clone(),
occurrence,
identity: node_key_identity(&node_key).to_string(),
node_key,
value,
span: ByteSpan { start, end },
});
}
debug_assert_eq!(cursor, body_start + body.len(), "item spans must tile body");
if cursor != body_start + body.len() {
return None;
}
}
Some(out)
}
fn split_keyed_children(name: &str, body: &str) -> Option<Vec<(String, String)>> {
let children = if name == "exchange" {
split_exchange_children(body)
} else if is_list_component(name) {
split_list_children(body)
} else {
None
}?;
let mut seen = std::collections::HashSet::new();
let mut pairs = Vec::with_capacity(children.len());
for child in &children {
let key = if child.key == PREAMBLE_KEY {
"preamble".to_string()
} else {
child.key.clone()
};
if !seen.insert(key.clone()) {
return None;
}
pairs.push((key, child.text.clone()));
}
Some(pairs)
}
fn diff_occurrence(old: &ComponentOccurrence, new: &ComponentOccurrence) -> ComponentDiff {
let ops = reconcile(&old.keyed_for_diff(), &new.keyed_for_diff());
ComponentDiff {
component: new.component.clone(),
occurrence: new.occurrence,
ops,
}
}
pub fn diff_document(old_doc: &str, new_doc: &str) -> Vec<ComponentDiff> {
let old = project_document(old_doc);
let new = project_document(new_doc);
let mut old_by_key: std::collections::HashMap<(String, usize), &ComponentOccurrence> =
std::collections::HashMap::new();
for occ in &old {
old_by_key.insert((occ.component.clone(), occ.occurrence), occ);
}
let mut new_keys: std::collections::HashSet<(String, usize)> = std::collections::HashSet::new();
let mut diffs = Vec::new();
for occ in &new {
new_keys.insert((occ.component.clone(), occ.occurrence));
let diff = match old_by_key.get(&(occ.component.clone(), occ.occurrence)) {
Some(old_occ) => diff_occurrence(old_occ, occ),
None => {
let empty = ComponentOccurrence {
component: occ.component.clone(),
occurrence: occ.occurrence,
items: Vec::new(),
};
diff_occurrence(&empty, occ)
}
};
if !diff.is_empty() {
diffs.push(diff);
}
}
for occ in &old {
if !new_keys.contains(&(occ.component.clone(), occ.occurrence)) {
let empty = ComponentOccurrence {
component: occ.component.clone(),
occurrence: occ.occurrence,
items: Vec::new(),
};
let diff = diff_occurrence(occ, &empty);
if !diff.is_empty() {
diffs.push(diff);
}
}
}
diffs
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConflictKind {
#[default]
Content,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CellConflict {
pub component: String,
pub occurrence: usize,
pub identity: String,
pub kind: ConflictKind,
pub chosen: ItemValue,
pub ours: ItemValue,
pub theirs: ItemValue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConflictPolicy {
#[default]
OursWins,
TheirsWins,
}
pub fn component_conflict_policy(component: &str) -> ConflictPolicy {
match component {
"exchange" | "status" | "log" => ConflictPolicy::OursWins,
_ => ConflictPolicy::TheirsWins,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CellMergeOutcome {
pub merged_text: String,
pub conflicts: Vec<CellConflict>,
pub fell_back: bool,
}
impl CellMergeOutcome {
fn fallback() -> Self {
CellMergeOutcome {
merged_text: String::new(),
conflicts: Vec::new(),
fell_back: true,
}
}
}
enum DocNode {
Interstitial(String),
Component {
framing: ComponentFraming,
occurrence: ComponentOccurrence,
},
}
struct ComponentFraming {
open: String,
close: String,
}
fn parse_doc_nodes(doc: &str) -> Option<Vec<DocNode>> {
let comps = component::parse(doc).ok()?;
let mut top: Vec<&Component> = comps
.iter()
.filter(|c| {
!comps.iter().any(|o| {
!std::ptr::eq(o, *c) && o.open_start <= c.open_start && c.close_end <= o.close_end
})
})
.collect();
top.sort_by_key(|c| c.open_start);
let mut occurrence_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut nodes = Vec::with_capacity(top.len() * 2 + 1);
let mut cursor = 0usize;
for c in top {
nodes.push(DocNode::Interstitial(doc[cursor..c.open_start].to_string()));
let occ_idx = occurrence_counts.entry(c.name.clone()).or_insert(0);
let occurrence = *occ_idx;
*occ_idx += 1;
let framing = ComponentFraming {
open: doc[c.open_start..c.open_end].to_string(),
close: doc[c.close_start..c.close_end].to_string(),
};
nodes.push(DocNode::Component {
framing,
occurrence: project_component(doc, c, occurrence),
});
cursor = c.close_end;
}
nodes.push(DocNode::Interstitial(doc[cursor..].to_string()));
Some(nodes)
}
fn reconcile_marker_operator_authoritative<'a>(
base: Option<&str>,
ours: &'a str,
theirs: &'a str,
) -> &'a str {
if ours == theirs {
return ours;
}
match base {
Some(b) if theirs != b => theirs,
Some(_) => ours,
None => theirs,
}
}
fn component_name_sequence(nodes: &[DocNode]) -> Vec<&str> {
nodes
.iter()
.filter_map(|n| match n {
DocNode::Component { occurrence, .. } => Some(occurrence.component.as_str()),
DocNode::Interstitial(_) => None,
})
.collect()
}
fn keys_unique(occ: &ComponentOccurrence) -> bool {
let mut seen = std::collections::HashSet::new();
occ.items
.iter()
.all(|(k, _)| seen.insert(node_key_identity(k).to_string()))
}
type ComposedOccurrence = (Vec<(String, ItemValue)>, Vec<CellConflict>);
fn compose_occurrence(
base: &ComponentOccurrence,
ours: &ComponentOccurrence,
theirs: &ComponentOccurrence,
policy: ConflictPolicy,
) -> Option<ComposedOccurrence> {
let base_to_ours = reconcile(&base.keyed_for_diff(), &ours.keyed_for_diff());
let base_to_theirs = reconcile(&base.keyed_for_diff(), &theirs.keyed_for_diff());
let ours_removed = removed_keys(&base_to_ours);
let theirs_removed = removed_keys(&base_to_theirs);
let ours_updated = updated_keys(&base_to_ours);
let theirs_updated = updated_keys(&base_to_theirs);
let to_map = |occ: &ComponentOccurrence| -> std::collections::HashMap<String, ItemValue> {
occ.items
.iter()
.map(|(k, v)| (node_key_identity(k).to_string(), v.clone()))
.collect()
};
let base_map = to_map(base);
let ours_map = to_map(ours);
let theirs_map = to_map(theirs);
let identity_seq = |occ: &ComponentOccurrence| -> Vec<String> {
occ.items
.iter()
.map(|(k, _)| node_key_identity(k).to_string())
.collect()
};
let is_exchange = ours.component == "exchange";
let lifecycle_governed = is_list_component(&ours.component);
let (spine, weave) = if is_exchange {
(identity_seq(ours), identity_seq(theirs))
} else {
(identity_seq(theirs), identity_seq(ours))
};
let order = weave_order(&spine, &weave);
let mut merged: Vec<(String, ItemValue)> = Vec::with_capacity(order.len());
let mut conflicts = Vec::new();
for id in &order {
let in_b = base_map.contains_key(id);
let in_o = ours_map.get(id);
let in_t = theirs_map.get(id);
let resolved: Option<ItemValue> = match (in_o, in_t) {
(Some(o), Some(t)) => {
if o == t {
Some(o.clone())
} else if lifecycle_governed {
let o_life = QueueItemLifecycle::classify(o);
let t_life = QueueItemLifecycle::classify(t);
let joined = o_life.join(t_life);
match (o_life == joined, t_life == joined) {
(true, false) => Some(o.clone()),
(false, true) => Some(t.clone()),
_ => Some(resolve_content_divergence(
id,
o,
t,
in_b,
base_map.get(id),
&ours_updated,
&theirs_updated,
ours,
policy,
&mut conflicts,
)),
}
} else {
Some(resolve_content_divergence(
id,
o,
t,
in_b,
base_map.get(id),
&ours_updated,
&theirs_updated,
ours,
policy,
&mut conflicts,
))
}
}
(Some(o), None) => {
let authoritative_theirs_delete =
policy == ConflictPolicy::TheirsWins || !ours_updated.contains(id);
if theirs_removed.contains(id) && authoritative_theirs_delete && !is_exchange {
None
} else {
Some(o.clone())
}
}
(None, Some(t)) => {
if ours_removed.contains(id) && !theirs_updated.contains(id) && !is_exchange {
None
} else {
Some(t.clone())
}
}
(None, None) => None,
};
if let Some(v) = resolved {
merged.push((id.clone(), v));
}
}
Some((merged, conflicts))
}
#[allow(clippy::too_many_arguments)]
fn resolve_content_divergence(
id: &str,
o: &ItemValue,
t: &ItemValue,
in_b: bool,
base_value: Option<&ItemValue>,
ours_updated: &std::collections::HashSet<String>,
theirs_updated: &std::collections::HashSet<String>,
occ: &ComponentOccurrence,
policy: ConflictPolicy,
conflicts: &mut Vec<CellConflict>,
) -> ItemValue {
let o_changed = ours_updated.contains(id) || !in_b;
let t_changed = theirs_updated.contains(id) || !in_b;
match (o_changed, t_changed) {
(true, false) => o.clone(),
(false, true) => t.clone(),
(true, true) => {
if cell_merge_opcapture_enabled()
&& let Some(base) = base_value
&& let Some(merged) = op_level_merge(base, o, t)
{
return merged;
}
let chosen = match policy {
ConflictPolicy::OursWins => o.clone(),
ConflictPolicy::TheirsWins => t.clone(),
};
conflicts.push(CellConflict {
component: occ.component.clone(),
occurrence: occ.occurrence,
identity: id.to_string(),
kind: ConflictKind::Content,
chosen: chosen.clone(),
ours: o.clone(),
theirs: t.clone(),
});
chosen
}
(false, false) => o.clone(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CharSpanEdit {
start: usize,
end: usize,
replacement: Vec<char>,
}
fn char_span_edit(base: &[char], side: &[char]) -> Option<CharSpanEdit> {
if base == side {
return None;
}
let mut start = 0usize;
let max_pre = base.len().min(side.len());
while start < max_pre && base[start] == side[start] {
start += 1;
}
let mut suffix = 0usize;
let max_suf = (base.len() - start).min(side.len() - start);
while suffix < max_suf && base[base.len() - 1 - suffix] == side[side.len() - 1 - suffix] {
suffix += 1;
}
let end = base.len() - suffix;
let replacement = side[start..side.len() - suffix].to_vec();
Some(CharSpanEdit {
start,
end,
replacement,
})
}
fn ranges_overlap(a: &CharSpanEdit, b: &CharSpanEdit) -> bool {
if a.start == a.end && b.start == b.end {
return a.start == b.start;
}
let (a_lo, a_hi) = (a.start, a.end.max(a.start));
let (b_lo, b_hi) = (b.start, b.end.max(b.start));
a_lo < b_hi && b_lo < a_hi
|| (a.start == a.end && a.start > b_lo && a.start < b_hi)
|| (b.start == b.end && b.start > a_lo && b.start < a_hi)
}
fn apply_span_edit(crdt: &mut TextCrdt, edit: &CharSpanEdit) {
for idx in (edit.start..edit.end).rev() {
crdt.delete(idx);
}
for (i, ch) in edit.replacement.iter().enumerate() {
crdt.insert(edit.start + i, *ch);
}
}
fn op_level_merge(base: &str, ours: &str, theirs: &str) -> Option<String> {
let base_chars: Vec<char> = base.chars().collect();
let ours_edit = char_span_edit(&base_chars, &ours.chars().collect::<Vec<_>>())?;
let theirs_edit = char_span_edit(&base_chars, &theirs.chars().collect::<Vec<_>>())?;
if ranges_overlap(&ours_edit, &theirs_edit) {
return None;
}
let base_crdt = TextCrdt::from_str(1, base);
let mut ours_fork = base_crdt.fork(1);
let mut theirs_fork = base_crdt.fork(2);
apply_span_edit(&mut ours_fork, &ours_edit);
apply_span_edit(&mut theirs_fork, &theirs_edit);
ours_fork.merge(&theirs_fork);
Some(ours_fork.text())
}
fn removed_keys(ops: &[DiffOp<String, ItemValue>]) -> std::collections::HashSet<String> {
ops.iter()
.filter_map(|op| match op {
DiffOp::Remove { key } => Some(key.clone()),
_ => None,
})
.collect()
}
fn updated_keys(ops: &[DiffOp<String, ItemValue>]) -> std::collections::HashSet<String> {
ops.iter()
.filter_map(|op| match op {
DiffOp::Update { key, .. } => Some(key.clone()),
_ => None,
})
.collect()
}
fn weave_order(spine: &[String], weave: &[String]) -> Vec<String> {
let spine_set: std::collections::HashSet<&str> = spine.iter().map(|s| s.as_str()).collect();
let mut result: Vec<String> = spine.to_vec();
for (i, k) in weave.iter().enumerate() {
if spine_set.contains(k.as_str()) || result.iter().any(|r| r == k) {
continue;
}
let mut insert_pos = 0usize;
for j in (0..i).rev() {
if let Some(p) = result.iter().position(|r| r == &weave[j]) {
insert_pos = p + 1;
break;
}
}
result.insert(insert_pos, k.clone());
}
result
}
pub fn merge_3way(base_doc: &str, ours_doc: &str, theirs_doc: &str) -> CellMergeOutcome {
if ours_doc == theirs_doc {
return CellMergeOutcome {
merged_text: ours_doc.to_string(),
conflicts: Vec::new(),
fell_back: false,
};
}
let ours_nodes = match parse_doc_nodes(ours_doc) {
Some(n) => n,
None => return CellMergeOutcome::fallback(),
};
let theirs_nodes = match parse_doc_nodes(theirs_doc) {
Some(n) => n,
None => return CellMergeOutcome::fallback(),
};
let base_nodes = parse_doc_nodes(base_doc).unwrap_or_default();
let ours_names = component_name_sequence(&ours_nodes);
let theirs_names = component_name_sequence(&theirs_nodes);
if ours_names != theirs_names {
return CellMergeOutcome::fallback();
}
if ours_names.is_empty() {
return CellMergeOutcome::fallback();
}
let mut base_by_key: std::collections::HashMap<(String, usize), &ComponentOccurrence> =
std::collections::HashMap::new();
let mut base_framing_by_key: std::collections::HashMap<(String, usize), &ComponentFraming> =
std::collections::HashMap::new();
for n in &base_nodes {
if let DocNode::Component {
framing,
occurrence,
} = n
{
base_by_key.insert(
(occurrence.component.clone(), occurrence.occurrence),
occurrence,
);
base_framing_by_key.insert(
(occurrence.component.clone(), occurrence.occurrence),
framing,
);
}
}
let base_interstitials: Vec<&str> = if component_name_sequence(&base_nodes) == ours_names {
base_nodes
.iter()
.filter_map(|n| match n {
DocNode::Interstitial(s) => Some(s.as_str()),
DocNode::Component { .. } => None,
})
.collect()
} else {
Vec::new()
};
let mut out = String::new();
let mut all_conflicts = Vec::new();
let empty_occurrence = |o: &ComponentOccurrence| ComponentOccurrence {
component: o.component.clone(),
occurrence: o.occurrence,
items: Vec::new(),
};
let mut theirs_iter = theirs_nodes.iter();
let mut interstitial_index = 0usize;
for ours_node in &ours_nodes {
let theirs_node = theirs_iter.next().expect("node sequences aligned");
match (ours_node, theirs_node) {
(DocNode::Interstitial(o), DocNode::Interstitial(t)) => {
let base = base_interstitials.get(interstitial_index).copied();
let chosen = match base {
Some(b) if *o == b => t.as_str(), Some(b) if t == b => o.as_str(), _ => o.as_str(), };
out.push_str(chosen);
interstitial_index += 1;
}
(
DocNode::Component {
framing: o_framing,
occurrence: o_occ,
},
DocNode::Component {
framing: t_framing,
occurrence: t_occ,
},
) => {
if is_body_only(o_occ) != is_body_only(t_occ) {
let body_only_side = if is_body_only(o_occ) { o_occ } else { t_occ };
let body_only_has_content = body_only_side
.items
.iter()
.any(|(_, value)| !value.trim().is_empty());
if body_only_has_content {
return CellMergeOutcome::fallback();
}
}
let base_occ = base_by_key
.get(&(o_occ.component.clone(), o_occ.occurrence))
.copied();
let base_holder;
let base_ref = match base_occ {
Some(b) => b,
None => {
base_holder = empty_occurrence(o_occ);
&base_holder
}
};
if !keys_unique(o_occ) || !keys_unique(t_occ) || !keys_unique(base_ref) {
return CellMergeOutcome::fallback();
}
let comp_policy = component_conflict_policy(&o_occ.component);
let (mut merged_items, conflicts) =
match compose_occurrence(base_ref, o_occ, t_occ, comp_policy) {
Some(r) => r,
None => return CellMergeOutcome::fallback(),
};
if cell_merge_conflict_markers_enabled() {
surface_conflict_markers(&mut merged_items, &conflicts);
}
all_conflicts.extend(conflicts);
let body: String = merged_items.iter().map(|(_, v)| v.as_str()).collect();
let base_framing = base_framing_by_key
.get(&(o_occ.component.clone(), o_occ.occurrence))
.copied();
let open = reconcile_marker_operator_authoritative(
base_framing.map(|f| f.open.as_str()),
&o_framing.open,
&t_framing.open,
);
let close = reconcile_marker_operator_authoritative(
base_framing.map(|f| f.close.as_str()),
&o_framing.close,
&t_framing.close,
);
out.push_str(open);
out.push_str(&body);
out.push_str(close);
}
_ => return CellMergeOutcome::fallback(),
}
}
match parse_doc_nodes(&out) {
Some(merged_nodes) if component_name_sequence(&merged_nodes) == ours_names => {
log_conflicts(&all_conflicts);
CellMergeOutcome {
merged_text: out,
conflicts: all_conflicts,
fell_back: false,
}
}
_ => CellMergeOutcome::fallback(),
}
}
fn conflict_marker(conflict: &CellConflict) -> String {
let policy_chose_ours = conflict.chosen == conflict.ours;
let chosen_side = if policy_chose_ours { "ours" } else { "theirs" };
let ours = conflict.ours.strip_suffix('\n').unwrap_or(&conflict.ours);
let theirs = conflict
.theirs
.strip_suffix('\n')
.unwrap_or(&conflict.theirs);
format!(
"<!-- cell-merge-conflict component={} occurrence={} identity={} kind={:?} chosen={} (operator: resolve; ours kept active)\n\
<<<<<<< ours\n{ours}\n=======\n{theirs}\n>>>>>>> theirs\n-->\n",
conflict.component, conflict.occurrence, conflict.identity, conflict.kind, chosen_side,
)
}
fn marker_is_framing_safe(conflict: &CellConflict) -> bool {
let carries_framing =
|s: &str| s.contains("<!-- agent:") || s.contains("<!-- /agent:") || s.contains("-->");
!(carries_framing(&conflict.ours) || carries_framing(&conflict.theirs))
}
fn surface_conflict_markers(merged_items: &mut [(String, ItemValue)], conflicts: &[CellConflict]) {
for conflict in conflicts {
if !marker_is_framing_safe(conflict) {
eprintln!(
"cell_merge conflict identity={} not surfaced in-band (side text carries framing); stderr-only",
conflict.identity,
);
continue;
}
if let Some((_, value)) = merged_items
.iter_mut()
.find(|(id, _)| id == &conflict.identity)
{
if !value.ends_with('\n') {
value.push('\n');
}
value.push_str(&conflict_marker(conflict));
}
}
}
fn log_conflicts(conflicts: &[CellConflict]) {
for c in conflicts {
eprintln!(
"cell_merge conflict kind={:?} component={} occurrence={} identity={} policy_chose_ours={}",
c.kind,
c.component,
c.occurrence,
c.identity,
c.chosen == c.ours,
);
}
}
pub fn route_ops_to_cells(
doc: &str,
ops: &[EditorOp],
) -> Option<std::collections::HashMap<NodeKey, Vec<EditorOp>>> {
if ops.is_empty() {
return Some(std::collections::HashMap::new());
}
let spans = project_document_spans(doc)?;
let mut routed: std::collections::HashMap<NodeKey, Vec<EditorOp>> =
std::collections::HashMap::new();
for op in ops {
let (node_key, span) = match op {
EditorOp::Insert { offset, .. } => {
let found = spans
.iter()
.find(|s| s.span.contains_offset(*offset))
.or_else(|| {
spans.iter().find(|s| {
s.span.end == *offset && !spans.iter().any(|o| o.span.start == *offset)
})
});
match found {
Some(s) => (s.node_key.clone(), s.span),
None => return None,
}
}
EditorOp::Delete { offset, len } => {
let hi = offset.checked_add(*len)?;
let found = spans.iter().find(|s| s.span.contains_range(*offset, hi));
match found {
Some(s) => (s.node_key.clone(), s.span),
None => return None,
}
}
};
let local = translate_to_cell_local(op, span.start)?;
routed.entry(node_key).or_default().push(local);
}
Some(routed)
}
fn translate_to_cell_local(op: &EditorOp, span_start: usize) -> Option<EditorOp> {
match op {
EditorOp::Insert { offset, text } => Some(EditorOp::Insert {
offset: offset.checked_sub(span_start)?,
text: text.clone(),
}),
EditorOp::Delete { offset, len } => Some(EditorOp::Delete {
offset: offset.checked_sub(span_start)?,
len: *len,
}),
}
}
pub fn merge_3way_with_ops(
base: &str,
ours: &str,
theirs: &str,
theirs_ops: &[EditorOp],
) -> CellMergeOutcome {
let routed = match route_ops_to_cells(base, theirs_ops) {
Some(r) => r,
None => return CellMergeOutcome::fallback(),
};
let reconstructed_theirs = match reconstruct_theirs_from_ops(base, &routed) {
Some(t) => t,
None => return CellMergeOutcome::fallback(),
};
if reconstructed_theirs != theirs {
eprintln!(
"[cell_merge] op-routing replay != theirs (stale/misaligned ops) — falling back to text-diff path"
);
return CellMergeOutcome::fallback();
}
merge_3way(base, ours, &reconstructed_theirs)
}
fn reconstruct_theirs_from_ops(
base: &str,
routed: &std::collections::HashMap<NodeKey, Vec<EditorOp>>,
) -> Option<String> {
let spans = project_document_spans(base)?;
let mut out = String::new();
let mut cursor = 0usize;
for item in &spans {
out.push_str(&base[cursor..item.span.start]);
let cell_text = match routed.get(&item.node_key) {
Some(ops) => replay_editor_ops(&item.value, ops)?,
None => item.value.clone(),
};
out.push_str(&cell_text);
cursor = item.span.end;
}
out.push_str(&base[cursor..]);
Some(out)
}
pub struct CellDocTree {
root: CellTree<String, String>,
occurrences: std::collections::HashMap<(String, usize), CellMap<String, String>>,
}
impl CellDocTree {
pub fn from_document(ctx: &Context, doc: &str) -> Self {
let root: CellTree<String, String> = CellTree::leaf(ctx, String::new(), String::new());
let mut occurrences = std::collections::HashMap::new();
for occ in project_document(doc) {
let occ_id = format!("{}:{}", occ.component, occ.occurrence);
let occ_node = root.insert_child(ctx, occ_id.clone(), String::new());
let map: CellMap<String, String> = CellMap::new(ctx);
for (key, value) in &occ.items {
let identity = node_key_identity(key).to_string();
occ_node.insert_child(ctx, identity.clone(), value.clone());
map.entry(ctx, identity, value.clone());
}
occurrences.insert((occ.component.clone(), occ.occurrence), map);
}
Self { root, occurrences }
}
pub fn root(&self) -> &CellTree<String, String> {
&self.root
}
pub fn item_value(
&self,
ctx: &Context,
component: &str,
occurrence: usize,
identity: &str,
) -> Option<String> {
self.occurrences
.get(&(component.to_string(), occurrence))
.and_then(|m| m.get(ctx, &identity.to_string()))
}
pub fn item_ids(&self, ctx: &Context, component: &str, occurrence: usize) -> Vec<String> {
self.occurrences
.get(&(component.to_string(), occurrence))
.map(|m| m.keys(ctx))
.unwrap_or_default()
}
pub fn update_to(&mut self, ctx: &Context, old_doc: &str, new_doc: &str) {
for diff in diff_document(old_doc, new_doc) {
self.apply(ctx, &diff);
}
let new_occurrences: std::collections::HashSet<(String, usize)> = project_document(new_doc)
.into_iter()
.map(|occ| (occ.component, occ.occurrence))
.collect();
let existing: Vec<(String, usize)> = self.occurrences.keys().cloned().collect();
for (component, occurrence) in existing {
if !new_occurrences.contains(&(component.clone(), occurrence)) {
self.occurrences.remove(&(component.clone(), occurrence));
self.root
.remove_child(ctx, &format!("{component}:{occurrence}"));
}
}
}
pub fn unresolved_prompt_counts(&self, ctx: &Context) -> SemTree<String, usize> {
SemTree::build(ctx, &self.root, |value: &String, kids: &[usize]| {
unresolved_prompt_count(value) + kids.iter().sum::<usize>()
})
}
pub fn apply(&mut self, ctx: &Context, diff: &ComponentDiff) {
let occ_key = (diff.component.clone(), diff.occurrence);
let occ_id = format!("{}:{}", diff.component, diff.occurrence);
let occ_node = self
.root
.child(&occ_id)
.unwrap_or_else(|| self.root.insert_child(ctx, occ_id.clone(), String::new()));
let map = self
.occurrences
.entry(occ_key)
.or_insert_with(|| CellMap::new(ctx));
apply_to_map(ctx, map, &diff.ops);
apply_to_tree(ctx, &occ_node, &diff.ops);
}
}
fn unresolved_prompt_count(value: &str) -> usize {
value
.lines()
.filter(|line| line.contains("[#") && !line.contains("~~"))
.count()
}
#[cfg(test)]
mod tests {
use super::*;
const DOC: &str = "\
---
agent_doc_format: template
---
<!-- agent:queue -->
- do [#alpha] first task
- do [#beta] second task
- do [#gamma] third task
<!-- /agent:queue -->
<!-- agent:backlog -->
- [#one] backlog item one
- [#two] backlog item two
<!-- /agent:backlog -->
";
fn queue_diff(diffs: &[ComponentDiff]) -> &ComponentDiff {
diffs
.iter()
.find(|d| d.component == "queue")
.expect("queue diff present")
}
#[test]
fn project_keys_use_component_occurrence_item_index_scheme() {
let occs = project_document(DOC);
let queue = occs
.iter()
.find(|o| o.component == "queue")
.expect("queue projected");
assert_eq!(queue.occurrence, 0);
let keys: Vec<&str> = queue.items.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys[0], "queue:0:id:alpha:0");
assert_eq!(keys[1], "queue:0:id:beta:1");
assert_eq!(keys[2], "queue:0:id:gamma:2");
}
#[test]
fn reorder_yields_moves_not_replace() {
let reordered = DOC.replace(
"- do [#alpha] first task\n- do [#beta] second task\n- do [#gamma] third task\n",
"- do [#gamma] third task\n- do [#alpha] first task\n- do [#beta] second task\n",
);
let diffs = diff_document(DOC, &reordered);
let q = queue_diff(&diffs);
assert!(
q.ops.iter().all(|op| matches!(op, DiffOp::Move { .. })),
"pure reorder must be moves only, not remove+insert: {:?}",
q.ops
);
assert!(!q.ops.is_empty(), "reorder must produce at least one move");
assert!(q.ops.iter().all(|op| matches!(op, DiffOp::Move { .. })),);
}
#[test]
fn insert_one_item_yields_single_insert() {
let added = DOC.replace(
"- do [#gamma] third task\n",
"- do [#gamma] third task\n- do [#delta] fourth task\n",
);
let diffs = diff_document(DOC, &added);
let q = queue_diff(&diffs);
let inserts: Vec<_> = q
.ops
.iter()
.filter(|op| matches!(op, DiffOp::Insert { .. }))
.collect();
assert_eq!(inserts.len(), 1, "exactly one insert: {:?}", q.ops);
assert!(matches!(
inserts[0],
DiffOp::Insert { key, .. } if key == "queue:0:id:delta"
));
assert!(
!q.ops
.iter()
.any(|op| matches!(op, DiffOp::Remove { .. } | DiffOp::Update { .. })),
"insert must not touch siblings: {:?}",
q.ops
);
}
#[test]
fn remove_one_item_yields_single_remove() {
let removed = DOC.replace("- do [#beta] second task\n", "");
let diffs = diff_document(DOC, &removed);
let q = queue_diff(&diffs);
let removes: Vec<_> = q
.ops
.iter()
.filter(|op| matches!(op, DiffOp::Remove { .. }))
.collect();
assert_eq!(removes.len(), 1, "exactly one remove: {:?}", q.ops);
assert!(matches!(
removes[0],
DiffOp::Remove { key } if key == "queue:0:id:beta"
));
}
#[test]
fn operator_queue_delete_wins_over_agent_update() {
let ours = DOC.replace(
"- do [#beta] second task\n",
"- ~~do [#beta] second task~~\n",
);
let theirs = DOC.replace("- do [#beta] second task\n", "");
let outcome = merge_3way(DOC, &ours, &theirs);
assert!(!outcome.fell_back, "cell merge should handle queue delete");
assert!(
outcome.conflicts.is_empty(),
"authoritative operator delete is not a conflict: {:?}",
outcome.conflicts
);
assert!(
!outcome.merged_text.contains("[#beta]"),
"operator-deleted queue item must not be resurrected:\n{}",
outcome.merged_text
);
assert!(outcome.merged_text.contains("[#alpha]"));
assert!(outcome.merged_text.contains("[#gamma]"));
}
#[test]
fn single_item_edit_yields_one_update_isolated() {
let edited = DOC.replace(
"- do [#beta] second task\n",
"- do [#beta] second task EDITED\n",
);
let diffs = diff_document(DOC, &edited);
let q = queue_diff(&diffs);
assert_eq!(
q.ops.len(),
1,
"exactly one op for a single-item edit: {:?}",
q.ops
);
match &q.ops[0] {
DiffOp::Update { key, value } => {
assert_eq!(key, "queue:0:id:beta");
assert!(value.contains("EDITED"));
}
other => panic!("expected a single Update, got {other:?}"),
}
assert!(
!q.ops.iter().any(|op| {
let k = match op {
DiffOp::Update { key, .. }
| DiffOp::Move { key, .. }
| DiffOp::Remove { key } => key.as_str(),
DiffOp::Insert { key, .. } => key.as_str(),
};
k.contains("alpha") || k.contains("gamma")
}),
"sibling items must be untouched: {:?}",
q.ops
);
}
#[test]
fn cross_component_isolation() {
let edited = DOC.replace(
"- do [#alpha] first task\n",
"- do [#alpha] first task CHANGED\n",
);
let diffs = diff_document(DOC, &edited);
assert!(
diffs.iter().all(|d| d.component != "backlog"),
"an edit in queue must produce no ops for backlog: {diffs:?}"
);
let q = queue_diff(&diffs);
assert_eq!(q.ops.len(), 1);
assert!(matches!(&q.ops[0], DiffOp::Update { key, .. } if key == "queue:0:id:alpha"));
}
#[test]
fn reactive_tree_single_edit_spares_sibling_cell() {
let ctx = Context::new();
let mut tree = CellDocTree::from_document(&ctx, DOC);
let alpha_view = {
let tree_ids = tree.item_value(&ctx, "queue", 0, "queue:0:id:alpha");
assert!(tree_ids.is_some());
ctx.computed({
let map = tree
.occurrences
.get(&("queue".to_string(), 0))
.unwrap()
.clone();
move |ctx| {
map.get(ctx, &"queue:0:id:alpha".to_string())
.unwrap_or_default()
}
})
};
let _ = ctx.get(&alpha_view);
assert!(ctx.is_set(&alpha_view));
let edited = DOC.replace(
"- do [#beta] second task\n",
"- do [#beta] second task EDITED\n",
);
let diffs = diff_document(DOC, &edited);
for d in &diffs {
tree.apply(&ctx, d);
}
assert!(
tree.item_value(&ctx, "queue", 0, "queue:0:id:beta")
.unwrap()
.contains("EDITED")
);
assert!(
ctx.is_set(&alpha_view),
"sibling value cell must not be invalidated by a per-item edit"
);
}
#[test]
fn reactive_tree_reorder_keeps_membership_cached() {
let ctx = Context::new();
let mut tree = CellDocTree::from_document(&ctx, DOC);
let occ_node = tree.root().child(&"queue:0".to_string()).unwrap();
let len_view = {
let node = occ_node.clone();
ctx.computed(move |ctx| node.len(ctx))
};
assert_eq!(ctx.get(&len_view), 3);
let reordered = DOC.replace(
"- do [#alpha] first task\n- do [#beta] second task\n- do [#gamma] third task\n",
"- do [#gamma] third task\n- do [#alpha] first task\n- do [#beta] second task\n",
);
let diffs = diff_document(DOC, &reordered);
for d in &diffs {
tree.apply(&ctx, d);
}
assert_eq!(
tree.item_ids(&ctx, "queue", 0),
vec![
"queue:0:id:gamma".to_string(),
"queue:0:id:alpha".to_string(),
"queue:0:id:beta".to_string(),
]
);
assert!(
ctx.is_set(&len_view),
"a pure reorder must not invalidate the membership-count reader"
);
assert_eq!(ctx.get(&len_view), 3);
}
fn tree_snapshot(
ctx: &Context,
tree: &CellDocTree,
) -> Vec<(String, usize, Vec<(String, String)>)> {
let mut occurrences: Vec<(String, usize)> = tree.occurrences.keys().cloned().collect();
occurrences.sort();
occurrences
.into_iter()
.map(|(component, occurrence)| {
let map = tree
.occurrences
.get(&(component.clone(), occurrence))
.expect("occurrence map present");
let map_items: Vec<(String, String)> = map
.keys(ctx)
.into_iter()
.map(|id| {
let value = map.get(ctx, &id).expect("mapped value present");
(id, value)
})
.collect();
let occ_id = format!("{component}:{occurrence}");
let occ_node = tree.root().child(&occ_id).expect("occurrence node present");
assert_eq!(
occ_node.child_ids(ctx),
map_items
.iter()
.map(|(id, _)| id.clone())
.collect::<Vec<_>>(),
"CellTree child order must match CellMap order for {occ_id}"
);
for (id, value) in &map_items {
assert_eq!(
occ_node.child(id).expect("child present").get(ctx),
*value,
"CellTree child value must match CellMap value for {id}"
);
}
(component, occurrence, map_items)
})
.collect()
}
fn assert_incremental_matches_rebuild(ctx: &Context, tree: &CellDocTree, doc: &str) {
let rebuilt = CellDocTree::from_document(ctx, doc);
assert_eq!(tree_snapshot(ctx, tree), tree_snapshot(ctx, &rebuilt));
}
fn eager_unresolved_prompt_count(doc: &str) -> usize {
project_document(doc)
.into_iter()
.flat_map(|occ| occ.items.into_iter().map(|(_, value)| value))
.map(|value| unresolved_prompt_count(&value))
.sum()
}
#[test]
fn update_to_matches_rebuild_across_revisions() {
let ctx = Context::new();
let mut tree = CellDocTree::from_document(&ctx, DOC);
let mut old = DOC.to_string();
let rev1 = old.replace(
"- do [#beta] second task\n",
"- do [#beta] second task EDITED\n",
);
let rev2 = rev1.replace(
"- do [#alpha] first task\n- do [#beta] second task EDITED\n- do [#gamma] third task\n",
"- do [#gamma] third task\n- do [#alpha] first task\n- do [#beta] second task EDITED\n",
);
let rev3 = format!(
"{rev2}<!-- agent:review -->\n- do [#review] inspect\n<!-- /agent:review -->\n"
);
let rev4 = rev3.replace(
"<!-- agent:backlog -->\n- [#one] backlog item one\n- [#two] backlog item two\n<!-- /agent:backlog -->\n",
"",
);
let rev5 = rev4
.replace(
"- do [#alpha] first task\n",
"- do [#alpha] first task CHANGED\n",
)
.replace(
"- do [#review] inspect\n",
"- do [#review] inspect EDITED\n",
);
for new_doc in [rev1, rev2, rev3, rev4, rev5] {
tree.update_to(&ctx, &old, &new_doc);
assert_incremental_matches_rebuild(&ctx, &tree, &new_doc);
old = new_doc;
}
}
#[test]
fn sem_tree_unresolved_prompt_counts_are_subtree_scoped() {
use std::cell::Cell as StdCell;
use std::rc::Rc;
let ctx = Context::new();
let mut tree = CellDocTree::from_document(&ctx, DOC);
let counts = tree.unresolved_prompt_counts(&ctx);
assert_eq!(counts.value(&ctx), eager_unresolved_prompt_count(DOC));
assert_eq!(counts.node_value(&ctx, &"queue:0".to_string()), Some(3));
assert_eq!(counts.node_value(&ctx, &"backlog:0".to_string()), Some(2));
let queue_alpha_calls = Rc::new(StdCell::new(0usize));
let backlog_one_calls = Rc::new(StdCell::new(0usize));
let probe = SemTree::build(&ctx, tree.root(), {
let queue_alpha_calls = Rc::clone(&queue_alpha_calls);
let backlog_one_calls = Rc::clone(&backlog_one_calls);
move |value: &String, kids: &[usize]| {
if value.contains("#alpha") {
queue_alpha_calls.set(queue_alpha_calls.get() + 1);
}
if value.contains("#one") {
backlog_one_calls.set(backlog_one_calls.get() + 1);
}
unresolved_prompt_count(value) + kids.iter().sum::<usize>()
}
});
assert_eq!(probe.value(&ctx), 5);
let queue_alpha_baseline = queue_alpha_calls.get();
let backlog_one_baseline = backlog_one_calls.get();
assert_eq!(probe.value(&ctx), 5);
assert_eq!(
queue_alpha_calls.get(),
queue_alpha_baseline,
"memoized re-read must not recompute queue leaf"
);
assert_eq!(
backlog_one_calls.get(),
backlog_one_baseline,
"memoized re-read must not recompute backlog leaf"
);
let backlog_slot = probe.node(&"backlog:0".to_string()).unwrap();
let edited = DOC.replace(
"- do [#alpha] first task\n",
"- ~~do [#alpha] first task~~\n",
);
tree.update_to(&ctx, DOC, &edited);
assert!(
ctx.is_set(&backlog_slot),
"editing queue must not invalidate the backlog semantic subtree"
);
assert_eq!(probe.node_value(&ctx, &"queue:0".to_string()), Some(2));
assert_eq!(probe.node_value(&ctx, &"backlog:0".to_string()), Some(2));
assert_eq!(queue_alpha_calls.get(), queue_alpha_baseline + 1);
assert_eq!(
backlog_one_calls.get(),
backlog_one_baseline,
"backlog semantic leaf must stay cached after a queue edit"
);
let updated_counts = tree.unresolved_prompt_counts(&ctx);
assert_eq!(
updated_counts.value(&ctx),
eager_unresolved_prompt_count(&edited)
);
}
use super::CELL_MERGE_ENV_LOCK as ENV_LOCK;
const BASE3: &str = "\
---
agent_doc_format: template
---
<!-- agent:queue -->
- do [#alpha] first task
- do [#beta] second task
- do [#gamma] third task
<!-- /agent:queue -->
<!-- agent:exchange -->
### Re: earlier
prior response.
<!-- /agent:exchange -->
";
#[test]
fn concurrent_edits_to_different_items_both_land_no_conflict() {
let ours = BASE3.replace(
"- do [#alpha] first task\n",
"- do [#alpha] first task OURS\n",
);
let theirs = BASE3.replace(
"- do [#gamma] third task\n",
"- do [#gamma] third task THEIRS\n",
);
let out = merge_3way(BASE3, &ours, &theirs);
assert!(!out.fell_back, "should not fall back");
assert!(out.conflicts.is_empty(), "no conflict: {:?}", out.conflicts);
assert!(out.merged_text.contains("first task OURS"));
assert!(out.merged_text.contains("third task THEIRS"));
assert!(out.merged_text.contains("- do [#beta] second task\n"));
}
#[test]
fn concurrent_edits_to_different_components_both_land() {
let ours = BASE3.replace(
"- do [#beta] second task\n",
"- do [#beta] second task OURS\n",
);
let theirs = BASE3.replace("prior response.", "prior response. THEIRS");
let out = merge_3way(BASE3, &ours, &theirs);
assert!(!out.fell_back);
assert!(out.conflicts.is_empty());
assert!(out.merged_text.contains("second task OURS"));
assert!(out.merged_text.contains("prior response. THEIRS"));
}
#[test]
fn exchange_response_does_not_splice_into_queue() {
let with_response = BASE3.replace(
"### Re: earlier\nprior response.\n",
"### Re: earlier\nprior response.\n### Re: new prompt\nbrand new agent response.\n",
);
let edited_queue = BASE3.replace(
"- do [#beta] second task\n",
"- do [#beta] second task EDITED\n",
);
let out = merge_3way(BASE3, &with_response, &edited_queue);
assert!(!out.fell_back, "should per-cell merge");
let q_open = out.merged_text.find("<!-- agent:queue -->").unwrap();
let q_close = out.merged_text.find("<!-- /agent:queue -->").unwrap();
let queue_body = &out.merged_text[q_open..q_close];
assert!(
!queue_body.contains("### Re:"),
"exchange response spliced into queue body: {queue_body}"
);
assert!(out.merged_text.contains("brand new agent response."));
assert!(out.merged_text.contains("second task EDITED"));
}
#[test]
fn same_item_edited_both_sides_surfaces_conflict() {
let _guard = ENV_LOCK.lock().unwrap();
let ours = BASE3.replace(
"- do [#beta] second task\n",
"- do [#beta] second task OURS-VERSION\n",
);
let theirs = BASE3.replace(
"- do [#beta] second task\n",
"- do [#beta] second task THEIRS-VERSION\n",
);
let out = merge_3way(BASE3, &ours, &theirs);
assert!(!out.fell_back);
assert_eq!(out.conflicts.len(), 1, "one conflict: {:?}", out.conflicts);
let c = &out.conflicts[0];
assert_eq!(c.component, "queue");
assert_eq!(c.identity, "queue:0:id:beta");
assert!(c.ours.contains("OURS-VERSION"));
assert!(c.theirs.contains("THEIRS-VERSION"));
assert!(c.chosen.contains("THEIRS-VERSION"));
assert!(out.merged_text.contains("THEIRS-VERSION"));
assert!(
!out.merged_text.contains("OURS-VERSION"),
"no fabricated blend: the losing ours value must not also appear"
);
}
#[test]
fn reorder_one_side_edit_other_side_compose() {
let ours = BASE3.replace(
"- do [#alpha] first task\n- do [#beta] second task\n- do [#gamma] third task\n",
"- do [#gamma] third task\n- do [#alpha] first task\n- do [#beta] second task\n",
);
let theirs = BASE3.replace(
"- do [#alpha] first task\n",
"- do [#alpha] first task ALPHA-EDIT\n",
);
let out = merge_3way(BASE3, &ours, &theirs);
assert!(!out.fell_back);
assert!(out.conflicts.is_empty(), "reorder+edit compose cleanly");
assert!(out.merged_text.contains("first task ALPHA-EDIT"));
let q_open = out.merged_text.find("<!-- agent:queue -->").unwrap();
let q_close = out.merged_text.find("<!-- /agent:queue -->").unwrap();
let body = &out.merged_text[q_open..q_close];
let pa = body.find("ALPHA-EDIT").unwrap();
let pg = body.find("[#gamma]").unwrap();
assert!(
pa < pg,
"theirs (live) order is the spine: alpha before gamma"
);
}
#[test]
fn structural_divergence_falls_back() {
let theirs = "\
---
agent_doc_format: template
---
<!-- agent:queue -->
- do [#alpha] first task
- do [#beta] second task
- do [#gamma] third task
<!-- /agent:queue -->
";
let ours = BASE3.replace("- do [#alpha] first task\n", "- do [#alpha] first task X\n");
let out = merge_3way(BASE3, &ours, theirs);
assert!(out.fell_back, "structural divergence must fall back");
assert!(out.merged_text.is_empty());
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::set_var(CELL_MERGE_ENV, "1");
}
let base_state = crate::crdt::CrdtDoc::from_text(BASE3).encode_state();
let legacy = crate::crdt::merge_by_component(Some(&base_state), &ours, theirs).unwrap();
unsafe {
std::env::remove_var(CELL_MERGE_ENV);
}
assert!(legacy.contains("<!-- agent:queue -->"));
assert!(legacy.contains("first task X"));
}
#[test]
fn keyed_exchange_vs_body_only_with_content_falls_back() {
let base = "<!-- agent:exchange -->\n\
❯ Please reply\n\
<!-- agent:boundary:base1234 -->\n\
<!-- /agent:exchange -->\n";
let ours = "<!-- agent:exchange -->\n\
❯ Please reply\n\
### Re: answer\n\nDone.\n\n\
<!-- agent:boundary:fresh5678 -->\n\
<!-- /agent:exchange -->\n";
let theirs = "<!-- agent:exchange -->\n\
❯ Please reply\n\
while I was typing the next queue item\n\
<!-- agent:boundary:base1234 -->\n\
<!-- /agent:exchange -->\n";
let out = merge_3way(base, ours, theirs);
assert!(
out.fell_back,
"keyed-vs-body-only-with-content exchange must fall back to the legacy leaf merge"
);
assert!(out.merged_text.is_empty());
}
#[test]
fn keyed_exchange_vs_empty_body_only_does_not_fall_back() {
let base = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n";
let ours =
"<!-- agent:exchange -->\n### Re: answer\nResponse body.\n<!-- /agent:exchange -->\n";
let theirs = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n";
let out = merge_3way(base, ours, theirs);
assert!(
!out.fell_back,
"an empty body-only exchange must stay on the per-cell path"
);
assert!(out.merged_text.contains("Response body."));
}
#[test]
fn flag_off_byte_identical_to_legacy() {
let ours = BASE3.replace(
"- do [#alpha] first task\n",
"- do [#alpha] first task OURS\n",
);
let theirs = BASE3.replace(
"- do [#gamma] third task\n",
"- do [#gamma] third task THEIRS\n",
);
let base_state = crate::crdt::CrdtDoc::from_text(BASE3).encode_state();
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::set_var(CELL_MERGE_ENV, "0");
}
let off = crate::crdt::merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
let off2 = crate::crdt::merge_by_component(Some(&base_state), &ours, &theirs).unwrap();
assert_eq!(off, off2, "legacy path is deterministic with the flag off");
assert!(!cell_merge_enabled(), "kill-switch must disable");
assert!(off.contains("first task OURS"));
assert!(off.contains("third task THEIRS"));
unsafe {
std::env::remove_var(CELL_MERGE_ENV);
}
}
#[test]
fn cell_merge_enabled_default_on_with_kill_switch() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::remove_var(CELL_MERGE_ENV);
}
assert!(cell_merge_enabled(), "absent ⇒ default ON");
for v in ["1", "true", "on", "yes", "TRUE", " On ", "", "maybe"] {
unsafe {
std::env::set_var(CELL_MERGE_ENV, v);
}
assert!(cell_merge_enabled(), "{v:?} should stay ON");
}
for v in ["0", "false", "off", "no", "FALSE", " Off "] {
unsafe {
std::env::set_var(CELL_MERGE_ENV, v);
}
assert!(!cell_merge_enabled(), "{v:?} should kill (OFF)");
}
unsafe {
std::env::remove_var(CELL_MERGE_ENV);
}
}
#[test]
fn cell_merge_opcapture_default_on_and_master_kill_switch_gates_it() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::remove_var(CELL_MERGE_ENV);
std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
}
assert!(cell_merge_opcapture_enabled(), "absent ⇒ default ON");
for v in ["0", "false", "off", "no"] {
unsafe {
std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, v);
}
assert!(!cell_merge_opcapture_enabled(), "{v:?} should kill");
}
unsafe {
std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "1");
std::env::set_var(CELL_MERGE_ENV, "0");
}
assert!(
!cell_merge_opcapture_enabled(),
"master kill-switch must force the sub-feature OFF"
);
unsafe {
std::env::remove_var(CELL_MERGE_ENV);
std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
}
}
#[test]
fn unsplittable_component_diffs_as_whole_body_update() {
let doc = "\
<!-- agent:status -->
working on it
<!-- /agent:status -->
";
let edited = doc.replace("working on it", "done now");
let diffs = diff_document(doc, &edited);
let s = diffs
.iter()
.find(|d| d.component == "status")
.expect("status diff");
assert_eq!(s.ops.len(), 1);
assert!(matches!(&s.ops[0], DiffOp::Update { key, .. } if key == "status:0:body"));
}
fn component_body<'a>(merged: &'a str, name: &str) -> &'a str {
let open = format!("<!-- agent:{name} -->");
let close = format!("<!-- /agent:{name} -->");
merged
.split(&open)
.nth(1)
.and_then(|s| s.split(&close).next())
.unwrap_or("")
}
fn live_count(body: &str, needle: &str) -> usize {
body.lines()
.filter(|l| l.contains(needle) && !l.contains("~~"))
.count()
}
#[test]
fn struck_id_head_not_resurrected_by_stale_unstrike_cell_path() {
let base = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
<!-- agent:queue -->\n- ~~do [#abcd] fix the thing~~\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:exchange -->\n### Re: topic\nAgent response body.\n<!-- /agent:exchange -->\n\n\
<!-- agent:queue -->\n- do [#abcd] fix the thing\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n\
<!-- agent:queue -->\n- ~~do [#abcd] fix the thing~~\n<!-- /agent:queue -->\n";
let out = merge_3way(base, ours, theirs);
assert!(!out.fell_back, "should per-cell merge");
let body = component_body(&out.merged_text, "queue");
assert!(
body.contains("~~do [#abcd] fix the thing~~"),
"struck head must stay struck:\n{body}"
);
assert_eq!(
live_count(body, "do [#abcd]"),
0,
"stale un-strike resurrected the struck head LIVE:\n{body}"
);
assert!(
out.conflicts.is_empty(),
"lifecycle join must not be a conflict: {:?}",
out.conflicts
);
assert!(out.merged_text.contains("Agent response body."));
}
#[test]
fn struck_free_text_head_not_resurrected_by_stale_unstrike_cell_path() {
let base = "<!-- agent:queue -->\n- ~~Still getting JB File Cache Conflict dialogs.~~\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- Still getting JB File Cache Conflict dialogs.\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue -->\n- ~~Still getting JB File Cache Conflict dialogs.~~\n<!-- /agent:queue -->\n";
let out = merge_3way(base, ours, theirs);
assert!(!out.fell_back);
let body = component_body(&out.merged_text, "queue");
assert!(
body.contains("~~Still getting JB File Cache Conflict dialogs.~~"),
"struck free-text head must stay struck:\n{body}"
);
assert_eq!(
live_count(body, "Still getting JB File Cache Conflict dialogs."),
0,
"stale un-strike resurrected the struck free-text head LIVE:\n{body}"
);
assert!(out.conflicts.is_empty());
}
#[test]
fn struck_head_stable_across_cycles_no_churn_cell_path() {
let mut base =
"<!-- agent:queue -->\n- ~~do [#abcd] answered~~\n<!-- /agent:queue -->\n".to_string();
for cycle in 0..3 {
let ours = "<!-- agent:queue -->\n- do [#abcd] answered\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue -->\n- ~~do [#abcd] answered~~\n<!-- /agent:queue -->\n";
let out = merge_3way(&base, ours, theirs);
assert!(!out.fell_back, "cycle {cycle}: fell back");
let body = component_body(&out.merged_text, "queue");
assert_eq!(
live_count(body, "do [#abcd]"),
0,
"cycle {cycle}: head resurrected LIVE (churn):\n{body}"
);
assert!(
body.contains("~~do [#abcd] answered~~"),
"cycle {cycle}: struck head lost:\n{body}"
);
assert_eq!(
body.matches("do [#abcd] answered").count(),
1,
"cycle {cycle}: head duplicated:\n{body}"
);
assert!(out.conflicts.is_empty(), "cycle {cycle}: spurious conflict");
base = out.merged_text;
}
}
#[test]
fn struck_backlog_head_not_resurrected_cell_path() {
let base =
"<!-- agent:backlog -->\n- ~~[#bk1] backlog answered~~\n<!-- /agent:backlog -->\n";
let ours = "<!-- agent:backlog -->\n- [#bk1] backlog answered\n<!-- /agent:backlog -->\n";
let theirs =
"<!-- agent:backlog -->\n- ~~[#bk1] backlog answered~~\n<!-- /agent:backlog -->\n";
let out = merge_3way(base, ours, theirs);
assert!(!out.fell_back);
let body = component_body(&out.merged_text, "backlog");
assert!(
body.contains("~~[#bk1] backlog answered~~"),
"struck backlog head must stay struck:\n{body}"
);
assert_eq!(
live_count(body, "[#bk1]"),
0,
"backlog head resurrected:\n{body}"
);
assert!(out.conflicts.is_empty());
}
#[test]
fn struck_review_head_not_resurrected_cell_path() {
let base = "<!-- agent:review -->\n- ~~[#rv1] review item~~\n<!-- /agent:review -->\n";
let ours = "<!-- agent:review -->\n- [#rv1] review item\n<!-- /agent:review -->\n";
let theirs = "<!-- agent:review -->\n- ~~[#rv1] review item~~\n<!-- /agent:review -->\n";
let out = merge_3way(base, ours, theirs);
assert!(!out.fell_back);
let body = component_body(&out.merged_text, "review");
assert!(body.contains("~~[#rv1] review item~~"));
assert_eq!(live_count(body, "[#rv1]"), 0);
assert!(out.conflicts.is_empty());
}
#[test]
fn struck_head_holds_when_theirs_is_the_stale_unstrike() {
let base = "<!-- agent:queue -->\n- ~~do [#zz] q~~\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- ~~do [#zz] q~~\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue -->\n- do [#zz] q\n<!-- /agent:queue -->\n";
let out = merge_3way(base, ours, theirs);
assert!(!out.fell_back);
let body = component_body(&out.merged_text, "queue");
assert!(
body.contains("~~do [#zz] q~~"),
"struck side must win:\n{body}"
);
assert_eq!(live_count(body, "do [#zz]"), 0);
assert!(out.conflicts.is_empty());
}
#[test]
fn strike_vs_unstrike_is_not_a_conflict() {
let base = "<!-- agent:queue -->\n- ~~do [#abcd] x~~\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- do [#abcd] x\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue -->\n- ~~do [#abcd] x~~\n<!-- /agent:queue -->\n";
let out = merge_3way(base, ours, theirs);
assert!(!out.fell_back);
assert!(
out.conflicts.is_empty(),
"a lawful lifecycle join must never be a conflict: {:?}",
out.conflicts
);
}
#[test]
fn genuine_same_level_content_conflict_is_recorded() {
let _guard = ENV_LOCK.lock().unwrap();
let base = "<!-- agent:queue -->\n- do [#beta] orig\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- do [#beta] OURS-VERSION\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue -->\n- do [#beta] THEIRS-VERSION\n<!-- /agent:queue -->\n";
let out = merge_3way(base, ours, theirs);
assert!(!out.fell_back);
assert_eq!(out.conflicts.len(), 1, "one conflict: {:?}", out.conflicts);
let c = &out.conflicts[0];
assert_eq!(c.component, "queue");
assert_eq!(c.identity, "queue:0:id:beta");
assert_eq!(c.kind, ConflictKind::Content);
assert!(c.ours.contains("OURS-VERSION"));
assert!(c.theirs.contains("THEIRS-VERSION"));
assert!(
c.chosen.contains("THEIRS-VERSION"),
"operator-owned theirs-wins"
);
assert!(out.merged_text.contains("THEIRS-VERSION"));
assert!(
!out.merged_text.contains("OURS-VERSION"),
"no fabricated blend: losing ours value must not also appear:\n{}",
out.merged_text
);
}
#[test]
fn both_sides_struck_different_text_is_a_content_conflict() {
let _guard = ENV_LOCK.lock().unwrap();
let base = "<!-- agent:queue -->\n- do [#k] live\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- ~~do [#k] struck OURS~~\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue -->\n- ~~do [#k] struck THEIRS~~\n<!-- /agent:queue -->\n";
let out = merge_3way(base, ours, theirs);
assert!(!out.fell_back);
assert_eq!(
out.conflicts.len(),
1,
"both-struck-differ is a conflict: {:?}",
out.conflicts
);
assert_eq!(out.conflicts[0].kind, ConflictKind::Content);
assert!(out.merged_text.contains("struck THEIRS"));
assert!(!out.merged_text.contains("struck OURS"));
}
#[test]
fn op_level_merge_disjoint_edits_converge_both_present() {
let base = "do [#beta] the original task body";
let ours = "do [#beta] the EDITED original task body";
let theirs = "do [#beta] the original task BODY!!";
let merged = op_level_merge(base, ours, theirs).expect("disjoint edits merge");
assert!(merged.contains("EDITED"), "ours edit present: {merged}");
assert!(merged.contains("BODY!!"), "theirs edit present: {merged}");
}
#[test]
fn op_level_merge_same_region_overlap_is_none() {
let base = "do [#beta] original";
let ours = "do [#beta] OURS";
let theirs = "do [#beta] THEIRS";
assert!(
op_level_merge(base, ours, theirs).is_none(),
"overlapping same-region edits must NOT op-merge"
);
}
#[test]
fn char_span_edit_and_overlap_detection() {
let base: Vec<char> = "abcdef".chars().collect();
let e = char_span_edit(&base, &"abXYef".chars().collect::<Vec<_>>()).unwrap();
assert_eq!((e.start, e.end), (2, 4));
assert_eq!(e.replacement, vec!['X', 'Y']);
assert!(char_span_edit(&base, &base).is_none());
let front = char_span_edit(&base, &"aZcdef".chars().collect::<Vec<_>>()).unwrap();
let back = char_span_edit(&base, &"abcdeZ".chars().collect::<Vec<_>>()).unwrap();
assert!(!ranges_overlap(&front, &back), "front vs back disjoint");
let o = char_span_edit(&base, &"abXXef".chars().collect::<Vec<_>>()).unwrap();
let t = char_span_edit(&base, &"abYYef".chars().collect::<Vec<_>>()).unwrap();
assert!(ranges_overlap(&o, &t), "same middle region overlaps");
}
#[test]
fn merge_3way_preserves_operator_marker_attribute_qmarkerauth() {
let base = "<!-- agent:queue -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue priority go -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
let out = merge_3way(base, ours, theirs);
assert!(!out.fell_back, "should not fall back: {out:?}");
assert!(
out.merged_text.contains("<!-- agent:queue priority go -->"),
"operator marker attribute lost (reverted to ours' marker): {}",
out.merged_text
);
}
#[test]
fn merge_3way_keeps_agent_marker_when_operator_left_it_at_base_qmarkerauth() {
let base = "<!-- agent:queue -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue go -->\n- do [#beta] task\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue -->\n- do [#beta] task EDITED\n<!-- /agent:queue -->\n";
let out = merge_3way(base, ours, theirs);
assert!(!out.fell_back, "should not fall back: {out:?}");
assert!(
out.merged_text.contains("<!-- agent:queue go -->"),
"agent marker change lost: {}",
out.merged_text
);
assert!(
out.merged_text.contains("EDITED"),
"operator body edit lost: {}",
out.merged_text
);
}
#[test]
fn opcapture_on_disjoint_same_cell_edits_both_land_no_conflict() {
let _guard = ENV_LOCK.lock().unwrap();
let base = "<!-- agent:queue -->\n- do [#beta] the original answer body here\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- do [#beta] the EDITED answer body here\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue -->\n- do [#beta] the original answer body THERE!\n<!-- /agent:queue -->\n";
unsafe {
std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "1");
}
let out = merge_3way(base, ours, theirs);
unsafe {
std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
}
assert!(!out.fell_back, "should not fall back: {out:?}");
assert!(
out.conflicts.is_empty(),
"disjoint op-merge records NO conflict: {:?}",
out.conflicts
);
assert!(
out.merged_text.contains("EDITED"),
"ours edit: {}",
out.merged_text
);
assert!(
out.merged_text.contains("THERE!"),
"theirs edit: {}",
out.merged_text
);
}
#[test]
fn opcapture_on_same_region_overlap_still_conflicts() {
let _guard = ENV_LOCK.lock().unwrap();
let base = "<!-- agent:queue -->\n- do [#beta] original\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- do [#beta] OURS-ONLY\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue -->\n- do [#beta] THEIRS-ONLY\n<!-- /agent:queue -->\n";
unsafe {
std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "1");
std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, "0");
}
let out = merge_3way(base, ours, theirs);
unsafe {
std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
}
assert!(!out.fell_back);
assert_eq!(
out.conflicts.len(),
1,
"overlap still conflicts even with opcapture ON: {:?}",
out.conflicts
);
assert_eq!(out.conflicts[0].kind, ConflictKind::Content);
assert!(
out.merged_text.contains("THEIRS-ONLY"),
"operator-owned theirs-wins"
);
assert!(
!out.merged_text.contains("OURS-ONLY"),
"no blend on a real overlap: {}",
out.merged_text
);
}
#[test]
fn opcapture_off_legacy_owner_wins_unchanged() {
let _guard = ENV_LOCK.lock().unwrap();
let base = "<!-- agent:queue -->\n- do [#beta] the original answer body here\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- do [#beta] the EDITED answer body here\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue -->\n- do [#beta] the original answer body THERE!\n<!-- /agent:queue -->\n";
unsafe {
std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "0");
}
assert!(!cell_merge_opcapture_enabled(), "opcapture kill-switch off");
let out = merge_3way(base, ours, theirs);
assert!(!out.fell_back);
assert_eq!(
out.conflicts.len(),
1,
"OFF: disjoint same-cell edits are a conflict (no op-merge): {:?}",
out.conflicts
);
assert!(
out.merged_text.contains("THERE!"),
"operator-owned theirs-wins"
);
assert!(
!out.merged_text.contains("EDITED"),
"OFF: losing ours disjoint edit is dropped (no op-merge): {}",
out.merged_text
);
unsafe {
std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
}
}
fn merge_with_markers(
value: Option<&str>,
base: &str,
ours: &str,
theirs: &str,
) -> CellMergeOutcome {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
match value {
Some(v) => std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, v),
None => std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV),
}
}
let out = merge_3way(base, ours, theirs);
unsafe {
std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
}
out
}
#[test]
fn conflict_markers_on_surfaces_both_versions_and_round_trips() {
let base = "<!-- agent:queue -->\n- do [#beta] original\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- do [#beta] OURS-VERSION\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue -->\n- do [#beta] THEIRS-VERSION\n<!-- /agent:queue -->\n";
let out = merge_with_markers(Some("1"), base, ours, theirs);
assert!(!out.fell_back, "marker must not break round-trip: {out:?}");
assert_eq!(out.conflicts.len(), 1, "one conflict: {:?}", out.conflicts);
assert!(
out.merged_text.contains("OURS-VERSION"),
"ours verbatim: {}",
out.merged_text
);
assert!(
out.merged_text.contains("THEIRS-VERSION"),
"theirs surfaced for operator resolution: {}",
out.merged_text
);
assert!(
out.merged_text.contains("<<<<<<< ours"),
"{}",
out.merged_text
);
assert!(out.merged_text.contains("======="), "{}", out.merged_text);
assert!(
out.merged_text.contains(">>>>>>> theirs"),
"{}",
out.merged_text
);
assert!(
out.merged_text.contains("- do [#beta] THEIRS-VERSION\n"),
"theirs stays the active list line: {}",
out.merged_text
);
let parsed = component::parse(&out.merged_text).expect("merged doc parses");
let top: Vec<&str> = parsed.iter().map(|c| c.name.as_str()).collect();
assert_eq!(top, vec!["queue"], "component sequence unchanged: {top:?}");
}
#[test]
fn conflict_markers_on_no_marker_for_nonconflicting_edits() {
let base = "<!-- agent:queue -->\n- do [#a] one\n- do [#b] two\n- do [#c] three\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- do [#a] one\n- do [#b] two EDITED\n- do [#c] three\n<!-- /agent:queue -->\n";
let out = merge_with_markers(Some("1"), base, ours, base);
assert!(!out.fell_back);
assert!(out.conflicts.is_empty(), "single-side edit is no conflict");
assert!(
!out.merged_text.contains("<<<<<<<"),
"no conflict marker for a single-side edit: {}",
out.merged_text
);
let theirs = "<!-- agent:queue -->\n- do [#a] one\n- do [#b] two\n- do [#c] three EDITED-T\n<!-- /agent:queue -->\n";
let ours2 = "<!-- agent:queue -->\n- do [#a] one EDITED-O\n- do [#b] two\n- do [#c] three\n<!-- /agent:queue -->\n";
let out2 = merge_with_markers(Some("1"), base, &ours2, theirs);
assert!(!out2.fell_back);
assert!(
out2.conflicts.is_empty(),
"different-item edits never conflict"
);
assert!(
!out2.merged_text.contains("<<<<<<<"),
"no marker for disjoint different-item edits: {}",
out2.merged_text
);
assert!(out2.merged_text.contains("one EDITED-O"));
assert!(out2.merged_text.contains("three EDITED-T"));
{
let _g = ENV_LOCK.lock().unwrap();
let dbase = "<!-- agent:queue -->\n- do [#beta] the original answer body here\n<!-- /agent:queue -->\n";
let dours = "<!-- agent:queue -->\n- do [#beta] the EDITED answer body here\n<!-- /agent:queue -->\n";
let dtheirs = "<!-- agent:queue -->\n- do [#beta] the original answer body THERE!\n<!-- /agent:queue -->\n";
unsafe {
std::env::set_var(CELL_MERGE_OPCAPTURE_ENV, "1");
std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, "1");
}
let out3 = merge_3way(dbase, dours, dtheirs);
unsafe {
std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
}
assert!(!out3.fell_back);
assert!(
out3.conflicts.is_empty(),
"opcapture-clean disjoint edits are no conflict: {:?}",
out3.conflicts
);
assert!(
!out3.merged_text.contains("<<<<<<<"),
"no marker when opcapture cleanly merges: {}",
out3.merged_text
);
}
}
#[test]
fn conflict_markers_on_no_marker_for_lifecycle_join() {
let base = "<!-- agent:queue -->\n- ~~do [#abcd] x~~\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- do [#abcd] x\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue -->\n- ~~do [#abcd] x~~\n<!-- /agent:queue -->\n";
let out = merge_with_markers(Some("1"), base, ours, theirs);
assert!(!out.fell_back);
assert!(
out.conflicts.is_empty(),
"lifecycle join is never a conflict: {:?}",
out.conflicts
);
assert!(
!out.merged_text.contains("<<<<<<<"),
"no conflict marker for a lawful lifecycle join: {}",
out.merged_text
);
let body = component_body(&out.merged_text, "queue");
assert!(body.contains("~~do [#abcd] x~~"), "{body}");
}
#[test]
fn conflict_markers_default_off_optin_surfaces() {
let base = "<!-- agent:queue -->\n- do [#beta] original\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- do [#beta] OURS-VERSION\n<!-- /agent:queue -->\n";
let theirs = "<!-- agent:queue -->\n- do [#beta] THEIRS-VERSION\n<!-- /agent:queue -->\n";
let absent = merge_with_markers(None, base, ours, theirs);
assert!(absent.merged_text.contains("THEIRS-VERSION"));
assert!(
!absent.merged_text.contains("OURS-VERSION"),
"default OFF: losing ours not surfaced: {}",
absent.merged_text
);
assert!(
!absent.merged_text.contains("<<<<<<<"),
"default OFF: no conflict marker: {}",
absent.merged_text
);
assert_eq!(absent.conflicts.len(), 1, "conflict still recorded");
let on = merge_with_markers(Some("1"), base, ours, theirs);
assert!(
on.merged_text.contains("THEIRS-VERSION")
&& on.merged_text.contains("OURS-VERSION")
&& on.merged_text.contains("<<<<<<<"),
"opt-in ON surfaces both versions in-band: {}",
on.merged_text
);
assert_ne!(
absent.merged_text, on.merged_text,
"opt-in must diverge from the default-OFF clean output"
);
}
#[test]
fn conflict_markers_gate_default_off_with_optin() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::remove_var(CELL_MERGE_ENV);
std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
}
assert!(
!cell_merge_conflict_markers_enabled(),
"absent ⇒ default OFF"
);
for v in ["1", "true", "on", "yes", "TRUE", " On "] {
unsafe {
std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, v);
}
assert!(
cell_merge_conflict_markers_enabled(),
"{v:?} should opt-in ON"
);
}
for v in ["0", "false", "off", "no", "FALSE", " Off ", "", "maybe"] {
unsafe {
std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, v);
}
assert!(
!cell_merge_conflict_markers_enabled(),
"{v:?} should stay OFF"
);
}
unsafe {
std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, "1");
std::env::set_var(CELL_MERGE_ENV, "0");
}
assert!(
!cell_merge_conflict_markers_enabled(),
"master kill-switch must force the sub-feature OFF"
);
unsafe {
std::env::remove_var(CELL_MERGE_ENV);
std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
}
}
#[test]
fn conflict_markers_on_skips_framing_unsafe_side_text() {
let base = "<!-- agent:queue -->\n- do [#beta] original\n<!-- /agent:queue -->\n";
let ours = "<!-- agent:queue -->\n- do [#beta] OURS-VERSION\n<!-- /agent:queue -->\n";
let theirs =
"<!-- agent:queue -->\n- do [#beta] THEIRS with --> inside\n<!-- /agent:queue -->\n";
let out = merge_with_markers(Some("1"), base, ours, theirs);
assert!(!out.fell_back, "must still round-trip: {out:?}");
assert_eq!(out.conflicts.len(), 1, "conflict still recorded");
assert!(
!out.merged_text.contains("<<<<<<<"),
"framing-unsafe side must not be surfaced in-band: {}",
out.merged_text
);
assert!(out.merged_text.contains("THEIRS with --> inside"));
}
const OPDOC: &str = "\
<!-- agent:queue -->
- do [#alpha] first task
- do [#beta] second task
- do [#gamma] third task
<!-- /agent:queue -->
";
#[test]
fn project_document_spans_tiles_body_losslessly() {
let spans = project_document_spans(OPDOC).expect("spans project");
assert_eq!(spans.len(), 3, "three queue items: {spans:?}");
for s in &spans {
assert_eq!(&OPDOC[s.span.start..s.span.end], s.value.as_str());
}
for w in spans.windows(2) {
assert_eq!(w[0].span.end, w[1].span.start, "spans must be contiguous");
}
let body_start = OPDOC.find("first task").unwrap() - "- do [#alpha] ".len();
let body_end = OPDOC.find("<!-- /agent:queue -->").unwrap();
let reassembled: String = spans.iter().map(|s| s.value.clone()).collect();
assert_eq!(reassembled, &OPDOC[body_start..body_end]);
assert_eq!(spans[0].identity, "queue:0:id:alpha");
assert_eq!(spans[2].identity, "queue:0:id:gamma");
}
#[test]
fn route_ops_to_cells_places_by_span_and_bails_on_boundary_cross() {
let spans = project_document_spans(OPDOC).unwrap();
let beta = spans
.iter()
.find(|s| s.identity == "queue:0:id:beta")
.unwrap();
let abs = OPDOC.find("second task").unwrap() + "second task".len();
assert!(beta.span.contains_offset(abs), "offset must be in beta");
let ops = vec![EditorOp::Insert {
offset: abs,
text: " THEIRS".to_string(),
}];
let routed = route_ops_to_cells(OPDOC, &ops).expect("routes");
assert_eq!(routed.len(), 1, "exactly one cell touched");
let (key, cell_ops) = routed.iter().next().unwrap();
assert_eq!(node_key_identity(key), "queue:0:id:beta");
match &cell_ops[0] {
EditorOp::Insert { offset, text } => {
assert_eq!(*offset, abs - beta.span.start, "cell-local offset");
assert_eq!(text, " THEIRS");
}
other => panic!("expected insert, got {other:?}"),
}
let gamma = spans
.iter()
.find(|s| s.identity == "queue:0:id:gamma")
.unwrap();
let cross_len = gamma.span.start + 1 - beta.span.start;
let crossing = vec![EditorOp::Delete {
offset: beta.span.start,
len: cross_len,
}];
assert!(
route_ops_to_cells(OPDOC, &crossing).is_none(),
"boundary-crossing delete must bail to None"
);
let framing = vec![EditorOp::Insert {
offset: 1,
text: "x".to_string(),
}];
assert!(
route_ops_to_cells(OPDOC, &framing).is_none(),
"framing op must bail to None"
);
}
#[test]
fn merge_3way_with_ops_replays_real_ops_and_merges() {
let abs = OPDOC.find("second task").unwrap() + "second task".len();
let ops = vec![EditorOp::Insert {
offset: abs,
text: " THEIRS".to_string(),
}];
let theirs = OPDOC.replace(
"- do [#beta] second task\n",
"- do [#beta] second task THEIRS\n",
);
let out = merge_3way_with_ops(OPDOC, OPDOC, &theirs, &ops);
assert!(!out.fell_back, "real-op path should merge, not fall back");
assert!(out.conflicts.is_empty(), "single-sided: no conflict");
assert!(out.merged_text.contains("second task THEIRS"));
assert!(out.merged_text.contains("- do [#alpha] first task\n"));
assert!(out.merged_text.contains("- do [#gamma] third task\n"));
}
#[test]
fn merge_3way_with_ops_safety_gate_falls_back_on_stale_ops() {
let abs = OPDOC.find("second task").unwrap() + "second task".len();
let ops = vec![EditorOp::Insert {
offset: abs,
text: " THEIRS".to_string(),
}];
let divergent_theirs = OPDOC.replace(
"- do [#beta] second task\n",
"- do [#beta] second task DIVERGENT\n",
);
let out = merge_3way_with_ops(OPDOC, OPDOC, &divergent_theirs, &ops);
assert!(
out.fell_back,
"stale/misaligned ops must trip the byte-for-byte safety gate"
);
}
#[test]
fn merge_3way_with_ops_falls_back_on_framing_op() {
let ops = vec![EditorOp::Insert {
offset: 1,
text: "x".to_string(),
}];
let out = merge_3way_with_ops(OPDOC, OPDOC, OPDOC, &ops);
assert!(out.fell_back, "framing op must fall back");
}
#[test]
fn op_routed_same_cell_inherits_conflict_surfacing() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::set_var(CELL_MERGE_CONFLICT_MARKERS_ENV, "1");
}
let ours = OPDOC.replace(
"- do [#beta] second task\n",
"- do [#beta] second task OURS-VERSION\n",
);
let abs = OPDOC.find("second task").unwrap() + "second task".len();
let ops = vec![EditorOp::Insert {
offset: abs,
text: " THEIRS-VERSION".to_string(),
}];
let theirs = OPDOC.replace(
"- do [#beta] second task\n",
"- do [#beta] second task THEIRS-VERSION\n",
);
let out = merge_3way_with_ops(OPDOC, &ours, &theirs, &ops);
unsafe {
std::env::remove_var(CELL_MERGE_CONFLICT_MARKERS_ENV);
}
assert!(!out.fell_back, "real-op path should merge: {out:?}");
assert_eq!(
out.conflicts.len(),
1,
"same-cell divergence surfaces a conflict via merge_3way: {:?}",
out.conflicts
);
let c = &out.conflicts[0];
assert_eq!(c.identity, "queue:0:id:beta");
assert!(c.ours.contains("OURS-VERSION"));
assert!(c.theirs.contains("THEIRS-VERSION"));
assert!(
out.merged_text.contains("OURS-VERSION") && out.merged_text.contains("THEIRS-VERSION"),
"op-routed path must inherit in-band conflict-surfacing: {}",
out.merged_text
);
}
#[test]
fn op_routed_disjoint_same_cell_edits_inherit_op_level_merge() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
std::env::remove_var(CELL_MERGE_OPCAPTURE_ENV);
}
let ours = OPDOC.replace(
"- do [#beta] second task\n",
"- do [#beta] second task OURS_TAIL\n",
);
let head_abs = OPDOC.find("second task").unwrap();
let ops = vec![EditorOp::Insert {
offset: head_abs,
text: "THEIRS_HEAD ".to_string(),
}];
let theirs = OPDOC.replace(
"- do [#beta] second task\n",
"- do [#beta] THEIRS_HEAD second task\n",
);
let out = merge_3way_with_ops(OPDOC, &ours, &theirs, &ops);
assert!(!out.fell_back, "real-op path should merge: {out:?}");
assert!(
out.conflicts.is_empty(),
"disjoint same-cell edits must converge with no conflict: {:?}",
out.conflicts
);
assert!(
out.merged_text.contains("THEIRS_HEAD") && out.merged_text.contains("OURS_TAIL"),
"both disjoint edits must survive (op_level_merge): {}",
out.merged_text
);
}
#[test]
fn ops_absent_uses_text_diff_path() {
let ours = OPDOC.replace(
"- do [#alpha] first task\n",
"- do [#alpha] first task OURS\n",
);
let theirs = OPDOC.replace(
"- do [#gamma] third task\n",
"- do [#gamma] third task THEIRS\n",
);
let out = merge_3way(OPDOC, &ours, &theirs);
assert!(!out.fell_back);
assert!(out.conflicts.is_empty());
assert!(out.merged_text.contains("first task OURS"));
assert!(out.merged_text.contains("third task THEIRS"));
}
}