sara_core/graph/
diff.rs

1//! Graph diffing between two graph states.
2
3use std::collections::HashSet;
4
5use serde::Serialize;
6
7use crate::graph::KnowledgeGraph;
8use crate::model::{Item, ItemId};
9
10/// A diff between two knowledge graphs.
11#[derive(Debug, Clone, Serialize)]
12pub struct GraphDiff {
13    /// Items added (present in new, not in old).
14    pub added_items: Vec<ItemDiff>,
15    /// Items removed (present in old, not in new).
16    pub removed_items: Vec<ItemDiff>,
17    /// Items modified (present in both, but changed).
18    pub modified_items: Vec<ItemModification>,
19    /// Relationships added.
20    pub added_relationships: Vec<RelationshipDiff>,
21    /// Relationships removed.
22    pub removed_relationships: Vec<RelationshipDiff>,
23    /// Summary statistics.
24    pub stats: DiffStats,
25}
26
27/// Representation of an item in a diff.
28#[derive(Debug, Clone, Serialize)]
29pub struct ItemDiff {
30    pub id: String,
31    pub name: String,
32    pub item_type: String,
33    pub file_path: String,
34}
35
36impl From<&Item> for ItemDiff {
37    fn from(item: &Item) -> Self {
38        Self {
39            id: item.id.as_str().to_string(),
40            name: item.name.clone(),
41            item_type: item.item_type.display_name().to_string(),
42            file_path: item.source.file_path.display().to_string(),
43        }
44    }
45}
46
47/// A modification to an item.
48#[derive(Debug, Clone, Serialize)]
49pub struct ItemModification {
50    pub id: String,
51    pub name: String,
52    pub item_type: String,
53    pub changes: Vec<FieldChange>,
54}
55
56/// A change to a specific field.
57#[derive(Debug, Clone, Serialize)]
58pub struct FieldChange {
59    pub field: String,
60    pub old_value: String,
61    pub new_value: String,
62}
63
64/// A relationship in a diff.
65#[derive(Debug, Clone, Serialize)]
66pub struct RelationshipDiff {
67    pub from_id: String,
68    pub to_id: String,
69    pub relationship_type: String,
70}
71
72/// Summary statistics for a diff.
73#[derive(Debug, Clone, Default, Serialize)]
74pub struct DiffStats {
75    pub items_added: usize,
76    pub items_removed: usize,
77    pub items_modified: usize,
78    pub relationships_added: usize,
79    pub relationships_removed: usize,
80}
81
82impl GraphDiff {
83    /// Computes the diff between two graphs.
84    ///
85    /// `old_graph` is the baseline (e.g., main branch).
86    /// `new_graph` is the target (e.g., current HEAD).
87    pub fn compute(old_graph: &KnowledgeGraph, new_graph: &KnowledgeGraph) -> Self {
88        let mut added_items = Vec::new();
89        let mut removed_items = Vec::new();
90        let mut modified_items = Vec::new();
91
92        // Collect item IDs from both graphs
93        let old_ids: HashSet<_> = old_graph.item_ids().cloned().collect();
94        let new_ids: HashSet<_> = new_graph.item_ids().cloned().collect();
95
96        // Find added items (in new but not in old)
97        for id in new_ids.difference(&old_ids) {
98            if let Some(item) = new_graph.get(id) {
99                added_items.push(ItemDiff::from(item));
100            }
101        }
102
103        // Find removed items (in old but not in new)
104        for id in old_ids.difference(&new_ids) {
105            if let Some(item) = old_graph.get(id) {
106                removed_items.push(ItemDiff::from(item));
107            }
108        }
109
110        // Find modified items (in both, check for changes)
111        for id in old_ids.intersection(&new_ids) {
112            if let (Some(old_item), Some(new_item)) = (old_graph.get(id), new_graph.get(id)) {
113                let changes = Self::compute_item_changes(old_item, new_item);
114                if !changes.is_empty() {
115                    modified_items.push(ItemModification {
116                        id: id.as_str().to_string(),
117                        name: new_item.name.clone(),
118                        item_type: new_item.item_type.display_name().to_string(),
119                        changes,
120                    });
121                }
122            }
123        }
124
125        // Compute relationship diffs
126        let old_rels: HashSet<_> = old_graph
127            .relationships()
128            .into_iter()
129            .map(|(from, to, rel)| (from.as_str().to_string(), to.as_str().to_string(), rel))
130            .collect();
131        let new_rels: HashSet<_> = new_graph
132            .relationships()
133            .into_iter()
134            .map(|(from, to, rel)| (from.as_str().to_string(), to.as_str().to_string(), rel))
135            .collect();
136
137        let added_relationships: Vec<_> = new_rels
138            .difference(&old_rels)
139            .map(|(from, to, rel)| RelationshipDiff {
140                from_id: from.clone(),
141                to_id: to.clone(),
142                relationship_type: format!("{:?}", rel),
143            })
144            .collect();
145
146        let removed_relationships: Vec<_> = old_rels
147            .difference(&new_rels)
148            .map(|(from, to, rel)| RelationshipDiff {
149                from_id: from.clone(),
150                to_id: to.clone(),
151                relationship_type: format!("{:?}", rel),
152            })
153            .collect();
154
155        let stats = DiffStats {
156            items_added: added_items.len(),
157            items_removed: removed_items.len(),
158            items_modified: modified_items.len(),
159            relationships_added: added_relationships.len(),
160            relationships_removed: removed_relationships.len(),
161        };
162
163        Self {
164            added_items,
165            removed_items,
166            modified_items,
167            added_relationships,
168            removed_relationships,
169            stats,
170        }
171    }
172
173    /// Computes changes between two versions of the same item.
174    fn compute_item_changes(old: &Item, new: &Item) -> Vec<FieldChange> {
175        let mut changes = Vec::new();
176
177        // Check name change
178        if old.name != new.name {
179            changes.push(FieldChange {
180                field: "name".to_string(),
181                old_value: old.name.clone(),
182                new_value: new.name.clone(),
183            });
184        }
185
186        // Check description change
187        if old.description != new.description {
188            changes.push(FieldChange {
189                field: "description".to_string(),
190                old_value: old.description.clone().unwrap_or_default(),
191                new_value: new.description.clone().unwrap_or_default(),
192            });
193        }
194
195        // Check specification change
196        if old.attributes.specification != new.attributes.specification {
197            changes.push(FieldChange {
198                field: "specification".to_string(),
199                old_value: old.attributes.specification.clone().unwrap_or_default(),
200                new_value: new.attributes.specification.clone().unwrap_or_default(),
201            });
202        }
203
204        // Check file path change
205        if old.source.file_path != new.source.file_path {
206            changes.push(FieldChange {
207                field: "file_path".to_string(),
208                old_value: old.source.file_path.display().to_string(),
209                new_value: new.source.file_path.display().to_string(),
210            });
211        }
212
213        // Check upstream refs change
214        let old_upstream = Self::refs_to_string(&old.upstream.all_ids());
215        let new_upstream = Self::refs_to_string(&new.upstream.all_ids());
216        if old_upstream != new_upstream {
217            changes.push(FieldChange {
218                field: "upstream".to_string(),
219                old_value: old_upstream,
220                new_value: new_upstream,
221            });
222        }
223
224        // Check downstream refs change
225        let old_downstream = Self::refs_to_string(&old.downstream.all_ids());
226        let new_downstream = Self::refs_to_string(&new.downstream.all_ids());
227        if old_downstream != new_downstream {
228            changes.push(FieldChange {
229                field: "downstream".to_string(),
230                old_value: old_downstream,
231                new_value: new_downstream,
232            });
233        }
234
235        changes
236    }
237
238    fn refs_to_string(refs: &[&ItemId]) -> String {
239        let ids: Vec<_> = refs.iter().map(|id| id.as_str()).collect();
240        ids.join(", ")
241    }
242
243    /// Returns true if there are no changes.
244    pub fn is_empty(&self) -> bool {
245        self.added_items.is_empty()
246            && self.removed_items.is_empty()
247            && self.modified_items.is_empty()
248            && self.added_relationships.is_empty()
249            && self.removed_relationships.is_empty()
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::graph::GraphBuilder;
257    use crate::model::{ItemBuilder, ItemType, SourceLocation};
258    use std::path::PathBuf;
259
260    fn create_test_item(id: &str, item_type: ItemType, name: &str) -> Item {
261        let source = SourceLocation::new(PathBuf::from("/repo"), format!("{}.md", id));
262        let mut builder = ItemBuilder::new()
263            .id(ItemId::new_unchecked(id))
264            .item_type(item_type)
265            .name(name)
266            .source(source);
267
268        if item_type.requires_specification() {
269            builder = builder.specification("Test specification");
270        }
271
272        builder.build().unwrap()
273    }
274
275    #[test]
276    fn test_no_changes() {
277        let item = create_test_item("SOL-001", ItemType::Solution, "Solution");
278
279        let old_graph = GraphBuilder::new().add_item(item.clone()).build().unwrap();
280        let new_graph = GraphBuilder::new().add_item(item).build().unwrap();
281
282        let diff = GraphDiff::compute(&old_graph, &new_graph);
283        assert!(diff.is_empty());
284    }
285
286    #[test]
287    fn test_added_item() {
288        let old_graph = GraphBuilder::new().build().unwrap();
289        let new_graph = GraphBuilder::new()
290            .add_item(create_test_item("SOL-001", ItemType::Solution, "Solution"))
291            .build()
292            .unwrap();
293
294        let diff = GraphDiff::compute(&old_graph, &new_graph);
295        assert_eq!(diff.stats.items_added, 1);
296        assert_eq!(diff.added_items[0].id, "SOL-001");
297    }
298
299    #[test]
300    fn test_removed_item() {
301        let old_graph = GraphBuilder::new()
302            .add_item(create_test_item("SOL-001", ItemType::Solution, "Solution"))
303            .build()
304            .unwrap();
305        let new_graph = GraphBuilder::new().build().unwrap();
306
307        let diff = GraphDiff::compute(&old_graph, &new_graph);
308        assert_eq!(diff.stats.items_removed, 1);
309        assert_eq!(diff.removed_items[0].id, "SOL-001");
310    }
311
312    #[test]
313    fn test_modified_item() {
314        let old_item = create_test_item("SOL-001", ItemType::Solution, "Old Name");
315        let new_item = create_test_item("SOL-001", ItemType::Solution, "New Name");
316
317        let old_graph = GraphBuilder::new().add_item(old_item).build().unwrap();
318        let new_graph = GraphBuilder::new().add_item(new_item).build().unwrap();
319
320        let diff = GraphDiff::compute(&old_graph, &new_graph);
321        assert_eq!(diff.stats.items_modified, 1);
322        assert_eq!(diff.modified_items[0].id, "SOL-001");
323        assert!(
324            diff.modified_items[0]
325                .changes
326                .iter()
327                .any(|c| c.field == "name")
328        );
329    }
330}