1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use super::{AtomState, LoreAtom, WorkspaceState};
6
7#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
8#[serde(rename_all = "snake_case")]
9pub enum MergeConflictKind {
10 TypeConflict,
11 DependencyConflict,
12}
13
14#[derive(Clone, Debug, Serialize, Deserialize)]
15pub struct MergeConflict {
16 pub key: String,
17 pub kind: MergeConflictKind,
18 pub message: String,
19 pub base: Option<LoreAtom>,
20 pub left: Option<LoreAtom>,
21 pub right: Option<LoreAtom>,
22}
23
24#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct MergeOutcome {
26 pub merged: Vec<LoreAtom>,
27 pub conflicts: Vec<MergeConflict>,
28 pub notes: Vec<String>,
29}
30
31pub fn reconcile_lore(base: &WorkspaceState, left: &WorkspaceState, right: &WorkspaceState) -> MergeOutcome {
32 let base_map = group_by_location(&base.atoms);
33 let left_map = group_by_location(&left.atoms);
34 let right_map = group_by_location(&right.atoms);
35
36 let keys = base_map
37 .keys()
38 .chain(left_map.keys())
39 .chain(right_map.keys())
40 .cloned()
41 .collect::<BTreeSet<_>>();
42
43 let mut merged = Vec::new();
44 let mut conflicts = Vec::new();
45 let mut notes = Vec::new();
46
47 for key in keys {
48 let base_atom = base_map.get(&key).cloned();
49 let left_atom = left_map.get(&key).cloned();
50 let right_atom = right_map.get(&key).cloned();
51
52 match (&base_atom, &left_atom, &right_atom) {
53 (Some(base_atom), Some(left_atom), Some(right_atom)) if equivalent_atom(left_atom, right_atom) => {
54 merged.push(left_atom.clone());
55 if !equivalent_atom(base_atom, left_atom) {
56 notes.push(format!("Merged identical change at {key}"));
57 }
58 }
59 (Some(base_atom), Some(left_atom), Some(right_atom)) if equivalent_atom(base_atom, left_atom) => {
60 merged.push(right_atom.clone());
61 notes.push(format!("Right branch updated {key}"));
62 }
63 (Some(base_atom), Some(left_atom), Some(right_atom)) if equivalent_atom(base_atom, right_atom) => {
64 merged.push(left_atom.clone());
65 notes.push(format!("Left branch updated {key}"));
66 }
67 (Some(base_atom), Some(left_atom), Some(right_atom)) => {
68 let (winner, conflict_kind, message) = resolve_conflict(&key, base_atom, left_atom, right_atom);
69 merged.push(winner);
70 conflicts.push(MergeConflict {
71 key: key.clone(),
72 kind: conflict_kind,
73 message,
74 base: Some(base_atom.clone()),
75 left: Some(left_atom.clone()),
76 right: Some(right_atom.clone()),
77 });
78 }
79 (None, Some(left_atom), Some(right_atom)) if equivalent_atom(left_atom, right_atom) => {
80 merged.push(left_atom.clone());
81 notes.push(format!("Added identical atom at {key} from both branches"));
82 }
83 (None, Some(left_atom), Some(right_atom)) => {
84 let (winner, conflict_kind, message) = resolve_conflict(&key, left_atom, left_atom, right_atom);
85 merged.push(winner);
86 conflicts.push(MergeConflict {
87 key: key.clone(),
88 kind: conflict_kind,
89 message,
90 base: None,
91 left: Some(left_atom.clone()),
92 right: Some(right_atom.clone()),
93 });
94 }
95 (Some(base_atom), Some(left_atom), None) => {
96 merged.push(left_atom.clone());
97 if equivalent_atom(base_atom, left_atom) {
98 notes.push(format!("Carried forward unchanged atom at {key}"));
99 } else {
100 notes.push(format!("Left branch changed {key}"));
101 }
102 }
103 (Some(base_atom), None, Some(right_atom)) => {
104 merged.push(right_atom.clone());
105 if equivalent_atom(base_atom, right_atom) {
106 notes.push(format!("Carried forward unchanged atom at {key}"));
107 } else {
108 notes.push(format!("Right branch changed {key}"));
109 }
110 }
111 (None, Some(left_atom), None) => {
112 merged.push(left_atom.clone());
113 notes.push(format!("Added left-only atom at {key}"));
114 }
115 (None, None, Some(right_atom)) => {
116 merged.push(right_atom.clone());
117 notes.push(format!("Added right-only atom at {key}"));
118 }
119 (Some(base_atom), None, None) => {
120 merged.push(base_atom.clone());
121 }
122 (None, None, None) => {}
123 }
124 }
125
126 merged.sort_by(|left, right| location_key(left).cmp(&location_key(right)).then(left.id.cmp(&right.id)));
127
128 MergeOutcome {
129 merged,
130 conflicts,
131 notes,
132 }
133}
134
135fn group_by_location(atoms: &[LoreAtom]) -> BTreeMap<String, LoreAtom> {
136 let mut grouped = BTreeMap::new();
137 for atom in atoms {
138 grouped
139 .entry(location_key(atom))
140 .and_modify(|existing: &mut LoreAtom| {
141 if is_newer(atom, existing) {
142 *existing = atom.clone();
143 }
144 })
145 .or_insert_with(|| atom.clone());
146 }
147
148 grouped
149}
150
151fn location_key(atom: &LoreAtom) -> String {
152 let path = atom
153 .path
154 .as_ref()
155 .map(|value| value.to_string_lossy().replace('\\', "/"))
156 .unwrap_or_else(|| "<no-path>".to_string());
157 let scope = atom.scope.as_deref().unwrap_or("<no-scope>");
158 format!("{path}::{scope}")
159}
160
161fn equivalent_atom(left: &LoreAtom, right: &LoreAtom) -> bool {
162 left.kind == right.kind
163 && left.state == right.state
164 && left.title == right.title
165 && left.body == right.body
166 && left.scope == right.scope
167 && left.path == right.path
168}
169
170fn is_newer(candidate: &LoreAtom, current: &LoreAtom) -> bool {
171 candidate.created_unix_seconds > current.created_unix_seconds
172 || (candidate.created_unix_seconds == current.created_unix_seconds && candidate.id > current.id)
173}
174
175fn resolve_conflict(
176 key: &str,
177 base: &LoreAtom,
178 left: &LoreAtom,
179 right: &LoreAtom,
180) -> (LoreAtom, MergeConflictKind, String) {
181 if left.state == AtomState::Deprecated || right.state == AtomState::Deprecated {
182 let winner = if left.state != AtomState::Deprecated {
183 left.clone()
184 } else {
185 right.clone()
186 };
187 return (
188 winner,
189 MergeConflictKind::DependencyConflict,
190 format!("Dependency conflict at {key}: one branch deprecated an atom while the other kept it active"),
191 );
192 }
193
194 if left.kind != right.kind || left.title != right.title {
195 return (
196 left.clone(),
197 MergeConflictKind::TypeConflict,
198 format!("Type conflict at {key}: branches disagree on the lore atom shape or intent"),
199 );
200 }
201
202 if !equivalent_atom(base, left) && equivalent_atom(base, right) {
203 return (
204 left.clone(),
205 MergeConflictKind::TypeConflict,
206 format!("Left branch diverged at {key}"),
207 );
208 }
209
210 if !equivalent_atom(base, right) && equivalent_atom(base, left) {
211 return (
212 right.clone(),
213 MergeConflictKind::TypeConflict,
214 format!("Right branch diverged at {key}"),
215 );
216 }
217
218 (
219 left.clone(),
220 MergeConflictKind::TypeConflict,
221 format!("Type conflict at {key}: branches made incompatible changes"),
222 )
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::lore::{AtomState, LoreKind};
229 use std::path::PathBuf;
230
231 fn atom(id: &str, kind: LoreKind, state: AtomState, title: &str, body: Option<&str>) -> LoreAtom {
232 LoreAtom {
233 id: id.to_string(),
234 kind,
235 state,
236 title: title.to_string(),
237 body: body.map(str::to_string),
238 scope: Some("compute".to_string()),
239 path: Some(PathBuf::from("src/lib.rs")),
240 validation_script: None,
241 created_unix_seconds: 1,
242 }
243 }
244
245 #[test]
246 fn additive_merges_keep_non_conflicting_atoms() {
247 let base = WorkspaceState { version: 1, atoms: vec![] };
248 let left = WorkspaceState {
249 version: 1,
250 atoms: vec![LoreAtom {
251 path: Some(PathBuf::from("src/db.rs")),
252 ..atom("left-1", LoreKind::Decision, AtomState::Accepted, "Use SQLite", None)
253 }],
254 };
255 let right = WorkspaceState {
256 version: 1,
257 atoms: vec![LoreAtom {
258 path: Some(PathBuf::from("src/cache.rs")),
259 ..atom("right-1", LoreKind::Assumption, AtomState::Accepted, "Cache stays local", None)
260 }],
261 };
262
263 let outcome = reconcile_lore(&base, &left, &right);
264 assert_eq!(outcome.conflicts.len(), 0);
265 assert_eq!(outcome.merged.len(), 2);
266 assert!(!outcome.notes.is_empty());
267 }
268
269 #[test]
270 fn type_conflicts_are_reported() {
271 let base = WorkspaceState { version: 1, atoms: vec![] };
272 let left = WorkspaceState {
273 version: 1,
274 atoms: vec![atom("left-1", LoreKind::Decision, AtomState::Accepted, "Use OAuth", None)],
275 };
276 let right = WorkspaceState {
277 version: 1,
278 atoms: vec![atom("right-1", LoreKind::Decision, AtomState::Accepted, "Use SAML", None)],
279 };
280
281 let outcome = reconcile_lore(&base, &left, &right);
282 assert_eq!(outcome.conflicts.len(), 1);
283 assert_eq!(outcome.conflicts[0].kind, MergeConflictKind::TypeConflict);
284 }
285
286 #[test]
287 fn dependency_conflicts_are_reported() {
288 let base = WorkspaceState { version: 1, atoms: vec![] };
289 let left = WorkspaceState {
290 version: 1,
291 atoms: vec![atom("left-1", LoreKind::Decision, AtomState::Deprecated, "Remove helper", None)],
292 };
293 let right = WorkspaceState {
294 version: 1,
295 atoms: vec![atom("right-1", LoreKind::Decision, AtomState::Accepted, "Keep helper", None)],
296 };
297
298 let outcome = reconcile_lore(&base, &left, &right);
299 assert_eq!(outcome.conflicts.len(), 1);
300 assert_eq!(outcome.conflicts[0].kind, MergeConflictKind::DependencyConflict);
301 }
302}