Skip to main content

ontocore_catalog/
builder.rs

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    /// Additional workspace roots to scan and merge into one catalog (multi-root).
61    /// The primary [`workspace`](Self::workspace) root is always included.
62    pub fn scan_roots(mut self, roots: Vec<PathBuf>) -> Self {
63        self.scan_roots = roots;
64        self
65    }
66
67    /// Roots scanned by [`Self::build`] — primary workspace plus any [`Self::scan_roots`].
68    pub fn effective_scan_roots(workspace: &Path, extra_roots: &[PathBuf]) -> Vec<PathBuf> {
69        merge_scan_roots(workspace, extra_roots)
70    }
71
72    /// Use in-memory text instead of disk for specific paths (LSP open buffers).
73    pub fn document_overrides(mut self, overrides: HashMap<PathBuf, String>) -> Self {
74        self.document_overrides = overrides;
75        self
76    }
77
78    /// Enable persistent `.ontocore/cache/` snapshots keyed by content hash.
79    pub fn disk_cache(mut self, enabled: bool) -> Self {
80        self.disk_cache = enabled;
81        self
82    }
83
84    /// Restrict scanning to these paths (e.g. git-tracked files for diff worktree side).
85    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    /// Rebuild the catalog reusing unchanged documents from `previous` when content hashes match.
106    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            // Load quads even when parse_status is Error (partial recovery after a trailing fault).
245            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            // Load quads even when parse_status is Error (partial recovery after a trailing fault).
416            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    /// Entity IRI → index in [`OntologyCatalogData::documents`].
567    pub(crate) entity_to_document: HashMap<String, usize>,
568    /// Entity IRIs declared per document (parallel to `documents`).
569    pub(crate) document_entity_iris: Vec<Vec<String>>,
570    /// Per-file snapshots for incremental reindex (keyed by canonical path).
571    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    /// Oxigraph triple store for SPARQL — not a stable public API; use [`ontocore_query::sparql_catalog`].
584    #[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        // Duplicate content (identical file body): quads already in the store.
787        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    // Re-verify immediately before reuse to close TOCTOU between hash check and apply.
835    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
843/// Primary workspace root plus any additional scan roots (deduplicated).
844pub(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}