1use crate::OntologyCatalog;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::PathBuf;
5use strixonomy_core::{
6 document_for_entity, read_to_string_capped, Entity, EntityKind, PropertyCharacteristics,
7 AXIOM_KIND_CLASS_ASSERTION, AXIOM_KIND_DATATYPE_DEFINITION, AXIOM_KIND_DATA_PROPERTY_ASSERTION,
8 AXIOM_KIND_DIFFERENT_INDIVIDUALS, AXIOM_KIND_DISJOINT_CLASS,
9 AXIOM_KIND_DISJOINT_DATA_PROPERTIES, AXIOM_KIND_DISJOINT_OBJECT_PROPERTIES,
10 AXIOM_KIND_DISJOINT_UNION, AXIOM_KIND_DOMAIN, AXIOM_KIND_EQUIVALENT_CLASS,
11 AXIOM_KIND_EQUIVALENT_DATA_PROPERTIES, AXIOM_KIND_EQUIVALENT_OBJECT_PROPERTIES,
12 AXIOM_KIND_HAS_KEY, AXIOM_KIND_INVERSE_OBJECT_PROPERTIES,
13 AXIOM_KIND_NEGATIVE_DATA_PROPERTY_ASSERTION, AXIOM_KIND_NEGATIVE_OBJECT_PROPERTY_ASSERTION,
14 AXIOM_KIND_OBJECT_PROPERTY_ASSERTION, AXIOM_KIND_PROPERTY_CHAIN, AXIOM_KIND_RANGE,
15 AXIOM_KIND_SAME_INDIVIDUAL, AXIOM_KIND_SUB_CLASS_OF, AXIOM_KIND_SUB_DATA_PROPERTY_OF,
16 AXIOM_KIND_SUB_OBJECT_PROPERTY_OF, MAX_FILE_BYTES,
17};
18use strixonomy_diagnostics::{entity_needles, find_in_source};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SubclassEdge {
22 pub child: String,
23 pub parent: String,
24}
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct ClassHierarchy {
28 pub edges: Vec<SubclassEdge>,
29 pub parents: BTreeMap<String, Vec<String>>,
30 pub children: BTreeMap<String, Vec<String>>,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct SourceHint {
35 pub path: PathBuf,
36 pub line: u64,
37 pub column: u64,
38}
39
40#[derive(Debug, Clone, Serialize)]
41pub struct EntityAxiomSummary {
42 pub kind: String,
43 pub display: String,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub manchester: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub parent_iri: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub other_iri: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub predicate: Option<String>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub properties: Vec<String>,
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub annotations: Vec<EntityAnnotationSummary>,
59 pub editable: bool,
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct EntityAnnotationSummary {
64 pub predicate: String,
65 pub value: String,
66 pub editable: bool,
67}
68
69#[derive(Debug, Clone, Serialize)]
70pub struct EntityDetail {
71 pub entity: Entity,
72 pub parents: Vec<String>,
73 pub children: Vec<String>,
74 pub axioms: Vec<EntityAxiomSummary>,
75 pub annotations: Vec<EntityAnnotationSummary>,
76 #[serde(skip_serializing_if = "PropertyCharacteristics::is_empty")]
77 pub characteristics: PropertyCharacteristics,
78 pub source: Option<SourceHint>,
79 pub editable: bool,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub document_path: Option<String>,
82}
83
84impl OntologyCatalog {
85 pub fn find_entity(&self, iri: &str) -> Option<&Entity> {
86 self.data().entities.iter().find(|e| e.iri == iri)
87 }
88
89 pub fn entity_document(&self, iri: &str) -> Option<&strixonomy_core::OntologyDocument> {
90 if let Some(&doc_idx) = self.entity_to_document.get(iri) {
91 return self.data().documents.get(doc_idx);
92 }
93
94 let entity = self.find_entity(iri)?;
95 document_for_entity(&self.data().documents, entity)
96 }
97
98 pub fn class_hierarchy(&self) -> ClassHierarchy {
99 let mut edges = Vec::new();
100 let mut parents: BTreeMap<String, Vec<String>> = BTreeMap::new();
101 let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
102
103 let class_iris: BTreeSet<&str> = self
104 .data()
105 .entities
106 .iter()
107 .filter(|e| e.kind == EntityKind::Class)
108 .map(|e| e.iri.as_str())
109 .collect();
110
111 for axiom in &self.data().axioms {
112 if axiom.axiom_kind != AXIOM_KIND_SUB_CLASS_OF {
113 continue;
114 }
115 if !class_iris.contains(axiom.subject.as_str()) {
118 continue;
119 }
120 let edge = SubclassEdge { child: axiom.subject.clone(), parent: axiom.object.clone() };
121 edges.push(edge.clone());
122 parents.entry(edge.child.clone()).or_default().push(edge.parent.clone());
123 children.entry(edge.parent.clone()).or_default().push(edge.child.clone());
124 }
125
126 for list in parents.values_mut().chain(children.values_mut()) {
127 list.sort();
128 list.dedup();
129 }
130
131 ClassHierarchy { edges, parents, children }
132 }
133
134 pub fn entity_detail(&self, iri: &str) -> Option<EntityDetail> {
135 let hierarchy = self.class_hierarchy();
136 self.entity_detail_with_hierarchy(iri, &hierarchy)
137 }
138
139 pub fn entity_detail_with_hierarchy(
140 &self,
141 iri: &str,
142 hierarchy: &ClassHierarchy,
143 ) -> Option<EntityDetail> {
144 let entity = self.find_entity(iri)?.clone();
145
146 let parents = hierarchy.parents.get(iri).cloned().unwrap_or_default();
147 let children = hierarchy.children.get(iri).cloned().unwrap_or_default();
148
149 let source = self.find_source_location(iri);
150 let doc = self.entity_document(iri);
151 let editable = doc.is_some_and(|d| {
152 matches!(
153 d.format,
154 strixonomy_core::OntologyFormat::Turtle
155 | strixonomy_core::OntologyFormat::Obo
156 | strixonomy_core::OntologyFormat::Owl
157 | strixonomy_core::OntologyFormat::RdfXml
158 | strixonomy_core::OntologyFormat::OwlXml
159 ) && d.parse_status == strixonomy_core::ParseStatus::Ok
160 });
161 let document_path = doc.map(|d| d.path.display().to_string());
162
163 let axioms: Vec<EntityAxiomSummary> = self
164 .data()
165 .axioms
166 .iter()
167 .filter(|a| a.subject == iri)
168 .map(|a| axiom_summary(a, editable))
169 .collect();
170
171 const PROMOTED: &[&str] = &[
172 "http://www.w3.org/2000/01/rdf-schema#label",
173 "http://www.w3.org/2000/01/rdf-schema#comment",
174 "http://www.w3.org/2002/07/owl#deprecated",
175 ];
176 let annotations: Vec<EntityAnnotationSummary> = self
177 .data()
178 .annotations
179 .iter()
180 .filter(|a| a.subject == iri && !PROMOTED.contains(&a.predicate.as_str()))
181 .map(|a| EntityAnnotationSummary {
182 predicate: a.predicate.clone(),
183 value: a.object.clone(),
184 editable,
185 })
186 .collect();
187
188 Some(EntityDetail {
189 entity: entity.clone(),
190 parents,
191 children,
192 axioms,
193 annotations,
194 characteristics: entity.characteristics.clone(),
195 source,
196 editable,
197 document_path,
198 })
199 }
200
201 pub fn find_source_location(&self, iri: &str) -> Option<SourceHint> {
202 let entity = self.find_entity(iri)?;
203 let doc = self.entity_document(iri)?;
204
205 if let Some(loc) = entity.source_location.line {
206 return Some(SourceHint {
207 path: doc.path.clone(),
208 line: loc,
209 column: entity.source_location.column.unwrap_or(0),
210 });
211 }
212
213 scan_file_for_iri(&doc.path, iri, &entity.short_name, &doc.namespaces)
214 }
215
216 pub fn entities_in_document(&self, doc_path: &std::path::Path) -> Vec<&Entity> {
217 let doc_path = doc_path.canonicalize().unwrap_or_else(|_| doc_path.to_path_buf());
218 let Some(doc_idx) = self
219 .data()
220 .documents
221 .iter()
222 .position(|d| d.path.canonicalize().unwrap_or_else(|_| d.path.clone()) == doc_path)
223 else {
224 return Vec::new();
225 };
226 self.document_entity_iris
227 .get(doc_idx)
228 .into_iter()
229 .flatten()
230 .filter_map(|iri| self.find_entity(iri))
231 .collect()
232 }
233}
234
235fn axiom_summary(a: &strixonomy_core::Axiom, editable: bool) -> EntityAxiomSummary {
236 let is_named_iri = a.object.starts_with("http://") || a.object.starts_with("https://");
237 let manchester = if is_named_iri
238 || a.axiom_kind == AXIOM_KIND_HAS_KEY
239 || a.axiom_kind == AXIOM_KIND_DISJOINT_UNION
240 || a.axiom_kind == AXIOM_KIND_PROPERTY_CHAIN
241 {
242 None
243 } else if a.axiom_kind == AXIOM_KIND_DATATYPE_DEFINITION
244 || (!is_named_iri && !a.object.is_empty())
245 {
246 Some(a.object.clone())
247 } else {
248 None
249 };
250 let parent_iri = if (a.axiom_kind == AXIOM_KIND_SUB_CLASS_OF
251 || a.axiom_kind == AXIOM_KIND_CLASS_ASSERTION
252 || a.axiom_kind == AXIOM_KIND_SUB_OBJECT_PROPERTY_OF
253 || a.axiom_kind == AXIOM_KIND_SUB_DATA_PROPERTY_OF
254 || a.axiom_kind == AXIOM_KIND_DOMAIN
255 || a.axiom_kind == AXIOM_KIND_RANGE)
256 && is_named_iri
257 {
258 Some(a.object.clone())
259 } else {
260 None
261 };
262 let other_iri = if matches!(
263 a.axiom_kind.as_str(),
264 AXIOM_KIND_DISJOINT_CLASS
265 | AXIOM_KIND_INVERSE_OBJECT_PROPERTIES
266 | AXIOM_KIND_EQUIVALENT_OBJECT_PROPERTIES
267 | AXIOM_KIND_DISJOINT_OBJECT_PROPERTIES
268 | AXIOM_KIND_EQUIVALENT_DATA_PROPERTIES
269 | AXIOM_KIND_DISJOINT_DATA_PROPERTIES
270 | AXIOM_KIND_SAME_INDIVIDUAL
271 | AXIOM_KIND_DIFFERENT_INDIVIDUALS
272 | AXIOM_KIND_EQUIVALENT_CLASS
273 | AXIOM_KIND_NEGATIVE_OBJECT_PROPERTY_ASSERTION
274 | AXIOM_KIND_OBJECT_PROPERTY_ASSERTION
275 ) && is_named_iri
276 {
277 Some(a.object.clone())
278 } else {
279 None
280 };
281 let properties = if a.axiom_kind == AXIOM_KIND_PROPERTY_CHAIN {
282 a.object.split(" o ").map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
283 } else if a.axiom_kind == AXIOM_KIND_HAS_KEY || a.axiom_kind == AXIOM_KIND_DISJOINT_UNION {
284 a.object.split(" | ").map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
285 } else {
286 Vec::new()
287 };
288 let kind_label = match a.axiom_kind.as_str() {
289 AXIOM_KIND_EQUIVALENT_CLASS => "EquivalentClasses",
290 AXIOM_KIND_DISJOINT_CLASS => "DisjointClasses",
291 AXIOM_KIND_DOMAIN => "Domain",
292 AXIOM_KIND_RANGE => "Range",
293 AXIOM_KIND_PROPERTY_CHAIN => "PropertyChain",
294 AXIOM_KIND_CLASS_ASSERTION => "ClassAssertion",
295 AXIOM_KIND_OBJECT_PROPERTY_ASSERTION => "ObjectPropertyAssertion",
296 AXIOM_KIND_DATA_PROPERTY_ASSERTION => "DataPropertyAssertion",
297 AXIOM_KIND_HAS_KEY => "HasKey",
298 AXIOM_KIND_DISJOINT_UNION => "DisjointUnion",
299 AXIOM_KIND_INVERSE_OBJECT_PROPERTIES => "InverseObjectProperties",
300 AXIOM_KIND_EQUIVALENT_OBJECT_PROPERTIES => "EquivalentObjectProperties",
301 AXIOM_KIND_DISJOINT_OBJECT_PROPERTIES => "DisjointObjectProperties",
302 AXIOM_KIND_EQUIVALENT_DATA_PROPERTIES => "EquivalentDataProperties",
303 AXIOM_KIND_DISJOINT_DATA_PROPERTIES => "DisjointDataProperties",
304 AXIOM_KIND_SUB_OBJECT_PROPERTY_OF => "SubObjectPropertyOf",
305 AXIOM_KIND_SUB_DATA_PROPERTY_OF => "SubDataPropertyOf",
306 AXIOM_KIND_NEGATIVE_OBJECT_PROPERTY_ASSERTION => "NegativeObjectPropertyAssertion",
307 AXIOM_KIND_NEGATIVE_DATA_PROPERTY_ASSERTION => "NegativeDataPropertyAssertion",
308 AXIOM_KIND_SAME_INDIVIDUAL => "SameIndividual",
309 AXIOM_KIND_DIFFERENT_INDIVIDUALS => "DifferentIndividuals",
310 AXIOM_KIND_DATATYPE_DEFINITION => "DatatypeDefinition",
311 _ => "SubClassOf",
312 };
313 let display = match a.axiom_kind.as_str() {
314 AXIOM_KIND_NEGATIVE_OBJECT_PROPERTY_ASSERTION
315 | AXIOM_KIND_NEGATIVE_DATA_PROPERTY_ASSERTION
316 | AXIOM_KIND_OBJECT_PROPERTY_ASSERTION
317 | AXIOM_KIND_DATA_PROPERTY_ASSERTION => {
318 format!("{} {} {}", kind_label, short_tail(&a.predicate), short_tail(&a.object))
319 }
320 _ => format!("{} {}", kind_label, short_tail(&a.object)),
321 };
322 let annotations: Vec<EntityAnnotationSummary> = a
323 .annotations
324 .iter()
325 .map(|ann| EntityAnnotationSummary {
326 predicate: ann.predicate.clone(),
327 value: ann.value.clone(),
328 editable,
329 })
330 .collect();
331 let predicate = if matches!(
332 a.axiom_kind.as_str(),
333 AXIOM_KIND_OBJECT_PROPERTY_ASSERTION
334 | AXIOM_KIND_DATA_PROPERTY_ASSERTION
335 | AXIOM_KIND_NEGATIVE_OBJECT_PROPERTY_ASSERTION
336 | AXIOM_KIND_NEGATIVE_DATA_PROPERTY_ASSERTION
337 ) {
338 Some(a.predicate.clone())
339 } else {
340 None
341 };
342 EntityAxiomSummary {
343 kind: a.axiom_kind.clone(),
344 display,
345 manchester,
346 parent_iri,
347 other_iri,
348 predicate,
349 properties,
350 annotations,
351 editable,
352 }
353}
354
355fn short_tail(iri_or_text: &str) -> &str {
356 iri_or_text.rsplit(['#', '/']).next().unwrap_or(iri_or_text)
357}
358
359fn scan_file_for_iri(
360 path: &std::path::Path,
361 iri: &str,
362 short_name: &str,
363 namespaces: &BTreeMap<String, String>,
364) -> Option<SourceHint> {
365 if path.symlink_metadata().ok()?.file_type().is_symlink() {
366 return None;
367 }
368 let content = read_to_string_capped(path, MAX_FILE_BYTES).ok()?;
369 let loc = find_in_source(&content, &entity_needles(iri, short_name, namespaces));
370 loc.line.map(|line| SourceHint {
371 path: path.to_path_buf(),
372 line,
373 column: loc.column.unwrap_or(0),
374 })
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use crate::IndexBuilder;
381 use std::path::PathBuf;
382
383 fn fixture_workspace() -> PathBuf {
384 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures")
385 }
386
387 fn fixture_catalog() -> OntologyCatalog {
388 IndexBuilder::new().workspace(fixture_workspace()).build().expect("build catalog")
389 }
390
391 #[test]
392 fn find_entity_by_iri() {
393 let catalog = fixture_catalog();
394 let entity =
395 catalog.find_entity("http://example.org/people#Person").expect("Person entity");
396 assert_eq!(entity.short_name, "Person");
397 assert_eq!(entity.kind, EntityKind::Class);
398 }
399
400 #[test]
401 fn class_hierarchy_includes_subclass_axiom() {
402 let catalog = fixture_catalog();
403 let hierarchy = catalog.class_hierarchy();
404 assert!(!hierarchy.edges.is_empty());
405 assert!(hierarchy
406 .parents
407 .get("http://example.org/people#Person")
408 .is_some_and(|p| p.contains(&"http://example.org/people#Thing".to_string())));
409 }
410
411 #[test]
412 fn entity_detail_includes_labels_and_parents() {
413 let catalog = fixture_catalog();
414 let detail =
415 catalog.entity_detail("http://example.org/people#Person").expect("Person detail");
416 assert!(!detail.entity.labels.is_empty());
417 assert!(!detail.parents.is_empty());
418 }
419
420 #[test]
421 fn find_source_location_in_fixture() {
422 let catalog = fixture_catalog();
423 let source = catalog
424 .find_source_location("http://example.org/people#Person")
425 .expect("source location");
426 assert!(source.path.ends_with("example.ttl"));
427 assert!(source.line > 0);
428 }
429
430 #[test]
431 fn entities_in_document_uses_build_time_index() {
432 let catalog = fixture_catalog();
433 let doc_path = fixture_workspace().join("example.ttl");
434 let entities = catalog.entities_in_document(&doc_path);
435 assert!(entities.iter().any(|e| e.short_name == "Person"));
436 }
437
438 #[test]
439 fn find_source_location_skips_person_colon_in_comments() {
440 let dir = tempfile::tempdir().expect("tempdir");
441 let ttl_path = dir.path().join("test.ttl");
442 std::fs::write(
443 &ttl_path,
444 concat!(
445 "@prefix ex: <http://ex#> .\n",
446 "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n\n",
447 "# Note: Person: see documentation\n\n",
448 "ex:Person a owl:Class .\n"
449 ),
450 )
451 .expect("write ttl");
452
453 let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build catalog");
454 let source = catalog.find_source_location("http://ex#Person").expect("source location");
455 let entity_line = std::fs::read_to_string(&ttl_path)
456 .expect("read ttl")
457 .lines()
458 .position(|line| line.contains("ex:Person"))
459 .expect("entity line")
460 + 1;
461 assert_eq!(source.line, entity_line as u64);
462 assert_eq!(source.column, 0);
463 }
464
465 #[test]
466 fn obo_entity_detail_marks_axioms_editable() {
467 let dir = tempfile::tempdir().expect("tempdir");
468 let obo_path = dir.path().join("demo.obo");
469 std::fs::write(
470 &obo_path,
471 concat!(
472 "format-version: 1.2\n",
473 "ontology: demo\n\n",
474 "[Term]\n",
475 "id: DEMO:0001\n",
476 "name: child\n",
477 "is_a: DEMO:0002\n\n",
478 "[Term]\n",
479 "id: DEMO:0002\n",
480 "name: parent\n",
481 ),
482 )
483 .expect("write obo");
484
485 let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build catalog");
486 let child = catalog
487 .data()
488 .entities
489 .iter()
490 .find(|e| e.obo_id.as_deref() == Some("DEMO:0001"))
491 .expect("child term");
492 let detail = catalog.entity_detail(&child.iri).expect("child detail");
493 assert!(detail.editable);
494 assert!(!detail.axioms.is_empty());
495 assert!(detail.axioms.iter().all(|a| a.editable));
496 }
497
498 #[test]
499 fn property_chain_axiom_summary_includes_member_iris() {
500 let dir = tempfile::tempdir().expect("tempdir");
501 let ttl_path = dir.path().join("chains.ttl");
502 std::fs::write(
503 &ttl_path,
504 concat!(
505 "@prefix ex: <http://example.org/org#> .\n",
506 "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n",
507 "@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\n",
508 "ex:chases a owl:ObjectProperty .\n",
509 "ex:composed a owl:ObjectProperty ;\n",
510 " owl:propertyChainAxiom ( ex:chases ex:chases ) .\n"
511 ),
512 )
513 .expect("write ttl");
514 let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build");
515 let detail =
516 catalog.entity_detail("http://example.org/org#composed").expect("composed detail");
517 let chain = detail
518 .axioms
519 .iter()
520 .find(|a| a.kind == AXIOM_KIND_PROPERTY_CHAIN)
521 .expect("property_chain axiom");
522 assert_eq!(
523 chain.properties,
524 vec![
525 "http://example.org/org#chases".to_string(),
526 "http://example.org/org#chases".to_string()
527 ]
528 );
529 }
530}