Skip to main content

ontocore_diff/
format.rs

1use crate::model::DiffResult;
2
3pub fn format_diff_text(diff: &DiffResult, breaking_only: bool) -> String {
4    let mut out = String::new();
5    if !breaking_only {
6        out.push_str(&format!(
7            "Summary: {} entity, {} axiom, {} annotation, {} import, {} inference, {} breaking\n\n",
8            diff.entity_changes.len(),
9            diff.axiom_changes.len(),
10            diff.annotation_changes.len(),
11            diff.import_changes.len(),
12            diff.inference_changes.len(),
13            diff.breaking_changes.len(),
14        ));
15    }
16    if !diff.breaking_changes.is_empty() {
17        out.push_str("Breaking changes:\n");
18        for b in &diff.breaking_changes {
19            out.push_str(&format!("  - [{}] {}\n", reason_label(b.reason), b.message));
20        }
21        out.push('\n');
22    }
23    if breaking_only {
24        return out;
25    }
26    if !diff.entity_changes.is_empty() {
27        out.push_str("Entity changes:\n");
28        for e in &diff.entity_changes {
29            out.push_str(&format!("  - {:?} {}\n", e.kind, e.iri));
30        }
31        out.push('\n');
32    }
33    if !diff.axiom_changes.is_empty() {
34        out.push_str("Axiom changes:\n");
35        for a in &diff.axiom_changes {
36            out.push_str(&format!(
37                "  - {} {} {} {} {}\n",
38                a.change, a.axiom_kind, a.subject, a.predicate, a.object
39            ));
40        }
41        out.push('\n');
42    }
43    if !diff.annotation_changes.is_empty() {
44        out.push_str("Annotation changes:\n");
45        for a in &diff.annotation_changes {
46            out.push_str(&format!("  - {} {} {} {}\n", a.change, a.subject, a.predicate, a.object));
47        }
48        out.push('\n');
49    }
50    if !diff.import_changes.is_empty() {
51        out.push_str("Import changes:\n");
52        for i in &diff.import_changes {
53            out.push_str(&format!("  - {} {}\n", i.change, i.import_iri));
54        }
55        out.push('\n');
56    }
57    out
58}
59
60pub fn format_diff_markdown(diff: &DiffResult, breaking_only: bool) -> String {
61    let mut out = String::from("# Ontology semantic diff\n\n");
62    if !diff.breaking_changes.is_empty() {
63        out.push_str("## Breaking changes\n\n");
64        for b in &diff.breaking_changes {
65            out.push_str(&format!("- **{}**: {}\n", reason_label(b.reason), b.message));
66        }
67        out.push('\n');
68    }
69    if breaking_only {
70        return out;
71    }
72    out.push_str(&format!(
73        "## Summary\n\n| Category | Count |\n|---|---|\n| Entities | {} |\n| Axioms | {} |\n| Annotations | {} |\n| Imports | {} |\n| Inferences | {} |\n| Breaking | {} |\n\n",
74        diff.entity_changes.len(),
75        diff.axiom_changes.len(),
76        diff.annotation_changes.len(),
77        diff.import_changes.len(),
78        diff.inference_changes.len(),
79        diff.breaking_changes.len(),
80    ));
81    if !diff.entity_changes.is_empty() {
82        out.push_str("## Entity changes\n\n");
83        for e in &diff.entity_changes {
84            match &e.previous_iri {
85                Some(prev) => {
86                    out.push_str(&format!("- **{:?}** `{}` ← `{}`\n", e.kind, e.iri, prev))
87                }
88                None => out.push_str(&format!("- **{:?}** `{}`\n", e.kind, e.iri)),
89            }
90        }
91        out.push('\n');
92    }
93    if !diff.axiom_changes.is_empty() {
94        out.push_str("## Axiom changes\n\n");
95        for a in &diff.axiom_changes {
96            out.push_str(&format!(
97                "- **{}** `{}` — {} {} {}\n",
98                a.change, a.axiom_kind, a.subject, a.predicate, a.object
99            ));
100        }
101        out.push('\n');
102    }
103    if !diff.annotation_changes.is_empty() {
104        out.push_str("## Annotation changes\n\n");
105        for a in &diff.annotation_changes {
106            out.push_str(&format!(
107                "- **{}** `{}` {} {}\n",
108                a.change, a.subject, a.predicate, a.object
109            ));
110        }
111        out.push('\n');
112    }
113    if !diff.import_changes.is_empty() {
114        out.push_str("## Import changes\n\n");
115        for i in &diff.import_changes {
116            out.push_str(&format!("- **{}** {}\n", i.change, i.import_iri));
117        }
118        out.push('\n');
119    }
120    if !diff.inference_changes.is_empty() {
121        out.push_str("## Inference changes\n\n");
122        for i in &diff.inference_changes {
123            out.push_str(&format!("- **{}** `{}` — {}\n", i.change, i.class_iri, i.detail));
124        }
125        out.push('\n');
126    }
127    out
128}
129
130pub fn format_diff_pr_summary(diff: &DiffResult) -> String {
131    let mut out = String::from("## Ontology changes\n\n");
132    if !diff.breaking_changes.is_empty() {
133        out.push_str("> **Breaking changes detected** — review carefully before merge.\n\n");
134        for b in &diff.breaking_changes {
135            out.push_str(&format!("- **{}**: {}\n", reason_label(b.reason), b.message));
136        }
137        out.push('\n');
138    }
139    let counts = format!(
140        "| Entities | Axioms | Annotations | Imports | Inferences |\n|---|---|---|---|---|\n| {} | {} | {} | {} | {} |\n\n",
141        diff.entity_changes.len(),
142        diff.axiom_changes.len(),
143        diff.annotation_changes.len(),
144        diff.import_changes.len(),
145        diff.inference_changes.len(),
146    );
147    out.push_str(&counts);
148
149    if !diff.entity_changes.is_empty() {
150        out.push_str("### Entities\n\n");
151        for e in diff.entity_changes.iter().take(50) {
152            out.push_str(&format!("- `{:?}` {}\n", e.kind, e.iri));
153        }
154        if diff.entity_changes.len() > 50 {
155            out.push_str(&format!(
156                "\n_…and {} more entity changes_\n",
157                diff.entity_changes.len() - 50
158            ));
159        }
160        out.push('\n');
161    }
162    if !diff.axiom_changes.is_empty() {
163        out.push_str("### Axioms\n\n");
164        for a in diff.axiom_changes.iter().take(30) {
165            out.push_str(&format!(
166                "- **{}** `{}` — {} {} {}\n",
167                a.change, a.axiom_kind, a.subject, a.predicate, a.object
168            ));
169        }
170        if diff.axiom_changes.len() > 30 {
171            out.push_str(&format!(
172                "\n_…and {} more axiom changes_\n",
173                diff.axiom_changes.len() - 30
174            ));
175        }
176        out.push('\n');
177    }
178    if !diff.import_changes.is_empty() {
179        out.push_str("### Imports\n\n");
180        for i in &diff.import_changes {
181            out.push_str(&format!("- **{}** {}\n", i.change, i.import_iri));
182        }
183        out.push('\n');
184    }
185    out.push_str("---\n_Generated by `ontocore diff --pr-summary`_\n");
186    out
187}
188
189pub fn format_diff_json(diff: &DiffResult) -> String {
190    serde_json::to_string_pretty(diff).unwrap_or_else(|_| "{}".to_string())
191}
192
193fn reason_label(reason: crate::model::BreakingReason) -> &'static str {
194    use crate::model::BreakingReason::*;
195    match reason {
196        RemovedEntity => "removed_entity",
197        RenamedIri => "renamed_iri",
198        RemovedSuperclass => "removed_superclass",
199        RemovedImport => "removed_import",
200        UnsatisfiableClass => "unsatisfiable_class",
201        DomainRangeChange => "domain_range_change",
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::model::{AxiomChange, BreakingChange, DiffResult, EntityChange, EntityChangeKind};
209
210    #[test]
211    fn pr_summary_includes_breaking_callout() {
212        let diff = DiffResult {
213            entity_changes: vec![EntityChange {
214                iri: "http://ex#A".into(),
215                kind: EntityChangeKind::Removed,
216                previous_iri: None,
217                labels: vec![],
218            }],
219            axiom_changes: vec![],
220            annotation_changes: vec![],
221            import_changes: vec![],
222            inference_changes: vec![],
223            breaking_changes: vec![BreakingChange {
224                reason: crate::model::BreakingReason::RemovedEntity,
225                message: "class removed".into(),
226                entity_iri: Some("http://ex#A".into()),
227            }],
228        };
229        let md = format_diff_pr_summary(&diff);
230        assert!(md.contains("Breaking changes"));
231        assert!(md.contains("http://ex#A"));
232        assert!(md.contains("ontocore diff --pr-summary"));
233    }
234
235    #[test]
236    fn pr_summary_lists_axiom_rows() {
237        let diff = DiffResult {
238            entity_changes: vec![],
239            axiom_changes: vec![AxiomChange {
240                change: "added".into(),
241                axiom_kind: "SubClassOf".into(),
242                subject: "http://ex#Child".into(),
243                predicate: "rdfs:subClassOf".into(),
244                object: "http://ex#Parent".into(),
245            }],
246            annotation_changes: vec![],
247            import_changes: vec![],
248            inference_changes: vec![],
249            breaking_changes: vec![],
250        };
251        let md = format_diff_pr_summary(&diff);
252        assert!(md.contains("### Axioms"));
253        assert!(md.contains("SubClassOf"));
254    }
255
256    #[test]
257    fn markdown_includes_entity_and_axiom_sections() {
258        let diff = DiffResult {
259            entity_changes: vec![EntityChange {
260                iri: "http://ex#A".into(),
261                kind: EntityChangeKind::Added,
262                previous_iri: None,
263                labels: vec![],
264            }],
265            axiom_changes: vec![AxiomChange {
266                change: "added".into(),
267                axiom_kind: "SubClassOf".into(),
268                subject: "http://ex#Child".into(),
269                predicate: "rdfs:subClassOf".into(),
270                object: "http://ex#Parent".into(),
271            }],
272            annotation_changes: vec![],
273            import_changes: vec![],
274            inference_changes: vec![],
275            breaking_changes: vec![],
276        };
277        let md = format_diff_markdown(&diff, false);
278        assert!(md.contains("## Entity changes"));
279        assert!(md.contains("http://ex#A"));
280        assert!(md.contains("## Axiom changes"));
281        assert!(md.contains("SubClassOf"));
282    }
283}