Skip to main content

semtree_rag/
manifest.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::RagError;
7
8/// On-disk layout version. Bump when the manifest struct changes shape so an
9/// older file is treated as incompatible rather than mis-parsed.
10const MANIFEST_VERSION: u32 = 1;
11
12/// Version of the parse/chunk logic. Bump whenever chunk boundaries or IDs can
13/// change (grammar upgrade, new chunk kinds); a mismatch forces a full rebuild
14/// so stale chunk IDs never linger in the store.
15const CHUNKER_VERSION: u32 = 1;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct FileEntry {
19    /// Stable content hash for change detection (blake3, hex).
20    pub content_hash: String,
21    /// Chunk IDs produced from this file.
22    pub chunk_ids: Vec<String>,
23}
24
25/// Tracks per-file state to enable incremental re-indexing.
26///
27/// The header ([`manifest_version`], [`chunker_version`], [`embedder`], [`store`])
28/// pins the assumptions the entries were built under. When any of them no longer
29/// match the current pipeline, the index must be rebuilt from scratch - see
30/// [`is_compatible_with`](FileManifest::is_compatible_with).
31#[derive(Debug, Serialize, Deserialize)]
32pub struct FileManifest {
33    #[serde(default)]
34    manifest_version: u32,
35    #[serde(default)]
36    chunker_version: u32,
37    /// Fingerprint of the embedder that produced the stored vectors.
38    #[serde(default)]
39    embedder: String,
40    /// Fingerprint of the store the vectors live in (e.g. its distance metric):
41    /// switching it re-ranks results, so it invalidates the index too.
42    #[serde(default)]
43    store: String,
44    entries: HashMap<PathBuf, FileEntry>,
45}
46
47impl Default for FileManifest {
48    fn default() -> Self {
49        Self {
50            manifest_version: MANIFEST_VERSION,
51            chunker_version: CHUNKER_VERSION,
52            embedder: String::new(),
53            store: String::new(),
54            entries: HashMap::new(),
55        }
56    }
57}
58
59impl FileManifest {
60    /// A fresh manifest pinned to the current pipeline, the given embedder
61    /// fingerprint (see [`Embedder::fingerprint`](semtree_embed::Embedder::fingerprint)),
62    /// and the store fingerprint (e.g. its [`Metric`](semtree_store::Metric)).
63    pub fn new(
64        embedder_fingerprint: impl Into<String>,
65        store_fingerprint: impl Into<String>,
66    ) -> Self {
67        Self {
68            embedder: embedder_fingerprint.into(),
69            store: store_fingerprint.into(),
70            ..Self::default()
71        }
72    }
73
74    pub fn load(index_dir: &Path) -> Self {
75        let path = index_dir.join("manifest.json");
76        std::fs::read_to_string(&path)
77            .ok()
78            .and_then(|raw| serde_json::from_str(&raw).ok())
79            .unwrap_or_default()
80    }
81
82    pub fn save(&self, index_dir: &Path) -> Result<(), RagError> {
83        let path = index_dir.join("manifest.json");
84        let data =
85            serde_json::to_string(self).map_err(|e| RagError::Io(std::io::Error::other(e)))?;
86        std::fs::write(path, data)?;
87        Ok(())
88    }
89
90    /// Whether the stored entries can be trusted for an incremental update given
91    /// the current embedder and store. False means the schema, the chunker, the
92    /// embedder, or the store changed and the index must be rebuilt from scratch.
93    pub fn is_compatible_with(&self, embedder_fingerprint: &str, store_fingerprint: &str) -> bool {
94        self.manifest_version == MANIFEST_VERSION
95            && self.chunker_version == CHUNKER_VERSION
96            && self.embedder == embedder_fingerprint
97            && self.store == store_fingerprint
98    }
99
100    /// The embedder fingerprint the stored vectors were built with.
101    pub fn embedder(&self) -> &str {
102        &self.embedder
103    }
104
105    /// The store fingerprint the stored vectors were built with.
106    pub fn store(&self) -> &str {
107        &self.store
108    }
109
110    /// Returns `true` if the file is new or its content has changed.
111    pub fn is_changed(&self, path: &Path, content: &str) -> bool {
112        let hash = content_hash(content);
113        match self.entries.get(path) {
114            Some(entry) => entry.content_hash != hash,
115            None => true,
116        }
117    }
118
119    /// Returns the chunk IDs that were last indexed from this file.
120    pub fn chunk_ids(&self, path: &Path) -> &[String] {
121        self.entries
122            .get(path)
123            .map(|e| e.chunk_ids.as_slice())
124            .unwrap_or(&[])
125    }
126
127    /// Record the result of indexing a file.
128    pub fn record(&mut self, path: PathBuf, content: &str, chunk_ids: Vec<String>) {
129        self.entries.insert(
130            path,
131            FileEntry {
132                content_hash: content_hash(content),
133                chunk_ids,
134            },
135        );
136    }
137
138    /// Remove a file entry (e.g. when the file is deleted).
139    pub fn remove(&mut self, path: &Path) -> Option<FileEntry> {
140        self.entries.remove(path)
141    }
142
143    /// Returns all tracked paths.
144    pub fn paths(&self) -> impl Iterator<Item = &PathBuf> {
145        self.entries.keys()
146    }
147}
148
149/// Stable content hash. blake3 is deterministic across releases and platforms,
150/// so a manifest persisted today still matches an unchanged file tomorrow -
151/// unlike `DefaultHasher`, whose output std makes no stability promise about.
152fn content_hash(content: &str) -> String {
153    blake3::hash(content.as_bytes()).to_hex().to_string()
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn detects_content_change() {
162        let mut m = FileManifest::new("fastembed:AllMiniLML6V2/384d", "cosine");
163        let path = Path::new("src/lib.rs");
164        assert!(m.is_changed(path, "fn a() {}"), "new file is changed");
165
166        m.record(path.to_path_buf(), "fn a() {}", vec!["id1".into()]);
167        assert!(
168            !m.is_changed(path, "fn a() {}"),
169            "same content is unchanged"
170        );
171        assert!(m.is_changed(path, "fn b() {}"), "edited content is changed");
172    }
173
174    #[test]
175    fn content_hash_is_stable_and_deterministic() {
176        // A fixed input must always hash to the same value, or every restart
177        // would look like a full-tree change.
178        assert_eq!(content_hash("hello"), content_hash("hello"));
179        assert_ne!(content_hash("hello"), content_hash("world"));
180    }
181
182    #[test]
183    fn incompatible_on_embedder_change() {
184        let m = FileManifest::new("fastembed:AllMiniLML6V2/384d", "cosine");
185        assert!(m.is_compatible_with("fastembed:AllMiniLML6V2/384d", "cosine"));
186        assert!(!m.is_compatible_with("openai:text-embedding-3-small/1536d", "cosine"));
187    }
188
189    #[test]
190    fn incompatible_on_store_change() {
191        // Same embedder, different distance metric: results would re-rank, so
192        // the index is not reusable.
193        let m = FileManifest::new("fastembed:AllMiniLML6V2/384d", "cosine");
194        assert!(!m.is_compatible_with("fastembed:AllMiniLML6V2/384d", "euclidean"));
195    }
196
197    #[test]
198    fn incompatible_on_version_change() {
199        let mut m = FileManifest::new("e", "cosine");
200        m.chunker_version = CHUNKER_VERSION + 1;
201        assert!(
202            !m.is_compatible_with("e", "cosine"),
203            "bumped chunker forces rebuild"
204        );
205
206        let mut m = FileManifest::new("e", "cosine");
207        m.manifest_version = MANIFEST_VERSION + 1;
208        assert!(
209            !m.is_compatible_with("e", "cosine"),
210            "bumped schema forces rebuild"
211        );
212    }
213
214    #[test]
215    fn legacy_manifest_without_header_is_incompatible() {
216        // A pre-header manifest deserializes with zeroed version fields (serde
217        // default), so it never masquerades as compatible.
218        let legacy: FileManifest = serde_json::from_str(r#"{"entries":{}}"#).unwrap();
219        assert_eq!(legacy.manifest_version, 0);
220        assert!(!legacy.is_compatible_with("fastembed:AllMiniLML6V2/384d", "cosine"));
221    }
222}