Skip to main content

dirtydata_core/
merge.rs

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    // Track what each side is doing
23    let left_ops = collect_op_targets(&left.patches);
24    let right_ops = collect_op_targets(&right.patches);
25
26    // Iterate through all modified nodes
27    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                // Both modified the same node. Check for field conflicts.
41                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                // Only left modified
68                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                // Only right modified
79                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}