Skip to main content

strixonomy_catalog/
entity_api.rs

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    /// Property / relation IRI for assertion-like axioms.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub predicate: Option<String>,
53    /// Ordered member property IRIs for `property_chain` / `has_key` / `disjoint_union`.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub properties: Vec<String>,
56    /// Annotations attached to this axiom (not entity annotation assertions).
57    #[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            // Keep edges when the child is a known class, even if the parent is external
116            // (common for OBO is_a and imports).
117            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}