nomograph-sysml-core 0.2.0

SysML v2 knowledge graph library -- parser, graph builder, queries, and rendering
Documentation
use std::collections::{HashMap, HashSet};

use serde::Serialize;

use crate::core_traits::KnowledgeGraph;

use crate::element::SysmlElement;
use crate::graph::SysmlGraph;
use crate::relationship::SysmlRelationship;

#[derive(Debug, Clone, Serialize)]
pub struct DiffResult {
    pub elements_added: Vec<ElementChange>,
    pub elements_removed: Vec<ElementChange>,
    pub elements_modified: Vec<ElementModification>,
    pub relationships_added: Vec<RelationshipChange>,
    pub relationships_removed: Vec<RelationshipChange>,
    pub summary: DiffSummary,
}

#[derive(Debug, Clone, Serialize)]
pub struct ElementChange {
    pub qualified_name: String,
    pub kind: String,
    pub file_path: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct ElementModification {
    pub qualified_name: String,
    pub kind: String,
    pub changes: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct RelationshipChange {
    pub source: String,
    pub target: String,
    pub kind: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct DiffSummary {
    pub elements_added: usize,
    pub elements_removed: usize,
    pub elements_modified: usize,
    pub relationships_added: usize,
    pub relationships_removed: usize,
    pub total_changes: usize,
}

fn element_key(e: &SysmlElement) -> String {
    e.qualified_name.to_lowercase()
}

fn rel_key(r: &SysmlRelationship) -> String {
    format!(
        "{}|{}|{}",
        r.source.to_lowercase(),
        r.kind.to_lowercase(),
        r.target.to_lowercase()
    )
}

pub fn diff_graphs(base: &SysmlGraph, head: &SysmlGraph) -> DiffResult {
    let base_elements: HashMap<String, &SysmlElement> = base
        .elements()
        .iter()
        .map(|e| (element_key(e), e))
        .collect();
    let head_elements: HashMap<String, &SysmlElement> = head
        .elements()
        .iter()
        .map(|e| (element_key(e), e))
        .collect();

    let base_keys: HashSet<&String> = base_elements.keys().collect();
    let head_keys: HashSet<&String> = head_elements.keys().collect();

    let mut elements_added = Vec::new();
    for key in head_keys.difference(&base_keys) {
        if let Some(e) = head_elements.get(*key) {
            elements_added.push(ElementChange {
                qualified_name: e.qualified_name.clone(),
                kind: e.kind.clone(),
                file_path: e.file_path.to_string_lossy().to_string(),
            });
        }
    }
    elements_added.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));

    let mut elements_removed = Vec::new();
    for key in base_keys.difference(&head_keys) {
        if let Some(e) = base_elements.get(*key) {
            elements_removed.push(ElementChange {
                qualified_name: e.qualified_name.clone(),
                kind: e.kind.clone(),
                file_path: e.file_path.to_string_lossy().to_string(),
            });
        }
    }
    elements_removed.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));

    let mut elements_modified = Vec::new();
    for key in base_keys.intersection(&head_keys) {
        if let (Some(base_e), Some(head_e)) = (base_elements.get(*key), head_elements.get(*key)) {
            let mut changes = Vec::new();
            if base_e.kind != head_e.kind {
                changes.push(format!("kind: {} -> {}", base_e.kind, head_e.kind));
            }
            if base_e.doc != head_e.doc {
                changes.push("doc changed".to_string());
            }
            if base_e.layer != head_e.layer {
                changes.push(format!("layer: {:?} -> {:?}", base_e.layer, head_e.layer));
            }
            if base_e.members != head_e.members {
                let base_set: HashSet<&String> = base_e.members.iter().collect();
                let head_set: HashSet<&String> = head_e.members.iter().collect();
                let added: Vec<_> = head_set.difference(&base_set).collect();
                let removed: Vec<_> = base_set.difference(&head_set).collect();
                if !added.is_empty() {
                    changes.push(format!("members added: {}", added.len()));
                }
                if !removed.is_empty() {
                    changes.push(format!("members removed: {}", removed.len()));
                }
            }
            if !changes.is_empty() {
                elements_modified.push(ElementModification {
                    qualified_name: head_e.qualified_name.clone(),
                    kind: head_e.kind.clone(),
                    changes,
                });
            }
        }
    }
    elements_modified.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));

    let base_rels: HashSet<String> = base.relationships().iter().map(rel_key).collect();
    let head_rels: HashSet<String> = head.relationships().iter().map(rel_key).collect();
    let head_rel_map: HashMap<String, &SysmlRelationship> = head
        .relationships()
        .iter()
        .map(|r| (rel_key(r), r))
        .collect();
    let base_rel_map: HashMap<String, &SysmlRelationship> = base
        .relationships()
        .iter()
        .map(|r| (rel_key(r), r))
        .collect();

    let mut relationships_added: Vec<RelationshipChange> = head_rels
        .difference(&base_rels)
        .filter_map(|k| head_rel_map.get(k))
        .map(|r| RelationshipChange {
            source: r.source.clone(),
            target: r.target.clone(),
            kind: r.kind.clone(),
        })
        .collect();
    relationships_added.sort_by(|a, b| a.source.cmp(&b.source).then(a.kind.cmp(&b.kind)));

    let mut relationships_removed: Vec<RelationshipChange> = base_rels
        .difference(&head_rels)
        .filter_map(|k| base_rel_map.get(k))
        .map(|r| RelationshipChange {
            source: r.source.clone(),
            target: r.target.clone(),
            kind: r.kind.clone(),
        })
        .collect();
    relationships_removed.sort_by(|a, b| a.source.cmp(&b.source).then(a.kind.cmp(&b.kind)));

    let total = elements_added.len()
        + elements_removed.len()
        + elements_modified.len()
        + relationships_added.len()
        + relationships_removed.len();

    let summary = DiffSummary {
        elements_added: elements_added.len(),
        elements_removed: elements_removed.len(),
        elements_modified: elements_modified.len(),
        relationships_added: relationships_added.len(),
        relationships_removed: relationships_removed.len(),
        total_changes: total,
    };

    DiffResult {
        elements_added,
        elements_removed,
        elements_modified,
        relationships_added,
        relationships_removed,
        summary,
    }
}

pub fn format_compact(result: &DiffResult) -> Vec<String> {
    let mut lines = Vec::new();
    for e in &result.elements_added {
        lines.push(format!("+ {} ({})", e.qualified_name, e.kind));
    }
    for e in &result.elements_removed {
        lines.push(format!("- {} ({})", e.qualified_name, e.kind));
    }
    for e in &result.elements_modified {
        lines.push(format!("~ {} [{}]", e.qualified_name, e.changes.join(", ")));
    }
    for r in &result.relationships_added {
        lines.push(format!("+ {} -> {} -> {}", r.source, r.kind, r.target));
    }
    for r in &result.relationships_removed {
        lines.push(format!("- {} -> {} -> {}", r.source, r.kind, r.target));
    }
    lines
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::graph::SysmlGraph;
    use crate::core_types::ParseResult;
    use std::path::PathBuf;

    fn make_element(name: &str, kind: &str) -> SysmlElement {
        SysmlElement {
            qualified_name: name.to_string(),
            kind: kind.to_string(),
            file_path: PathBuf::from("test.sysml"),
            span: crate::core_types::Span {
                start_line: 0,
                start_col: 0,
                end_line: 0,
                end_col: 0,
            },
            doc: None,
            attributes: Vec::new(),
            members: Vec::new(),
            layer: None,
        }
    }

    fn make_rel(source: &str, kind: &str, target: &str) -> SysmlRelationship {
        SysmlRelationship {
            source: source.to_string(),
            target: target.to_string(),
            kind: kind.to_string(),
            file_path: PathBuf::from("test.sysml"),
            span: crate::core_types::Span {
                start_line: 0,
                start_col: 0,
                end_line: 0,
                end_col: 0,
            },
        }
    }

    fn build_graph(
        elements: Vec<SysmlElement>,
        relationships: Vec<SysmlRelationship>,
    ) -> SysmlGraph {
        let mut graph = SysmlGraph::new();
        let result = ParseResult {
            elements,
            relationships,
            diagnostics: Vec::new(),
        };
        graph.index(vec![result]).unwrap();
        graph
    }

    #[test]
    fn test_diff_no_changes() {
        let base = build_graph(vec![make_element("A", "part_usage")], vec![]);
        let head = build_graph(vec![make_element("A", "part_usage")], vec![]);
        let result = diff_graphs(&base, &head);
        assert_eq!(result.summary.total_changes, 0);
    }

    #[test]
    fn test_diff_element_added() {
        let base = build_graph(vec![make_element("A", "part_usage")], vec![]);
        let head = build_graph(
            vec![
                make_element("A", "part_usage"),
                make_element("B", "requirement_usage"),
            ],
            vec![],
        );
        let result = diff_graphs(&base, &head);
        assert_eq!(result.summary.elements_added, 1);
        assert_eq!(result.elements_added[0].qualified_name, "B");
    }

    #[test]
    fn test_diff_element_removed() {
        let base = build_graph(
            vec![
                make_element("A", "part_usage"),
                make_element("B", "requirement_usage"),
            ],
            vec![],
        );
        let head = build_graph(vec![make_element("A", "part_usage")], vec![]);
        let result = diff_graphs(&base, &head);
        assert_eq!(result.summary.elements_removed, 1);
        assert_eq!(result.elements_removed[0].qualified_name, "B");
    }

    #[test]
    fn test_diff_element_modified() {
        let mut e = make_element("A", "part_usage");
        e.doc = Some("old doc".to_string());
        let base = build_graph(vec![e], vec![]);

        let mut e2 = make_element("A", "part_usage");
        e2.doc = Some("new doc".to_string());
        let head = build_graph(vec![e2], vec![]);

        let result = diff_graphs(&base, &head);
        assert_eq!(result.summary.elements_modified, 1);
        assert!(result.elements_modified[0]
            .changes
            .contains(&"doc changed".to_string()));
    }

    #[test]
    fn test_diff_relationship_added() {
        let base = build_graph(vec![make_element("A", "part_usage")], vec![]);
        let head = build_graph(
            vec![make_element("A", "part_usage")],
            vec![make_rel("A", "Satisfy", "B")],
        );
        let result = diff_graphs(&base, &head);
        assert_eq!(result.summary.relationships_added, 1);
    }

    #[test]
    fn test_diff_relationship_removed() {
        let base = build_graph(
            vec![make_element("A", "part_usage")],
            vec![make_rel("A", "Satisfy", "B")],
        );
        let head = build_graph(vec![make_element("A", "part_usage")], vec![]);
        let result = diff_graphs(&base, &head);
        assert_eq!(result.summary.relationships_removed, 1);
    }

    #[test]
    fn test_compact_format() {
        let base = build_graph(vec![make_element("A", "part_usage")], vec![]);
        let head = build_graph(
            vec![
                make_element("A", "part_usage"),
                make_element("B", "requirement_usage"),
            ],
            vec![make_rel("A", "Satisfy", "B")],
        );
        let result = diff_graphs(&base, &head);
        let lines = format_compact(&result);
        assert!(lines.iter().any(|l| l.starts_with("+ B")));
        assert!(lines.iter().any(|l| l.contains("Satisfy")));
    }
}