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