Skip to main content

ontocore_reasoner/
input.rs

1use crate::error::{ReasonerError, Result};
2use crate::hierarchy::asserted_hierarchy_from_ontology;
3use ontocore_catalog::ClassHierarchy;
4use ontocore_core::{OntologyFile, OntologyFormat, WorkspaceScanner};
5use ontocore_parser::{parse_ontology_file, parse_ontology_text, serialize_quads_turtle};
6use ontologos_bridge::{core_to_triples_all, merge_triples_into_ontology};
7use ontologos_core::Ontology;
8use ontologos_parser::load_ontology;
9use sha2::{Digest, Sha256};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone)]
14pub struct ReasonerInput {
15    pub workspace: PathBuf,
16    pub content_hash: String,
17    pub ontology: Ontology,
18    pub asserted_hierarchy: ClassHierarchy,
19    pub document_overrides: HashMap<PathBuf, String>,
20}
21
22pub struct WorkspaceInputLoader {
23    workspace: PathBuf,
24    scan_roots: Vec<PathBuf>,
25    document_overrides: HashMap<PathBuf, String>,
26}
27
28impl WorkspaceInputLoader {
29    pub fn new(workspace: impl Into<PathBuf>) -> Self {
30        Self {
31            workspace: workspace.into(),
32            scan_roots: Vec::new(),
33            document_overrides: HashMap::new(),
34        }
35    }
36
37    pub fn document_overrides(mut self, overrides: HashMap<PathBuf, String>) -> Self {
38        self.document_overrides = overrides;
39        self
40    }
41
42    /// Additional workspace roots to scan (multi-root), matching catalog `scan_roots`.
43    pub fn scan_roots(mut self, roots: Vec<PathBuf>) -> Self {
44        self.scan_roots = roots;
45        self
46    }
47
48    pub fn load(&self) -> Result<ReasonerInput> {
49        let scan_roots =
50            ontocore_catalog::IndexBuilder::effective_scan_roots(&self.workspace, &self.scan_roots);
51        let mut files: Vec<OntologyFile> = Vec::new();
52        for root in &scan_roots {
53            let scanner = WorkspaceScanner::new(root);
54            for file in scanner.scan()? {
55                if !files.iter().any(|f| paths_equal(&f.path, &file.path)) {
56                    files.push(file);
57                }
58            }
59        }
60
61        let mut hasher = Sha256::new();
62        let mut ontology = Ontology::new();
63
64        for file in &files {
65            let loaded = if let Some(text) = self.document_override_text(&file.path) {
66                // Hash override body so open-buffer edits invalidate the reasoner cache.
67                hasher.update(file.path.to_string_lossy().as_bytes());
68                hasher.update(text.as_bytes());
69                load_workspace_file(&file.path, file.format, Some(text), file)?
70            } else {
71                hasher.update(file.content_hash.as_bytes());
72                load_workspace_file(&file.path, file.format, None, file)?
73            };
74            merge_ontology(&mut ontology, loaded)?;
75        }
76
77        for (path, text) in &self.document_overrides {
78            if files.iter().any(|f| paths_equal(&f.path, path)) {
79                continue;
80            }
81            hasher.update(path.to_string_lossy().as_bytes());
82            hasher.update(text.as_bytes());
83            let format = OntologyFormat::from_extension(
84                path.extension().and_then(|e| e.to_str()).unwrap_or("ttl"),
85            );
86            let file_stub = OntologyFile {
87                path: path.clone(),
88                format,
89                content_hash: String::new(),
90                modified_time: 0,
91                size_bytes: text.len() as u64,
92            };
93            let loaded = load_workspace_file(path, format, Some(text), &file_stub)?;
94            merge_ontology(&mut ontology, loaded)?;
95        }
96
97        let asserted_hierarchy = asserted_hierarchy_from_ontology(&ontology);
98
99        Ok(ReasonerInput {
100            workspace: self.workspace.clone(),
101            content_hash: hex::encode(hasher.finalize()),
102            ontology,
103            asserted_hierarchy,
104            document_overrides: self.document_overrides.clone(),
105        })
106    }
107
108    fn document_override_text(&self, path: &Path) -> Option<&String> {
109        if let Some(text) = self.document_overrides.get(path) {
110            return Some(text);
111        }
112        let canonical = path.canonicalize().ok();
113        if let Some(ref canon) = canonical {
114            if let Some(text) = self.document_overrides.get(canon) {
115                return Some(text);
116            }
117        }
118        // Match overrides stored under a different spelling of the same path
119        // (e.g. /var vs /private/var on macOS).
120        self.document_overrides.iter().find_map(|(key, text)| {
121            if paths_equal(key, path) {
122                Some(text)
123            } else {
124                None
125            }
126        })
127    }
128}
129
130fn paths_equal(a: &Path, b: &Path) -> bool {
131    a == b || a.canonicalize().ok().zip(b.canonicalize().ok()).is_some_and(|(x, y)| x == y)
132}
133
134fn load_workspace_file(
135    path: &Path,
136    format: OntologyFormat,
137    override_text: Option<&str>,
138    file: &OntologyFile,
139) -> Result<Ontology> {
140    if format == OntologyFormat::Obo {
141        return load_obo_as_ontology(path, override_text, file);
142    }
143    if let Some(text) = override_text {
144        return load_ontology_from_temp(path, text);
145    }
146    load_ontology(path)
147        .map_err(|e| ReasonerError::Load { path: path.to_path_buf(), message: e.to_string() })
148}
149
150fn load_obo_as_ontology(
151    path: &Path,
152    override_text: Option<&str>,
153    file: &OntologyFile,
154) -> Result<Ontology> {
155    let parsed = if let Some(text) = override_text {
156        parse_ontology_text(path, OntologyFormat::Obo, "reasoner", text, text.as_bytes())
157    } else {
158        parse_ontology_file(
159            path,
160            OntologyFormat::Obo,
161            "reasoner",
162            &file.content_hash,
163            file.modified_time,
164        )
165    }
166    .map_err(|e| ReasonerError::Load { path: path.to_path_buf(), message: e.to_string() })?;
167
168    if parsed.quads().is_empty() {
169        return Err(ReasonerError::Load {
170            path: path.to_path_buf(),
171            message: "OBO file produced no RDF quads".to_string(),
172        });
173    }
174
175    let turtle = serialize_quads_turtle(parsed.quads())
176        .map_err(|e| ReasonerError::Load { path: path.to_path_buf(), message: e.to_string() })?;
177    load_ontology_from_temp_with_suffix(path, &turtle, "ttl")
178}
179
180fn load_ontology_from_temp(path: &Path, text: &str) -> Result<Ontology> {
181    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("ttl");
182    load_ontology_from_temp_with_suffix(path, text, ext)
183}
184
185fn load_ontology_from_temp_with_suffix(path: &Path, text: &str, ext: &str) -> Result<Ontology> {
186    use ontocore_core::MAX_FILE_BYTES;
187    if text.len() as u64 > MAX_FILE_BYTES {
188        return Err(ReasonerError::Load {
189            path: path.to_path_buf(),
190            message: format!("document exceeds maximum size of {MAX_FILE_BYTES} bytes"),
191        });
192    }
193    let tmp = tempfile::Builder::new()
194        .suffix(&format!(".{ext}"))
195        .tempfile()
196        .map_err(|e| ReasonerError::Load { path: path.to_path_buf(), message: e.to_string() })?;
197    std::fs::write(tmp.path(), text)
198        .map_err(|e| ReasonerError::Load { path: path.to_path_buf(), message: e.to_string() })?;
199    load_ontology(tmp.path())
200        .map_err(|e| ReasonerError::Load { path: path.to_path_buf(), message: e.to_string() })
201}
202
203fn merge_ontology(target: &mut Ontology, source: Ontology) -> Result<()> {
204    let triples =
205        core_to_triples_all(&source).map_err(|e| ReasonerError::Ontology(e.to_string()))?;
206    merge_triples_into_ontology(target, &triples, &[])
207        .map_err(|e| ReasonerError::Ontology(e.to_string()))?;
208    Ok(())
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::{classify, ReasonerId};
215    use ontocore_catalog::IndexBuilder;
216    use std::fs;
217
218    #[test]
219    fn content_hash_changes_when_override_differs_from_disk() {
220        let dir = tempfile::tempdir().unwrap();
221        let path = dir.path().join("ex.ttl");
222        fs::write(
223            &path,
224            "@prefix ex: <http://ex#> .\n@prefix owl: <http://www.w3.org/2002/07/owl#> .\nex:A a owl:Class .\n",
225        )
226        .unwrap();
227
228        let disk_input = WorkspaceInputLoader::new(dir.path()).load().expect("disk load");
229
230        let mut overrides = HashMap::new();
231        overrides.insert(
232            path.clone(),
233            "@prefix ex: <http://ex#> .\n@prefix owl: <http://www.w3.org/2002/07/owl#> .\nex:A a owl:Class .\nex:B a owl:Class .\n"
234                .to_string(),
235        );
236        let override_input = WorkspaceInputLoader::new(dir.path())
237            .document_overrides(overrides)
238            .load()
239            .expect("override load");
240
241        assert_ne!(
242            disk_input.content_hash, override_input.content_hash,
243            "open-buffer overrides must change reasoner content_hash"
244        );
245    }
246
247    #[test]
248    fn buffer_subclass_axiom_not_reported_as_new_inference() {
249        let dir = tempfile::tempdir().unwrap();
250        let path = dir.path().join("ex.ttl");
251        fs::write(
252            &path,
253            "@prefix ex: <http://ex#> .\n\
254             @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
255             @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
256             ex:A a owl:Class .\n\
257             ex:C a owl:Class ; rdfs:subClassOf ex:A .\n",
258        )
259        .unwrap();
260
261        let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("index");
262        assert!(
263            !catalog.class_hierarchy().edges.iter().any(|e| e.child.ends_with("D")),
264            "stale catalog must not yet include ex:D"
265        );
266
267        let mut overrides = HashMap::new();
268        overrides.insert(
269            path,
270            "@prefix ex: <http://ex#> .\n\
271             @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
272             @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
273             ex:A a owl:Class .\n\
274             ex:C a owl:Class ; rdfs:subClassOf ex:A .\n\
275             ex:D a owl:Class ; rdfs:subClassOf ex:C .\n"
276                .to_string(),
277        );
278
279        let input = WorkspaceInputLoader::new(dir.path())
280            .document_overrides(overrides)
281            .load()
282            .expect("override load");
283
284        assert!(
285            input
286                .asserted_hierarchy
287                .edges
288                .iter()
289                .any(|e| e.child.ends_with("D") && e.parent.ends_with("C")),
290            "asserted hierarchy must include buffer subclass axiom D ⊑ C"
291        );
292
293        let result = classify(ReasonerId::Rdfs, &input, false).expect("classify");
294        assert!(
295            !result
296                .new_inferences
297                .iter()
298                .any(|e| e.child.ends_with("D") && e.parent.ends_with("C")),
299            "buffer-authored D ⊑ C must not appear in new_inferences"
300        );
301    }
302
303    #[test]
304    fn asserted_hierarchy_matches_catalog_without_overrides() {
305        let dir = tempfile::tempdir().unwrap();
306        let path = dir.path().join("ex.ttl");
307        fs::write(
308            &path,
309            "@prefix ex: <http://ex#> .\n\
310             @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
311             @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n\
312             ex:A a owl:Class .\n\
313             ex:C a owl:Class ; rdfs:subClassOf ex:A .\n",
314        )
315        .unwrap();
316
317        let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("index");
318        let input = WorkspaceInputLoader::new(dir.path()).load().expect("load");
319
320        let catalog_edges: std::collections::BTreeSet<_> = catalog
321            .class_hierarchy()
322            .edges
323            .iter()
324            .map(|e| (e.child.clone(), e.parent.clone()))
325            .collect();
326        let input_edges: std::collections::BTreeSet<_> = input
327            .asserted_hierarchy
328            .edges
329            .iter()
330            .map(|e| (e.child.clone(), e.parent.clone()))
331            .collect();
332        assert_eq!(catalog_edges, input_edges);
333    }
334
335    #[test]
336    fn loads_minimal_obo_workspace() {
337        let dir = tempfile::tempdir().unwrap();
338        let path = dir.path().join("test.obo");
339        fs::write(
340            &path,
341            "format-version: 1.2\nontology: test\n\n\
342[Term]\n\
343id: TEST:0000001\n\
344name: child\n\
345is_a: TEST:0000002 ! parent\n\n\
346[Term]\n\
347id: TEST:0000002\n\
348name: parent\n",
349        )
350        .unwrap();
351
352        let input = WorkspaceInputLoader::new(dir.path())
353            .load()
354            .expect("OBO workspace should load for reasoner");
355        let triples = core_to_triples_all(&input.ontology).expect("triples");
356        assert!(!triples.is_empty(), "OBO-derived ontology should contain triples");
357    }
358}