use std::path::Path;
pub const SCHEMA_VERSION: u32 = 2;
fn absorb(hasher: &mut blake3::Hasher, field: &[u8]) {
hasher.update(&(field.len() as u64).to_le_bytes());
hasher.update(field);
}
pub fn cache_key(
schema_version: u32,
path: &str,
repo: &str,
content: &str,
filters: &str,
branch: &str,
) -> String {
let mut hasher = blake3::Hasher::new();
absorb(&mut hasher, &schema_version.to_le_bytes());
absorb(&mut hasher, path.as_bytes());
absorb(&mut hasher, repo.as_bytes());
absorb(&mut hasher, content.as_bytes());
absorb(&mut hasher, filters.as_bytes());
absorb(&mut hasher, branch.as_bytes());
hex::encode(hasher.finalize().as_bytes())
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct IndexHashRow {
pub path: String,
pub hash: String,
}
pub fn load_hashes(
db: &crate::graph::GraphEngine,
) -> Result<Vec<IndexHashRow>, Box<dyn std::error::Error + Send + Sync>> {
db.run_raw_query(
"?[path, hash] := *index_hashes[path, hash]",
std::collections::BTreeMap::new(),
)
.map(|rows| {
rows.rows
.iter()
.map(|row| IndexHashRow {
path: row[0].get_str().unwrap_or("").to_string(),
hash: row[1].get_str().unwrap_or("").to_string(),
})
.collect()
})
}
pub fn save_hashes(
db: &crate::graph::GraphEngine,
rows: &[IndexHashRow],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
for row in rows {
let params = std::collections::BTreeMap::from([
("path".to_string(), row.path.clone().into()),
("hash".to_string(), row.hash.clone().into()),
]);
db.run_raw_query(
r#"?[path, hash] <- [[$path, $hash]] :put index_hashes {path => hash}"#,
params,
)?;
}
Ok(())
}
pub fn hash_files(
root: &Path,
files: &[String],
repo: &str,
filters: &str,
branch: &str,
) -> Vec<IndexHashRow> {
let mut out: Vec<IndexHashRow> = Vec::with_capacity(files.len());
for f in files {
let path = if f.starts_with('/') {
std::path::PathBuf::from(f)
} else {
root.join(f)
};
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
out.push(IndexHashRow {
path: f.clone(),
hash: cache_key(SCHEMA_VERSION, f, repo, &content, filters, branch),
});
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
pub fn files_needing_index(previous: &[IndexHashRow], current: &[IndexHashRow]) -> Vec<String> {
let prev: std::collections::HashMap<&str, &str> = previous
.iter()
.map(|r| (r.path.as_str(), r.hash.as_str()))
.collect();
current
.iter()
.filter(|r| prev.get(r.path.as_str()) != Some(&r.hash.as_str()))
.map(|r| r.path.clone())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn cache_key_is_deterministic_and_sensitive() {
let a = cache_key(2, "src/a.rs", "repo", "fn a(){}", "lang=go", "main");
let b = cache_key(2, "src/a.rs", "repo", "fn a(){}", "lang=go", "main");
assert_eq!(a, b);
let c = cache_key(3, "src/a.rs", "repo", "fn a(){}", "lang=go", "main");
assert_ne!(a, c, "schema_version changes key");
let d = cache_key(2, "src/a.rs", "repo", "fn b(){}", "lang=go", "main");
assert_ne!(a, d, "content changes key");
let e = cache_key(2, "src/a.rs", "other", "fn a(){}", "lang=go", "main");
assert_ne!(a, e, "repo changes key");
}
#[test]
fn cache_key_length_is_blake3_64_hex() {
let h = cache_key(2, "x", "y", "z", "", "main");
assert_eq!(h.len(), 64, "BLAKE3 default digest = 32 bytes hex");
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn cache_key_blake3_fixed_vector() {
let h = blake3::hash(b"abc");
assert_eq!(
hex::encode(h.as_bytes()),
"6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85"
);
}
#[test]
fn cache_key_uses_length_prefix_framing() {
let x = cache_key(2, "a", "bc", "content", "", "main");
let y = cache_key(2, "ab", "c", "content", "", "main");
assert_ne!(
x, y,
"length-prefix framing prevents path|repo boundary collisions"
);
let z = cache_key(2, "abc", "", "content", "", "main");
assert_ne!(x, z);
}
#[test]
fn hash_files_sorts_and_skips_missing() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("b.rs"), "fn b(){}").unwrap();
std::fs::write(tmp.path().join("a.rs"), "fn a(){}").unwrap();
let files = vec![
"a.rs".to_string(),
"b.rs".to_string(),
"ghost.rs".to_string(),
];
let rows = hash_files(tmp.path(), &files, "r", "", "main");
assert_eq!(rows.len(), 2, "missing file skipped");
assert_eq!(rows[0].path, "a.rs", "sorted by path");
}
#[test]
fn files_needing_index_tracks_new_and_changed() {
let prev = vec![IndexHashRow {
path: "a.rs".into(),
hash: "old".into(),
}];
let cur = vec![
IndexHashRow {
path: "a.rs".into(),
hash: "new".into(),
},
IndexHashRow {
path: "b.rs".into(),
hash: "x".into(),
},
];
let need = files_needing_index(&prev, &cur);
assert_eq!(need, vec!["a.rs", "b.rs"]);
}
#[test]
fn unchanged_files_are_skipped() {
let prev = vec![IndexHashRow {
path: "a.rs".into(),
hash: "same".into(),
}];
let cur = vec![IndexHashRow {
path: "a.rs".into(),
hash: "same".into(),
}];
assert!(files_needing_index(&prev, &cur).is_empty());
}
}