use std::collections::HashSet;
use serde::Serialize;
use crate::graph::KnowledgeGraph;
use crate::model::{Item, ItemId};
#[derive(Debug, Clone, Serialize)]
pub struct GraphDiff {
pub added_items: Vec<ItemDiff>,
pub removed_items: Vec<ItemDiff>,
pub modified_items: Vec<ItemModification>,
pub added_relationships: Vec<RelationshipDiff>,
pub removed_relationships: Vec<RelationshipDiff>,
pub stats: DiffStats,
}
#[derive(Debug, Clone, Serialize)]
pub struct ItemDiff {
pub id: String,
pub name: String,
pub item_type: String,
pub file_path: String,
}
impl From<&Item> for ItemDiff {
fn from(item: &Item) -> Self {
Self {
id: item.id.as_str().to_string(),
name: item.name.clone(),
item_type: item.item_type.display_name().to_string(),
file_path: item.source.file_path.display().to_string(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ItemModification {
pub id: String,
pub name: String,
pub item_type: String,
pub changes: Vec<FieldChange>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FieldChange {
pub field: String,
pub old_value: String,
pub new_value: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct RelationshipDiff {
pub from_id: String,
pub to_id: String,
pub relationship_type: String,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct DiffStats {
pub items_added: usize,
pub items_removed: usize,
pub items_modified: usize,
pub relationships_added: usize,
pub relationships_removed: usize,
}
impl GraphDiff {
pub fn compute(old_graph: &KnowledgeGraph, new_graph: &KnowledgeGraph) -> Self {
let mut added_items = Vec::new();
let mut removed_items = Vec::new();
let mut modified_items = Vec::new();
let old_ids: HashSet<_> = old_graph.item_ids().cloned().collect();
let new_ids: HashSet<_> = new_graph.item_ids().cloned().collect();
for id in new_ids.difference(&old_ids) {
if let Some(item) = new_graph.get(id) {
added_items.push(ItemDiff::from(item));
}
}
for id in old_ids.difference(&new_ids) {
if let Some(item) = old_graph.get(id) {
removed_items.push(ItemDiff::from(item));
}
}
for id in old_ids.intersection(&new_ids) {
if let (Some(old_item), Some(new_item)) = (old_graph.get(id), new_graph.get(id)) {
let changes = Self::compute_item_changes(old_item, new_item);
if !changes.is_empty() {
modified_items.push(ItemModification {
id: id.as_str().to_string(),
name: new_item.name.clone(),
item_type: new_item.item_type.display_name().to_string(),
changes,
});
}
}
}
let old_rels: HashSet<_> = old_graph
.relationships()
.into_iter()
.map(|(from, to, rel)| (from.as_str().to_string(), to.as_str().to_string(), rel))
.collect();
let new_rels: HashSet<_> = new_graph
.relationships()
.into_iter()
.map(|(from, to, rel)| (from.as_str().to_string(), to.as_str().to_string(), rel))
.collect();
let added_relationships: Vec<_> = new_rels
.difference(&old_rels)
.map(|(from, to, rel)| RelationshipDiff {
from_id: from.clone(),
to_id: to.clone(),
relationship_type: format!("{:?}", rel),
})
.collect();
let removed_relationships: Vec<_> = old_rels
.difference(&new_rels)
.map(|(from, to, rel)| RelationshipDiff {
from_id: from.clone(),
to_id: to.clone(),
relationship_type: format!("{:?}", rel),
})
.collect();
let stats = DiffStats {
items_added: added_items.len(),
items_removed: removed_items.len(),
items_modified: modified_items.len(),
relationships_added: added_relationships.len(),
relationships_removed: removed_relationships.len(),
};
Self {
added_items,
removed_items,
modified_items,
added_relationships,
removed_relationships,
stats,
}
}
fn compute_item_changes(old: &Item, new: &Item) -> Vec<FieldChange> {
let mut changes = Vec::new();
if old.name != new.name {
changes.push(FieldChange {
field: "name".to_string(),
old_value: old.name.clone(),
new_value: new.name.clone(),
});
}
if old.description != new.description {
changes.push(FieldChange {
field: "description".to_string(),
old_value: old.description.clone().unwrap_or_default(),
new_value: new.description.clone().unwrap_or_default(),
});
}
if old.attributes.specification() != new.attributes.specification() {
changes.push(FieldChange {
field: "specification".to_string(),
old_value: old.attributes.specification().cloned().unwrap_or_default(),
new_value: new.attributes.specification().cloned().unwrap_or_default(),
});
}
if old.source.file_path != new.source.file_path {
changes.push(FieldChange {
field: "file_path".to_string(),
old_value: old.source.file_path.display().to_string(),
new_value: new.source.file_path.display().to_string(),
});
}
let old_upstream = Self::refs_to_string(old.upstream.all_ids());
let new_upstream = Self::refs_to_string(new.upstream.all_ids());
if old_upstream != new_upstream {
changes.push(FieldChange {
field: "upstream".to_string(),
old_value: old_upstream,
new_value: new_upstream,
});
}
let old_downstream = Self::refs_to_string(old.downstream.all_ids());
let new_downstream = Self::refs_to_string(new.downstream.all_ids());
if old_downstream != new_downstream {
changes.push(FieldChange {
field: "downstream".to_string(),
old_value: old_downstream,
new_value: new_downstream,
});
}
changes
}
fn refs_to_string<'a>(refs: impl Iterator<Item = &'a ItemId>) -> String {
let ids: Vec<_> = refs.map(|id| id.as_str()).collect();
ids.join(", ")
}
pub fn is_empty(&self) -> bool {
self.added_items.is_empty()
&& self.removed_items.is_empty()
&& self.modified_items.is_empty()
&& self.added_relationships.is_empty()
&& self.removed_relationships.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::KnowledgeGraphBuilder;
use crate::model::ItemType;
use crate::test_utils::create_test_item_with_name;
#[test]
fn test_no_changes() {
let item = create_test_item_with_name("SOL-001", ItemType::Solution, "Solution");
let old_graph = KnowledgeGraphBuilder::new()
.add_item(item.clone())
.build()
.unwrap();
let new_graph = KnowledgeGraphBuilder::new().add_item(item).build().unwrap();
let diff = GraphDiff::compute(&old_graph, &new_graph);
assert!(diff.is_empty());
}
#[test]
fn test_added_item() {
let old_graph = KnowledgeGraphBuilder::new().build().unwrap();
let new_graph = KnowledgeGraphBuilder::new()
.add_item(create_test_item_with_name(
"SOL-001",
ItemType::Solution,
"Solution",
))
.build()
.unwrap();
let diff = GraphDiff::compute(&old_graph, &new_graph);
assert_eq!(diff.stats.items_added, 1);
assert_eq!(diff.added_items[0].id, "SOL-001");
}
#[test]
fn test_removed_item() {
let old_graph = KnowledgeGraphBuilder::new()
.add_item(create_test_item_with_name(
"SOL-001",
ItemType::Solution,
"Solution",
))
.build()
.unwrap();
let new_graph = KnowledgeGraphBuilder::new().build().unwrap();
let diff = GraphDiff::compute(&old_graph, &new_graph);
assert_eq!(diff.stats.items_removed, 1);
assert_eq!(diff.removed_items[0].id, "SOL-001");
}
#[test]
fn test_modified_item() {
let old_item = create_test_item_with_name("SOL-001", ItemType::Solution, "Old Name");
let new_item = create_test_item_with_name("SOL-001", ItemType::Solution, "New Name");
let old_graph = KnowledgeGraphBuilder::new()
.add_item(old_item)
.build()
.unwrap();
let new_graph = KnowledgeGraphBuilder::new()
.add_item(new_item)
.build()
.unwrap();
let diff = GraphDiff::compute(&old_graph, &new_graph);
assert_eq!(diff.stats.items_modified, 1);
assert_eq!(diff.modified_items[0].id, "SOL-001");
assert!(
diff.modified_items[0]
.changes
.iter()
.any(|c| c.field == "name")
);
}
}