1use crate::disk_cache::DiskCache;
2use crate::incremental::{
3 config_fingerprint, content_hash_text, effective_content_hash, paths_equal, DocumentSnapshot,
4 IncrementalStats,
5};
6use crate::OntologyCatalogData;
7use oxigraph::model::{BlankNode, GraphName, Quad, Subject, Term, Triple};
8use oxigraph::store::Store;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use strixonomy_core::{
12 limits::{MAX_ENTITIES, MAX_TOTAL_TRIPLES, MAX_TRIPLES_PER_FILE},
13 read_to_string_capped, Annotation, Axiom, Diagnostic, DiagnosticCode, DiagnosticSeverity,
14 Entity, EntityKind, Import, Namespace, OntologyDocument, OntologyFormat, ParseStatus,
15 SourceLocation, WorkspaceScanner, MAX_FILE_BYTES,
16};
17use strixonomy_diagnostics::{collect_diagnostics_with_config, find_config, DiagnosticInput};
18use strixonomy_owl::{load_owx_text, load_turtle_text, supports_horned_load};
19use strixonomy_parser::{parse_ontology_file, parse_ontology_text, ParsedOntology};
20use thiserror::Error;
21
22#[derive(Debug, Error)]
23pub enum CatalogError {
24 #[error("core error: {0}")]
25 Core(#[from] strixonomy_core::StrixonomyError),
26
27 #[error("parse error in {path}: {message}")]
28 Parse { path: PathBuf, message: String },
29
30 #[error("store error: {0}")]
31 Store(String),
32}
33
34pub type Result<T> = std::result::Result<T, CatalogError>;
35
36pub struct IndexBuilder {
37 workspace: PathBuf,
38 scan_roots: Vec<PathBuf>,
39 document_overrides: HashMap<PathBuf, String>,
40 disk_cache: bool,
41 only_paths: Option<Vec<PathBuf>>,
42}
43
44impl IndexBuilder {
45 pub fn new() -> Self {
46 Self {
47 workspace: PathBuf::from("."),
48 scan_roots: Vec::new(),
49 document_overrides: HashMap::new(),
50 disk_cache: false,
51 only_paths: None,
52 }
53 }
54
55 pub fn workspace(mut self, path: impl Into<PathBuf>) -> Self {
56 self.workspace = path.into();
57 self
58 }
59
60 pub fn scan_roots(mut self, roots: Vec<PathBuf>) -> Self {
63 self.scan_roots = roots;
64 self
65 }
66
67 pub fn effective_scan_roots(workspace: &Path, extra_roots: &[PathBuf]) -> Vec<PathBuf> {
69 merge_scan_roots(workspace, extra_roots)
70 }
71
72 pub fn document_overrides(mut self, overrides: HashMap<PathBuf, String>) -> Self {
74 self.document_overrides = overrides;
75 self
76 }
77
78 pub fn disk_cache(mut self, enabled: bool) -> Self {
80 self.disk_cache = enabled;
81 self
82 }
83
84 pub fn only_paths(mut self, paths: Vec<PathBuf>) -> Self {
86 self.only_paths = Some(paths);
87 self
88 }
89
90 fn document_override_text(&self, path: &Path) -> Option<&String> {
91 self.document_overrides
92 .get(path)
93 .or_else(|| path.canonicalize().ok().and_then(|p| self.document_overrides.get(&p)))
94 }
95
96 fn config_fingerprint(&self) -> String {
97 let scan_roots = merge_scan_roots(&self.workspace, &self.scan_roots);
98 config_fingerprint(&scan_roots, self.disk_cache, &self.document_overrides)
99 }
100
101 pub fn build(self) -> Result<OntologyCatalog> {
102 self.build_with_snapshots(None, false).map(|(catalog, _)| catalog)
103 }
104
105 pub fn build_incremental(self, previous: &OntologyCatalog) -> Result<OntologyCatalog> {
107 if !paths_equal(&previous.workspace, &self.workspace) {
108 return self.build();
109 }
110 if previous.config_fingerprint != self.config_fingerprint() {
111 return self.build();
112 }
113 self.build_with_snapshots(Some(&previous.document_snapshots), true)
114 .map(|(catalog, _)| catalog)
115 }
116
117 fn build_with_snapshots(
118 self,
119 previous_snapshots: Option<&HashMap<PathBuf, DocumentSnapshot>>,
120 incremental: bool,
121 ) -> Result<(OntologyCatalog, IncrementalStats)> {
122 let scan_roots = merge_scan_roots(&self.workspace, &self.scan_roots);
123 let mut files = Vec::new();
124 let mut seen = std::collections::HashSet::new();
125 if let Some(ref only) = self.only_paths {
126 for path in only {
127 if !path.is_file() {
128 continue;
129 }
130 let key = path.canonicalize().unwrap_or_else(|_| path.clone());
131 if seen.insert(key.clone()) {
132 files.push(WorkspaceScanner::new(&self.workspace).describe_path(&key)?);
133 }
134 }
135 } else {
136 for root in scan_roots {
137 for file in WorkspaceScanner::new(&root).scan()? {
138 let key = file.path.canonicalize().unwrap_or_else(|_| file.path.clone());
139 if seen.insert(key) {
140 files.push(file);
141 }
142 }
143 }
144 }
145
146 let mut documents = Vec::new();
147 let mut entities: Vec<Entity> = Vec::new();
148 let mut entity_index: HashMap<String, usize> = HashMap::new();
149 let mut entity_to_document: HashMap<String, usize> = HashMap::new();
150 let mut document_entity_iris: Vec<Vec<String>> = Vec::new();
151 let mut annotations = Vec::new();
152 let mut axioms = Vec::new();
153 let mut namespaces = Vec::new();
154 let mut imports = Vec::new();
155 let mut triple_count = 0usize;
156 let store = Store::new().map_err(|e| CatalogError::Store(e.to_string()))?;
157
158 let mut bridge_diagnostics = Vec::new();
159 let mut document_snapshots = HashMap::new();
160 let mut reused = 0usize;
161 let mut reparsed = 0usize;
162
163 let disk_cache = DiskCache::enabled(self.disk_cache, &self.workspace);
164 let mut merged_previous = previous_snapshots.cloned().unwrap_or_default();
165 if let Some(ref cache) = disk_cache {
166 for file in &files {
167 let override_text = self.document_override_text(&file.path);
168 let effective_hash =
169 effective_content_hash(&file.content_hash, override_text.map(String::as_str));
170 let lookup_path = file.path.canonicalize().unwrap_or_else(|_| file.path.clone());
171 cache.hydrate_previous(
172 &mut merged_previous,
173 &lookup_path,
174 &effective_hash,
175 file.modified_time,
176 );
177 }
178 }
179 let previous_snapshots =
180 if merged_previous.is_empty() { None } else { Some(&merged_previous) };
181
182 for (idx, file) in files.iter().enumerate() {
183 let doc_id = format!("doc-{}", idx + 1);
184 let override_text = self.document_override_text(&file.path);
185 let effective_hash =
186 effective_content_hash(&file.content_hash, override_text.map(String::as_str));
187 let lookup_path = file.path.canonicalize().unwrap_or_else(|_| file.path.clone());
188
189 if let Some(prev) = previous_snapshots.and_then(|m| m.get(&lookup_path)) {
190 if prev.content_hash == effective_hash {
191 if let Some((reuse_path, modified_time)) = verified_snapshot_source(
192 &self.workspace,
193 &file.path,
194 override_text.map(String::as_str),
195 &effective_hash,
196 ) {
197 let snap = prev.with_reuse_context(&doc_id, &reuse_path, modified_time);
198 apply_document_snapshot(
199 &snap,
200 &doc_id,
201 &mut documents,
202 &mut entities,
203 &mut entity_index,
204 &mut entity_to_document,
205 &mut document_entity_iris,
206 &mut annotations,
207 &mut axioms,
208 &mut namespaces,
209 &mut imports,
210 &mut triple_count,
211 &store,
212 &mut bridge_diagnostics,
213 )?;
214 document_snapshots.insert(lookup_path, snap);
215 reused += 1;
216 continue;
217 }
218 }
219 }
220
221 reparsed += 1;
222 let parsed = if let Some(text) = override_text {
223 parse_ontology_text(&file.path, file.format, &doc_id, text, text.as_bytes())
224 .map_err(|e| CatalogError::Parse {
225 path: file.path.clone(),
226 message: e.to_string(),
227 })?
228 } else {
229 parse_ontology_file(
230 &file.path,
231 file.format,
232 &doc_id,
233 &file.content_hash,
234 file.modified_time,
235 )
236 .map_err(|e| CatalogError::Parse {
237 path: file.path.clone(),
238 message: e.to_string(),
239 })?
240 };
241
242 if !parsed.quads().is_empty() {
244 load_quads_into_store(&store, parsed.quads(), &mut triple_count, &doc_id)?;
245 }
246
247 documents.push(OntologyDocument {
248 id: doc_id.clone(),
249 path: file.path.clone(),
250 format: file.format,
251 base_iri: parsed.base_iri.clone(),
252 version_iri: parsed.version_iri.clone(),
253 imports: parsed.imports.clone(),
254 namespaces: parsed.namespaces.clone(),
255 parse_status: parsed.parse_status,
256 content_hash: file.content_hash.clone(),
257 modified_time: file.modified_time,
258 parse_message: parsed.parse_message.clone(),
259 parse_error_location: parsed.parse_error_location.clone(),
260 });
261
262 let doc_idx = documents.len() - 1;
263 let mut doc_entity_iris = Vec::new();
264
265 let semantics = semantics_for_document(
266 &file.path,
267 file.format,
268 &doc_id,
269 &parsed,
270 self.document_override_text(&file.path),
271 )?;
272
273 let snapshot_quads = if !parsed.quads().is_empty() {
275 parsed.quads().to_vec()
276 } else if !semantics.rdf_quads.is_empty() {
277 load_quads_into_store(&store, &semantics.rdf_quads, &mut triple_count, &doc_id)?;
278 semantics.rdf_quads.clone()
279 } else {
280 Vec::new()
281 };
282
283 let stored_hash = if override_text.is_some() {
284 effective_hash.clone()
285 } else {
286 file.content_hash.clone()
287 };
288 let snapshot = DocumentSnapshot {
289 content_hash: stored_hash.clone(),
290 document: documents.last().cloned().expect("document"),
291 entities: semantics.entities.clone(),
292 annotations: semantics.annotations.clone(),
293 axioms: semantics.axioms.clone(),
294 namespace_rows: semantics.namespace_rows.clone(),
295 imports: semantics.imports.clone(),
296 quads: snapshot_quads.clone(),
297 triple_count: snapshot_quads.len(),
298 bridge_warning: semantics.bridge_warning.clone(),
299 };
300
301 if let Some(diag) = semantics.bridge_warning {
302 bridge_diagnostics.push(diag);
303 }
304
305 for entity in semantics.entities {
306 if entities.len() >= MAX_ENTITIES && !entity_index.contains_key(&entity.iri) {
307 return Err(CatalogError::Core(strixonomy_core::StrixonomyError::Scanner(
308 format!("workspace exceeds maximum of {MAX_ENTITIES} entities"),
309 )));
310 }
311 if let Some(&prev_doc_idx) = entity_to_document.get(&entity.iri) {
312 if prev_doc_idx != doc_idx {
313 document_entity_iris[prev_doc_idx].retain(|iri| iri != &entity.iri);
314 }
315 }
316 entity_to_document.insert(entity.iri.clone(), doc_idx);
317 doc_entity_iris.push(entity.iri.clone());
318 if let Some(&existing_idx) = entity_index.get(&entity.iri) {
319 merge_entity(&mut entities[existing_idx], &entity);
320 } else {
321 let idx = entities.len();
322 entity_index.insert(entity.iri.clone(), idx);
323 entities.push(entity);
324 }
325 }
326 document_entity_iris.push(doc_entity_iris);
327 annotations.extend(semantics.annotations);
328 axioms.extend(semantics.axioms);
329 namespaces.extend(semantics.namespace_rows);
330 imports.extend(semantics.imports);
331
332 if entities.len() > MAX_ENTITIES {
333 return Err(CatalogError::Core(strixonomy_core::StrixonomyError::Scanner(
334 format!("workspace exceeds maximum of {MAX_ENTITIES} entities"),
335 )));
336 }
337
338 if let Some(doc) = documents.last_mut() {
339 doc.content_hash = stored_hash.clone();
340 }
341 if let Some(ref cache) = disk_cache {
342 if let Err(e) = cache.store(&snapshot) {
343 bridge_diagnostics.push(Diagnostic {
344 code: DiagnosticCode::IoReadError,
345 severity: DiagnosticSeverity::Warning,
346 message: format!("disk cache write failed: {e}"),
347 file: lookup_path.clone(),
348 range: SourceLocation::default(),
349 entity_iri: None,
350 quick_fix: None,
351 plugin_id: None,
352 plugin_code: None,
353 });
354 }
355 }
356 document_snapshots.insert(lookup_path, snapshot);
357 }
358
359 let previous_paths: HashMap<PathBuf, ()> = previous_snapshots
360 .map(|m| m.keys().cloned().map(|p| (p, ())).collect())
361 .unwrap_or_default();
362 let mut removed = 0usize;
363 for path in previous_paths.keys() {
364 if !document_snapshots.contains_key(path) {
365 removed += 1;
366 }
367 }
368
369 for (override_path, override_text) in &self.document_overrides {
370 if files.iter().any(|f| paths_equal(&f.path, override_path)) {
371 continue;
372 }
373 let format = OntologyFormat::from_extension(
374 override_path.extension().and_then(|e| e.to_str()).unwrap_or("ttl"),
375 );
376 if matches!(format, OntologyFormat::Unknown) {
377 continue;
378 }
379 let doc_id = format!("doc-{}", documents.len() + 1);
380 let lookup_path =
381 override_path.canonicalize().unwrap_or_else(|_| override_path.clone());
382 let effective_hash = content_hash_text(override_text);
383
384 if let Some(prev) = previous_snapshots.and_then(|m| m.get(&lookup_path)) {
385 if prev.content_hash == effective_hash {
386 if let Some((reuse_path, modified_time)) = verified_snapshot_source(
387 &self.workspace,
388 override_path,
389 Some(override_text.as_str()),
390 &effective_hash,
391 ) {
392 let snap = prev.with_reuse_context(&doc_id, &reuse_path, modified_time);
393 apply_document_snapshot(
394 &snap,
395 &doc_id,
396 &mut documents,
397 &mut entities,
398 &mut entity_index,
399 &mut entity_to_document,
400 &mut document_entity_iris,
401 &mut annotations,
402 &mut axioms,
403 &mut namespaces,
404 &mut imports,
405 &mut triple_count,
406 &store,
407 &mut bridge_diagnostics,
408 )?;
409 document_snapshots.insert(lookup_path, snap);
410 reused += 1;
411 continue;
412 }
413 }
414 }
415
416 reparsed += 1;
417 let parsed = parse_ontology_text(
418 override_path,
419 format,
420 &doc_id,
421 override_text,
422 override_text.as_bytes(),
423 )
424 .map_err(|e| CatalogError::Parse {
425 path: override_path.clone(),
426 message: e.to_string(),
427 })?;
428
429 if !parsed.quads().is_empty() {
431 load_quads_into_store(&store, parsed.quads(), &mut triple_count, &doc_id)?;
432 }
433
434 documents.push(OntologyDocument {
435 id: doc_id.clone(),
436 path: override_path.clone(),
437 format,
438 base_iri: parsed.base_iri.clone(),
439 version_iri: parsed.version_iri.clone(),
440 imports: parsed.imports.clone(),
441 namespaces: parsed.namespaces.clone(),
442 parse_status: parsed.parse_status,
443 content_hash: effective_hash.clone(),
444 modified_time: 0,
445 parse_message: parsed.parse_message.clone(),
446 parse_error_location: parsed.parse_error_location.clone(),
447 });
448
449 let doc_idx = documents.len() - 1;
450 let mut doc_entity_iris = Vec::new();
451
452 let semantics = semantics_for_document(
453 override_path,
454 format,
455 &doc_id,
456 &parsed,
457 Some(override_text),
458 )?;
459
460 let snapshot_quads = if !parsed.quads().is_empty() {
461 parsed.quads().to_vec()
462 } else if !semantics.rdf_quads.is_empty() {
463 load_quads_into_store(&store, &semantics.rdf_quads, &mut triple_count, &doc_id)?;
464 semantics.rdf_quads.clone()
465 } else {
466 Vec::new()
467 };
468
469 let snapshot = DocumentSnapshot {
470 content_hash: effective_hash.clone(),
471 document: documents.last().cloned().expect("document"),
472 entities: semantics.entities.clone(),
473 annotations: semantics.annotations.clone(),
474 axioms: semantics.axioms.clone(),
475 namespace_rows: semantics.namespace_rows.clone(),
476 imports: semantics.imports.clone(),
477 quads: snapshot_quads.clone(),
478 triple_count: snapshot_quads.len(),
479 bridge_warning: semantics.bridge_warning.clone(),
480 };
481
482 if let Some(diag) = semantics.bridge_warning {
483 bridge_diagnostics.push(diag);
484 }
485
486 for entity in semantics.entities {
487 if entities.len() >= MAX_ENTITIES && !entity_index.contains_key(&entity.iri) {
488 return Err(CatalogError::Core(strixonomy_core::StrixonomyError::Scanner(
489 format!("workspace exceeds maximum of {MAX_ENTITIES} entities"),
490 )));
491 }
492 if let Some(&prev_doc_idx) = entity_to_document.get(&entity.iri) {
493 if prev_doc_idx != doc_idx {
494 document_entity_iris[prev_doc_idx].retain(|iri| iri != &entity.iri);
495 }
496 }
497 entity_to_document.insert(entity.iri.clone(), doc_idx);
498 doc_entity_iris.push(entity.iri.clone());
499 if let Some(&existing_idx) = entity_index.get(&entity.iri) {
500 merge_entity(&mut entities[existing_idx], &entity);
501 } else {
502 let idx = entities.len();
503 entity_index.insert(entity.iri.clone(), idx);
504 entities.push(entity);
505 }
506 }
507 document_entity_iris.push(doc_entity_iris);
508 annotations.extend(semantics.annotations);
509 axioms.extend(semantics.axioms);
510 namespaces.extend(semantics.namespace_rows);
511 imports.extend(semantics.imports);
512
513 if entities.len() > MAX_ENTITIES {
514 return Err(CatalogError::Core(strixonomy_core::StrixonomyError::Scanner(
515 format!("workspace exceeds maximum of {MAX_ENTITIES} entities"),
516 )));
517 }
518
519 document_snapshots.insert(lookup_path.clone(), snapshot);
520 if let Some(ref cache) = disk_cache {
521 if let Err(e) = cache.store(document_snapshots.get(&lookup_path).expect("snapshot"))
522 {
523 bridge_diagnostics.push(Diagnostic {
524 code: DiagnosticCode::IoReadError,
525 severity: DiagnosticSeverity::Warning,
526 message: format!("disk cache write failed: {e}"),
527 file: lookup_path.clone(),
528 range: SourceLocation::default(),
529 entity_iri: None,
530 quick_fix: None,
531 plugin_id: None,
532 plugin_code: None,
533 });
534 }
535 }
536 }
537
538 let stats = if incremental || reused > 0 || removed > 0 {
539 IncrementalStats::Incremental { reused, reparsed, removed }
540 } else {
541 IncrementalStats::FullBuild
542 };
543
544 let config_fingerprint = self.config_fingerprint();
545 let mut data = OntologyCatalogData {
546 documents,
547 entities,
548 annotations,
549 axioms,
550 namespaces,
551 imports,
552 triple_count,
553 diagnostics: Vec::new(),
554 };
555 let lint_input = DiagnosticInput {
556 documents: &data.documents,
557 entities: &data.entities,
558 annotations: &data.annotations,
559 axioms: &data.axioms,
560 namespaces: &data.namespaces,
561 imports: &data.imports,
562 };
563 let diag_config = find_config(&self.workspace);
564 data.diagnostics = collect_diagnostics_with_config(
565 &lint_input,
566 &self.document_overrides,
567 diag_config.as_ref(),
568 );
569 data.diagnostics.extend(bridge_diagnostics);
570
571 if let Some(ref cache) = disk_cache {
572 let live_hashes: std::collections::HashSet<String> =
573 document_snapshots.values().map(|s| s.content_hash.clone()).collect();
574 if let Err(e) = cache.prune(&live_hashes) {
575 data.diagnostics.push(Diagnostic {
576 code: DiagnosticCode::IoReadError,
577 severity: DiagnosticSeverity::Warning,
578 message: format!("disk cache prune failed: {e}"),
579 file: strixonomy_core::cache_dir(&self.workspace).join("snapshots"),
580 range: SourceLocation::default(),
581 entity_iri: None,
582 quick_fix: None,
583 plugin_id: None,
584 plugin_code: None,
585 });
586 }
587 }
588
589 Ok((
590 OntologyCatalog {
591 workspace: self.workspace,
592 config_fingerprint,
593 data,
594 store,
595 entity_to_document,
596 document_entity_iris,
597 document_snapshots,
598 },
599 stats,
600 ))
601 }
602}
603
604impl Default for IndexBuilder {
605 fn default() -> Self {
606 Self::new()
607 }
608}
609
610pub struct OntologyCatalog {
611 workspace: PathBuf,
612 pub(crate) config_fingerprint: String,
613 data: OntologyCatalogData,
614 store: Store,
615 pub(crate) entity_to_document: HashMap<String, usize>,
617 pub(crate) document_entity_iris: Vec<Vec<String>>,
619 pub(crate) document_snapshots: HashMap<PathBuf, DocumentSnapshot>,
621}
622
623impl OntologyCatalog {
624 pub fn workspace(&self) -> &Path {
625 &self.workspace
626 }
627
628 pub fn data(&self) -> &OntologyCatalogData {
629 &self.data
630 }
631
632 #[doc(hidden)]
634 pub fn store(&self) -> &Store {
635 &self.store
636 }
637}
638
639#[allow(clippy::too_many_arguments)]
640fn apply_document_snapshot(
641 snap: &DocumentSnapshot,
642 doc_id: &str,
643 documents: &mut Vec<OntologyDocument>,
644 entities: &mut Vec<Entity>,
645 entity_index: &mut HashMap<String, usize>,
646 entity_to_document: &mut HashMap<String, usize>,
647 document_entity_iris: &mut Vec<Vec<String>>,
648 annotations: &mut Vec<Annotation>,
649 axioms: &mut Vec<Axiom>,
650 namespaces: &mut Vec<Namespace>,
651 imports: &mut Vec<Import>,
652 triple_count: &mut usize,
653 store: &Store,
654 bridge_diagnostics: &mut Vec<Diagnostic>,
655) -> Result<()> {
656 if snap.triple_count != snap.quads.len() {
657 return Err(CatalogError::Core(strixonomy_core::StrixonomyError::Scanner(
658 "document snapshot triple_count does not match quads length".to_string(),
659 )));
660 }
661 if !snap.quads.is_empty() {
662 load_quads_into_store(store, &snap.quads, triple_count, doc_id)?;
663 }
664
665 documents.push(snap.document.clone());
666 let doc_idx = documents.len() - 1;
667 let mut doc_entity_iris = Vec::new();
668
669 if let Some(diag) = &snap.bridge_warning {
670 bridge_diagnostics.push(diag.clone());
671 }
672
673 for entity in &snap.entities {
674 if entities.len() >= MAX_ENTITIES && !entity_index.contains_key(&entity.iri) {
675 return Err(CatalogError::Core(strixonomy_core::StrixonomyError::Scanner(format!(
676 "workspace exceeds maximum of {MAX_ENTITIES} entities"
677 ))));
678 }
679 if let Some(&prev_doc_idx) = entity_to_document.get(&entity.iri) {
680 if prev_doc_idx != doc_idx {
681 document_entity_iris[prev_doc_idx].retain(|iri| iri != &entity.iri);
682 }
683 }
684 entity_to_document.insert(entity.iri.clone(), doc_idx);
685 doc_entity_iris.push(entity.iri.clone());
686 if let Some(&existing_idx) = entity_index.get(&entity.iri) {
687 merge_entity(&mut entities[existing_idx], entity);
688 } else {
689 let idx = entities.len();
690 entity_index.insert(entity.iri.clone(), idx);
691 entities.push(entity.clone());
692 }
693 }
694 document_entity_iris.push(doc_entity_iris);
695 annotations.extend(snap.annotations.clone());
696 axioms.extend(snap.axioms.clone());
697 namespaces.extend(snap.namespace_rows.clone());
698 imports.extend(snap.imports.clone());
699
700 let _ = doc_id;
701 Ok(())
702}
703
704fn merge_entity(existing: &mut Entity, incoming: &Entity) {
705 for label in &incoming.labels {
706 if !existing.labels.contains(label) {
707 existing.labels.push(label.clone());
708 }
709 }
710 for comment in &incoming.comments {
711 if !existing.comments.contains(comment) {
712 existing.comments.push(comment.clone());
713 }
714 }
715 existing.deprecated |= incoming.deprecated;
716 existing.ontology_id = incoming.ontology_id.clone();
717 if existing.short_name.is_empty() {
718 existing.short_name = incoming.short_name.clone();
719 }
720 if existing.obo_id.is_none() {
723 existing.obo_id = incoming.obo_id.clone();
724 }
725 existing.characteristics.functional |= incoming.characteristics.functional;
727 existing.characteristics.inverse_functional |= incoming.characteristics.inverse_functional;
728 existing.characteristics.transitive |= incoming.characteristics.transitive;
729 existing.characteristics.symmetric |= incoming.characteristics.symmetric;
730 existing.characteristics.asymmetric |= incoming.characteristics.asymmetric;
731 existing.characteristics.reflexive |= incoming.characteristics.reflexive;
732 existing.characteristics.irreflexive |= incoming.characteristics.irreflexive;
733 if existing.kind == EntityKind::Other && incoming.kind != EntityKind::Other {
735 existing.kind = incoming.kind;
736 }
737 if existing.source_location.line.is_none() && incoming.source_location.line.is_some() {
738 existing.source_location = incoming.source_location.clone();
739 }
740}
741
742fn incomplete_bridge_warning(path: &Path, message: String) -> Diagnostic {
743 Diagnostic {
744 code: DiagnosticCode::OwlBridgeFailed,
745 severity: DiagnosticSeverity::Warning,
746 message,
747 file: path.to_path_buf(),
748 range: SourceLocation::default(),
749 entity_iri: None,
750 quick_fix: None,
751 plugin_id: None,
752 plugin_code: None,
753 }
754}
755
756struct DocumentSemantics {
757 entities: Vec<Entity>,
758 annotations: Vec<Annotation>,
759 axioms: Vec<Axiom>,
760 namespace_rows: Vec<Namespace>,
761 imports: Vec<Import>,
762 bridge_warning: Option<Diagnostic>,
763 rdf_quads: Vec<oxigraph::model::Quad>,
765}
766
767fn semantics_for_document(
768 path: &Path,
769 format: OntologyFormat,
770 doc_id: &str,
771 parsed: &ParsedOntology,
772 override_text: Option<&String>,
773) -> Result<DocumentSemantics> {
774 if parsed.parse_status == ParseStatus::Error || !supports_horned_load(format) {
775 return Ok(DocumentSemantics {
776 entities: parsed.entities.clone(),
777 annotations: parsed.annotations.clone(),
778 axioms: parsed.axioms.clone(),
779 namespace_rows: parsed.namespace_rows.clone(),
780 imports: parsed.import_rows.clone(),
781 bridge_warning: None,
782 rdf_quads: Vec::new(),
783 });
784 }
785
786 let source_text = if let Some(text) = override_text {
787 text.clone()
788 } else {
789 read_to_string_capped(path, MAX_FILE_BYTES).map_err(CatalogError::Core)?
790 };
791
792 if format == OntologyFormat::OwlXml {
793 return match load_owx_text(path, doc_id, &source_text, &parsed.namespaces) {
794 Ok(owl) => Ok(DocumentSemantics {
795 entities: owl.bridge.entities,
796 annotations: owl.bridge.annotations,
797 axioms: owl.bridge.axioms,
798 namespace_rows: owl.bridge.namespace_rows,
799 imports: owl.bridge.imports,
800 bridge_warning: owl
801 .load_warning
802 .map(|message| incomplete_bridge_warning(path, message)),
803 rdf_quads: owl.quads,
804 }),
805 Err(e) => {
806 eprintln!(
807 "strixonomy-catalog: Horned-OWL OWX load failed for {}: {e}; using parser entities",
808 path.display()
809 );
810 Ok(DocumentSemantics {
811 entities: parsed.entities.clone(),
812 annotations: parsed.annotations.clone(),
813 axioms: parsed.axioms.clone(),
814 namespace_rows: parsed.namespace_rows.clone(),
815 imports: parsed.import_rows.clone(),
816 bridge_warning: Some(Diagnostic {
817 code: DiagnosticCode::OwlBridgeFailed,
818 severity: DiagnosticSeverity::Warning,
819 message: format!(
820 "Horned-OWL OWL/XML bridge failed; using parser-only entities and axioms: {e}"
821 ),
822 file: path.to_path_buf(),
823 range: SourceLocation::default(),
824 entity_iri: None,
825 quick_fix: None,
826 plugin_id: None,
827 plugin_code: None,
828 }),
829 rdf_quads: Vec::new(),
830 })
831 }
832 };
833 }
834
835 match load_turtle_text(path, doc_id, &source_text, parsed.quads(), &parsed.namespaces) {
836 Ok(owl) => Ok(DocumentSemantics {
837 entities: owl.bridge.entities,
838 annotations: owl.bridge.annotations,
839 axioms: owl.bridge.axioms,
840 namespace_rows: owl.bridge.namespace_rows,
841 imports: owl.bridge.imports,
842 bridge_warning: owl
843 .load_warning
844 .map(|message| incomplete_bridge_warning(path, message)),
845 rdf_quads: Vec::new(),
846 }),
847 Err(e) => {
848 eprintln!(
849 "strixonomy-catalog: Horned-OWL load failed for {}: {e}; using parser entities",
850 path.display()
851 );
852 Ok(DocumentSemantics {
853 entities: parsed.entities.clone(),
854 annotations: parsed.annotations.clone(),
855 axioms: parsed.axioms.clone(),
856 namespace_rows: parsed.namespace_rows.clone(),
857 imports: parsed.import_rows.clone(),
858 bridge_warning: Some(Diagnostic {
859 code: DiagnosticCode::OwlBridgeFailed,
860 severity: DiagnosticSeverity::Warning,
861 message: format!(
862 "Horned-OWL bridge failed; using parser-only entities and axioms: {e}"
863 ),
864 file: path.to_path_buf(),
865 range: SourceLocation::default(),
866 entity_iri: None,
867 quick_fix: None,
868 plugin_id: None,
869 plugin_code: None,
870 }),
871 rdf_quads: Vec::new(),
872 })
873 }
874 }
875}
876
877fn load_quads_into_store(
880 store: &Store,
881 quads: &[Quad],
882 triple_count: &mut usize,
883 doc_id: &str,
884) -> Result<()> {
885 let mut blank_map = HashMap::new();
886 let mut file_triples = 0usize;
887 for quad in quads {
888 file_triples += 1;
889 if file_triples > MAX_TRIPLES_PER_FILE {
890 return Err(CatalogError::Core(strixonomy_core::StrixonomyError::Scanner(format!(
891 "file exceeds {MAX_TRIPLES_PER_FILE} triples"
892 ))));
893 }
894 *triple_count += 1;
895 if *triple_count > MAX_TOTAL_TRIPLES {
896 return Err(CatalogError::Core(strixonomy_core::StrixonomyError::Scanner(format!(
897 "workspace exceeds maximum of {MAX_TOTAL_TRIPLES} triples"
898 ))));
899 }
900 let remapped = remap_quad(quad, doc_id, &mut blank_map);
901 store.insert(&remapped).map_err(|e| CatalogError::Store(e.to_string()))?;
902 }
903 Ok(())
904}
905
906fn blank_id_prefix(doc_id: &str) -> String {
907 let sanitized: String =
908 doc_id.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
909 if sanitized.is_empty() {
910 "doc".to_string()
911 } else {
912 sanitized
913 }
914}
915
916fn remap_blank_node(
917 node: &BlankNode,
918 doc_id: &str,
919 blank_map: &mut HashMap<BlankNode, BlankNode>,
920) -> BlankNode {
921 blank_map
922 .entry(node.clone())
923 .or_insert_with(|| {
924 let id = format!("{}_{}", blank_id_prefix(doc_id), node.as_str());
925 BlankNode::new_unchecked(id)
926 })
927 .clone()
928}
929
930fn remap_quad(quad: &Quad, doc_id: &str, blank_map: &mut HashMap<BlankNode, BlankNode>) -> Quad {
931 Quad {
932 subject: remap_subject(&quad.subject, doc_id, blank_map),
933 predicate: quad.predicate.clone(),
934 object: remap_term(&quad.object, doc_id, blank_map),
935 graph_name: remap_graph_name(&quad.graph_name, doc_id, blank_map),
936 }
937}
938
939fn remap_subject(
940 subject: &Subject,
941 doc_id: &str,
942 blank_map: &mut HashMap<BlankNode, BlankNode>,
943) -> Subject {
944 match subject {
945 Subject::NamedNode(n) => Subject::NamedNode(n.clone()),
946 Subject::BlankNode(b) => Subject::BlankNode(remap_blank_node(b, doc_id, blank_map)),
947 Subject::Triple(t) => Subject::Triple(Box::new(remap_triple(t, doc_id, blank_map))),
948 }
949}
950
951fn remap_term(term: &Term, doc_id: &str, blank_map: &mut HashMap<BlankNode, BlankNode>) -> Term {
952 match term {
953 Term::NamedNode(n) => Term::NamedNode(n.clone()),
954 Term::BlankNode(b) => Term::BlankNode(remap_blank_node(b, doc_id, blank_map)),
955 Term::Literal(l) => Term::Literal(l.clone()),
956 Term::Triple(t) => Term::Triple(Box::new(remap_triple(t, doc_id, blank_map))),
957 }
958}
959
960fn remap_triple(
961 triple: &Triple,
962 doc_id: &str,
963 blank_map: &mut HashMap<BlankNode, BlankNode>,
964) -> Triple {
965 Triple {
966 subject: remap_subject(&triple.subject, doc_id, blank_map),
967 predicate: triple.predicate.clone(),
968 object: remap_term(&triple.object, doc_id, blank_map),
969 }
970}
971
972fn remap_graph_name(
973 graph: &GraphName,
974 doc_id: &str,
975 blank_map: &mut HashMap<BlankNode, BlankNode>,
976) -> GraphName {
977 match graph {
978 GraphName::NamedNode(n) => GraphName::NamedNode(n.clone()),
979 GraphName::BlankNode(b) => GraphName::BlankNode(remap_blank_node(b, doc_id, blank_map)),
980 GraphName::DefaultGraph => GraphName::DefaultGraph,
981 }
982}
983
984fn verified_snapshot_source(
985 workspace: &Path,
986 path: &Path,
987 override_text: Option<&str>,
988 effective_hash: &str,
989) -> Option<(PathBuf, u64)> {
990 if let Some(text) = override_text {
991 if content_hash_text(text) != effective_hash {
992 return None;
993 }
994 let modified_time = std::fs::metadata(path)
995 .ok()
996 .and_then(|m| m.modified().ok())
997 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
998 .map(|d| d.as_secs())
999 .unwrap_or(0);
1000 return Some((path.to_path_buf(), modified_time));
1001 }
1002 if !path.exists() {
1003 return None;
1004 }
1005 let file = WorkspaceScanner::new(workspace).describe_path(path).ok()?;
1006 if file.content_hash != effective_hash {
1007 return None;
1008 }
1009 let file = WorkspaceScanner::new(workspace).describe_path(path).ok()?;
1011 if file.content_hash == effective_hash {
1012 Some((file.path, file.modified_time))
1013 } else {
1014 None
1015 }
1016}
1017
1018pub(crate) fn merge_scan_roots(workspace: &Path, extra_roots: &[PathBuf]) -> Vec<PathBuf> {
1020 let mut roots = vec![workspace.to_path_buf()];
1021 for root in extra_roots {
1022 if roots.iter().any(|existing| paths_equal(existing, root)) {
1023 continue;
1024 }
1025 roots.push(root.clone());
1026 }
1027 roots
1028}
1029
1030#[cfg(test)]
1031mod merge_entity_tests {
1032 use super::*;
1033 use strixonomy_core::EntityKind;
1034
1035 fn entity(obo_id: Option<&str>) -> Entity {
1036 Entity {
1037 iri: "http://example.org/A".into(),
1038 short_name: "A".into(),
1039 kind: EntityKind::Class,
1040 ontology_id: "doc".into(),
1041 obo_id: obo_id.map(str::to_string),
1042 ..Default::default()
1043 }
1044 }
1045
1046 #[test]
1047 fn merge_entity_takes_incoming_obo_id_when_missing() {
1048 let mut existing = entity(None);
1049 merge_entity(&mut existing, &entity(Some("TEST:0000001")));
1050 assert_eq!(existing.obo_id.as_deref(), Some("TEST:0000001"));
1051 }
1052
1053 #[test]
1054 fn merge_entity_keeps_existing_obo_id_when_incoming_missing() {
1055 let mut existing = entity(Some("TEST:0000001"));
1056 merge_entity(&mut existing, &entity(None));
1057 assert_eq!(existing.obo_id.as_deref(), Some("TEST:0000001"));
1058 }
1059
1060 #[test]
1061 fn merge_entity_keeps_first_obo_id_on_conflict() {
1062 let mut existing = entity(Some("TEST:0000001"));
1063 merge_entity(&mut existing, &entity(Some("TEST:0000002")));
1064 assert_eq!(existing.obo_id.as_deref(), Some("TEST:0000001"));
1065 }
1066
1067 #[test]
1068 fn merge_entity_or_merges_property_characteristics() {
1069 let mut existing = Entity {
1070 iri: "http://example.org/p".into(),
1071 short_name: "p".into(),
1072 kind: EntityKind::ObjectProperty,
1073 ontology_id: "doc-a".into(),
1074 characteristics: strixonomy_core::PropertyCharacteristics {
1075 functional: true,
1076 ..Default::default()
1077 },
1078 ..Default::default()
1079 };
1080 let incoming = Entity {
1081 iri: "http://example.org/p".into(),
1082 short_name: "p".into(),
1083 kind: EntityKind::ObjectProperty,
1084 ontology_id: "doc-b".into(),
1085 characteristics: strixonomy_core::PropertyCharacteristics {
1086 transitive: true,
1087 ..Default::default()
1088 },
1089 ..Default::default()
1090 };
1091 merge_entity(&mut existing, &incoming);
1092 assert!(existing.characteristics.functional);
1093 assert!(existing.characteristics.transitive);
1094 assert!(!existing.characteristics.symmetric);
1095 }
1096
1097 #[test]
1098 fn index_merge_preserves_obo_id_from_later_document() {
1099 let dir = tempfile::tempdir().unwrap();
1100 std::fs::write(
1102 dir.path().join("a.ttl"),
1103 r#"@prefix owl: <http://www.w3.org/2002/07/owl#> .
1104@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
1105<http://purl.obolibrary.org/obo/TEST_0000001> a owl:Class ;
1106 rdfs:label "From Turtle" .
1107"#,
1108 )
1109 .unwrap();
1110 std::fs::write(
1112 dir.path().join("b.obo"),
1113 "format-version: 1.2\nontology: test\n\n[Term]\nid: TEST:0000001\nname: From OBO\n",
1114 )
1115 .unwrap();
1116
1117 let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build");
1118 let entity = catalog
1119 .data()
1120 .entities
1121 .iter()
1122 .find(|e| e.iri.contains("TEST_0000001") || e.obo_id.as_deref() == Some("TEST:0000001"))
1123 .expect("merged entity");
1124 assert_eq!(
1125 entity.obo_id.as_deref(),
1126 Some("TEST:0000001"),
1127 "merged catalog entity must retain obo_id from the OBO document"
1128 );
1129 assert!(
1130 entity.labels.iter().any(|l| l == "From Turtle" || l == "From OBO"),
1131 "labels should still merge: {:?}",
1132 entity.labels
1133 );
1134 }
1135}
1136
1137#[cfg(test)]
1138mod blank_remap_tests {
1139 use super::*;
1140 use oxigraph::model::NamedNode;
1141
1142 #[test]
1143 fn remap_blank_nodes_are_scoped_per_document() {
1144 let blank = BlankNode::new_unchecked("b0");
1145 let mut map_a = HashMap::new();
1146 let mut map_b = HashMap::new();
1147 let a = remap_blank_node(&blank, "doc-1", &mut map_a);
1148 let b = remap_blank_node(&blank, "doc-2", &mut map_b);
1149 assert_ne!(a.as_str(), b.as_str());
1150 assert!(a.as_str().starts_with("doc_1_"));
1151 assert!(b.as_str().starts_with("doc_2_"));
1152 assert_eq!(remap_blank_node(&blank, "doc-1", &mut map_a).as_str(), a.as_str());
1153 }
1154
1155 #[test]
1156 fn multi_file_restrictions_do_not_fuse_in_store() {
1157 let dir = tempfile::tempdir().unwrap();
1158 std::fs::write(
1159 dir.path().join("a.ttl"),
1160 r#"@prefix owl: <http://www.w3.org/2002/07/owl#> .
1161@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
1162@prefix ex: <http://ex.org/a#> .
1163ex:A a owl:Class ;
1164 rdfs:subClassOf [ a owl:Restriction ; owl:onProperty ex:p1 ; owl:someValuesFrom ex:B ] .
1165ex:B a owl:Class .
1166ex:p1 a owl:ObjectProperty .
1167"#,
1168 )
1169 .unwrap();
1170 std::fs::write(
1171 dir.path().join("b.ttl"),
1172 r#"@prefix owl: <http://www.w3.org/2002/07/owl#> .
1173@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
1174@prefix ex: <http://ex.org/b#> .
1175ex:X a owl:Class ;
1176 rdfs:subClassOf [ a owl:Restriction ; owl:onProperty ex:p2 ; owl:someValuesFrom ex:Y ] .
1177ex:Y a owl:Class .
1178ex:p2 a owl:ObjectProperty .
1179"#,
1180 )
1181 .unwrap();
1182
1183 let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build");
1184 let store = catalog.store();
1185 let on_property = NamedNode::new_unchecked("http://www.w3.org/2002/07/owl#onProperty");
1186 let some_values = NamedNode::new_unchecked("http://www.w3.org/2002/07/owl#someValuesFrom");
1187 let p1 = NamedNode::new_unchecked("http://ex.org/a#p1");
1188 let filler_b = NamedNode::new_unchecked("http://ex.org/a#B");
1189 let filler_y = NamedNode::new_unchecked("http://ex.org/b#Y");
1190
1191 let mut fused = 0usize;
1192 let mut intact_a = 0usize;
1193 for quad in store.quads_for_pattern(
1194 None,
1195 Some(on_property.as_ref()),
1196 Some(p1.as_ref().into()),
1197 None,
1198 ) {
1199 let quad = quad.expect("store iterate");
1200 let fused_quad = Quad {
1201 subject: quad.subject.clone(),
1202 predicate: some_values.clone(),
1203 object: filler_y.clone().into(),
1204 graph_name: GraphName::DefaultGraph,
1205 };
1206 if store.contains(&fused_quad).unwrap_or(false) {
1207 fused += 1;
1208 }
1209 let intact_quad = Quad {
1210 subject: quad.subject.clone(),
1211 predicate: some_values.clone(),
1212 object: filler_b.clone().into(),
1213 graph_name: GraphName::DefaultGraph,
1214 };
1215 if store.contains(&intact_quad).unwrap_or(false) {
1216 intact_a += 1;
1217 }
1218 }
1219 assert_eq!(
1220 fused, 0,
1221 "cross-file blank collision would fuse a#p1 with b#Y on one restriction"
1222 );
1223 assert_eq!(intact_a, 1, "file A restriction should remain intact");
1224 }
1225}
1226
1227#[cfg(test)]
1228mod merge_scan_roots_tests {
1229 use super::*;
1230
1231 #[test]
1232 fn merge_scan_roots_includes_primary_when_extras_set() {
1233 let dir = tempfile::tempdir().unwrap();
1234 let primary = dir.path().join("ws");
1235 let extra = dir.path().join("imports");
1236 std::fs::create_dir_all(&primary).unwrap();
1237 std::fs::create_dir_all(&extra).unwrap();
1238 let merged = merge_scan_roots(&primary, std::slice::from_ref(&extra));
1239 assert_eq!(merged.len(), 2);
1240 assert!(merged.iter().any(|p| paths_equal(p, &primary)));
1241 assert!(merged.iter().any(|p| paths_equal(p, &extra)));
1242 }
1243}
1244
1245#[cfg(test)]
1246mod incremental_tests {
1247 use super::*;
1248
1249 #[test]
1250 fn incremental_rebuild_preserves_unchanged_documents() {
1251 let dir = tempfile::tempdir().unwrap();
1252 let ttl = dir.path().join("a.ttl");
1253 std::fs::write(
1254 &ttl,
1255 "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n@prefix ex: <http://ex/> .\nex:A a owl:Class .\n",
1256 )
1257 .unwrap();
1258
1259 let first = IndexBuilder::new().workspace(dir.path()).build().expect("first build");
1260 let entity_count = first.data().entities.len();
1261 assert!(entity_count > 0);
1262
1263 let second = IndexBuilder::new()
1264 .workspace(dir.path())
1265 .build_incremental(&first)
1266 .expect("incremental build");
1267 assert_eq!(second.data().entities.len(), entity_count);
1268 assert_eq!(second.data().documents.len(), 1);
1269 }
1270
1271 #[test]
1272 fn incremental_rebuild_picks_up_edited_file() {
1273 let dir = tempfile::tempdir().unwrap();
1274 let ttl = dir.path().join("a.ttl");
1275 std::fs::write(
1276 &ttl,
1277 "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n@prefix ex: <http://ex/> .\nex:A a owl:Class .\n",
1278 )
1279 .unwrap();
1280 let first = IndexBuilder::new().workspace(dir.path()).build().expect("first build");
1281
1282 std::fs::write(
1283 &ttl,
1284 "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n@prefix ex: <http://ex/> .\nex:A a owl:Class .\nex:B a owl:Class .\n",
1285 )
1286 .unwrap();
1287
1288 let second = IndexBuilder::new()
1289 .workspace(dir.path())
1290 .build_incremental(&first)
1291 .expect("incremental build");
1292 assert!(
1293 second.data().entities.len() > first.data().entities.len(),
1294 "edited ontology should add entities"
1295 );
1296 }
1297
1298 #[test]
1299 fn owx_workspace_populates_sparql_store() {
1300 let dir = tempfile::tempdir().unwrap();
1301 let owx = include_str!("../../../examples/protege-roundtrip/example.owx");
1302 std::fs::write(dir.path().join("example.owx"), owx).unwrap();
1303
1304 let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build owx");
1305 assert!(catalog.find_entity("http://example.org/org#Department").is_some());
1306 assert!(
1307 catalog.data().stats().triple_count > 0,
1308 "OWL/XML workspace must load RDF quads into the SPARQL store"
1309 );
1310
1311 let mut found = false;
1312 for quad in catalog.store().quads_for_pattern(None, None, None, None) {
1313 let q = quad.expect("quad");
1314 if q.subject.to_string().contains("Department")
1315 || q.object.to_string().contains("Department")
1316 {
1317 found = true;
1318 break;
1319 }
1320 }
1321 assert!(found, "expected Department IRI in Oxigraph store");
1322 }
1323
1324 #[test]
1325 fn owx_horned_load_failure_surfaces_bridge_diagnostic() {
1326 let dir = tempfile::tempdir().unwrap();
1327 std::fs::write(
1329 dir.path().join("broken.owx"),
1330 "<?xml version=\"1.0\"?>\n<NotAnOntology>garbage</NotAnOntology>\n",
1331 )
1332 .unwrap();
1333
1334 let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build");
1335 let has_bridge = catalog.data().diagnostics.iter().any(|d| {
1336 d.code == DiagnosticCode::OwlBridgeFailed
1337 && d.file.file_name().and_then(|n| n.to_str()) == Some("broken.owx")
1338 });
1339 assert!(
1340 has_bridge,
1341 "OWX Horned failure must emit OwlBridgeFailed; got {:?}",
1342 catalog
1343 .data()
1344 .diagnostics
1345 .iter()
1346 .map(|d| (&d.code, d.message.as_str()))
1347 .collect::<Vec<_>>()
1348 );
1349 }
1350}