Skip to main content

ontocore_diff/
compare.rs

1use crate::model::{
2    AnnotationChange, AxiomChange, BreakingChange, BreakingReason, DiffResult, EntityChange,
3    EntityChangeKind, ImportChange,
4};
5use ontocore_catalog::{IndexBuilder, OntologyCatalog};
6use ontocore_core::{
7    Annotation, Axiom, Entity, Import, AXIOM_KIND_DOMAIN, AXIOM_KIND_RANGE, AXIOM_KIND_SUB_CLASS_OF,
8};
9use std::collections::{BTreeMap, BTreeSet};
10use std::path::Path;
11use thiserror::Error;
12
13#[derive(Debug, Error)]
14pub enum DiffError {
15    #[error("catalog error: {0}")]
16    Catalog(#[from] ontocore_catalog::CatalogError),
17
18    #[error("{0}")]
19    Message(String),
20}
21
22pub type Result<T> = std::result::Result<T, DiffError>;
23
24pub fn diff_directories(left: &Path, right: &Path) -> Result<DiffResult> {
25    let left_cat = IndexBuilder::new().workspace(left).build()?;
26    let right_cat = IndexBuilder::new().workspace(right).build()?;
27    Ok(diff_catalogs(&left_cat, &right_cat))
28}
29
30pub fn diff_catalogs(base: &OntologyCatalog, head: &OntologyCatalog) -> DiffResult {
31    let mut result = DiffResult::default();
32    diff_entities(base, head, &mut result);
33    diff_annotations(base, head, &mut result);
34    detect_renames(&mut result);
35    diff_axioms(base, head, &mut result);
36    diff_imports(base, head, &mut result);
37    detect_breaking(&mut result);
38    result
39}
40
41fn diff_entities(base: &OntologyCatalog, head: &OntologyCatalog, result: &mut DiffResult) {
42    let base_map: BTreeMap<&str, &Entity> =
43        base.data().entities.iter().map(|e| (e.iri.as_str(), e)).collect();
44    let head_map: BTreeMap<&str, &Entity> =
45        head.data().entities.iter().map(|e| (e.iri.as_str(), e)).collect();
46
47    for (iri, entity) in &head_map {
48        if !base_map.contains_key(iri) {
49            result.entity_changes.push(EntityChange {
50                kind: EntityChangeKind::Added,
51                iri: iri.to_string(),
52                previous_iri: None,
53                labels: entity.labels.clone(),
54            });
55        }
56    }
57
58    for (iri, entity) in &base_map {
59        if !head_map.contains_key(iri) {
60            result.entity_changes.push(EntityChange {
61                kind: EntityChangeKind::Removed,
62                iri: iri.to_string(),
63                previous_iri: None,
64                labels: entity.labels.clone(),
65            });
66        }
67    }
68
69    for (iri, head_entity) in &head_map {
70        if let Some(base_entity) = base_map.get(iri) {
71            if head_entity.deprecated && !base_entity.deprecated {
72                result.entity_changes.push(EntityChange {
73                    kind: EntityChangeKind::Deprecated,
74                    iri: iri.to_string(),
75                    previous_iri: None,
76                    labels: head_entity.labels.clone(),
77                });
78            }
79        }
80    }
81}
82
83fn detect_renames(result: &mut DiffResult) {
84    let added: Vec<EntityChange> = result
85        .entity_changes
86        .iter()
87        .filter(|c| c.kind == EntityChangeKind::Added)
88        .cloned()
89        .collect();
90    let removed: Vec<EntityChange> = result
91        .entity_changes
92        .iter()
93        .filter(|c| c.kind == EntityChangeKind::Removed)
94        .cloned()
95        .collect();
96    let mut drop_removed = BTreeSet::new();
97    let mut drop_added = BTreeSet::new();
98    for r in &removed {
99        for a in &added {
100            if drop_added.contains(&a.iri) {
101                continue;
102            }
103            let label_match = !r.labels.is_empty() && r.labels == a.labels;
104            let sameas = result.annotation_changes.iter().any(|ann| {
105                (ann.predicate.contains("sameAs") || ann.predicate.ends_with("#sameAs"))
106                    && ((ann.subject == r.iri && ann.object == a.iri)
107                        || (ann.subject == a.iri && ann.object == r.iri))
108            });
109            if sameas || label_match {
110                result.entity_changes.push(EntityChange {
111                    kind: EntityChangeKind::Renamed,
112                    iri: a.iri.clone(),
113                    previous_iri: Some(r.iri.clone()),
114                    labels: a.labels.clone(),
115                });
116                drop_removed.insert(r.iri.clone());
117                drop_added.insert(a.iri.clone());
118                break;
119            }
120        }
121    }
122    result.entity_changes.retain(|c| {
123        !((c.kind == EntityChangeKind::Removed && drop_removed.contains(&c.iri))
124            || (c.kind == EntityChangeKind::Added && drop_added.contains(&c.iri)))
125    });
126}
127
128#[derive(Eq, PartialEq, Ord, PartialOrd, Clone)]
129struct AnnotationKey(String, String, String);
130
131fn annotation_key(a: &Annotation) -> AnnotationKey {
132    AnnotationKey(a.subject.clone(), a.predicate.clone(), a.object.clone())
133}
134
135fn diff_annotations(base: &OntologyCatalog, head: &OntologyCatalog, result: &mut DiffResult) {
136    let base_set: BTreeSet<AnnotationKey> =
137        base.data().annotations.iter().map(annotation_key).collect();
138    let head_set: BTreeSet<AnnotationKey> =
139        head.data().annotations.iter().map(annotation_key).collect();
140
141    for ann in &head.data().annotations {
142        if !base_set.contains(&annotation_key(ann)) {
143            result.annotation_changes.push(AnnotationChange {
144                change: "added".to_string(),
145                subject: ann.subject.clone(),
146                predicate: ann.predicate.clone(),
147                object: ann.object.clone(),
148            });
149        }
150    }
151    for ann in &base.data().annotations {
152        if !head_set.contains(&annotation_key(ann)) {
153            result.annotation_changes.push(AnnotationChange {
154                change: "removed".to_string(),
155                subject: ann.subject.clone(),
156                predicate: ann.predicate.clone(),
157                object: ann.object.clone(),
158            });
159        }
160    }
161}
162
163#[derive(Eq, PartialEq, Ord, PartialOrd, Clone)]
164struct AxiomKey(String, String, String, String);
165
166fn axiom_key(a: &Axiom) -> AxiomKey {
167    AxiomKey(a.axiom_kind.clone(), a.subject.clone(), a.predicate.clone(), a.object.clone())
168}
169
170fn diff_axioms(base: &OntologyCatalog, head: &OntologyCatalog, result: &mut DiffResult) {
171    let base_set: BTreeSet<AxiomKey> = base.data().axioms.iter().map(axiom_key).collect();
172    let head_set: BTreeSet<AxiomKey> = head.data().axioms.iter().map(axiom_key).collect();
173
174    for ax in &head.data().axioms {
175        if !base_set.contains(&axiom_key(ax)) {
176            result.axiom_changes.push(AxiomChange {
177                change: "added".to_string(),
178                subject: ax.subject.clone(),
179                predicate: ax.predicate.clone(),
180                object: ax.object.clone(),
181                axiom_kind: ax.axiom_kind.clone(),
182            });
183        }
184    }
185    for ax in &base.data().axioms {
186        if !head_set.contains(&axiom_key(ax)) {
187            result.axiom_changes.push(AxiomChange {
188                change: "removed".to_string(),
189                subject: ax.subject.clone(),
190                predicate: ax.predicate.clone(),
191                object: ax.object.clone(),
192                axiom_kind: ax.axiom_kind.clone(),
193            });
194            if ax.axiom_kind == AXIOM_KIND_SUB_CLASS_OF {
195                result.breaking_changes.push(BreakingChange {
196                    reason: BreakingReason::RemovedSuperclass,
197                    message: format!(
198                        "removed subclass axiom: {} subClassOf {}",
199                        ax.subject, ax.object
200                    ),
201                    entity_iri: Some(ax.subject.clone()),
202                });
203            }
204            if ax.axiom_kind == AXIOM_KIND_DOMAIN || ax.axiom_kind == AXIOM_KIND_RANGE {
205                result.breaking_changes.push(BreakingChange {
206                    reason: BreakingReason::DomainRangeChange,
207                    message: format!("removed {} axiom on {}", ax.axiom_kind, ax.subject),
208                    entity_iri: Some(ax.subject.clone()),
209                });
210            }
211        }
212    }
213}
214
215#[derive(Eq, PartialEq, Ord, PartialOrd, Clone)]
216struct ImportKey(String, String);
217
218fn import_key(i: &Import) -> ImportKey {
219    ImportKey(i.ontology_id.clone(), i.import_iri.clone())
220}
221
222fn diff_imports(base: &OntologyCatalog, head: &OntologyCatalog, result: &mut DiffResult) {
223    let base_set: BTreeSet<ImportKey> = base.data().imports.iter().map(import_key).collect();
224    let head_set: BTreeSet<ImportKey> = head.data().imports.iter().map(import_key).collect();
225
226    for imp in &head.data().imports {
227        if !base_set.contains(&import_key(imp)) {
228            result.import_changes.push(ImportChange {
229                change: "added".to_string(),
230                ontology_id: imp.ontology_id.clone(),
231                import_iri: imp.import_iri.clone(),
232            });
233        }
234    }
235    for imp in &base.data().imports {
236        if !head_set.contains(&import_key(imp)) {
237            result.import_changes.push(ImportChange {
238                change: "removed".to_string(),
239                ontology_id: imp.ontology_id.clone(),
240                import_iri: imp.import_iri.clone(),
241            });
242            result.breaking_changes.push(BreakingChange {
243                reason: BreakingReason::RemovedImport,
244                message: format!("removed import: {}", imp.import_iri),
245                entity_iri: None,
246            });
247        }
248    }
249}
250
251fn detect_breaking(result: &mut DiffResult) {
252    let mut seen = BTreeSet::new();
253    for change in &result.entity_changes {
254        match change.kind {
255            EntityChangeKind::Removed => {
256                let key = format!("removed:{}", change.iri);
257                if seen.insert(key) {
258                    result.breaking_changes.push(BreakingChange {
259                        reason: BreakingReason::RemovedEntity,
260                        message: format!("removed entity: {}", change.iri),
261                        entity_iri: Some(change.iri.clone()),
262                    });
263                }
264            }
265            EntityChangeKind::Renamed => {
266                let key = format!("renamed:{}", change.iri);
267                if seen.insert(key) {
268                    result.breaking_changes.push(BreakingChange {
269                        reason: BreakingReason::RenamedIri,
270                        message: format!(
271                            "renamed entity: {} -> {}",
272                            change.previous_iri.as_deref().unwrap_or("?"),
273                            change.iri
274                        ),
275                        entity_iri: Some(change.iri.clone()),
276                    });
277                }
278            }
279            _ => {}
280        }
281    }
282}
283
284/// Merge unsatisfiability sets into inference + breaking sections.
285pub fn apply_unsat_diff(result: &mut DiffResult, base_unsat: &[String], head_unsat: &[String]) {
286    let base: BTreeSet<&str> = base_unsat.iter().map(String::as_str).collect();
287    let head: BTreeSet<&str> = head_unsat.iter().map(String::as_str).collect();
288    for iri in head.difference(&base) {
289        result.inference_changes.push(crate::model::InferenceChange {
290            class_iri: (*iri).to_string(),
291            change: "became_unsatisfiable".to_string(),
292            detail: "class is unsatisfiable in head but not base".to_string(),
293        });
294        result.breaking_changes.push(BreakingChange {
295            reason: BreakingReason::UnsatisfiableClass,
296            message: format!("class became unsatisfiable: {iri}"),
297            entity_iri: Some((*iri).to_string()),
298        });
299    }
300    for iri in base.difference(&head) {
301        result.inference_changes.push(crate::model::InferenceChange {
302            class_iri: (*iri).to_string(),
303            change: "became_satisfiable".to_string(),
304            detail: "class is satisfiable in head but was unsatisfiable in base".to_string(),
305        });
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use std::path::PathBuf;
313
314    fn fixtures() -> PathBuf {
315        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures")
316    }
317
318    #[test]
319    fn diff_same_directory_is_empty() {
320        let path = fixtures();
321        let cat = IndexBuilder::new().workspace(&path).build().expect("index");
322        let diff = diff_catalogs(&cat, &cat);
323        assert!(diff.is_empty());
324    }
325}