1use std::collections::HashSet;
4
5use serde::Serialize;
6
7use crate::graph::KnowledgeGraph;
8use crate::model::{Item, ItemId};
9
10#[derive(Debug, Clone, Serialize)]
12pub struct GraphDiff {
13 pub added_items: Vec<ItemDiff>,
15 pub removed_items: Vec<ItemDiff>,
17 pub modified_items: Vec<ItemModification>,
19 pub added_relationships: Vec<RelationshipDiff>,
21 pub removed_relationships: Vec<RelationshipDiff>,
23 pub stats: DiffStats,
25}
26
27#[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#[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#[derive(Debug, Clone, Serialize)]
58pub struct FieldChange {
59 pub field: String,
60 pub old_value: String,
61 pub new_value: String,
62}
63
64#[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#[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 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 let old_ids: HashSet<_> = old_graph.item_ids().cloned().collect();
94 let new_ids: HashSet<_> = new_graph.item_ids().cloned().collect();
95
96 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 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 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 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 fn compute_item_changes(old: &Item, new: &Item) -> Vec<FieldChange> {
175 let mut changes = Vec::new();
176
177 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 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 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 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 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 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 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}