use std::collections::BTreeMap;
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::error::{ClaudixError, RecoveryHint, Result};
use crate::prompts::hints;
use crate::util::parse_rfc3339;
pub const SCHEMA_VERSION: u32 = 1;
pub(crate) const MANIFEST_FILE_NAME: &str = "manifest.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
pub schema_version: u32,
pub embedding_model: String,
pub dimensions: u16,
pub last_full_index_at: Option<String>,
pub last_incremental_at: Option<String>,
pub chunk_count: u64,
pub file_count: u64,
#[serde(default)]
pub file_hashes: BTreeMap<String, [u8; 16]>,
}
impl Manifest {
pub fn new(embedding_model: impl Into<String>, dimensions: u16) -> Self {
Self {
schema_version: SCHEMA_VERSION,
embedding_model: embedding_model.into(),
dimensions,
last_full_index_at: None,
last_incremental_at: None,
chunk_count: 0,
file_count: 0,
file_hashes: BTreeMap::new(),
}
}
pub fn for_config(config: &Config) -> Self {
Self::new(&config.embedding.model, config.embedding.dimensions)
}
pub fn is_stale(&self, config: &Config) -> bool {
let Some(last) = self.last_full_index_at.as_deref() else {
return true;
};
let Ok(last) = parse_rfc3339(last) else {
return true;
};
let Ok(age) = SystemTime::now().duration_since(last) else {
return false;
};
age > Duration::from_secs(config.indexing.reindex_after_hours.saturating_mul(3_600))
}
}
pub(super) fn validate_manifest_compatibility(
manifest: Manifest,
expected_model: &str,
expected_dimensions: u16,
) -> Result<Manifest> {
if manifest.schema_version != SCHEMA_VERSION {
return Err(ClaudixError::SchemaMismatch {
store: manifest.schema_version,
binary: SCHEMA_VERSION,
recovery: RecoveryHint(hints::REINDEX_SCHEMA_VERSION),
});
}
if manifest.embedding_model != expected_model {
return Err(ClaudixError::EmbeddingModelMismatch {
store_model: manifest.embedding_model,
active_model: expected_model.to_owned(),
recovery: RecoveryHint(hints::REINDEX_AFTER_MODEL_CHANGE),
});
}
if manifest.dimensions != expected_dimensions {
return Err(ClaudixError::DimensionMismatch {
store_dim: manifest.dimensions,
model_dim: expected_dimensions,
recovery: RecoveryHint(hints::REINDEX_AFTER_DIMENSION_CHANGE),
});
}
Ok(manifest)
}
#[cfg(test)]
mod tests {
use super::*;
mod config_support {
use crate as claudix;
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/common/config_support.rs"
));
}
use config_support::stub_config;
#[test]
fn stale_index_detection_respects_threshold() {
let mut config = stub_config();
config.indexing.reindex_after_hours = 24;
let mut manifest = Manifest::new("stub-v1", 8);
manifest.last_full_index_at = Some("2026-04-20T00:00:00Z".to_owned());
assert!(manifest.is_stale(&config));
}
#[test]
fn fresh_index_detection_allows_recent_manifest() {
let mut config = stub_config();
config.indexing.reindex_after_hours = 24 * 365 * 20;
let mut manifest = Manifest::new("stub-v1", 8);
manifest.last_full_index_at = Some(crate::util::now_rfc3339());
assert!(!manifest.is_stale(&config));
}
}