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 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
129fn 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
302pub 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}