Skip to main content

strixonomy_catalog/
incremental.rs

1//! Per-document snapshots for content-hash incremental reindexing.
2
3use oxigraph::model::Quad;
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use strixonomy_core::{Annotation, Axiom, Diagnostic, Entity, Import, Namespace, OntologyDocument};
7
8/// Cached parse + semantics for one ontology file at a specific content hash.
9#[derive(Debug, Clone)]
10pub(crate) struct DocumentSnapshot {
11    pub content_hash: String,
12    pub document: OntologyDocument,
13    pub entities: Vec<Entity>,
14    pub annotations: Vec<Annotation>,
15    pub axioms: Vec<Axiom>,
16    pub namespace_rows: Vec<Namespace>,
17    pub imports: Vec<Import>,
18    pub quads: Vec<Quad>,
19    pub triple_count: usize,
20    pub bridge_warning: Option<Diagnostic>,
21}
22
23impl DocumentSnapshot {
24    pub fn with_doc_id(&self, doc_id: &str) -> Self {
25        let mut snap = self.clone();
26        snap.document.id = doc_id.to_string();
27        for entity in &mut snap.entities {
28            entity.ontology_id = doc_id.to_string();
29        }
30        for ann in &mut snap.annotations {
31            ann.ontology_id = doc_id.to_string();
32        }
33        for ax in &mut snap.axioms {
34            ax.ontology_id = doc_id.to_string();
35        }
36        for ns in &mut snap.namespace_rows {
37            ns.ontology_id = doc_id.to_string();
38        }
39        for imp in &mut snap.imports {
40            imp.ontology_id = doc_id.to_string();
41        }
42        snap
43    }
44
45    pub fn with_reuse_context(&self, doc_id: &str, path: &Path, modified_time: u64) -> Self {
46        let mut snap = self.with_doc_id(doc_id);
47        snap.document.path = path.to_path_buf();
48        snap.document.modified_time = modified_time;
49        snap
50    }
51}
52
53pub(crate) fn effective_content_hash(disk_hash: &str, override_text: Option<&str>) -> String {
54    if let Some(text) = override_text {
55        content_hash_text(text)
56    } else {
57        disk_hash.to_string()
58    }
59}
60
61pub(crate) fn content_hash_text(text: &str) -> String {
62    use sha2::{Digest, Sha256};
63    let mut hasher = Sha256::new();
64    hasher.update(text.as_bytes());
65    hex::encode(hasher.finalize())
66}
67
68pub(crate) fn paths_equal(a: &Path, b: &Path) -> bool {
69    a == b || a.canonicalize().ok().zip(b.canonicalize().ok()).is_some_and(|(x, y)| x == y)
70}
71
72/// Fingerprint of incremental-relevant builder config (scan roots, disk cache, override paths).
73pub(crate) fn config_fingerprint(
74    scan_roots: &[PathBuf],
75    disk_cache: bool,
76    document_overrides: &HashMap<PathBuf, String>,
77) -> String {
78    use sha2::{Digest, Sha256};
79    let mut hasher = Sha256::new();
80    hasher.update(b"v1\0scan_roots\0");
81    for root in scan_roots {
82        hasher.update(root.to_string_lossy().as_bytes());
83        hasher.update([0]);
84    }
85    hasher.update([if disk_cache { 1 } else { 0 }]);
86    hasher.update(b"\0overrides\0");
87    let mut keys: Vec<_> = document_overrides.keys().collect();
88    keys.sort_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
89    for key in keys {
90        hasher.update(key.to_string_lossy().as_bytes());
91        hasher.update([0]);
92    }
93    hex::encode(hasher.finalize())
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum IncrementalStats {
98    FullBuild,
99    Incremental { reused: usize, reparsed: usize, removed: usize },
100}
101
102impl IncrementalStats {
103    pub fn reused_documents(&self) -> usize {
104        match self {
105            Self::FullBuild => 0,
106            Self::Incremental { reused, .. } => *reused,
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use std::collections::BTreeMap;
115    use std::path::PathBuf;
116    use strixonomy_core::{OntologyFormat, ParseStatus, SourceLocation};
117
118    #[test]
119    fn content_hash_is_stable() {
120        let h1 = content_hash_text("hello");
121        let h2 = content_hash_text("hello");
122        assert_eq!(h1, h2);
123        assert_ne!(h1, content_hash_text("world"));
124    }
125
126    #[test]
127    fn remap_doc_id_updates_ontology_ids() {
128        let snap = DocumentSnapshot {
129            content_hash: "abc".to_string(),
130            document: OntologyDocument {
131                id: "doc-1".to_string(),
132                path: PathBuf::from("a.ttl"),
133                format: OntologyFormat::Turtle,
134                base_iri: None,
135                version_iri: None,
136                imports: vec![],
137                namespaces: BTreeMap::new(),
138                parse_status: ParseStatus::Ok,
139                content_hash: "abc".to_string(),
140                modified_time: 0,
141                parse_message: None,
142                parse_error_location: None,
143            },
144            entities: vec![Entity {
145                iri: "http://ex#A".to_string(),
146                kind: strixonomy_core::EntityKind::Class,
147                short_name: "A".to_string(),
148                ontology_id: "doc-1".to_string(),
149                source_location: SourceLocation::default(),
150                labels: vec![],
151                comments: vec![],
152                deprecated: false,
153                obo_id: None,
154                characteristics: Default::default(),
155            }],
156            annotations: vec![],
157            axioms: vec![],
158            namespace_rows: vec![],
159            imports: vec![],
160            quads: vec![],
161            triple_count: 0,
162            bridge_warning: None,
163        };
164        let remapped = snap.with_doc_id("doc-2");
165        assert_eq!(remapped.document.id, "doc-2");
166        assert_eq!(remapped.entities[0].ontology_id, "doc-2");
167    }
168}