1use ontocore_core::{
4 limits::MAX_TRIPLES_PER_FILE, Annotation, Axiom, Entity, EntityKind, SourceLocation,
5 AXIOM_KIND_SUB_CLASS_OF,
6};
7use std::collections::BTreeMap;
8use std::path::Path;
9
10use crate::rdf::{assemble_parsed_ontology, ParseError, ParsedOntology, Result};
11
12const DEFAULT_OBO_BASE: &str = "http://purl.obolibrary.org/obo/";
14
15pub fn parse_obo_text(path: &Path, ontology_id: &str, source_text: &str) -> Result<ParsedOntology> {
16 let mut namespaces = BTreeMap::new();
17 let mut entities = Vec::new();
18 let mut annotations = Vec::new();
19 let mut axioms = Vec::new();
20 let mut axiom_counter = 0usize;
21
22 let mut in_term = false;
23 let mut current_id: Option<String> = None;
24 let mut current_iri: Option<String> = None;
25 let mut labels = Vec::new();
26 let mut comments = Vec::new();
27 let mut deprecated = false;
28
29 let flush_term = |entities: &mut Vec<Entity>,
30 current_id: &mut Option<String>,
31 current_iri: &mut Option<String>,
32 labels: &mut Vec<String>,
33 comments: &mut Vec<String>,
34 deprecated: &mut bool| {
35 if let (Some(obo_id), Some(iri)) = (current_id.take(), current_iri.take()) {
36 let short_name = obo_id.split(':').next_back().unwrap_or(&obo_id).to_string();
37 entities.push(Entity {
38 iri,
39 short_name,
40 kind: EntityKind::Class,
41 ontology_id: ontology_id.to_string(),
42 source_location: SourceLocation::default(),
43 labels: std::mem::take(labels),
44 comments: std::mem::take(comments),
45 deprecated: *deprecated,
46 obo_id: Some(obo_id),
47 });
48 *deprecated = false;
49 }
50 };
51
52 for line in source_text.lines() {
53 let line = line.trim();
54 if line.is_empty() || line.starts_with('!') {
55 continue;
56 }
57 if line.starts_with('[') && line.ends_with(']') {
58 flush_term(
59 &mut entities,
60 &mut current_id,
61 &mut current_iri,
62 &mut labels,
63 &mut comments,
64 &mut deprecated,
65 );
66 in_term = line == "[Term]";
67 continue;
68 }
69 if !in_term {
70 if let Some(rest) = line.strip_prefix("idspace:") {
72 let mut parts = rest.split_whitespace();
73 if let (Some(prefix), Some(url)) = (parts.next(), parts.next()) {
74 namespaces.insert(prefix.to_string(), url.to_string());
75 }
76 }
77 continue;
78 }
79
80 if let Some(value) = line.strip_prefix("id:") {
81 flush_term(
82 &mut entities,
83 &mut current_id,
84 &mut current_iri,
85 &mut labels,
86 &mut comments,
87 &mut deprecated,
88 );
89 let obo_id = value.split('!').next().unwrap_or(value).trim().to_string();
90 current_iri = Some(obo_id_to_iri(&obo_id, &namespaces));
91 current_id = Some(obo_id);
92 in_term = true;
93 continue;
94 }
95
96 let Some(iri) = current_iri.clone() else {
97 continue;
98 };
99
100 if let Some(value) = line.strip_prefix("name:") {
101 labels.push(value.trim().to_string());
102 } else if let Some(value) = line.strip_prefix("comment:") {
103 comments.push(value.trim().to_string());
104 } else if line == "is_obsolete: true" {
105 deprecated = true;
106 } else if let Some(value) = line.strip_prefix("is_a:") {
107 let parent_id = value.split('!').next().unwrap_or(value).trim().to_string();
108 axiom_counter += 1;
109 axioms.push(Axiom {
110 id: format!("{ontology_id}#axiom-{axiom_counter}"),
111 ontology_id: ontology_id.to_string(),
112 subject: iri.clone(),
113 predicate: "rdfs:subClassOf".to_string(),
114 object: obo_id_to_iri(&parent_id, &namespaces),
115 axiom_kind: AXIOM_KIND_SUB_CLASS_OF.to_string(),
116 source_location: SourceLocation::default(),
117 });
118 } else if let Some(value) = line.strip_prefix("synonym:") {
119 annotations.push(Annotation {
120 subject: iri.clone(),
121 predicate: "obo:hasExactSynonym".to_string(),
122 object: value.trim().trim_matches('"').to_string(),
123 ontology_id: ontology_id.to_string(),
124 source_location: SourceLocation::default(),
125 });
126 } else if let Some(value) = line.strip_prefix("xref:") {
127 annotations.push(Annotation {
128 subject: iri.clone(),
129 predicate: "obo:hasDbXref".to_string(),
130 object: value.trim().to_string(),
131 ontology_id: ontology_id.to_string(),
132 source_location: SourceLocation::default(),
133 });
134 } else if let Some(value) = line.strip_prefix("property_value:") {
135 let mut parts = value.split_whitespace();
136 if let (Some(prop), Some(val)) = (parts.next(), parts.next()) {
137 annotations.push(Annotation {
138 subject: iri,
139 predicate: prop.to_string(),
140 object: val.to_string(),
141 ontology_id: ontology_id.to_string(),
142 source_location: SourceLocation::default(),
143 });
144 }
145 }
146 if entities.len() + annotations.len() + axioms.len() > MAX_TRIPLES_PER_FILE {
147 return Err(ParseError::LimitExceeded(format!(
148 "OBO file exceeds entity/axiom limit: {}",
149 path.display()
150 )));
151 }
152 }
153
154 flush_term(
155 &mut entities,
156 &mut current_id,
157 &mut current_iri,
158 &mut labels,
159 &mut comments,
160 &mut deprecated,
161 );
162
163 let total = entities.len() + annotations.len() + axioms.len();
164 if entities.len() > MAX_TRIPLES_PER_FILE || total > MAX_TRIPLES_PER_FILE {
165 return Err(ParseError::LimitExceeded(format!(
166 "OBO file exceeds entity/axiom limit: {}",
167 path.display()
168 )));
169 }
170
171 if !namespaces.contains_key("") {
173 namespaces.insert(String::new(), DEFAULT_OBO_BASE.to_string());
174 }
175
176 Ok(assemble_parsed_ontology(
177 ontology_id,
178 Some(DEFAULT_OBO_BASE.to_string()),
179 namespaces,
180 entities,
181 annotations,
182 axioms,
183 ))
184}
185
186fn obo_id_to_iri(obo_id: &str, namespaces: &BTreeMap<String, String>) -> String {
190 if obo_id.starts_with("http://") || obo_id.starts_with("https://") {
191 return obo_id.to_string();
192 }
193 if let Some((prefix, local)) = obo_id.split_once(':') {
194 if let Some(ns) = namespaces.get(prefix) {
195 return format!("{ns}{local}");
196 }
197 }
198 let normalized = obo_id.replace(':', "_");
199 format!("{DEFAULT_OBO_BASE}{normalized}")
200}
201
202pub fn parse_obo_file(
203 path: &Path,
204 ontology_id: &str,
205 _content_hash: &str,
206 _modified_time: u64,
207) -> Result<ParsedOntology> {
208 let metadata = std::fs::metadata(path).map_err(ParseError::Io)?;
209 if metadata.len() > ontocore_core::MAX_FILE_BYTES {
210 return Err(ParseError::Io(std::io::Error::new(
211 std::io::ErrorKind::InvalidData,
212 format!("file exceeds maximum size of {} bytes", ontocore_core::MAX_FILE_BYTES),
213 )));
214 }
215 let content = std::fs::read_to_string(path).map_err(ParseError::Io)?;
216 parse_obo_text(path, ontology_id, &content)
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use std::io::Write;
223 use tempfile::NamedTempFile;
224
225 #[test]
226 fn parses_minimal_obo() {
227 let mut file = NamedTempFile::new().unwrap();
228 writeln!(
229 file,
230 "format-version: 1.2\nontology: test\n\n[Term]\nid: TEST:0000001\nname: example term\nis_a: TEST:0000002 ! parent\n"
231 )
232 .unwrap();
233 let parsed = parse_obo_file(file.path(), "doc-1", "hash", 0).unwrap();
234 assert_eq!(parsed.entities.len(), 1);
235 assert_eq!(parsed.entities[0].obo_id.as_deref(), Some("TEST:0000001"));
236 assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/TEST_0000001");
237 assert_eq!(parsed.entities[0].labels, vec!["example term"]);
238 assert_eq!(parsed.axioms.len(), 1);
239 assert_eq!(parsed.axioms[0].object, "http://purl.obolibrary.org/obo/TEST_0000002");
240 assert!(parsed.triple_count > 0);
241 assert!(!parsed.quads().is_empty(), "OBO must materialize RDF quads");
242 }
243
244 #[test]
245 fn idspace_overrides_default_base() {
246 let text = "format-version: 1.2\n\
247idspace: GO http://purl.obolibrary.org/obo/GO_\n\n\
248[Term]\n\
249id: GO:0000001\n\
250name: mitochondrion\n";
251 let parsed = parse_obo_text(Path::new("go.obo"), "doc-1", text).unwrap();
252 assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/GO_0000001");
253 }
254
255 #[test]
256 fn rejects_oversized_obo_source_text() {
257 let huge = "x".repeat((ontocore_core::MAX_FILE_BYTES as usize) + 1);
258 let err = crate::rdf::parse_ontology_text(
259 Path::new("big.obo"),
260 ontocore_core::OntologyFormat::Obo,
261 "doc-1",
262 &huge,
263 huge.as_bytes(),
264 )
265 .unwrap_err();
266 assert!(err.to_string().contains("exceeds"));
267 }
268}