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.clone().unwrap_or_default(),
200 new_value: new.attributes.specification.clone().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(&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 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 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}