use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::RagError;
const MANIFEST_VERSION: u32 = 1;
const CHUNKER_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
pub content_hash: String,
pub chunk_ids: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FileManifest {
#[serde(default)]
manifest_version: u32,
#[serde(default)]
chunker_version: u32,
#[serde(default)]
embedder: String,
#[serde(default)]
store: String,
entries: HashMap<PathBuf, FileEntry>,
}
impl Default for FileManifest {
fn default() -> Self {
Self {
manifest_version: MANIFEST_VERSION,
chunker_version: CHUNKER_VERSION,
embedder: String::new(),
store: String::new(),
entries: HashMap::new(),
}
}
}
impl FileManifest {
pub fn new(
embedder_fingerprint: impl Into<String>,
store_fingerprint: impl Into<String>,
) -> Self {
Self {
embedder: embedder_fingerprint.into(),
store: store_fingerprint.into(),
..Self::default()
}
}
pub fn load(index_dir: &Path) -> Self {
let path = index_dir.join("manifest.json");
std::fs::read_to_string(&path)
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
pub fn save(&self, index_dir: &Path) -> Result<(), RagError> {
let path = index_dir.join("manifest.json");
let data =
serde_json::to_string(self).map_err(|e| RagError::Io(std::io::Error::other(e)))?;
std::fs::write(path, data)?;
Ok(())
}
pub fn is_compatible_with(&self, embedder_fingerprint: &str, store_fingerprint: &str) -> bool {
self.manifest_version == MANIFEST_VERSION
&& self.chunker_version == CHUNKER_VERSION
&& self.embedder == embedder_fingerprint
&& self.store == store_fingerprint
}
pub fn embedder(&self) -> &str {
&self.embedder
}
pub fn store(&self) -> &str {
&self.store
}
pub fn is_changed(&self, path: &Path, content: &str) -> bool {
let hash = content_hash(content);
match self.entries.get(path) {
Some(entry) => entry.content_hash != hash,
None => true,
}
}
pub fn chunk_ids(&self, path: &Path) -> &[String] {
self.entries
.get(path)
.map(|e| e.chunk_ids.as_slice())
.unwrap_or(&[])
}
pub fn record(&mut self, path: PathBuf, content: &str, chunk_ids: Vec<String>) {
self.entries.insert(
path,
FileEntry {
content_hash: content_hash(content),
chunk_ids,
},
);
}
pub fn remove(&mut self, path: &Path) -> Option<FileEntry> {
self.entries.remove(path)
}
pub fn paths(&self) -> impl Iterator<Item = &PathBuf> {
self.entries.keys()
}
}
fn content_hash(content: &str) -> String {
blake3::hash(content.as_bytes()).to_hex().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_content_change() {
let mut m = FileManifest::new("fastembed:AllMiniLML6V2/384d", "cosine");
let path = Path::new("src/lib.rs");
assert!(m.is_changed(path, "fn a() {}"), "new file is changed");
m.record(path.to_path_buf(), "fn a() {}", vec!["id1".into()]);
assert!(
!m.is_changed(path, "fn a() {}"),
"same content is unchanged"
);
assert!(m.is_changed(path, "fn b() {}"), "edited content is changed");
}
#[test]
fn content_hash_is_stable_and_deterministic() {
assert_eq!(content_hash("hello"), content_hash("hello"));
assert_ne!(content_hash("hello"), content_hash("world"));
}
#[test]
fn incompatible_on_embedder_change() {
let m = FileManifest::new("fastembed:AllMiniLML6V2/384d", "cosine");
assert!(m.is_compatible_with("fastembed:AllMiniLML6V2/384d", "cosine"));
assert!(!m.is_compatible_with("openai:text-embedding-3-small/1536d", "cosine"));
}
#[test]
fn incompatible_on_store_change() {
let m = FileManifest::new("fastembed:AllMiniLML6V2/384d", "cosine");
assert!(!m.is_compatible_with("fastembed:AllMiniLML6V2/384d", "euclidean"));
}
#[test]
fn incompatible_on_version_change() {
let mut m = FileManifest::new("e", "cosine");
m.chunker_version = CHUNKER_VERSION + 1;
assert!(
!m.is_compatible_with("e", "cosine"),
"bumped chunker forces rebuild"
);
let mut m = FileManifest::new("e", "cosine");
m.manifest_version = MANIFEST_VERSION + 1;
assert!(
!m.is_compatible_with("e", "cosine"),
"bumped schema forces rebuild"
);
}
#[test]
fn legacy_manifest_without_header_is_incompatible() {
let legacy: FileManifest = serde_json::from_str(r#"{"entries":{}}"#).unwrap();
assert_eq!(legacy.manifest_version, 0);
assert!(!legacy.is_compatible_with("fastembed:AllMiniLML6V2/384d", "cosine"));
}
}