1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::RagError;
7
8const MANIFEST_VERSION: u32 = 1;
11
12const CHUNKER_VERSION: u32 = 1;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct FileEntry {
19 pub content_hash: String,
21 pub chunk_ids: Vec<String>,
23}
24
25#[derive(Debug, Serialize, Deserialize)]
32pub struct FileManifest {
33 #[serde(default)]
34 manifest_version: u32,
35 #[serde(default)]
36 chunker_version: u32,
37 #[serde(default)]
39 embedder: String,
40 #[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 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 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 pub fn embedder(&self) -> &str {
102 &self.embedder
103 }
104
105 pub fn store(&self) -> &str {
107 &self.store
108 }
109
110 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 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 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 pub fn remove(&mut self, path: &Path) -> Option<FileEntry> {
140 self.entries.remove(path)
141 }
142
143 pub fn paths(&self) -> impl Iterator<Item = &PathBuf> {
145 self.entries.keys()
146 }
147}
148
149fn 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 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 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 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}