ontocore-parser 0.9.0

RDF and OWL parsers for OntoCore (ontocore-*)
Documentation
//! Minimal OBO Format 1.4 parser → OntoCore catalog model.

use ontocore_core::{
    limits::MAX_TRIPLES_PER_FILE, Annotation, Axiom, Entity, EntityKind, SourceLocation,
    AXIOM_KIND_SUB_CLASS_OF,
};
use std::collections::BTreeMap;
use std::path::Path;

use crate::rdf::{assemble_parsed_ontology, ParseError, ParsedOntology, Result};

/// Default OBO PURL prefix (terms use `GO:0000001` → `…/obo/GO_0000001`).
const DEFAULT_OBO_BASE: &str = "http://purl.obolibrary.org/obo/";

pub fn parse_obo_text(path: &Path, ontology_id: &str, source_text: &str) -> Result<ParsedOntology> {
    let mut namespaces = BTreeMap::new();
    let mut entities = Vec::new();
    let mut annotations = Vec::new();
    let mut axioms = Vec::new();
    let mut axiom_counter = 0usize;

    let mut in_term = false;
    let mut current_id: Option<String> = None;
    let mut current_iri: Option<String> = None;
    let mut labels = Vec::new();
    let mut comments = Vec::new();
    let mut deprecated = false;

    let flush_term = |entities: &mut Vec<Entity>,
                      current_id: &mut Option<String>,
                      current_iri: &mut Option<String>,
                      labels: &mut Vec<String>,
                      comments: &mut Vec<String>,
                      deprecated: &mut bool| {
        if let (Some(obo_id), Some(iri)) = (current_id.take(), current_iri.take()) {
            let short_name = obo_id.split(':').next_back().unwrap_or(&obo_id).to_string();
            entities.push(Entity {
                iri,
                short_name,
                kind: EntityKind::Class,
                ontology_id: ontology_id.to_string(),
                source_location: SourceLocation::default(),
                labels: std::mem::take(labels),
                comments: std::mem::take(comments),
                deprecated: *deprecated,
                obo_id: Some(obo_id),
            });
            *deprecated = false;
        }
    };

    for line in source_text.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('!') {
            continue;
        }
        if line.starts_with('[') && line.ends_with(']') {
            flush_term(
                &mut entities,
                &mut current_id,
                &mut current_iri,
                &mut labels,
                &mut comments,
                &mut deprecated,
            );
            in_term = line == "[Term]";
            continue;
        }
        if !in_term {
            // ontology: is metadata only — do not rewrite the term IRI base.
            if let Some(rest) = line.strip_prefix("idspace:") {
                let mut parts = rest.split_whitespace();
                if let (Some(prefix), Some(url)) = (parts.next(), parts.next()) {
                    namespaces.insert(prefix.to_string(), url.to_string());
                }
            }
            continue;
        }

        if let Some(value) = line.strip_prefix("id:") {
            flush_term(
                &mut entities,
                &mut current_id,
                &mut current_iri,
                &mut labels,
                &mut comments,
                &mut deprecated,
            );
            let obo_id = value.split('!').next().unwrap_or(value).trim().to_string();
            current_iri = Some(obo_id_to_iri(&obo_id, &namespaces));
            current_id = Some(obo_id);
            in_term = true;
            continue;
        }

        let Some(iri) = current_iri.clone() else {
            continue;
        };

        if let Some(value) = line.strip_prefix("name:") {
            labels.push(value.trim().to_string());
        } else if let Some(value) = line.strip_prefix("comment:") {
            comments.push(value.trim().to_string());
        } else if line == "is_obsolete: true" {
            deprecated = true;
        } else if let Some(value) = line.strip_prefix("is_a:") {
            let parent_id = value.split('!').next().unwrap_or(value).trim().to_string();
            axiom_counter += 1;
            axioms.push(Axiom {
                id: format!("{ontology_id}#axiom-{axiom_counter}"),
                ontology_id: ontology_id.to_string(),
                subject: iri.clone(),
                predicate: "rdfs:subClassOf".to_string(),
                object: obo_id_to_iri(&parent_id, &namespaces),
                axiom_kind: AXIOM_KIND_SUB_CLASS_OF.to_string(),
                source_location: SourceLocation::default(),
            });
        } else if let Some(value) = line.strip_prefix("synonym:") {
            annotations.push(Annotation {
                subject: iri.clone(),
                predicate: "obo:hasExactSynonym".to_string(),
                object: value.trim().trim_matches('"').to_string(),
                ontology_id: ontology_id.to_string(),
                source_location: SourceLocation::default(),
            });
        } else if let Some(value) = line.strip_prefix("xref:") {
            annotations.push(Annotation {
                subject: iri.clone(),
                predicate: "obo:hasDbXref".to_string(),
                object: value.trim().to_string(),
                ontology_id: ontology_id.to_string(),
                source_location: SourceLocation::default(),
            });
        } else if let Some(value) = line.strip_prefix("property_value:") {
            let mut parts = value.split_whitespace();
            if let (Some(prop), Some(val)) = (parts.next(), parts.next()) {
                annotations.push(Annotation {
                    subject: iri,
                    predicate: prop.to_string(),
                    object: val.to_string(),
                    ontology_id: ontology_id.to_string(),
                    source_location: SourceLocation::default(),
                });
            }
        }
        if entities.len() + annotations.len() + axioms.len() > MAX_TRIPLES_PER_FILE {
            return Err(ParseError::LimitExceeded(format!(
                "OBO file exceeds entity/axiom limit: {}",
                path.display()
            )));
        }
    }

    flush_term(
        &mut entities,
        &mut current_id,
        &mut current_iri,
        &mut labels,
        &mut comments,
        &mut deprecated,
    );

    let total = entities.len() + annotations.len() + axioms.len();
    if entities.len() > MAX_TRIPLES_PER_FILE || total > MAX_TRIPLES_PER_FILE {
        return Err(ParseError::LimitExceeded(format!(
            "OBO file exceeds entity/axiom limit: {}",
            path.display()
        )));
    }

    // Ensure default OBO namespace is visible for consumers.
    if !namespaces.contains_key("") {
        namespaces.insert(String::new(), DEFAULT_OBO_BASE.to_string());
    }

    Ok(assemble_parsed_ontology(
        ontology_id,
        Some(DEFAULT_OBO_BASE.to_string()),
        namespaces,
        entities,
        annotations,
        axioms,
    ))
}

/// Map an OBO ID to an IRI using `idspace:` expansions when present.
///
/// Default: `GO:0000001` → `http://purl.obolibrary.org/obo/GO_0000001`.
fn obo_id_to_iri(obo_id: &str, namespaces: &BTreeMap<String, String>) -> String {
    if obo_id.starts_with("http://") || obo_id.starts_with("https://") {
        return obo_id.to_string();
    }
    if let Some((prefix, local)) = obo_id.split_once(':') {
        if let Some(ns) = namespaces.get(prefix) {
            return format!("{ns}{local}");
        }
    }
    let normalized = obo_id.replace(':', "_");
    format!("{DEFAULT_OBO_BASE}{normalized}")
}

pub fn parse_obo_file(
    path: &Path,
    ontology_id: &str,
    _content_hash: &str,
    _modified_time: u64,
) -> Result<ParsedOntology> {
    let metadata = std::fs::metadata(path).map_err(ParseError::Io)?;
    if metadata.len() > ontocore_core::MAX_FILE_BYTES {
        return Err(ParseError::Io(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            format!("file exceeds maximum size of {} bytes", ontocore_core::MAX_FILE_BYTES),
        )));
    }
    let content = std::fs::read_to_string(path).map_err(ParseError::Io)?;
    parse_obo_text(path, ontology_id, &content)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    #[test]
    fn parses_minimal_obo() {
        let mut file = NamedTempFile::new().unwrap();
        writeln!(
            file,
            "format-version: 1.2\nontology: test\n\n[Term]\nid: TEST:0000001\nname: example term\nis_a: TEST:0000002 ! parent\n"
        )
        .unwrap();
        let parsed = parse_obo_file(file.path(), "doc-1", "hash", 0).unwrap();
        assert_eq!(parsed.entities.len(), 1);
        assert_eq!(parsed.entities[0].obo_id.as_deref(), Some("TEST:0000001"));
        assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/TEST_0000001");
        assert_eq!(parsed.entities[0].labels, vec!["example term"]);
        assert_eq!(parsed.axioms.len(), 1);
        assert_eq!(parsed.axioms[0].object, "http://purl.obolibrary.org/obo/TEST_0000002");
        assert!(parsed.triple_count > 0);
        assert!(!parsed.quads().is_empty(), "OBO must materialize RDF quads");
    }

    #[test]
    fn idspace_overrides_default_base() {
        let text = "format-version: 1.2\n\
idspace: GO http://purl.obolibrary.org/obo/GO_\n\n\
[Term]\n\
id: GO:0000001\n\
name: mitochondrion\n";
        let parsed = parse_obo_text(Path::new("go.obo"), "doc-1", text).unwrap();
        assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/GO_0000001");
    }

    #[test]
    fn rejects_oversized_obo_source_text() {
        let huge = "x".repeat((ontocore_core::MAX_FILE_BYTES as usize) + 1);
        let err = crate::rdf::parse_ontology_text(
            Path::new("big.obo"),
            ontocore_core::OntologyFormat::Obo,
            "doc-1",
            &huge,
            huge.as_bytes(),
        )
        .unwrap_err();
        assert!(err.to_string().contains("exceeds"));
    }
}