1use std::path::Path;
7use strixonomy_core::OntologyDocument;
8use thiserror::Error;
9
10pub const IRI_ID_PREFIX: &str = "http://purl.obolibrary.org/obo/IAO_0000599";
12pub const IRI_ID_DIGITS: &str = "http://purl.obolibrary.org/obo/IAO_0000596";
14pub const IRI_IDS_FOR: &str = "http://purl.obolibrary.org/obo/IAO_0000598";
16pub 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#[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#[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
51pub 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
91pub 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
97pub 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 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 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 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 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 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 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 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 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 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 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 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}