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 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 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 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}