Skip to main content

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 (for requirement types)
196        if old.attributes.specification() != new.attributes.specification() {
197            changes.push(FieldChange {
198                field: "specification".to_string(),
199                old_value: old.attributes.specification().cloned().unwrap_or_default(),
200                new_value: new.attributes.specification().cloned().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(
215            old.relationships
216                .iter()
217                .filter(|r| r.relationship_type.is_upstream())
218                .map(|r| &r.to),
219        );
220        let new_upstream = Self::refs_to_string(
221            new.relationships
222                .iter()
223                .filter(|r| r.relationship_type.is_upstream())
224                .map(|r| &r.to),
225        );
226        if old_upstream != new_upstream {
227            changes.push(FieldChange {
228                field: "upstream".to_string(),
229                old_value: old_upstream,
230                new_value: new_upstream,
231            });
232        }
233
234        // Check downstream refs change
235        let old_downstream = Self::refs_to_string(
236            old.relationships
237                .iter()
238                .filter(|r| r.relationship_type.is_downstream())
239                .map(|r| &r.to),
240        );
241        let new_downstream = Self::refs_to_string(
242            new.relationships
243                .iter()
244                .filter(|r| r.relationship_type.is_downstream())
245                .map(|r| &r.to),
246        );
247        if old_downstream != new_downstream {
248            changes.push(FieldChange {
249                field: "downstream".to_string(),
250                old_value: old_downstream,
251                new_value: new_downstream,
252            });
253        }
254
255        changes
256    }
257
258    fn refs_to_string<'a>(refs: impl Iterator<Item = &'a ItemId>) -> String {
259        let ids: Vec<_> = refs.map(|id| id.as_str()).collect();
260        ids.join(", ")
261    }
262
263    /// Returns true if there are no changes.
264    pub fn is_empty(&self) -> bool {
265        self.added_items.is_empty()
266            && self.removed_items.is_empty()
267            && self.modified_items.is_empty()
268            && self.added_relationships.is_empty()
269            && self.removed_relationships.is_empty()
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::graph::KnowledgeGraphBuilder;
277    use crate::model::ItemType;
278    use crate::test_utils::create_test_item_with_name;
279
280    #[test]
281    fn test_no_changes() {
282        let item = create_test_item_with_name("SOL-001", ItemType::Solution, "Solution");
283
284        let old_graph = KnowledgeGraphBuilder::new()
285            .add_item(item.clone())
286            .build()
287            .unwrap();
288        let new_graph = KnowledgeGraphBuilder::new().add_item(item).build().unwrap();
289
290        let diff = GraphDiff::compute(&old_graph, &new_graph);
291        assert!(diff.is_empty());
292    }
293
294    #[test]
295    fn test_added_item() {
296        let old_graph = KnowledgeGraphBuilder::new().build().unwrap();
297        let new_graph = KnowledgeGraphBuilder::new()
298            .add_item(create_test_item_with_name(
299                "SOL-001",
300                ItemType::Solution,
301                "Solution",
302            ))
303            .build()
304            .unwrap();
305
306        let diff = GraphDiff::compute(&old_graph, &new_graph);
307        assert_eq!(diff.stats.items_added, 1);
308        assert_eq!(diff.added_items[0].id, "SOL-001");
309    }
310
311    #[test]
312    fn test_removed_item() {
313        let old_graph = KnowledgeGraphBuilder::new()
314            .add_item(create_test_item_with_name(
315                "SOL-001",
316                ItemType::Solution,
317                "Solution",
318            ))
319            .build()
320            .unwrap();
321        let new_graph = KnowledgeGraphBuilder::new().build().unwrap();
322
323        let diff = GraphDiff::compute(&old_graph, &new_graph);
324        assert_eq!(diff.stats.items_removed, 1);
325        assert_eq!(diff.removed_items[0].id, "SOL-001");
326    }
327
328    #[test]
329    fn test_modified_item() {
330        let old_item = create_test_item_with_name("SOL-001", ItemType::Solution, "Old Name");
331        let new_item = create_test_item_with_name("SOL-001", ItemType::Solution, "New Name");
332
333        let old_graph = KnowledgeGraphBuilder::new()
334            .add_item(old_item)
335            .build()
336            .unwrap();
337        let new_graph = KnowledgeGraphBuilder::new()
338            .add_item(new_item)
339            .build()
340            .unwrap();
341
342        let diff = GraphDiff::compute(&old_graph, &new_graph);
343        assert_eq!(diff.stats.items_modified, 1);
344        assert_eq!(diff.modified_items[0].id, "SOL-001");
345        assert!(
346            diff.modified_items[0]
347                .changes
348                .iter()
349                .any(|c| c.field == "name")
350        );
351    }
352}