Skip to main content

git_lore/lore/
merge.rs

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}