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            // Require a newly added structural identity link (#20 / #389).
104            let sameas = result.annotation_changes.iter().any(|ann| {
105                ann.change == "added"
106                    && is_rename_link_predicate(&ann.predicate)
107                    && ((ann.subject == r.iri && ann.object == a.iri)
108                        || (ann.subject == a.iri && ann.object == r.iri))
109            });
110            if sameas {
111                result.entity_changes.push(EntityChange {
112                    kind: EntityChangeKind::Renamed,
113                    iri: a.iri.clone(),
114                    previous_iri: Some(r.iri.clone()),
115                    labels: a.labels.clone(),
116                });
117                drop_removed.insert(r.iri.clone());
118                drop_added.insert(a.iri.clone());
119                break;
120            }
121        }
122    }
123    result.entity_changes.retain(|c| {
124        !((c.kind == EntityChangeKind::Removed && drop_removed.contains(&c.iri))
125            || (c.kind == EntityChangeKind::Added && drop_added.contains(&c.iri)))
126    });
127}
128
129/// Exact identity-link predicates used for rename detection (#389).
130fn is_rename_link_predicate(pred: &str) -> bool {
131    matches!(
132        pred,
133        "http://www.w3.org/2002/07/owl#sameAs"
134            | "owl:sameAs"
135            | "http://www.w3.org/2004/02/skos/core#exactMatch"
136            | "skos:exactMatch"
137    )
138}
139
140#[derive(Eq, PartialEq, Ord, PartialOrd, Clone)]
141struct AnnotationKey(String, String, String, String);
142
143fn annotation_key(a: &Annotation) -> AnnotationKey {
144    AnnotationKey(a.ontology_id.clone(), a.subject.clone(), a.predicate.clone(), a.object.clone())
145}
146
147fn diff_annotations(base: &OntologyCatalog, head: &OntologyCatalog, result: &mut DiffResult) {
148    let base_set: BTreeSet<AnnotationKey> =
149        base.data().annotations.iter().map(annotation_key).collect();
150    let head_set: BTreeSet<AnnotationKey> =
151        head.data().annotations.iter().map(annotation_key).collect();
152
153    for ann in &head.data().annotations {
154        if !base_set.contains(&annotation_key(ann)) {
155            result.annotation_changes.push(AnnotationChange {
156                change: "added".to_string(),
157                subject: ann.subject.clone(),
158                predicate: ann.predicate.clone(),
159                object: ann.object.clone(),
160            });
161        }
162    }
163    for ann in &base.data().annotations {
164        if !head_set.contains(&annotation_key(ann)) {
165            result.annotation_changes.push(AnnotationChange {
166                change: "removed".to_string(),
167                subject: ann.subject.clone(),
168                predicate: ann.predicate.clone(),
169                object: ann.object.clone(),
170            });
171        }
172    }
173}
174
175#[derive(Eq, PartialEq, Ord, PartialOrd, Clone)]
176struct AxiomKey(String, String, String, String, String);
177
178fn axiom_key(a: &Axiom) -> AxiomKey {
179    AxiomKey(
180        a.ontology_id.clone(),
181        a.axiom_kind.clone(),
182        a.subject.clone(),
183        a.predicate.clone(),
184        a.object.clone(),
185    )
186}
187
188fn diff_axioms(base: &OntologyCatalog, head: &OntologyCatalog, result: &mut DiffResult) {
189    let base_set: BTreeSet<AxiomKey> = base.data().axioms.iter().map(axiom_key).collect();
190    let head_set: BTreeSet<AxiomKey> = head.data().axioms.iter().map(axiom_key).collect();
191
192    for ax in &head.data().axioms {
193        if !base_set.contains(&axiom_key(ax)) {
194            result.axiom_changes.push(AxiomChange {
195                change: "added".to_string(),
196                subject: ax.subject.clone(),
197                predicate: ax.predicate.clone(),
198                object: ax.object.clone(),
199                axiom_kind: ax.axiom_kind.clone(),
200            });
201        }
202    }
203    for ax in &base.data().axioms {
204        if !head_set.contains(&axiom_key(ax)) {
205            result.axiom_changes.push(AxiomChange {
206                change: "removed".to_string(),
207                subject: ax.subject.clone(),
208                predicate: ax.predicate.clone(),
209                object: ax.object.clone(),
210                axiom_kind: ax.axiom_kind.clone(),
211            });
212            if ax.axiom_kind == AXIOM_KIND_SUB_CLASS_OF {
213                result.breaking_changes.push(BreakingChange {
214                    reason: BreakingReason::RemovedSuperclass,
215                    message: format!(
216                        "removed subclass axiom: {} subClassOf {}",
217                        ax.subject, ax.object
218                    ),
219                    entity_iri: Some(ax.subject.clone()),
220                });
221            }
222            if ax.axiom_kind == AXIOM_KIND_DOMAIN || ax.axiom_kind == AXIOM_KIND_RANGE {
223                result.breaking_changes.push(BreakingChange {
224                    reason: BreakingReason::DomainRangeChange,
225                    message: format!("removed {} axiom on {}", ax.axiom_kind, ax.subject),
226                    entity_iri: Some(ax.subject.clone()),
227                });
228            }
229        }
230    }
231}
232
233#[derive(Eq, PartialEq, Ord, PartialOrd, Clone)]
234struct ImportKey(String, String);
235
236fn import_key(i: &Import) -> ImportKey {
237    ImportKey(i.ontology_id.clone(), i.import_iri.clone())
238}
239
240fn diff_imports(base: &OntologyCatalog, head: &OntologyCatalog, result: &mut DiffResult) {
241    let base_set: BTreeSet<ImportKey> = base.data().imports.iter().map(import_key).collect();
242    let head_set: BTreeSet<ImportKey> = head.data().imports.iter().map(import_key).collect();
243
244    for imp in &head.data().imports {
245        if !base_set.contains(&import_key(imp)) {
246            result.import_changes.push(ImportChange {
247                change: "added".to_string(),
248                ontology_id: imp.ontology_id.clone(),
249                import_iri: imp.import_iri.clone(),
250            });
251        }
252    }
253    for imp in &base.data().imports {
254        if !head_set.contains(&import_key(imp)) {
255            result.import_changes.push(ImportChange {
256                change: "removed".to_string(),
257                ontology_id: imp.ontology_id.clone(),
258                import_iri: imp.import_iri.clone(),
259            });
260            result.breaking_changes.push(BreakingChange {
261                reason: BreakingReason::RemovedImport,
262                message: format!("removed import: {}", imp.import_iri),
263                entity_iri: None,
264            });
265        }
266    }
267}
268
269fn detect_breaking(result: &mut DiffResult) {
270    let mut seen = BTreeSet::new();
271    for change in &result.entity_changes {
272        match change.kind {
273            EntityChangeKind::Removed => {
274                let key = format!("removed:{}", change.iri);
275                if seen.insert(key) {
276                    result.breaking_changes.push(BreakingChange {
277                        reason: BreakingReason::RemovedEntity,
278                        message: format!("removed entity: {}", change.iri),
279                        entity_iri: Some(change.iri.clone()),
280                    });
281                }
282            }
283            EntityChangeKind::Renamed => {
284                let key = format!("renamed:{}", change.iri);
285                if seen.insert(key) {
286                    result.breaking_changes.push(BreakingChange {
287                        reason: BreakingReason::RenamedIri,
288                        message: format!(
289                            "renamed entity: {} -> {}",
290                            change.previous_iri.as_deref().unwrap_or("?"),
291                            change.iri
292                        ),
293                        entity_iri: Some(change.iri.clone()),
294                    });
295                }
296            }
297            _ => {}
298        }
299    }
300}
301
302/// Merge unsatisfiability sets into inference + breaking sections.
303pub fn apply_unsat_diff(result: &mut DiffResult, base_unsat: &[String], head_unsat: &[String]) {
304    let base: BTreeSet<&str> = base_unsat.iter().map(String::as_str).collect();
305    let head: BTreeSet<&str> = head_unsat.iter().map(String::as_str).collect();
306    for iri in head.difference(&base) {
307        result.inference_changes.push(crate::model::InferenceChange {
308            class_iri: (*iri).to_string(),
309            change: "became_unsatisfiable".to_string(),
310            detail: "class is unsatisfiable in head but not base".to_string(),
311        });
312        result.breaking_changes.push(BreakingChange {
313            reason: BreakingReason::UnsatisfiableClass,
314            message: format!("class became unsatisfiable: {iri}"),
315            entity_iri: Some((*iri).to_string()),
316        });
317    }
318    for iri in base.difference(&head) {
319        result.inference_changes.push(crate::model::InferenceChange {
320            class_iri: (*iri).to_string(),
321            change: "became_satisfiable".to_string(),
322            detail: "class is satisfiable in head but was unsatisfiable in base".to_string(),
323        });
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use std::path::PathBuf;
331
332    fn fixtures() -> PathBuf {
333        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures")
334    }
335
336    #[test]
337    fn diff_same_directory_is_empty() {
338        let path = fixtures();
339        let cat = IndexBuilder::new().workspace(&path).build().expect("index");
340        let diff = diff_catalogs(&cat, &cat);
341        assert!(diff.is_empty());
342    }
343}