ontocore-diff 0.14.0

Semantic ontology diff for OntoCore
Documentation
use crate::model::DiffResult;

pub fn format_diff_text(diff: &DiffResult, breaking_only: bool) -> String {
    let mut out = String::new();
    if !breaking_only {
        out.push_str(&format!(
            "Summary: {} entity, {} axiom, {} annotation, {} import, {} inference, {} breaking\n\n",
            diff.entity_changes.len(),
            diff.axiom_changes.len(),
            diff.annotation_changes.len(),
            diff.import_changes.len(),
            diff.inference_changes.len(),
            diff.breaking_changes.len(),
        ));
    }
    if !diff.breaking_changes.is_empty() {
        out.push_str("Breaking changes:\n");
        for b in &diff.breaking_changes {
            out.push_str(&format!("  - [{}] {}\n", reason_label(b.reason), b.message));
        }
        out.push('\n');
    }
    if breaking_only {
        return out;
    }
    if !diff.entity_changes.is_empty() {
        out.push_str("Entity changes:\n");
        for e in &diff.entity_changes {
            out.push_str(&format!("  - {:?} {}\n", e.kind, e.iri));
        }
        out.push('\n');
    }
    if !diff.axiom_changes.is_empty() {
        out.push_str("Axiom changes:\n");
        for a in &diff.axiom_changes {
            out.push_str(&format!(
                "  - {} {} {} {} {}\n",
                a.change, a.axiom_kind, a.subject, a.predicate, a.object
            ));
        }
        out.push('\n');
    }
    if !diff.annotation_changes.is_empty() {
        out.push_str("Annotation changes:\n");
        for a in &diff.annotation_changes {
            out.push_str(&format!("  - {} {} {} {}\n", a.change, a.subject, a.predicate, a.object));
        }
        out.push('\n');
    }
    if !diff.import_changes.is_empty() {
        out.push_str("Import changes:\n");
        for i in &diff.import_changes {
            out.push_str(&format!("  - {} {}\n", i.change, i.import_iri));
        }
        out.push('\n');
    }
    out
}

pub fn format_diff_markdown(diff: &DiffResult, breaking_only: bool) -> String {
    let mut out = String::from("# Ontology semantic diff\n\n");
    if !diff.breaking_changes.is_empty() {
        out.push_str("## Breaking changes\n\n");
        for b in &diff.breaking_changes {
            out.push_str(&format!("- **{}**: {}\n", reason_label(b.reason), b.message));
        }
        out.push('\n');
    }
    if breaking_only {
        return out;
    }
    out.push_str(&format!(
        "## Summary\n\n| Category | Count |\n|---|---|\n| Entities | {} |\n| Axioms | {} |\n| Annotations | {} |\n| Imports | {} |\n| Inferences | {} |\n| Breaking | {} |\n\n",
        diff.entity_changes.len(),
        diff.axiom_changes.len(),
        diff.annotation_changes.len(),
        diff.import_changes.len(),
        diff.inference_changes.len(),
        diff.breaking_changes.len(),
    ));
    out
}

pub fn format_diff_pr_summary(diff: &DiffResult) -> String {
    let mut out = String::from("## Ontology changes\n\n");
    if !diff.breaking_changes.is_empty() {
        out.push_str("> **Breaking changes detected** — review carefully before merge.\n\n");
        for b in &diff.breaking_changes {
            out.push_str(&format!("- **{}**: {}\n", reason_label(b.reason), b.message));
        }
        out.push('\n');
    }
    let counts = format!(
        "| Entities | Axioms | Annotations | Imports | Inferences |\n|---|---|---|---|---|\n| {} | {} | {} | {} | {} |\n\n",
        diff.entity_changes.len(),
        diff.axiom_changes.len(),
        diff.annotation_changes.len(),
        diff.import_changes.len(),
        diff.inference_changes.len(),
    );
    out.push_str(&counts);

    if !diff.entity_changes.is_empty() {
        out.push_str("### Entities\n\n");
        for e in diff.entity_changes.iter().take(50) {
            out.push_str(&format!("- `{:?}` {}\n", e.kind, e.iri));
        }
        if diff.entity_changes.len() > 50 {
            out.push_str(&format!(
                "\n_…and {} more entity changes_\n",
                diff.entity_changes.len() - 50
            ));
        }
        out.push('\n');
    }
    if !diff.axiom_changes.is_empty() {
        out.push_str("### Axioms\n\n");
        for a in diff.axiom_changes.iter().take(30) {
            out.push_str(&format!(
                "- **{}** `{}` — {} {} {}\n",
                a.change, a.axiom_kind, a.subject, a.predicate, a.object
            ));
        }
        if diff.axiom_changes.len() > 30 {
            out.push_str(&format!(
                "\n_…and {} more axiom changes_\n",
                diff.axiom_changes.len() - 30
            ));
        }
        out.push('\n');
    }
    if !diff.import_changes.is_empty() {
        out.push_str("### Imports\n\n");
        for i in &diff.import_changes {
            out.push_str(&format!("- **{}** {}\n", i.change, i.import_iri));
        }
        out.push('\n');
    }
    out.push_str("---\n_Generated by `ontocore diff --pr-summary`_\n");
    out
}

pub fn format_diff_json(diff: &DiffResult) -> String {
    serde_json::to_string_pretty(diff).unwrap_or_else(|_| "{}".to_string())
}

fn reason_label(reason: crate::model::BreakingReason) -> &'static str {
    use crate::model::BreakingReason::*;
    match reason {
        RemovedEntity => "removed_entity",
        RenamedIri => "renamed_iri",
        RemovedSuperclass => "removed_superclass",
        RemovedImport => "removed_import",
        UnsatisfiableClass => "unsatisfiable_class",
        DomainRangeChange => "domain_range_change",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{AxiomChange, BreakingChange, DiffResult, EntityChange, EntityChangeKind};

    #[test]
    fn pr_summary_includes_breaking_callout() {
        let diff = DiffResult {
            entity_changes: vec![EntityChange {
                iri: "http://ex#A".into(),
                kind: EntityChangeKind::Removed,
                previous_iri: None,
                labels: vec![],
            }],
            axiom_changes: vec![],
            annotation_changes: vec![],
            import_changes: vec![],
            inference_changes: vec![],
            breaking_changes: vec![BreakingChange {
                reason: crate::model::BreakingReason::RemovedEntity,
                message: "class removed".into(),
                entity_iri: Some("http://ex#A".into()),
            }],
        };
        let md = format_diff_pr_summary(&diff);
        assert!(md.contains("Breaking changes"));
        assert!(md.contains("http://ex#A"));
        assert!(md.contains("ontocore diff --pr-summary"));
    }

    #[test]
    fn pr_summary_lists_axiom_rows() {
        let diff = DiffResult {
            entity_changes: vec![],
            axiom_changes: vec![AxiomChange {
                change: "added".into(),
                axiom_kind: "SubClassOf".into(),
                subject: "http://ex#Child".into(),
                predicate: "rdfs:subClassOf".into(),
                object: "http://ex#Parent".into(),
            }],
            annotation_changes: vec![],
            import_changes: vec![],
            inference_changes: vec![],
            breaking_changes: vec![],
        };
        let md = format_diff_pr_summary(&diff);
        assert!(md.contains("### Axioms"));
        assert!(md.contains("SubClassOf"));
    }
}