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
284pub 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}