1use crate::patch::{Operation, Patch, PatchSet};
2use crate::types::{ConfigDelta, StableId};
3use std::collections::{BTreeMap, HashMap, HashSet};
4
5#[derive(Debug, thiserror::Error)]
6pub enum MergeError {
7 #[error("Conflict on node {node_id}: key '{key}' modified by both sides")]
8 ConfigConflict { node_id: StableId, key: String },
9 #[error("Conflict on edge {edge_id}: both sides modified this edge")]
10 EdgeConflict { edge_id: StableId },
11 #[error("Conflict on node {node_id}: one side removed, other side modified")]
12 RemoveModifyConflict { node_id: StableId },
13}
14
15pub fn merge_three_way(
16 _base: &PatchSet,
17 left: &PatchSet,
18 right: &PatchSet,
19) -> Result<PatchSet, MergeError> {
20 let mut merged_ops = Vec::new();
21
22 let left_ops = collect_op_targets(&left.patches);
24 let right_ops = collect_op_targets(&right.patches);
25
26 let all_nodes: HashSet<_> = left_ops
28 .nodes
29 .keys()
30 .chain(right_ops.nodes.keys())
31 .cloned()
32 .collect();
33
34 for node_id in all_nodes {
35 let l_mod = left_ops.nodes.get(&node_id);
36 let r_mod = right_ops.nodes.get(&node_id);
37
38 match (l_mod, r_mod) {
39 (Some(l), Some(r)) => {
40 if l.removed || r.removed {
42 return Err(MergeError::RemoveModifyConflict { node_id });
43 }
44
45 let mut merged_delta = l.config_delta.clone();
46 for (key, r_change) in &r.config_delta {
47 if let Some(l_change) = l.config_delta.get(key) {
48 if l_change != r_change {
49 return Err(MergeError::ConfigConflict {
50 node_id,
51 key: key.clone(),
52 });
53 }
54 } else {
55 merged_delta.insert(key.clone(), r_change.clone());
56 }
57 }
58
59 if !merged_delta.is_empty() {
60 merged_ops.push(Operation::ModifyConfig {
61 node_id,
62 delta: merged_delta,
63 });
64 }
65 }
66 (Some(l), None) => {
67 if l.removed {
69 merged_ops.push(Operation::RemoveNode(node_id));
70 } else if !l.config_delta.is_empty() {
71 merged_ops.push(Operation::ModifyConfig {
72 node_id,
73 delta: l.config_delta.clone(),
74 });
75 }
76 }
77 (None, Some(r)) => {
78 if r.removed {
80 merged_ops.push(Operation::RemoveNode(node_id));
81 } else if !r.config_delta.is_empty() {
82 merged_ops.push(Operation::ModifyConfig {
83 node_id,
84 delta: r.config_delta.clone(),
85 });
86 }
87 }
88 _ => unreachable!(),
89 }
90 }
91
92 Ok(PatchSet {
93 patches: vec![Patch::from_operations_with_provenance(
94 merged_ops,
95 crate::types::PatchSource::System,
96 crate::types::TrustLevel::Trusted,
97 )],
98 })
99}
100
101struct OpTargets {
102 nodes: HashMap<StableId, NodeOpSummary>,
103}
104
105struct NodeOpSummary {
106 removed: bool,
107 config_delta: ConfigDelta,
108}
109
110fn collect_op_targets(patches: &[Patch]) -> OpTargets {
111 let mut nodes = HashMap::new();
112
113 for patch in patches {
114 for op in &patch.operations {
115 match op {
116 Operation::RemoveNode(id) => {
117 nodes
118 .entry(*id)
119 .or_insert(NodeOpSummary {
120 removed: true,
121 config_delta: BTreeMap::new(),
122 })
123 .removed = true;
124 }
125 Operation::ModifyConfig { node_id, delta } => {
126 let summary = nodes.entry(*node_id).or_insert(NodeOpSummary {
127 removed: false,
128 config_delta: BTreeMap::new(),
129 });
130 for (k, v) in delta {
131 summary.config_delta.insert(k.clone(), v.clone());
132 }
133 }
134 _ => {}
135 }
136 }
137 }
138
139 OpTargets { nodes }
140}