use anyhow::{bail, Result};
use camino::Utf8Path;
use rayon::prelude::*;
use serde::Serialize;
use weaveffi_ir::ir::Api;
use crate::cache;
pub mod common;
pub mod writer;
fn run_hook(label: &str, cmd: &str) -> Result<()> {
let status = if cfg!(target_os = "windows") {
std::process::Command::new("cmd")
.args(["/C", cmd])
.status()?
} else {
std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.status()?
};
if !status.success() {
bail!("{label} hook failed with {status}");
}
Ok(())
}
pub trait Generator: Send + Sync {
type Config: Serialize + Default + Clone + Send + Sync;
fn name(&self) -> &'static str;
fn generate(&self, api: &Api, out_dir: &Utf8Path, config: &Self::Config) -> Result<()>;
fn output_files(&self, _api: &Api, _out_dir: &Utf8Path, _config: &Self::Config) -> Vec<String> {
vec![]
}
}
pub trait DynGenerator: Send + Sync {
fn name(&self) -> &'static str;
fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()>;
fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String>;
fn config_hash_input(&self) -> Vec<u8>;
}
pub struct ConfiguredGenerator<G: Generator> {
inner: G,
config: G::Config,
}
impl<G: Generator> ConfiguredGenerator<G> {
pub fn new(inner: G, config: G::Config) -> Self {
Self { inner, config }
}
pub fn config(&self) -> &G::Config {
&self.config
}
pub fn inner(&self) -> &G {
&self.inner
}
}
impl<G: Generator> DynGenerator for ConfiguredGenerator<G> {
fn name(&self) -> &'static str {
self.inner.name()
}
fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()> {
self.inner.generate(api, out_dir, &self.config)
}
fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String> {
self.inner.output_files(api, out_dir, &self.config)
}
fn config_hash_input(&self) -> Vec<u8> {
let value =
serde_json::to_value(&self.config).expect("generator config should serialize to JSON");
serde_json::to_vec(&value).expect("JSON Value should serialize")
}
}
#[derive(Default, Debug, Clone)]
pub struct OrchestratorHooks {
pub pre_generate: Option<String>,
pub post_generate: Option<String>,
}
#[derive(Default)]
pub struct Orchestrator<'a> {
generators: Vec<&'a dyn DynGenerator>,
}
impl<'a> Orchestrator<'a> {
pub fn new() -> Self {
Self::default()
}
pub fn with_generator(mut self, gen: &'a dyn DynGenerator) -> Self {
self.generators.push(gen);
self
}
pub fn run(
&self,
api: &Api,
out_dir: &Utf8Path,
hooks: &OrchestratorHooks,
force: bool,
) -> Result<()> {
if force {
cache::invalidate_all(out_dir)?;
}
let mut pending: Vec<(&'a dyn DynGenerator, String)> = Vec::new();
for &g in &self.generators {
let cfg_bytes = g.config_hash_input();
let hash = cache::hash_generator_inputs(api, g.name(), &cfg_bytes);
let cached = cache::read_generator_cache(out_dir, g.name());
if cached.as_deref() != Some(hash.as_str()) {
pending.push((g, hash));
}
}
if pending.is_empty() {
println!("No changes detected, skipping code generation.");
return Ok(());
}
if let Some(cmd) = &hooks.pre_generate {
run_hook("pre_generate", cmd)?;
}
pending
.par_iter()
.map(|(g, _)| g.generate(api, out_dir))
.collect::<Result<Vec<_>>>()?;
if let Some(cmd) = &hooks.post_generate {
run_hook("post_generate", cmd)?;
}
for (g, hash) in &pending {
cache::write_generator_cache(out_dir, g.name(), hash)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
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>,
}
struct CountingGenerator {
name: &'static str,
calls: Arc<AtomicUsize>,
}
impl Generator for CountingGenerator {
type Config = TestConfig;
fn name(&self) -> &'static str {
self.name
}
fn generate(&self, _api: &Api, out_dir: &Utf8Path, _config: &Self::Config) -> 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 test_api() -> Api {
Api {
version: "0.1.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,
}
}
fn configured(
name: &'static str,
calls: Arc<AtomicUsize>,
) -> ConfiguredGenerator<CountingGenerator> {
ConfiguredGenerator::new(CountingGenerator { name, calls }, TestConfig::default())
}
#[test]
fn incremental_skips_when_unchanged() {
let dir = tempfile::tempdir().unwrap();
let out_dir = Utf8Path::from_path(dir.path()).unwrap();
let api = test_api();
let hooks = OrchestratorHooks::default();
let calls = Arc::new(AtomicUsize::new(0));
let gen = configured("counting", Arc::clone(&calls));
let orch = Orchestrator::new().with_generator(&gen);
orch.run(&api, out_dir, &hooks, false).unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
let content_after_first =
std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
orch.run(&api, out_dir, &hooks, false).unwrap();
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"generator should not run again"
);
let content_after_second =
std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
assert_eq!(content_after_first, content_after_second);
}
#[test]
fn force_bypasses_cache() {
let dir = tempfile::tempdir().unwrap();
let out_dir = Utf8Path::from_path(dir.path()).unwrap();
let api = test_api();
let hooks = OrchestratorHooks::default();
let calls = Arc::new(AtomicUsize::new(0));
let gen = configured("counting", Arc::clone(&calls));
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, true).unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
}
#[test]
fn parallel_orchestrator_runs_all_generators() {
let dir = tempfile::tempdir().unwrap();
let out_dir = Utf8Path::from_path(dir.path()).unwrap();
let api = test_api();
let hooks = OrchestratorHooks::default();
let names = ["g0", "g1", "g2", "g3", "g4", "g5"];
let counters: Vec<Arc<AtomicUsize>> = names
.iter()
.map(|_| Arc::new(AtomicUsize::new(0)))
.collect();
let gens: Vec<ConfiguredGenerator<CountingGenerator>> = names
.iter()
.zip(counters.iter())
.map(|(name, calls)| configured(name, Arc::clone(calls)))
.collect();
let mut orch = Orchestrator::new();
for g in &gens {
orch = orch.with_generator(g);
}
orch.run(&api, out_dir, &hooks, false).unwrap();
for (name, calls) in names.iter().zip(counters.iter()) {
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"generator '{name}' should have run exactly once",
);
assert!(
out_dir.join(name).join("output.txt").exists(),
"generator '{name}' should have written its output",
);
}
}
#[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));
let s_gen = configured("swift", Arc::clone(&s_calls));
let orch = Orchestrator::new()
.with_generator(&c_gen)
.with_generator(&s_gen);
let api = test_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);
let mut modified = api.clone();
modified.modules[0].name = "math2".to_string();
let new_swift_hash =
cache::hash_generator_inputs(&modified, "swift", &s_gen.config_hash_input());
cache::write_generator_cache(out_dir, "swift", &new_swift_hash).unwrap();
orch.run(&modified, out_dir, &hooks, false).unwrap();
assert_eq!(
c_calls.load(Ordering::SeqCst),
2,
"C generator should re-run because its cache entry no longer matches",
);
assert_eq!(
s_calls.load(Ordering::SeqCst),
1,
"Swift generator's cache matched the new API and must be skipped",
);
}
#[test]
fn config_change_invalidates_cache() {
let dir = tempfile::tempdir().unwrap();
let out_dir = Utf8Path::from_path(dir.path()).unwrap();
let hooks = OrchestratorHooks::default();
let api = test_api();
let calls = Arc::new(AtomicUsize::new(0));
let g1 = ConfiguredGenerator::new(
CountingGenerator {
name: "counting",
calls: Arc::clone(&calls),
},
TestConfig::default(),
);
Orchestrator::new()
.with_generator(&g1)
.run(&api, out_dir, &hooks, false)
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
let g2 = ConfiguredGenerator::new(
CountingGenerator {
name: "counting",
calls: Arc::clone(&calls),
},
TestConfig {
knob: Some("changed".into()),
},
);
Orchestrator::new()
.with_generator(&g2)
.run(&api, out_dir, &hooks, false)
.unwrap();
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"config-only change must invalidate the cache",
);
}
}