use anyhow::{Context, Result};
use camino::Utf8Path;
use sha2::{Digest, Sha256};
use weaveffi_ir::ir::Api;
const CACHE_DIR: &str = ".weaveffi-cache";
pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
pub fn hash_api(api: &Api) -> String {
let value = serde_json::to_value(api).expect("Api serialization should not fail");
let json = serde_json::to_string(&value).expect("Value serialization should not fail");
let hash = Sha256::digest(json.as_bytes());
format!("{hash:x}")
}
pub fn hash_api_for_generator(api: &Api, generator_name: &str) -> String {
let value = serde_json::to_value(api).expect("Api serialization should not fail");
let json = serde_json::to_string(&value).expect("Value serialization should not fail");
let mut hasher = Sha256::new();
hasher.update(generator_name.as_bytes());
hasher.update(b":");
hasher.update(json.as_bytes());
let hash = hasher.finalize();
format!("{hash:x}")
}
pub fn hash_generator_inputs(api: &Api, generator_name: &str, config_bytes: &[u8]) -> String {
let api_value = serde_json::to_value(api).expect("Api serialization should not fail");
let api_json = serde_json::to_string(&api_value).expect("Value serialization should not fail");
let mut hasher = Sha256::new();
hasher.update(b"v1\0");
hasher.update(CLI_VERSION.as_bytes());
hasher.update(b"\0");
hasher.update(generator_name.as_bytes());
hasher.update(b"\0");
hasher.update(api_json.as_bytes());
hasher.update(b"\0");
hasher.update(config_bytes);
let hash = hasher.finalize();
format!("{hash:x}")
}
pub fn read_generator_cache(out_dir: &Utf8Path, generator_name: &str) -> Option<String> {
let path = out_dir
.join(CACHE_DIR)
.join(format!("{generator_name}.hash"));
std::fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn write_generator_cache(out_dir: &Utf8Path, generator_name: &str, hash: &str) -> Result<()> {
let cache_dir = out_dir.join(CACHE_DIR);
migrate_legacy_cache(out_dir)?;
std::fs::create_dir_all(cache_dir.as_std_path())
.with_context(|| format!("failed to create cache directory: {cache_dir}"))?;
let path = cache_dir.join(format!("{generator_name}.hash"));
std::fs::write(path.as_std_path(), hash)
.with_context(|| format!("failed to write cache file: {path}"))?;
Ok(())
}
pub fn invalidate_all(out_dir: &Utf8Path) -> Result<()> {
let cache_dir = out_dir.join(CACHE_DIR);
if cache_dir.is_dir() {
std::fs::remove_dir_all(cache_dir.as_std_path())
.with_context(|| format!("failed to remove cache directory: {cache_dir}"))?;
} else if cache_dir.exists() {
std::fs::remove_file(cache_dir.as_std_path())
.with_context(|| format!("failed to remove legacy cache file: {cache_dir}"))?;
}
Ok(())
}
fn migrate_legacy_cache(out_dir: &Utf8Path) -> Result<()> {
let cache_path = out_dir.join(CACHE_DIR);
if cache_path.is_file() {
std::fs::remove_file(cache_path.as_std_path())
.with_context(|| format!("failed to remove legacy cache file: {cache_path}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codegen::{ConfiguredGenerator, Generator, Orchestrator, OrchestratorHooks};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use weaveffi_ir::ir::{Function, Module, Param, TypeRef};
#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
struct TestConfig {
knob: Option<String>,
}
fn config_bytes(c: &TestConfig) -> Vec<u8> {
let v = serde_json::to_value(c).unwrap();
serde_json::to_vec(&v).unwrap()
}
fn minimal_api() -> Api {
Api {
version: "0.3.0".to_string(),
modules: vec![Module {
name: "math".to_string(),
functions: vec![Function {
name: "add".to_string(),
params: vec![
Param {
name: "a".to_string(),
ty: TypeRef::I32,
mutable: false,
doc: None,
},
Param {
name: "b".to_string(),
ty: TypeRef::I32,
mutable: false,
doc: None,
},
],
returns: Some(TypeRef::I32),
doc: None,
r#async: false,
cancellable: false,
deprecated: None,
since: None,
}],
structs: vec![],
enums: vec![],
callbacks: vec![],
listeners: vec![],
errors: None,
modules: vec![],
}],
generators: None,
package: None,
}
}
struct CountingGenerator {
name: &'static str,
calls: Arc<AtomicUsize>,
}
impl Generator for CountingGenerator {
type Config = TestConfig;
fn name(&self) -> &'static str {
self.name
}
fn capabilities(&self) -> crate::capabilities::TargetCapabilities {
crate::capabilities::TargetCapabilities::full()
}
fn generate(
&self,
_api: &Api,
out_dir: &Utf8Path,
_config: &Self::Config,
) -> anyhow::Result<()> {
self.calls.fetch_add(1, Ordering::SeqCst);
let dir = out_dir.join(self.name);
std::fs::create_dir_all(dir.as_std_path())?;
std::fs::write(dir.join("output.txt").as_std_path(), "generated")?;
Ok(())
}
}
fn configured(
name: &'static str,
calls: Arc<AtomicUsize>,
cfg: TestConfig,
) -> ConfiguredGenerator<CountingGenerator> {
ConfiguredGenerator::new(CountingGenerator { name, calls }, cfg)
}
#[test]
fn hash_deterministic() {
let api = minimal_api();
let h1 = hash_api(&api);
let h2 = hash_api(&api);
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
}
#[test]
fn hash_is_deterministic_across_runs() {
let mut api = minimal_api();
let mut generators = std::collections::BTreeMap::new();
let mut swift = toml::value::Table::new();
swift.insert(
"module_name".into(),
toml::Value::String("MySwiftModule".into()),
);
generators.insert("swift".into(), toml::Value::Table(swift));
let mut android = toml::value::Table::new();
android.insert(
"package".into(),
toml::Value::String("com.example.app".into()),
);
generators.insert("android".into(), toml::Value::Table(android));
api.generators = Some(generators);
let baseline = hash_api(&api);
for _ in 0..100 {
assert_eq!(
hash_api(&api),
baseline,
"hash_api must produce identical output on every call"
);
}
}
#[test]
fn hash_changes_on_modification() {
let mut api = minimal_api();
let h1 = hash_api(&api);
api.modules[0].functions.push(Function {
name: "subtract".to_string(),
params: vec![
Param {
name: "a".to_string(),
ty: TypeRef::I32,
mutable: false,
doc: None,
},
Param {
name: "b".to_string(),
ty: TypeRef::I32,
mutable: false,
doc: None,
},
],
returns: Some(TypeRef::I32),
doc: None,
r#async: false,
cancellable: false,
deprecated: None,
since: None,
});
let h2 = hash_api(&api);
assert_ne!(h1, h2);
}
#[test]
fn per_generator_hash_includes_name() {
let api = minimal_api();
let h_c = hash_api_for_generator(&api, "c");
let h_swift = hash_api_for_generator(&api, "swift");
assert_ne!(h_c, h_swift);
assert_eq!(h_c.len(), 64);
}
#[test]
fn per_generator_hash_deterministic() {
let api = minimal_api();
assert_eq!(
hash_api_for_generator(&api, "c"),
hash_api_for_generator(&api, "c"),
);
}
#[test]
fn per_generator_cache_round_trip() {
let dir = tempfile::tempdir().unwrap();
let dir_path = Utf8Path::from_path(dir.path()).unwrap();
let hash = hash_api_for_generator(&minimal_api(), "c");
write_generator_cache(dir_path, "c", &hash).unwrap();
let read_back = read_generator_cache(dir_path, "c");
assert_eq!(read_back, Some(hash));
assert_eq!(read_generator_cache(dir_path, "swift"), None);
}
#[test]
fn read_generator_cache_returns_none_when_missing() {
let dir = tempfile::tempdir().unwrap();
let dir_path = Utf8Path::from_path(dir.path()).unwrap();
assert_eq!(read_generator_cache(dir_path, "c"), None);
}
#[test]
fn invalidate_all_clears_cache() {
let dir = tempfile::tempdir().unwrap();
let dir_path = Utf8Path::from_path(dir.path()).unwrap();
write_generator_cache(dir_path, "c", "abc").unwrap();
write_generator_cache(dir_path, "swift", "def").unwrap();
invalidate_all(dir_path).unwrap();
assert_eq!(read_generator_cache(dir_path, "c"), None);
assert_eq!(read_generator_cache(dir_path, "swift"), None);
}
#[test]
fn legacy_cache_file_is_replaced_by_directory() {
let dir = tempfile::tempdir().unwrap();
let dir_path = Utf8Path::from_path(dir.path()).unwrap();
std::fs::write(dir_path.join(CACHE_DIR), "stale-global-hash").unwrap();
assert!(dir_path.join(CACHE_DIR).is_file());
write_generator_cache(dir_path, "c", "fresh-hash").unwrap();
assert!(dir_path.join(CACHE_DIR).is_dir());
assert_eq!(
read_generator_cache(dir_path, "c"),
Some("fresh-hash".to_string())
);
}
#[test]
fn cache_file_written_after_generate() {
let dir = tempfile::tempdir().unwrap();
let out_dir = Utf8Path::from_path(dir.path()).unwrap();
let api = minimal_api();
let hooks = OrchestratorHooks::default();
let calls = Arc::new(AtomicUsize::new(0));
let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
let orch = Orchestrator::new().with_generator(&gen);
orch.run(&api, out_dir, &hooks, false).unwrap();
assert!(out_dir.join(CACHE_DIR).join("counting.hash").exists());
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
fn cache_prevents_regeneration() {
let dir = tempfile::tempdir().unwrap();
let out_dir = Utf8Path::from_path(dir.path()).unwrap();
let api = minimal_api();
let hooks = OrchestratorHooks::default();
let calls = Arc::new(AtomicUsize::new(0));
let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
let orch = Orchestrator::new().with_generator(&gen);
orch.run(&api, out_dir, &hooks, false).unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
orch.run(&api, out_dir, &hooks, false).unwrap();
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"second run should skip generation"
);
}
#[test]
fn cache_invalidated_on_api_change() {
let dir = tempfile::tempdir().unwrap();
let out_dir = Utf8Path::from_path(dir.path()).unwrap();
let api = minimal_api();
let hooks = OrchestratorHooks::default();
let calls = Arc::new(AtomicUsize::new(0));
let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
let orch = Orchestrator::new().with_generator(&gen);
orch.run(&api, out_dir, &hooks, false).unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
let mut modified_api = api;
modified_api.modules[0].functions.push(Function {
name: "subtract".to_string(),
params: vec![
Param {
name: "a".to_string(),
ty: TypeRef::I32,
mutable: false,
doc: None,
},
Param {
name: "b".to_string(),
ty: TypeRef::I32,
mutable: false,
doc: None,
},
],
returns: Some(TypeRef::I32),
doc: None,
r#async: false,
cancellable: false,
deprecated: None,
since: None,
});
orch.run(&modified_api, out_dir, &hooks, false).unwrap();
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"changed API should trigger regeneration"
);
}
#[test]
fn force_flag_bypasses_cache() {
let dir = tempfile::tempdir().unwrap();
let out_dir = Utf8Path::from_path(dir.path()).unwrap();
let api = minimal_api();
let hooks = OrchestratorHooks::default();
let calls = Arc::new(AtomicUsize::new(0));
let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
let orch = Orchestrator::new().with_generator(&gen);
orch.run(&api, out_dir, &hooks, true).unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
orch.run(&api, out_dir, &hooks, true).unwrap();
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"force=true should bypass cache"
);
}
#[test]
fn legacy_cache_file_ignored_on_first_run() {
let dir = tempfile::tempdir().unwrap();
let out_dir = Utf8Path::from_path(dir.path()).unwrap();
std::fs::write(out_dir.join(CACHE_DIR), "stale-legacy").unwrap();
let api = minimal_api();
let hooks = OrchestratorHooks::default();
let calls = Arc::new(AtomicUsize::new(0));
let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
let orch = Orchestrator::new().with_generator(&gen);
orch.run(&api, out_dir, &hooks, false).unwrap();
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"legacy single-file cache must not skip first run"
);
assert!(out_dir.join(CACHE_DIR).is_dir());
}
#[test]
fn single_generator_cache_invalidates_independently() {
let dir = tempfile::tempdir().unwrap();
let out_dir = Utf8Path::from_path(dir.path()).unwrap();
let hooks = OrchestratorHooks::default();
let c_calls = Arc::new(AtomicUsize::new(0));
let s_calls = Arc::new(AtomicUsize::new(0));
let c_gen = configured("c", Arc::clone(&c_calls), TestConfig::default());
let s_gen = configured("swift", Arc::clone(&s_calls), TestConfig::default());
let orch = Orchestrator::new()
.with_generator(&c_gen)
.with_generator(&s_gen);
let api = minimal_api();
orch.run(&api, out_dir, &hooks, false).unwrap();
assert_eq!(c_calls.load(Ordering::SeqCst), 1);
assert_eq!(s_calls.load(Ordering::SeqCst), 1);
std::fs::remove_file(out_dir.join(CACHE_DIR).join("c.hash")).unwrap();
orch.run(&api, out_dir, &hooks, false).unwrap();
assert_eq!(
c_calls.load(Ordering::SeqCst),
2,
"C generator should re-run after its cache entry was removed"
);
assert_eq!(
s_calls.load(Ordering::SeqCst),
1,
"Swift generator's cache is intact and must be skipped"
);
}
#[test]
fn hash_generator_inputs_changes_when_config_bytes_change() {
let api = minimal_api();
let base = config_bytes(&TestConfig::default());
let changed = config_bytes(&TestConfig {
knob: Some("flipped".into()),
});
assert_ne!(
hash_generator_inputs(&api, "c", &base),
hash_generator_inputs(&api, "c", &changed),
"changing config bytes must change the per-generator hash"
);
}
#[test]
fn hash_generator_inputs_includes_cli_version() {
let api = minimal_api();
let cfg = config_bytes(&TestConfig::default());
let real = hash_generator_inputs(&api, "c", &cfg);
let api_value = serde_json::to_value(&api).unwrap();
let api_json = serde_json::to_string(&api_value).unwrap();
let mut h = Sha256::new();
h.update(b"v1\0");
h.update(b"0.0.0-pretend-old\0");
h.update(b"c\0");
h.update(api_json.as_bytes());
h.update(b"\0");
h.update(&cfg);
let pretend = format!("{:x}", h.finalize());
assert_ne!(
real, pretend,
"CLI_VERSION must be part of the cache key so an upgrade invalidates it"
);
assert_eq!(CLI_VERSION, env!("CARGO_PKG_VERSION"));
}
#[test]
fn cache_invalidated_on_config_only_change() {
let dir = tempfile::tempdir().unwrap();
let out_dir = Utf8Path::from_path(dir.path()).unwrap();
let api = minimal_api();
let hooks = OrchestratorHooks::default();
let calls = Arc::new(AtomicUsize::new(0));
let gen = configured("c", Arc::clone(&calls), TestConfig::default());
Orchestrator::new()
.with_generator(&gen)
.run(&api, out_dir, &hooks, false)
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
let gen2 = configured(
"c",
Arc::clone(&calls),
TestConfig {
knob: Some("changed".into()),
},
);
Orchestrator::new()
.with_generator(&gen2)
.run(&api, out_dir, &hooks, false)
.unwrap();
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"changing generator config must invalidate the cache and re-run the generator"
);
Orchestrator::new()
.with_generator(&gen2)
.run(&api, out_dir, &hooks, false)
.unwrap();
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"running with the same config twice should not regenerate"
);
}
#[test]
fn cache_invalidated_when_pre_generated_hash_has_wrong_version() {
let dir = tempfile::tempdir().unwrap();
let out_dir = Utf8Path::from_path(dir.path()).unwrap();
let api = minimal_api();
let hooks = OrchestratorHooks::default();
let calls = Arc::new(AtomicUsize::new(0));
let gen = configured("c", Arc::clone(&calls), TestConfig::default());
let orch = Orchestrator::new().with_generator(&gen);
let stale = hash_api_for_generator(&api, "c");
write_generator_cache(out_dir, "c", &stale).unwrap();
orch.run(&api, out_dir, &hooks, false).unwrap();
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"legacy IR-only hash must not satisfy the new cache key shape"
);
}
}