use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[must_use]
pub fn content_hash(bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CacheEntry<T> {
pub hash: String,
pub value: T,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DriftCache<T> {
pub version: String,
pub entries: BTreeMap<String, CacheEntry<T>>,
}
impl<T> DriftCache<T> {
#[must_use]
pub fn new(version: impl Into<String>) -> Self {
Self {
version: version.into(),
entries: BTreeMap::new(),
}
}
}
impl<T> Default for DriftCache<T> {
fn default() -> Self {
Self::new(String::new())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DriftSet {
pub changed: Vec<String>,
pub removed: Vec<String>,
}
impl DriftSet {
#[must_use]
pub fn is_empty(&self) -> bool {
self.changed.is_empty() && self.removed.is_empty()
}
}
#[must_use]
pub fn drift<T>(cache: &DriftCache<T>, current: &BTreeMap<String, String>) -> DriftSet {
let changed = current
.iter()
.filter(|(unit, hash)| cache.entries.get(*unit).map(|e| &e.hash) != Some(hash))
.map(|(unit, _)| unit.clone())
.collect();
let removed = cache
.entries
.keys()
.filter(|u| !current.contains_key(*u))
.cloned()
.collect();
DriftSet { changed, removed }
}
#[must_use]
pub fn apply_drift<T: Clone>(
prev: &DriftCache<T>,
current: &BTreeMap<String, String>,
derive: impl Fn(&str, &str) -> T,
) -> DriftCache<T> {
let entries = current
.iter()
.map(|(unit, hash)| {
let value = match prev.entries.get(unit) {
Some(e) if &e.hash == hash => e.value.clone(),
_ => derive(unit, hash),
};
(
unit.clone(),
CacheEntry {
hash: hash.clone(),
value,
},
)
})
.collect();
DriftCache {
version: prev.version.clone(),
entries,
}
}
#[must_use]
pub fn rebuild<T>(
version: &str,
current: &BTreeMap<String, String>,
derive: impl Fn(&str, &str) -> T,
) -> DriftCache<T> {
let entries = current
.iter()
.map(|(unit, hash)| {
(
unit.clone(),
CacheEntry {
hash: hash.clone(),
value: derive(unit, hash),
},
)
})
.collect();
DriftCache {
version: version.to_string(),
entries,
}
}
#[must_use]
pub fn store_path(config_dir: &Path, repo_root: &Path, name: &str) -> PathBuf {
let repo_hash = content_hash(repo_root.to_string_lossy().as_bytes());
config_dir
.join("index")
.join(&repo_hash[..16])
.join(format!("{name}.json"))
}
#[must_use]
pub fn load<T: for<'de> Deserialize<'de>>(
path: &Path,
expected_version: &str,
) -> Option<DriftCache<T>> {
let text = std::fs::read_to_string(path).ok()?;
let cache: DriftCache<T> = serde_json::from_str(&text).ok()?;
(cache.version == expected_version).then_some(cache)
}
pub fn save<T: Serialize>(path: &Path, cache: &DriftCache<T>) {
let Ok(json) = serde_json::to_string(cache) else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn cache(version: &str, kv: &[(&str, &str, &str)]) -> DriftCache<String> {
let mut c = DriftCache::new(version);
for (u, h, v) in kv {
c.entries.insert(
(*u).to_string(),
CacheEntry {
hash: (*h).to_string(),
value: (*v).to_string(),
},
);
}
c
}
fn hashes(kv: &[(&str, &str)]) -> BTreeMap<String, String> {
kv.iter()
.map(|(u, h)| (u.to_string(), h.to_string()))
.collect()
}
#[test]
fn content_hash_is_stable_blake3_hex() {
assert_eq!(content_hash(b"x"), content_hash(b"x"));
assert_ne!(content_hash(b"x"), content_hash(b"y"));
assert_eq!(content_hash(b"x").len(), 64);
}
#[test]
fn drift_reports_changed_new_and_removed_no_false_clean() {
let c = cache(
"v1",
&[("a", "h1", "A"), ("b", "h2", "B"), ("gone", "h9", "G")],
);
let d = drift(&c, &hashes(&[("a", "h1"), ("b", "h2_NEW"), ("c", "h3")]));
let mut changed = d.changed.clone();
changed.sort();
assert_eq!(changed, vec!["b".to_string(), "c".to_string()]);
assert_eq!(d.removed, vec!["gone".to_string()]);
assert!(drift(&c, &hashes(&[("a", "h1"), ("b", "h2"), ("gone", "h9")])).is_empty());
}
#[test]
fn apply_drift_equals_full_rebuild_for_every_edit() {
let prev = cache("v1", &[("a", "h1", "A"), ("b", "h2", "B")]);
let derive = |u: &str, _h: &str| format!("D:{u}");
for current in [
hashes(&[("a", "h1"), ("b", "h2_NEW")]), hashes(&[("a", "h1"), ("b", "h2"), ("c", "h3")]), hashes(&[("a", "h1")]), hashes(&[("x", "hx")]), ] {
let incremental = apply_drift(&prev, ¤t, derive);
let full = rebuild(&prev.version, ¤t, derive);
assert_eq!(
incremental.entries.keys().collect::<Vec<_>>(),
full.entries.keys().collect::<Vec<_>>()
);
for (u, e) in &incremental.entries {
assert_eq!(&e.hash, &full.entries[u].hash);
let clean = prev.entries.get(u).map(|p| &p.hash) == Some(&e.hash);
if clean {
assert_eq!(&e.value, &prev.entries[u].value);
} else {
assert_eq!(&e.value, &full.entries[u].value);
}
}
}
}
#[test]
fn apply_drift_only_rederives_changed_units() {
let prev = cache(
"v1",
&[("a", "h1", "A"), ("b", "h2", "B"), ("c", "h3", "C")],
);
let calls = RefCell::new(Vec::new());
let derive = |u: &str, _h: &str| {
calls.borrow_mut().push(u.to_string());
format!("D:{u}")
};
let _ = apply_drift(
&prev,
&hashes(&[("a", "h1"), ("b", "h2_NEW"), ("c", "h3")]),
derive,
);
assert_eq!(
*calls.borrow(),
vec!["b".to_string()],
"only the drifted unit re-derives"
);
}
#[test]
fn store_path_is_repo_scoped_under_index_dir() {
let p = store_path(Path::new("/home/x/.newt"), Path::new("/repo/foo"), "model");
let comps: Vec<String> = p
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
assert!(comps.contains(&"index".to_string()), "{p:?}");
assert_eq!(p.file_name().and_then(|f| f.to_str()), Some("model.json"));
let idx = comps.iter().position(|c| c == "index").unwrap();
assert_eq!(comps[idx + 1].len(), 16, "16-char repo hash: {p:?}");
}
#[test]
fn load_rejects_a_version_mismatch() {
let c = cache("v1", &[("a", "h1", "A")]);
let json = serde_json::to_string(&c).unwrap();
let back: DriftCache<String> = serde_json::from_str(&json).unwrap();
assert_eq!(back, c);
assert_ne!(
back.version, "v2",
"a v2 expectation would reject this v1 cache"
);
}
}