Skip to main content

strixonomy_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 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    /// 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 `.strixonomy/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
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            // Load quads even when parse_status is Error (partial recovery after a trailing fault).
243            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            // OWL/XML produces no parser quads; load Horned RDF projection for SPARQL (#75).
274            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            // Load quads even when parse_status is Error (partial recovery after a trailing fault).
430            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    /// Entity IRI → index in [`OntologyCatalogData::documents`].
616    pub(crate) entity_to_document: HashMap<String, usize>,
617    /// Entity IRIs declared per document (parallel to `documents`).
618    pub(crate) document_entity_iris: Vec<Vec<String>>,
619    /// Per-file snapshots for incremental reindex (keyed by canonical path).
620    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    /// Oxigraph triple store for SPARQL — not a stable public API; use [`strixonomy_query::sparql_catalog`].
633    #[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    // Preserve OBO term ids across multi-document IRI merges. Prefer an existing
721    // value when both sides set one (first-wins); otherwise take the non-empty side.
722    if existing.obo_id.is_none() {
723        existing.obo_id = incoming.obo_id.clone();
724    }
725    // OR-merge property characteristics declared across documents.
726    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    // Prefer a more specific kind when the existing entry is Other.
734    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    /// Extra RDF quads when the parser produced none (OWL/XML → Horned RDF projection).
764    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
877/// Insert document quads into the shared Oxigraph store, remapping blank nodes so
878/// parser-local IDs (often `b0`, `b1`, …) cannot collide across files.
879fn 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    // Re-verify immediately before reuse to close TOCTOU between hash check and apply.
1010    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
1018/// Primary workspace root plus any additional scan roots (deduplicated).
1019pub(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        // Turtle declares the IRI first without an OBO id.
1101        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        // OBO document contributes the same IRI with an obo_id.
1111        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        // Recognized as OWL/XML by extension; Horned OWX reader must fail.
1328        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}