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 ontocore_core::{
8 limits::{MAX_ENTITIES, MAX_TOTAL_TRIPLES, MAX_TRIPLES_PER_FILE},
9 read_to_string_capped, Annotation, Axiom, Diagnostic, DiagnosticCode, DiagnosticSeverity,
10 Entity, Import, Namespace, OntologyDocument, OntologyFormat, ParseStatus, SourceLocation,
11 WorkspaceScanner, MAX_FILE_BYTES,
12};
13use ontocore_diagnostics::{collect_diagnostics_with_config, find_config, DiagnosticInput};
14use ontocore_owl::{load_owx_text, load_turtle_text, supports_horned_load};
15use ontocore_parser::{parse_ontology_file, parse_ontology_text, ParsedOntology};
16use oxigraph::model::Quad;
17use oxigraph::store::Store;
18use std::collections::{HashMap, HashSet};
19use std::path::{Path, PathBuf};
20use thiserror::Error;
21
22#[derive(Debug, Error)]
23pub enum CatalogError {
24 #[error("core error: {0}")]
25 Core(#[from] ontocore_core::OntoCoreError),
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 let mut loaded_content_hashes = HashSet::new();
163
164 let disk_cache = DiskCache::enabled(self.disk_cache, &self.workspace);
165 let mut merged_previous = previous_snapshots.cloned().unwrap_or_default();
166 if let Some(ref cache) = disk_cache {
167 for file in &files {
168 let override_text = self.document_override_text(&file.path);
169 let effective_hash =
170 effective_content_hash(&file.content_hash, override_text.map(String::as_str));
171 let lookup_path = file.path.canonicalize().unwrap_or_else(|_| file.path.clone());
172 cache.hydrate_previous(
173 &mut merged_previous,
174 &lookup_path,
175 &effective_hash,
176 file.modified_time,
177 );
178 }
179 }
180 let previous_snapshots =
181 if merged_previous.is_empty() { None } else { Some(&merged_previous) };
182
183 for (idx, file) in files.iter().enumerate() {
184 let doc_id = format!("doc-{}", idx + 1);
185 let override_text = self.document_override_text(&file.path);
186 let effective_hash =
187 effective_content_hash(&file.content_hash, override_text.map(String::as_str));
188 let lookup_path = file.path.canonicalize().unwrap_or_else(|_| file.path.clone());
189
190 if let Some(prev) = previous_snapshots.and_then(|m| m.get(&lookup_path)) {
191 if prev.content_hash == effective_hash {
192 if let Some((reuse_path, modified_time)) = verified_snapshot_source(
193 &self.workspace,
194 &file.path,
195 override_text.map(String::as_str),
196 &effective_hash,
197 ) {
198 let snap = prev.with_reuse_context(&doc_id, &reuse_path, modified_time);
199 apply_document_snapshot(
200 &snap,
201 &doc_id,
202 &mut documents,
203 &mut entities,
204 &mut entity_index,
205 &mut entity_to_document,
206 &mut document_entity_iris,
207 &mut annotations,
208 &mut axioms,
209 &mut namespaces,
210 &mut imports,
211 &mut triple_count,
212 &store,
213 &mut bridge_diagnostics,
214 &mut loaded_content_hashes,
215 )?;
216 document_snapshots.insert(lookup_path, snap);
217 reused += 1;
218 continue;
219 }
220 }
221 }
222
223 reparsed += 1;
224 let parsed = if let Some(text) = override_text {
225 parse_ontology_text(&file.path, file.format, &doc_id, text, text.as_bytes())
226 .map_err(|e| CatalogError::Parse {
227 path: file.path.clone(),
228 message: e.to_string(),
229 })?
230 } else {
231 parse_ontology_file(
232 &file.path,
233 file.format,
234 &doc_id,
235 &file.content_hash,
236 file.modified_time,
237 )
238 .map_err(|e| CatalogError::Parse {
239 path: file.path.clone(),
240 message: e.to_string(),
241 })?
242 };
243
244 if !parsed.quads().is_empty() {
246 load_quads_into_store(
247 &store,
248 parsed.quads(),
249 &mut triple_count,
250 &effective_hash,
251 &mut loaded_content_hashes,
252 )?;
253 }
254
255 documents.push(OntologyDocument {
256 id: doc_id.clone(),
257 path: file.path.clone(),
258 format: file.format,
259 base_iri: parsed.base_iri.clone(),
260 imports: parsed.imports.clone(),
261 namespaces: parsed.namespaces.clone(),
262 parse_status: parsed.parse_status,
263 content_hash: file.content_hash.clone(),
264 modified_time: file.modified_time,
265 parse_message: parsed.parse_message.clone(),
266 parse_error_location: parsed.parse_error_location.clone(),
267 });
268
269 let doc_idx = documents.len() - 1;
270 let mut doc_entity_iris = Vec::new();
271
272 let semantics = semantics_for_document(
273 &file.path,
274 file.format,
275 &doc_id,
276 &parsed,
277 self.document_override_text(&file.path),
278 )?;
279
280 let stored_hash = if override_text.is_some() {
281 effective_hash.clone()
282 } else {
283 file.content_hash.clone()
284 };
285 let snapshot = DocumentSnapshot {
286 content_hash: stored_hash.clone(),
287 document: documents.last().cloned().expect("document"),
288 entities: semantics.entities.clone(),
289 annotations: semantics.annotations.clone(),
290 axioms: semantics.axioms.clone(),
291 namespace_rows: semantics.namespace_rows.clone(),
292 imports: semantics.imports.clone(),
293 quads: parsed.quads().to_vec(),
294 triple_count: parsed.triple_count,
295 bridge_warning: semantics.bridge_warning.clone(),
296 };
297
298 if let Some(diag) = semantics.bridge_warning {
299 bridge_diagnostics.push(diag);
300 }
301
302 for entity in semantics.entities {
303 if entities.len() >= MAX_ENTITIES && !entity_index.contains_key(&entity.iri) {
304 return Err(CatalogError::Core(ontocore_core::OntoCoreError::Scanner(
305 format!("workspace exceeds maximum of {MAX_ENTITIES} entities"),
306 )));
307 }
308 if let Some(&prev_doc_idx) = entity_to_document.get(&entity.iri) {
309 if prev_doc_idx != doc_idx {
310 document_entity_iris[prev_doc_idx].retain(|iri| iri != &entity.iri);
311 }
312 }
313 entity_to_document.insert(entity.iri.clone(), doc_idx);
314 doc_entity_iris.push(entity.iri.clone());
315 if let Some(&existing_idx) = entity_index.get(&entity.iri) {
316 merge_entity(&mut entities[existing_idx], &entity);
317 } else {
318 let idx = entities.len();
319 entity_index.insert(entity.iri.clone(), idx);
320 entities.push(entity);
321 }
322 }
323 document_entity_iris.push(doc_entity_iris);
324 annotations.extend(semantics.annotations);
325 axioms.extend(semantics.axioms);
326 namespaces.extend(semantics.namespace_rows);
327 imports.extend(semantics.imports);
328
329 if entities.len() > MAX_ENTITIES {
330 return Err(CatalogError::Core(ontocore_core::OntoCoreError::Scanner(format!(
331 "workspace exceeds maximum of {MAX_ENTITIES} entities"
332 ))));
333 }
334
335 if let Some(doc) = documents.last_mut() {
336 doc.content_hash = stored_hash.clone();
337 }
338 if let Some(ref cache) = disk_cache {
339 let _ = cache.store(&snapshot);
340 }
341 document_snapshots.insert(lookup_path, snapshot);
342 }
343
344 let previous_paths: HashMap<PathBuf, ()> = previous_snapshots
345 .map(|m| m.keys().cloned().map(|p| (p, ())).collect())
346 .unwrap_or_default();
347 let mut removed = 0usize;
348 for path in previous_paths.keys() {
349 if !document_snapshots.contains_key(path) {
350 removed += 1;
351 }
352 }
353
354 for (override_path, override_text) in &self.document_overrides {
355 if files.iter().any(|f| paths_equal(&f.path, override_path)) {
356 continue;
357 }
358 let format = OntologyFormat::from_extension(
359 override_path.extension().and_then(|e| e.to_str()).unwrap_or("ttl"),
360 );
361 if matches!(format, OntologyFormat::Unknown) {
362 continue;
363 }
364 let doc_id = format!("doc-{}", documents.len() + 1);
365 let lookup_path =
366 override_path.canonicalize().unwrap_or_else(|_| override_path.clone());
367 let effective_hash = content_hash_text(override_text);
368
369 if let Some(prev) = previous_snapshots.and_then(|m| m.get(&lookup_path)) {
370 if prev.content_hash == effective_hash {
371 if let Some((reuse_path, modified_time)) = verified_snapshot_source(
372 &self.workspace,
373 override_path,
374 Some(override_text.as_str()),
375 &effective_hash,
376 ) {
377 let snap = prev.with_reuse_context(&doc_id, &reuse_path, modified_time);
378 apply_document_snapshot(
379 &snap,
380 &doc_id,
381 &mut documents,
382 &mut entities,
383 &mut entity_index,
384 &mut entity_to_document,
385 &mut document_entity_iris,
386 &mut annotations,
387 &mut axioms,
388 &mut namespaces,
389 &mut imports,
390 &mut triple_count,
391 &store,
392 &mut bridge_diagnostics,
393 &mut loaded_content_hashes,
394 )?;
395 document_snapshots.insert(lookup_path, snap);
396 reused += 1;
397 continue;
398 }
399 }
400 }
401
402 reparsed += 1;
403 let parsed = parse_ontology_text(
404 override_path,
405 format,
406 &doc_id,
407 override_text,
408 override_text.as_bytes(),
409 )
410 .map_err(|e| CatalogError::Parse {
411 path: override_path.clone(),
412 message: e.to_string(),
413 })?;
414
415 if !parsed.quads().is_empty() {
417 load_quads_into_store(
418 &store,
419 parsed.quads(),
420 &mut triple_count,
421 &effective_hash,
422 &mut loaded_content_hashes,
423 )?;
424 }
425
426 documents.push(OntologyDocument {
427 id: doc_id.clone(),
428 path: override_path.clone(),
429 format,
430 base_iri: parsed.base_iri.clone(),
431 imports: parsed.imports.clone(),
432 namespaces: parsed.namespaces.clone(),
433 parse_status: parsed.parse_status,
434 content_hash: effective_hash.clone(),
435 modified_time: 0,
436 parse_message: parsed.parse_message.clone(),
437 parse_error_location: parsed.parse_error_location.clone(),
438 });
439
440 let doc_idx = documents.len() - 1;
441 let mut doc_entity_iris = Vec::new();
442
443 let semantics = semantics_for_document(
444 override_path,
445 format,
446 &doc_id,
447 &parsed,
448 Some(override_text),
449 )?;
450
451 let snapshot = DocumentSnapshot {
452 content_hash: effective_hash.clone(),
453 document: documents.last().cloned().expect("document"),
454 entities: semantics.entities.clone(),
455 annotations: semantics.annotations.clone(),
456 axioms: semantics.axioms.clone(),
457 namespace_rows: semantics.namespace_rows.clone(),
458 imports: semantics.imports.clone(),
459 quads: parsed.quads().to_vec(),
460 triple_count: parsed.triple_count,
461 bridge_warning: semantics.bridge_warning.clone(),
462 };
463
464 if let Some(diag) = semantics.bridge_warning {
465 bridge_diagnostics.push(diag);
466 }
467
468 for entity in semantics.entities {
469 if entities.len() >= MAX_ENTITIES && !entity_index.contains_key(&entity.iri) {
470 return Err(CatalogError::Core(ontocore_core::OntoCoreError::Scanner(
471 format!("workspace exceeds maximum of {MAX_ENTITIES} entities"),
472 )));
473 }
474 if let Some(&prev_doc_idx) = entity_to_document.get(&entity.iri) {
475 if prev_doc_idx != doc_idx {
476 document_entity_iris[prev_doc_idx].retain(|iri| iri != &entity.iri);
477 }
478 }
479 entity_to_document.insert(entity.iri.clone(), doc_idx);
480 doc_entity_iris.push(entity.iri.clone());
481 if let Some(&existing_idx) = entity_index.get(&entity.iri) {
482 merge_entity(&mut entities[existing_idx], &entity);
483 } else {
484 let idx = entities.len();
485 entity_index.insert(entity.iri.clone(), idx);
486 entities.push(entity);
487 }
488 }
489 document_entity_iris.push(doc_entity_iris);
490 annotations.extend(semantics.annotations);
491 axioms.extend(semantics.axioms);
492 namespaces.extend(semantics.namespace_rows);
493 imports.extend(semantics.imports);
494
495 if entities.len() > MAX_ENTITIES {
496 return Err(CatalogError::Core(ontocore_core::OntoCoreError::Scanner(format!(
497 "workspace exceeds maximum of {MAX_ENTITIES} entities"
498 ))));
499 }
500
501 document_snapshots.insert(lookup_path.clone(), snapshot);
502 if let Some(ref cache) = disk_cache {
503 let _ = cache.store(document_snapshots.get(&lookup_path).expect("snapshot"));
504 }
505 }
506
507 let stats = if incremental || reused > 0 || removed > 0 {
508 IncrementalStats::Incremental { reused, reparsed, removed }
509 } else {
510 IncrementalStats::FullBuild
511 };
512
513 let config_fingerprint = self.config_fingerprint();
514 let mut data = OntologyCatalogData {
515 documents,
516 entities,
517 annotations,
518 axioms,
519 namespaces,
520 imports,
521 triple_count,
522 diagnostics: Vec::new(),
523 };
524 let lint_input = DiagnosticInput {
525 documents: &data.documents,
526 entities: &data.entities,
527 annotations: &data.annotations,
528 axioms: &data.axioms,
529 namespaces: &data.namespaces,
530 imports: &data.imports,
531 };
532 let diag_config = find_config(&self.workspace);
533 data.diagnostics = collect_diagnostics_with_config(
534 &lint_input,
535 &self.document_overrides,
536 diag_config.as_ref(),
537 );
538 data.diagnostics.extend(bridge_diagnostics);
539
540 Ok((
541 OntologyCatalog {
542 workspace: self.workspace,
543 config_fingerprint,
544 data,
545 store,
546 entity_to_document,
547 document_entity_iris,
548 document_snapshots,
549 },
550 stats,
551 ))
552 }
553}
554
555impl Default for IndexBuilder {
556 fn default() -> Self {
557 Self::new()
558 }
559}
560
561pub struct OntologyCatalog {
562 workspace: PathBuf,
563 pub(crate) config_fingerprint: String,
564 data: OntologyCatalogData,
565 store: Store,
566 pub(crate) entity_to_document: HashMap<String, usize>,
568 pub(crate) document_entity_iris: Vec<Vec<String>>,
570 pub(crate) document_snapshots: HashMap<PathBuf, DocumentSnapshot>,
572}
573
574impl OntologyCatalog {
575 pub fn workspace(&self) -> &Path {
576 &self.workspace
577 }
578
579 pub fn data(&self) -> &OntologyCatalogData {
580 &self.data
581 }
582
583 #[doc(hidden)]
585 pub fn store(&self) -> &Store {
586 &self.store
587 }
588}
589
590#[allow(clippy::too_many_arguments)]
591fn apply_document_snapshot(
592 snap: &DocumentSnapshot,
593 doc_id: &str,
594 documents: &mut Vec<OntologyDocument>,
595 entities: &mut Vec<Entity>,
596 entity_index: &mut HashMap<String, usize>,
597 entity_to_document: &mut HashMap<String, usize>,
598 document_entity_iris: &mut Vec<Vec<String>>,
599 annotations: &mut Vec<Annotation>,
600 axioms: &mut Vec<Axiom>,
601 namespaces: &mut Vec<Namespace>,
602 imports: &mut Vec<Import>,
603 triple_count: &mut usize,
604 store: &Store,
605 bridge_diagnostics: &mut Vec<Diagnostic>,
606 loaded_content_hashes: &mut HashSet<String>,
607) -> Result<()> {
608 if snap.triple_count != snap.quads.len() {
609 return Err(CatalogError::Core(ontocore_core::OntoCoreError::Scanner(
610 "document snapshot triple_count does not match quads length".to_string(),
611 )));
612 }
613 if !snap.quads.is_empty() {
614 load_quads_into_store(
615 store,
616 &snap.quads,
617 triple_count,
618 &snap.content_hash,
619 loaded_content_hashes,
620 )?;
621 }
622
623 documents.push(snap.document.clone());
624 let doc_idx = documents.len() - 1;
625 let mut doc_entity_iris = Vec::new();
626
627 if let Some(diag) = &snap.bridge_warning {
628 bridge_diagnostics.push(diag.clone());
629 }
630
631 for entity in &snap.entities {
632 if entities.len() >= MAX_ENTITIES && !entity_index.contains_key(&entity.iri) {
633 return Err(CatalogError::Core(ontocore_core::OntoCoreError::Scanner(format!(
634 "workspace exceeds maximum of {MAX_ENTITIES} entities"
635 ))));
636 }
637 if let Some(&prev_doc_idx) = entity_to_document.get(&entity.iri) {
638 if prev_doc_idx != doc_idx {
639 document_entity_iris[prev_doc_idx].retain(|iri| iri != &entity.iri);
640 }
641 }
642 entity_to_document.insert(entity.iri.clone(), doc_idx);
643 doc_entity_iris.push(entity.iri.clone());
644 if let Some(&existing_idx) = entity_index.get(&entity.iri) {
645 merge_entity(&mut entities[existing_idx], entity);
646 } else {
647 let idx = entities.len();
648 entity_index.insert(entity.iri.clone(), idx);
649 entities.push(entity.clone());
650 }
651 }
652 document_entity_iris.push(doc_entity_iris);
653 annotations.extend(snap.annotations.clone());
654 axioms.extend(snap.axioms.clone());
655 namespaces.extend(snap.namespace_rows.clone());
656 imports.extend(snap.imports.clone());
657
658 let _ = doc_id;
659 Ok(())
660}
661
662fn merge_entity(existing: &mut Entity, incoming: &Entity) {
663 for label in &incoming.labels {
664 if !existing.labels.contains(label) {
665 existing.labels.push(label.clone());
666 }
667 }
668 for comment in &incoming.comments {
669 if !existing.comments.contains(comment) {
670 existing.comments.push(comment.clone());
671 }
672 }
673 existing.deprecated |= incoming.deprecated;
674 existing.ontology_id = incoming.ontology_id.clone();
675 if existing.short_name.is_empty() {
676 existing.short_name = incoming.short_name.clone();
677 }
678}
679
680struct DocumentSemantics {
681 entities: Vec<Entity>,
682 annotations: Vec<Annotation>,
683 axioms: Vec<Axiom>,
684 namespace_rows: Vec<Namespace>,
685 imports: Vec<Import>,
686 bridge_warning: Option<Diagnostic>,
687}
688
689fn semantics_for_document(
690 path: &Path,
691 format: OntologyFormat,
692 doc_id: &str,
693 parsed: &ParsedOntology,
694 override_text: Option<&String>,
695) -> Result<DocumentSemantics> {
696 if parsed.parse_status == ParseStatus::Error || !supports_horned_load(format) {
697 return Ok(DocumentSemantics {
698 entities: parsed.entities.clone(),
699 annotations: parsed.annotations.clone(),
700 axioms: parsed.axioms.clone(),
701 namespace_rows: parsed.namespace_rows.clone(),
702 imports: parsed.import_rows.clone(),
703 bridge_warning: None,
704 });
705 }
706
707 let source_text = if let Some(text) = override_text {
708 text.clone()
709 } else {
710 read_to_string_capped(path, MAX_FILE_BYTES).map_err(CatalogError::Core)?
711 };
712
713 if format == OntologyFormat::OwlXml {
714 return match load_owx_text(path, doc_id, &source_text, &parsed.namespaces) {
715 Ok(owl) => Ok(DocumentSemantics {
716 entities: owl.bridge.entities,
717 annotations: owl.bridge.annotations,
718 axioms: owl.bridge.axioms,
719 namespace_rows: owl.bridge.namespace_rows,
720 imports: owl.bridge.imports,
721 bridge_warning: None,
722 }),
723 Err(e) => {
724 eprintln!(
725 "ontocore-catalog: Horned-OWL OWX load failed for {}: {e}; using parser entities",
726 path.display()
727 );
728 Ok(DocumentSemantics {
729 entities: parsed.entities.clone(),
730 annotations: parsed.annotations.clone(),
731 axioms: parsed.axioms.clone(),
732 namespace_rows: parsed.namespace_rows.clone(),
733 imports: parsed.import_rows.clone(),
734 bridge_warning: None,
735 })
736 }
737 };
738 }
739
740 match load_turtle_text(path, doc_id, &source_text, parsed.quads(), &parsed.namespaces) {
741 Ok(owl) => Ok(DocumentSemantics {
742 entities: owl.bridge.entities,
743 annotations: owl.bridge.annotations,
744 axioms: owl.bridge.axioms,
745 namespace_rows: owl.bridge.namespace_rows,
746 imports: owl.bridge.imports,
747 bridge_warning: None,
748 }),
749 Err(e) => {
750 eprintln!(
751 "ontocore-catalog: Horned-OWL load failed for {}: {e}; using parser entities",
752 path.display()
753 );
754 Ok(DocumentSemantics {
755 entities: parsed.entities.clone(),
756 annotations: parsed.annotations.clone(),
757 axioms: parsed.axioms.clone(),
758 namespace_rows: parsed.namespace_rows.clone(),
759 imports: parsed.import_rows.clone(),
760 bridge_warning: Some(Diagnostic {
761 code: DiagnosticCode::OwlBridgeFailed,
762 severity: DiagnosticSeverity::Warning,
763 message: format!(
764 "Horned-OWL bridge failed; using parser-only entities and axioms: {e}"
765 ),
766 file: path.to_path_buf(),
767 range: SourceLocation::default(),
768 entity_iri: None,
769 quick_fix: None,
770 plugin_id: None,
771 plugin_code: None,
772 }),
773 })
774 }
775 }
776}
777
778fn load_quads_into_store(
779 store: &Store,
780 quads: &[Quad],
781 triple_count: &mut usize,
782 content_hash: &str,
783 loaded_hashes: &mut HashSet<String>,
784) -> Result<()> {
785 if !loaded_hashes.insert(content_hash.to_string()) {
786 return Ok(());
788 }
789
790 let mut file_triples = 0usize;
791 for quad in quads {
792 file_triples += 1;
793 if file_triples > MAX_TRIPLES_PER_FILE {
794 return Err(CatalogError::Core(ontocore_core::OntoCoreError::Scanner(format!(
795 "file exceeds {MAX_TRIPLES_PER_FILE} triples"
796 ))));
797 }
798 *triple_count += 1;
799 if *triple_count > MAX_TOTAL_TRIPLES {
800 return Err(CatalogError::Core(ontocore_core::OntoCoreError::Scanner(format!(
801 "workspace exceeds maximum of {MAX_TOTAL_TRIPLES} triples"
802 ))));
803 }
804 store.insert(quad).map_err(|e| CatalogError::Store(e.to_string()))?;
805 }
806 Ok(())
807}
808
809fn verified_snapshot_source(
810 workspace: &Path,
811 path: &Path,
812 override_text: Option<&str>,
813 effective_hash: &str,
814) -> Option<(PathBuf, u64)> {
815 if let Some(text) = override_text {
816 if content_hash_text(text) != effective_hash {
817 return None;
818 }
819 let modified_time = std::fs::metadata(path)
820 .ok()
821 .and_then(|m| m.modified().ok())
822 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
823 .map(|d| d.as_secs())
824 .unwrap_or(0);
825 return Some((path.to_path_buf(), modified_time));
826 }
827 if !path.exists() {
828 return None;
829 }
830 let file = WorkspaceScanner::new(workspace).describe_path(path).ok()?;
831 if file.content_hash != effective_hash {
832 return None;
833 }
834 let file = WorkspaceScanner::new(workspace).describe_path(path).ok()?;
836 if file.content_hash == effective_hash {
837 Some((file.path, file.modified_time))
838 } else {
839 None
840 }
841}
842
843pub(crate) fn merge_scan_roots(workspace: &Path, extra_roots: &[PathBuf]) -> Vec<PathBuf> {
845 let mut roots = vec![workspace.to_path_buf()];
846 for root in extra_roots {
847 if roots.iter().any(|existing| paths_equal(existing, root)) {
848 continue;
849 }
850 roots.push(root.clone());
851 }
852 roots
853}
854
855#[cfg(test)]
856mod merge_scan_roots_tests {
857 use super::*;
858
859 #[test]
860 fn merge_scan_roots_includes_primary_when_extras_set() {
861 let dir = tempfile::tempdir().unwrap();
862 let primary = dir.path().join("ws");
863 let extra = dir.path().join("imports");
864 std::fs::create_dir_all(&primary).unwrap();
865 std::fs::create_dir_all(&extra).unwrap();
866 let merged = merge_scan_roots(&primary, std::slice::from_ref(&extra));
867 assert_eq!(merged.len(), 2);
868 assert!(merged.iter().any(|p| paths_equal(p, &primary)));
869 assert!(merged.iter().any(|p| paths_equal(p, &extra)));
870 }
871}
872
873#[cfg(test)]
874mod incremental_tests {
875 use super::*;
876
877 #[test]
878 fn incremental_rebuild_preserves_unchanged_documents() {
879 let dir = tempfile::tempdir().unwrap();
880 let ttl = dir.path().join("a.ttl");
881 std::fs::write(
882 &ttl,
883 "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n@prefix ex: <http://ex/> .\nex:A a owl:Class .\n",
884 )
885 .unwrap();
886
887 let first = IndexBuilder::new().workspace(dir.path()).build().expect("first build");
888 let entity_count = first.data().entities.len();
889 assert!(entity_count > 0);
890
891 let second = IndexBuilder::new()
892 .workspace(dir.path())
893 .build_incremental(&first)
894 .expect("incremental build");
895 assert_eq!(second.data().entities.len(), entity_count);
896 assert_eq!(second.data().documents.len(), 1);
897 }
898
899 #[test]
900 fn incremental_rebuild_picks_up_edited_file() {
901 let dir = tempfile::tempdir().unwrap();
902 let ttl = dir.path().join("a.ttl");
903 std::fs::write(
904 &ttl,
905 "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n@prefix ex: <http://ex/> .\nex:A a owl:Class .\n",
906 )
907 .unwrap();
908 let first = IndexBuilder::new().workspace(dir.path()).build().expect("first build");
909
910 std::fs::write(
911 &ttl,
912 "@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",
913 )
914 .unwrap();
915
916 let second = IndexBuilder::new()
917 .workspace(dir.path())
918 .build_incremental(&first)
919 .expect("incremental build");
920 assert!(
921 second.data().entities.len() > first.data().entities.len(),
922 "edited ontology should add entities"
923 );
924 }
925}