Skip to main content

strixonomy_core/
document_lookup.rs

1use crate::{Entity, OntologyDocument};
2
3/// Normalize an ontology IRI for prefix/suffix comparison.
4pub fn normalize_iri(iri: &str) -> String {
5    iri.trim_end_matches('#').trim_end_matches('/').to_string()
6}
7
8/// Canonical `file://` URI for workspace document matching and diagnostics.
9///
10/// Uses the same encoding rules as LSP `path_to_uri` (`url::Url::from_file_path`) so
11/// Windows paths become `file:///C:/…` instead of invalid `file://C:\\…` (#218).
12pub fn file_uri_for_path(path: &std::path::Path) -> String {
13    let abs = path.canonicalize().unwrap_or_else(|_| {
14        if path.is_absolute() {
15            path.to_path_buf()
16        } else {
17            std::env::current_dir().unwrap_or_default().join(path)
18        }
19    });
20    url::Url::from_file_path(&abs).map(|u| u.to_string()).unwrap_or_else(|_| {
21        let display = abs.to_string_lossy().replace('\\', "/");
22        if display.starts_with('/') {
23            format!("file://{display}")
24        } else {
25            format!("file:///{display}")
26        }
27    })
28}
29
30/// Whether `entity` is owned by `doc` via exact ontology id / ontology IRI equality.
31///
32/// Does **not** use entity-IRI `starts_with(base_iri)` — that is ambiguous when multiple
33/// documents have nested bases. Use [`document_for_entity`] to resolve ownership among a set.
34pub fn document_matches_entity(entity: &Entity, doc: &OntologyDocument) -> bool {
35    if entity.ontology_id == doc.id {
36        return true;
37    }
38    if let Some(base) = &doc.base_iri {
39        if normalize_iri(base) == normalize_iri(&entity.ontology_id) {
40            return true;
41        }
42    }
43    false
44}
45
46fn base_iri_prefix_len(entity: &Entity, doc: &OntologyDocument) -> Option<usize> {
47    let base = doc.base_iri.as_ref()?;
48    if entity.iri.starts_with(base.as_str()) {
49        Some(base.len())
50    } else {
51        None
52    }
53}
54
55/// Whether an import ontology id matches a document.
56pub fn document_matches_ontology_id(ontology_id: &str, doc: &OntologyDocument) -> bool {
57    if ontology_id == doc.id {
58        return true;
59    }
60    if let Some(base) = &doc.base_iri {
61        if normalize_iri(base) == normalize_iri(ontology_id) {
62            return true;
63        }
64    }
65    false
66}
67
68/// Find the document that owns an entity.
69///
70/// Preference order:
71/// 1. Exact `entity.ontology_id == doc.id`
72/// 2. Normalized ontology IRI equality with `doc.base_iri`
73/// 3. Longest `doc.base_iri` that is a string prefix of `entity.iri` (nested-base fallback)
74pub fn document_for_entity<'a>(
75    documents: &'a [OntologyDocument],
76    entity: &Entity,
77) -> Option<&'a OntologyDocument> {
78    if let Some(doc) = documents.iter().find(|d| entity.ontology_id == d.id) {
79        return Some(doc);
80    }
81    if let Some(doc) = documents.iter().find(|d| {
82        d.base_iri
83            .as_ref()
84            .is_some_and(|base| normalize_iri(base) == normalize_iri(&entity.ontology_id))
85    }) {
86        return Some(doc);
87    }
88    documents
89        .iter()
90        .filter_map(|d| base_iri_prefix_len(entity, d).map(|len| (d, len)))
91        .max_by_key(|(_, len)| *len)
92        .map(|(d, _)| d)
93}
94
95/// Find the document for an import's owning ontology id.
96pub fn document_for_ontology_id<'a>(
97    documents: &'a [OntologyDocument],
98    ontology_id: &str,
99) -> Option<&'a OntologyDocument> {
100    documents.iter().find(|d| document_matches_ontology_id(ontology_id, d))
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::{EntityKind, OntologyFormat, ParseStatus};
107    use std::collections::BTreeMap;
108    use std::path::Path;
109
110    fn doc(id: &str, base_iri: Option<&str>) -> OntologyDocument {
111        OntologyDocument {
112            id: id.to_string(),
113            path: Path::new(&format!("{id}.ttl")).to_path_buf(),
114            format: OntologyFormat::Turtle,
115            base_iri: base_iri.map(str::to_string),
116            version_iri: None,
117            imports: vec![],
118            namespaces: BTreeMap::new(),
119            parse_status: ParseStatus::Ok,
120            content_hash: "h".to_string(),
121            modified_time: 0,
122            parse_message: None,
123            parse_error_location: None,
124        }
125    }
126
127    fn entity(iri: &str, ontology_id: &str) -> Entity {
128        Entity {
129            iri: iri.to_string(),
130            short_name: iri.rsplit(['#', '/']).next().unwrap_or(iri).to_string(),
131            kind: EntityKind::Class,
132            ontology_id: ontology_id.to_string(),
133            source_location: Default::default(),
134            labels: vec![],
135            comments: vec![],
136            deprecated: false,
137            obo_id: None,
138            characteristics: Default::default(),
139        }
140    }
141
142    #[test]
143    fn file_uri_for_path_uses_url_encoding() {
144        let dir = tempfile::tempdir().unwrap();
145        let file = dir.path().join("sample.ttl");
146        std::fs::write(&file, "").unwrap();
147        let uri = file_uri_for_path(&file);
148        assert!(uri.starts_with("file://"), "got {uri}");
149        assert!(!uri.contains('\\'), "Windows separators must not appear raw: {uri}");
150        // Drive-letter URIs are file:///C:/… (three slashes after file:).
151        #[cfg(windows)]
152        {
153            assert!(
154                uri.starts_with("file:///"),
155                "Windows file URI should use three slashes: {uri}"
156            );
157            assert!(uri.contains(":/") || uri.contains("%3A"), "got {uri}");
158        }
159        #[cfg(unix)]
160        {
161            assert!(uri.starts_with("file:///"), "got {uri}");
162        }
163    }
164
165    #[test]
166    fn matches_entity_by_ontology_iri() {
167        let document = doc("doc-1", Some("http://example.org/people"));
168        let ent = entity("http://example.org/people#Person", "http://example.org/people");
169        assert!(document_matches_entity(&ent, &document));
170    }
171
172    #[test]
173    fn broader_base_iri_does_not_match_when_ontology_id_differs() {
174        let broad = doc("doc-A", Some("http://example.org/"));
175        let ent = entity("http://example.org/people#Person", "doc-B");
176        assert!(!document_matches_entity(&ent, &broad));
177    }
178
179    #[test]
180    fn document_for_entity_prefers_exact_ontology_id_over_broader_base() {
181        let broad = doc("doc-A", Some("http://example.org/"));
182        let specific = doc("doc-B", Some("http://example.org/people#"));
183        let ent = entity("http://example.org/people#Person", "doc-B");
184        // Broader document listed first — must still resolve to the owning doc.
185        let documents = vec![broad, specific];
186        let found = document_for_entity(&documents, &ent).expect("owner");
187        assert_eq!(found.id, "doc-B");
188    }
189
190    #[test]
191    fn document_for_entity_longest_base_iri_wins_without_ontology_id_match() {
192        let broad = doc("doc-A", Some("http://example.org/"));
193        let specific = doc("doc-B", Some("http://example.org/people#"));
194        // ontology_id matches neither doc id / base equality — fall back to longest prefix.
195        let ent = entity("http://example.org/people#Person", "unknown");
196        let documents = vec![broad, specific];
197        let found = document_for_entity(&documents, &ent).expect("owner");
198        assert_eq!(found.id, "doc-B");
199    }
200
201    #[test]
202    fn document_for_entity_longest_base_iri_wins_when_broad_is_second() {
203        let specific = doc("doc-B", Some("http://example.org/people#"));
204        let broad = doc("doc-A", Some("http://example.org/"));
205        let ent = entity("http://example.org/people#Person", "unknown");
206        let documents = vec![specific, broad];
207        let found = document_for_entity(&documents, &ent).expect("owner");
208        assert_eq!(found.id, "doc-B");
209    }
210}