Skip to main content

strixonomy_obo/
idpolicy.rs

1//! OBO idranges policy parse (Protégé `IdRangesPolicyParser` behavioral port).
2//!
3//! Parses IAO ontology annotations and allocated datatype ranges from GO-style
4//! Manchester OWL idranges documents (and a thin Turtle equivalent).
5
6use std::path::Path;
7use strixonomy_core::OntologyDocument;
8use thiserror::Error;
9
10/// IAO_0000599 — id prefix (e.g. `http://purl.obolibrary.org/obo/GO_`).
11pub const IRI_ID_PREFIX: &str = "http://purl.obolibrary.org/obo/IAO_0000599";
12/// IAO_0000596 — number of digits in local ids.
13pub const IRI_ID_DIGITS: &str = "http://purl.obolibrary.org/obo/IAO_0000596";
14/// IAO_0000598 — idspace / "ids for" (e.g. `GO`).
15pub const IRI_IDS_FOR: &str = "http://purl.obolibrary.org/obo/IAO_0000598";
16/// IAO_0000597 — allocated-to annotation on range datatypes.
17pub const IRI_ALLOCATED_TO: &str = "http://purl.obolibrary.org/obo/IAO_0000597";
18
19#[derive(Debug, Clone, PartialEq, Eq, Error)]
20pub enum IdPolicyError {
21    #[error("{0}")]
22    Message(String),
23    #[error("io: {0}")]
24    Io(String),
25}
26
27impl IdPolicyError {
28    fn msg(s: impl Into<String>) -> Self {
29        Self::Message(s.into())
30    }
31}
32
33/// One user-allocated numeric id range.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct IdRange {
36    pub name: String,
37    pub allocated_to: String,
38    pub min: i64,
39    pub max: i64,
40}
41
42/// Parsed GO / OBO idranges policy.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct IdPolicy {
45    pub ids_for: String,
46    pub id_prefix: String,
47    pub id_digits: u32,
48    pub ranges: Vec<IdRange>,
49}
50
51/// Parse id policy Manchester OWL (or Turtle with expanded IAO IRIs) from text.
52pub fn parse_id_policy(text: &str) -> Result<IdPolicy, IdPolicyError> {
53    let prefixes = collect_manchester_prefixes(text);
54    let expand = |local: &str| -> String {
55        if let Some((pfx, rest)) = local.split_once(':') {
56            if let Some(base) = prefixes.get(pfx) {
57                return format!("{base}{rest}");
58            }
59        }
60        local.to_string()
61    };
62
63    let ids_for =
64        ontology_annotation_lexical(text, &prefixes, "idsfor", IRI_IDS_FOR).ok_or_else(|| {
65            IdPolicyError::msg(format!(
66                "'Id policy for' ({IRI_IDS_FOR}) ontology annotation not found"
67            ))
68        })?;
69    let id_prefix = ontology_annotation_lexical(text, &prefixes, "idprefix", IRI_ID_PREFIX)
70        .ok_or_else(|| {
71            IdPolicyError::msg(format!(
72                "'Id prefix' ({IRI_ID_PREFIX}) ontology annotation not found"
73            ))
74        })?;
75    let id_digits_lex = ontology_annotation_lexical(text, &prefixes, "iddigits", IRI_ID_DIGITS)
76        .ok_or_else(|| {
77            IdPolicyError::msg(format!(
78                "'Id digit count' ({IRI_ID_DIGITS}) ontology annotation not found"
79            ))
80        })?;
81    let id_digits: u32 = id_digits_lex.parse().map_err(|_| {
82        IdPolicyError::msg(format!(
83            "Invalid value for digit count ({id_digits_lex}).  Expected integer."
84        ))
85    })?;
86
87    let ranges = parse_user_ranges(text, &expand)?;
88    Ok(IdPolicy { ids_for, id_prefix, id_digits, ranges })
89}
90
91/// Read a file and parse as id policy.
92pub fn parse_id_policy_file(path: &Path) -> Result<IdPolicy, IdPolicyError> {
93    let text = std::fs::read_to_string(path).map_err(|e| IdPolicyError::Io(e.to_string()))?;
94    parse_id_policy(&text)
95}
96
97/// Look up `doc_id` (exact id, path suffix, or ontology IRI) in documents and parse its file.
98pub fn parse_id_policy_from_catalog(
99    documents: &[OntologyDocument],
100    doc_id: &str,
101) -> Result<IdPolicy, IdPolicyError> {
102    let doc = documents
103        .iter()
104        .find(|d| {
105            d.id == doc_id
106                || d.path.to_string_lossy() == doc_id
107                || d.path.ends_with(doc_id)
108                || d.base_iri.as_deref() == Some(doc_id)
109        })
110        .ok_or_else(|| IdPolicyError::msg(format!("document not found: {doc_id}")))?;
111    parse_id_policy_file(&doc.path)
112}
113
114fn collect_manchester_prefixes(text: &str) -> std::collections::BTreeMap<String, String> {
115    let mut map = std::collections::BTreeMap::new();
116    // Prefix: idsfor: <http://...>
117    // @prefix idsfor: <http://...> .
118    for line in text.lines() {
119        let t = line.trim();
120        let rest = if let Some(r) = t.strip_prefix("Prefix:") {
121            r.trim()
122        } else if let Some(r) = t.strip_prefix("@prefix").or_else(|| t.strip_prefix("@PREFIX")) {
123            r.trim()
124        } else {
125            continue;
126        };
127        let Some((name_part, iri_part)) = rest.split_once('<') else {
128            continue;
129        };
130        let name = name_part.trim().trim_end_matches(':').trim();
131        let Some(iri) = iri_part.split('>').next() else {
132            continue;
133        };
134        if !name.is_empty() {
135            map.insert(name.to_string(), iri.to_string());
136        }
137    }
138    map
139}
140
141fn ontology_annotation_lexical(
142    text: &str,
143    prefixes: &std::collections::BTreeMap<String, String>,
144    short_name: &str,
145    full_iri: &str,
146) -> Option<String> {
147    // Manchester Ontology Annotations: block — authoritative when present
148    if let Some(block) = manchester_annotations_block(text) {
149        if let Some(v) = annotation_value_in_block(&block, short_name) {
150            return Some(v);
151        }
152        // Do not fall through to whole-file Turtle scan: Prefix: lines embed the
153        // same IAO IRIs and would pick up the next quoted literal by accident.
154        let _ = (prefixes, full_iri);
155        return None;
156    }
157    turtle_ontology_annotation(text, full_iri)
158}
159
160fn manchester_annotations_block(text: &str) -> Option<String> {
161    let lower = text.to_ascii_lowercase();
162    let idx = lower.find("annotations:")?;
163    let after = &text[idx + "Annotations:".len()..];
164    // End at next top-level declaration
165    let end_markers = [
166        "\nAnnotationProperty:",
167        "\nDatatype:",
168        "\nClass:",
169        "\nObjectProperty:",
170        "\nDataProperty:",
171        "\nIndividual:",
172    ];
173    let mut end = after.len();
174    for m in end_markers {
175        if let Some(p) = after.find(m) {
176            end = end.min(p);
177        }
178    }
179    Some(after[..end].to_string())
180}
181
182fn annotation_value_in_block(block: &str, short_name: &str) -> Option<String> {
183    // idsfor: "GO"  or iddigits: 7  or idprefix: "http://..."
184    for line in block.lines() {
185        let t = line.trim().trim_end_matches(',');
186        let Some(idx) = t.find(':') else {
187            continue;
188        };
189        let name = t[..idx].trim();
190        if !name.eq_ignore_ascii_case(short_name) {
191            continue;
192        }
193        let rest = t[idx + 1..].trim().trim_end_matches(',');
194        if let Some(s) = strip_quoted(rest) {
195            return Some(s);
196        }
197        if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
198            return Some(rest.to_string());
199        }
200    }
201    None
202}
203
204fn strip_quoted(s: &str) -> Option<String> {
205    let s = s.trim();
206    if let Some(inner) = s.strip_prefix('"').and_then(|r| r.strip_suffix('"')) {
207        return Some(inner.to_string());
208    }
209    if let Some(inner) = s.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')) {
210        return Some(inner.to_string());
211    }
212    None
213}
214
215fn turtle_ontology_annotation(text: &str, property_iri: &str) -> Option<String> {
216    // Match <…IAO_…> "literal" or property compact form already expanded in text
217    let needle = property_iri;
218    let mut search = text;
219    while let Some(pos) = search.find(needle) {
220        let after = &search[pos + needle.len()..];
221        let after = after.trim_start();
222        if let Some(s) = strip_quoted(after.split_whitespace().next().unwrap_or("")) {
223            return Some(s);
224        }
225        // "7"^^xsd:integer
226        if let Some(q) = after.find('"') {
227            let rest = &after[q..];
228            if let Some(end) = rest[1..].find('"') {
229                return Some(rest[1..1 + end].to_string());
230            }
231        }
232        search = &search[pos + 1..];
233    }
234    None
235}
236
237fn parse_user_ranges(
238    text: &str,
239    expand: &dyn Fn(&str) -> String,
240) -> Result<Vec<IdRange>, IdPolicyError> {
241    let mut ranges = Vec::new();
242    let mut rest = text;
243    while let Some(idx) = find_ci(rest, "Datatype:") {
244        let after = &rest[idx + "Datatype:".len()..];
245        let next = find_ci(after, "Datatype:").unwrap_or(after.len());
246        let block = &after[..next];
247        rest = &after[next..];
248
249        let name_line = block.lines().next().unwrap_or("").trim();
250        let name_token = name_line.split_whitespace().next().unwrap_or("").trim_end_matches(':');
251        if name_token.is_empty() {
252            continue;
253        }
254        let name = expand(name_token);
255
256        // Only count ranges with allocatedto (matches Protégé hasAllocatedToAnnotation)
257        let Some(allocated) = datatype_allocated_to(block) else {
258            continue;
259        };
260        let (min, max) = datatype_bounds(block)?;
261        ranges.push(IdRange { name, allocated_to: allocated, min, max });
262    }
263    Ok(ranges)
264}
265
266fn datatype_allocated_to(block: &str) -> Option<String> {
267    // Annotations: allocatedto: "David Hill" (same line or following)
268    for line in block.lines() {
269        let lower = line.to_ascii_lowercase();
270        let Some(pos) = lower.find("allocatedto:") else {
271            continue;
272        };
273        let after = line[pos + "allocatedto:".len()..].trim().trim_end_matches(',');
274        if let Some(s) = strip_quoted(after) {
275            return Some(s);
276        }
277        if !after.is_empty() {
278            return Some(after.to_string());
279        }
280    }
281    turtle_ontology_annotation(block, IRI_ALLOCATED_TO)
282}
283
284fn datatype_bounds(block: &str) -> Result<(i64, i64), IdPolicyError> {
285    // EquivalentTo: xsd:integer[>= 0060001 , <= 0065000]
286    let lower = block.to_ascii_lowercase();
287    let Some(eq) = find_ci(block, "EquivalentTo:") else {
288        return Err(IdPolicyError::msg(format!(
289            "Expected datatype restriction definition, but not found ({})",
290            block.lines().next().unwrap_or("")
291        )));
292    };
293    let restriction = &block[eq..];
294    let min =
295        facet_bound(restriction, ">=").or_else(|| facet_bound(restriction, ">").map(|v| v + 1));
296    let max =
297        facet_bound(restriction, "<=").or_else(|| facet_bound(restriction, "<").map(|v| v - 1));
298    let min = min.ok_or_else(|| {
299        IdPolicyError::msg(format!(
300            "Expected min inclusive facet to specify lower bound of data range, but not found ({restriction})"
301        ))
302    })?;
303    let max = max.ok_or_else(|| {
304        IdPolicyError::msg(format!(
305            "Expected max inclusive facet to specify upper bound of data range, but not found ({restriction})"
306        ))
307    })?;
308    let _ = lower;
309    Ok((min, max))
310}
311
312fn facet_bound(text: &str, op: &str) -> Option<i64> {
313    let mut search = text;
314    while let Some(pos) = search.find(op) {
315        let after = search[pos + op.len()..].trim_start();
316        let num: String = after.chars().take_while(|c| c.is_ascii_digit() || *c == '-').collect();
317        if !num.is_empty() {
318            if let Ok(v) = num.parse::<i64>() {
319                return Some(v);
320            }
321        }
322        search = &search[pos + 1..];
323    }
324    // Turtle: xsd:minInclusive "60001"
325    let key = match op {
326        ">=" => "minInclusive",
327        "<=" => "maxInclusive",
328        ">" => "minExclusive",
329        "<" => "maxExclusive",
330        _ => return None,
331    };
332    if let Some(pos) = find_ci(text, key) {
333        let after = &text[pos + key.len()..];
334        if let Some(q) = after.find('"') {
335            let rest = &after[q + 1..];
336            if let Some(end) = rest.find('"') {
337                return rest[..end].trim().parse().ok();
338            }
339        }
340    }
341    None
342}
343
344fn find_ci(hay: &str, needle: &str) -> Option<usize> {
345    hay.to_ascii_lowercase().find(&needle.to_ascii_lowercase())
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn empty_ranges_policy() {
354        let text = r#"
355Prefix: idsfor: <http://purl.obolibrary.org/obo/IAO_0000598>
356Prefix: idprefix: <http://purl.obolibrary.org/obo/IAO_0000599>
357Prefix: iddigits: <http://purl.obolibrary.org/obo/IAO_0000596>
358Prefix: allocatedto: <http://purl.obolibrary.org/obo/IAO_0000597>
359Prefix: owl: <http://www.w3.org/2002/07/owl#>
360
361Ontology: <http://purl.obolibrary.org/obo/go/go-idranges.owl>
362
363Annotations:
364    idsfor: "GO",
365    idprefix: "http://purl.obolibrary.org/obo/GO_",
366    iddigits: 7
367
368AnnotationProperty: idprefix:
369"#;
370        let p = parse_id_policy(text).expect("parse");
371        assert_eq!(p.ids_for, "GO");
372        assert_eq!(p.id_digits, 7);
373        assert!(p.ranges.is_empty());
374    }
375
376    #[test]
377    fn missing_prefix_fails() {
378        let text = r#"
379Prefix: idsfor: <http://purl.obolibrary.org/obo/IAO_0000598>
380Prefix: iddigits: <http://purl.obolibrary.org/obo/IAO_0000596>
381Ontology: <http://example.org/x>
382Annotations:
383    idsfor: "GO",
384    iddigits: 7
385"#;
386        let err = parse_id_policy(text).unwrap_err();
387        assert!(err.to_string().contains("Id prefix"), "{err}");
388    }
389
390    #[test]
391    fn one_range() {
392        let text = r#"
393Prefix: idsfor: <http://purl.obolibrary.org/obo/IAO_0000598>
394Prefix: idprefix: <http://purl.obolibrary.org/obo/IAO_0000599>
395Prefix: iddigits: <http://purl.obolibrary.org/obo/IAO_0000596>
396Prefix: allocatedto: <http://purl.obolibrary.org/obo/IAO_0000597>
397Prefix: idrange: <http://purl.obolibrary.org/obo/ro/idrange/>
398Prefix: xsd: <http://www.w3.org/2001/XMLSchema#>
399
400Ontology: <http://example.org/x>
401Annotations:
402    idsfor: "GO",
403    idprefix: "http://purl.obolibrary.org/obo/GO_",
404    iddigits: 7
405
406Datatype: idrange:2
407Annotations: allocatedto: "David Hill"
408EquivalentTo: xsd:integer[>= 0060001 , <= 0065000]
409"#;
410        let p = parse_id_policy(text).unwrap();
411        assert_eq!(p.ranges.len(), 1);
412        assert_eq!(p.ranges[0].allocated_to, "David Hill");
413        assert_eq!(p.ranges[0].min, 60001);
414        assert_eq!(p.ranges[0].max, 65000);
415    }
416}