use lanekeep_core::ContentHash;
pub const FORMAT_VERSION: u32 = 4;
#[derive(Debug, Clone)]
pub struct RunKey {
prefix: blake3::Hasher,
}
impl RunKey {
#[must_use]
pub fn new(
engine_version: &str,
host_api_version: u32,
ruleset_hash: &[u8],
config_hash: &[u8],
grammars: &[GrammarKey],
) -> Self {
let mut prefix = blake3::Hasher::new();
write_field(&mut prefix, b"lanekeep-cache");
write_field(&mut prefix, &FORMAT_VERSION.to_le_bytes());
write_field(&mut prefix, engine_version.as_bytes());
write_field(&mut prefix, &host_api_version.to_le_bytes());
write_field(&mut prefix, ruleset_hash);
write_field(&mut prefix, config_hash);
write_field(&mut prefix, &(grammars.len() as u64).to_le_bytes());
for grammar in grammars {
write_field(&mut prefix, grammar.id.as_bytes());
write_field(&mut prefix, &grammar.abi.to_le_bytes());
}
Self { prefix }
}
#[must_use]
pub fn for_dated_file(&self, path: &str, content: &ContentHash, today: &str) -> CacheKey {
let mut hasher = self.prefix.clone();
write_field(&mut hasher, path.as_bytes());
write_field(&mut hasher, content.as_bytes());
write_field(&mut hasher, today.as_bytes());
CacheKey(*hasher.finalize().as_bytes())
}
#[must_use]
pub fn for_file(&self, path: &str, content: &ContentHash) -> CacheKey {
let mut hasher = self.prefix.clone();
write_field(&mut hasher, path.as_bytes());
write_field(&mut hasher, content.as_bytes());
CacheKey(*hasher.finalize().as_bytes())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrammarKey {
pub id: String,
pub abi: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CacheKey([u8; 32]);
impl CacheKey {
#[must_use]
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
#[must_use]
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl std::fmt::Display for CacheKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for byte in &self.0[..4] {
write!(f, "{byte:02x}")?;
}
Ok(())
}
}
fn write_field(hasher: &mut blake3::Hasher, bytes: &[u8]) {
hasher.update(&(bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
}
#[cfg(test)]
mod tests {
use super::*;
fn run() -> RunKey {
RunKey::new("0.1", 1, b"ruleset", b"config", &[grammar()])
}
fn grammar() -> GrammarKey {
GrammarKey {
id: "typescript".to_owned(),
abi: 15,
}
}
fn content(seed: u8) -> ContentHash {
ContentHash::new([seed; 32])
}
fn key_of(run: &RunKey, path: &str, seed: u8) -> CacheKey {
run.for_file(path, &content(seed))
}
#[test]
fn the_same_inputs_give_the_same_key() {
assert_eq!(key_of(&run(), "src/a.ts", 1), key_of(&run(), "src/a.ts", 1));
}
#[test]
fn changing_the_content_changes_the_key() {
assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&run(), "src/a.ts", 2));
}
#[test]
fn moving_a_file_changes_the_key() {
assert_ne!(
key_of(&run(), "src/a.ts", 1),
key_of(&run(), "test/a.ts", 1)
);
}
#[test]
fn changing_the_ruleset_changes_the_key() {
let other = RunKey::new("0.1", 1, b"different", b"config", &[grammar()]);
assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
}
#[test]
fn changing_the_config_changes_the_key() {
let other = RunKey::new("0.1", 1, b"ruleset", b"different", &[grammar()]);
assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
}
#[test]
fn changing_the_engine_version_changes_the_key() {
let other = RunKey::new("0.2", 1, b"ruleset", b"config", &[grammar()]);
assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
}
#[test]
fn changing_the_host_api_version_changes_the_key() {
let other = RunKey::new("0.1", 2, b"ruleset", b"config", &[grammar()]);
assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
}
#[test]
fn adding_a_grammar_changes_the_key() {
let more = RunKey::new(
"0.1",
1,
b"ruleset",
b"config",
&[
grammar(),
GrammarKey {
id: "javascript".to_owned(),
abi: 15,
},
],
);
assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&more, "src/a.ts", 1));
}
#[test]
fn changing_the_grammar_abi_changes_the_key() {
let bumped = RunKey::new(
"0.1",
1,
b"ruleset",
b"config",
&[GrammarKey {
id: "typescript".to_owned(),
abi: 16,
}],
);
assert_ne!(
key_of(&run(), "src/a.ts", 1),
key_of(&bumped, "src/a.ts", 1)
);
}
#[test]
fn changing_the_language_changes_the_key() {
let other = RunKey::new(
"0.1",
1,
b"ruleset",
b"config",
&[GrammarKey {
id: "javascript".to_owned(),
abi: 15,
}],
);
assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
}
#[test]
fn fields_cannot_run_together() {
let one = RunKey::new("0.1", 1, b"ab", b"c", &[grammar()]);
let other = RunKey::new("0.1", 1, b"a", b"bc", &[grammar()]);
assert_ne!(key_of(&one, "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
assert_ne!(
run().for_file("src/ab.ts", &content(1)),
run().for_file("src/a", &content(1))
);
}
#[test]
fn a_dated_key_changes_with_the_date() {
let content = content(1);
assert_ne!(
run().for_dated_file("src/a.ts", &content, "2026-08-01"),
run().for_dated_file("src/a.ts", &content, "2026-08-02")
);
}
#[test]
fn a_dated_key_differs_from_an_undated_one() {
let content = content(1);
assert_ne!(
run().for_file("src/a.ts", &content),
run().for_dated_file("src/a.ts", &content, "2026-08-01")
);
}
#[test]
fn a_key_renders_short_for_diagnostics() {
let rendered = key_of(&run(), "src/a.ts", 1).to_string();
assert_eq!(rendered.len(), 8);
assert!(rendered.chars().all(|c| c.is_ascii_hexdigit()));
}
}