1use anyhow::{bail, Result};
11use camino::Utf8Path;
12use rayon::prelude::*;
13use serde::Serialize;
14use weaveffi_ir::ir::Api;
15
16use crate::cache;
17
18pub mod common;
19
20fn run_hook(label: &str, cmd: &str) -> Result<()> {
21 let status = if cfg!(target_os = "windows") {
22 std::process::Command::new("cmd")
23 .args(["/C", cmd])
24 .status()?
25 } else {
26 std::process::Command::new("sh")
27 .arg("-c")
28 .arg(cmd)
29 .status()?
30 };
31 if !status.success() {
32 bail!("{label} hook failed with {status}");
33 }
34 Ok(())
35}
36
37pub trait Generator: Send + Sync {
46 type Config: Serialize + Default + Clone + Send + Sync;
53
54 fn name(&self) -> &'static str;
57
58 fn generate(&self, api: &Api, out_dir: &Utf8Path, config: &Self::Config) -> Result<()>;
60
61 fn output_files(&self, _api: &Api, _out_dir: &Utf8Path, _config: &Self::Config) -> Vec<String> {
66 vec![]
67 }
68}
69
70pub trait DynGenerator: Send + Sync {
76 fn name(&self) -> &'static str;
77 fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()>;
78 fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String>;
79 fn config_hash_input(&self) -> Vec<u8>;
82}
83
84pub struct ConfiguredGenerator<G: Generator> {
92 inner: G,
93 config: G::Config,
94}
95
96impl<G: Generator> ConfiguredGenerator<G> {
97 pub fn new(inner: G, config: G::Config) -> Self {
98 Self { inner, config }
99 }
100
101 pub fn config(&self) -> &G::Config {
102 &self.config
103 }
104
105 pub fn inner(&self) -> &G {
106 &self.inner
107 }
108}
109
110impl<G: Generator> DynGenerator for ConfiguredGenerator<G> {
111 fn name(&self) -> &'static str {
112 self.inner.name()
113 }
114
115 fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()> {
116 self.inner.generate(api, out_dir, &self.config)
117 }
118
119 fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String> {
120 self.inner.output_files(api, out_dir, &self.config)
121 }
122
123 fn config_hash_input(&self) -> Vec<u8> {
124 let value =
125 serde_json::to_value(&self.config).expect("generator config should serialize to JSON");
126 serde_json::to_vec(&value).expect("JSON Value should serialize")
127 }
128}
129
130#[derive(Default, Debug, Clone)]
132pub struct OrchestratorHooks {
133 pub pre_generate: Option<String>,
134 pub post_generate: Option<String>,
135}
136
137#[derive(Default)]
138pub struct Orchestrator<'a> {
139 generators: Vec<&'a dyn DynGenerator>,
140}
141
142impl<'a> Orchestrator<'a> {
143 pub fn new() -> Self {
144 Self::default()
145 }
146
147 pub fn with_generator(mut self, gen: &'a dyn DynGenerator) -> Self {
148 self.generators.push(gen);
149 self
150 }
151
152 pub fn run(
153 &self,
154 api: &Api,
155 out_dir: &Utf8Path,
156 hooks: &OrchestratorHooks,
157 force: bool,
158 ) -> Result<()> {
159 if force {
160 cache::invalidate_all(out_dir)?;
161 }
162
163 let mut pending: Vec<(&'a dyn DynGenerator, String)> = Vec::new();
167 for &g in &self.generators {
168 let cfg_bytes = g.config_hash_input();
169 let hash = cache::hash_generator_inputs(api, g.name(), &cfg_bytes);
170 let cached = cache::read_generator_cache(out_dir, g.name());
171 if cached.as_deref() != Some(hash.as_str()) {
172 pending.push((g, hash));
173 }
174 }
175
176 if pending.is_empty() {
177 println!("No changes detected, skipping code generation.");
178 return Ok(());
179 }
180
181 if let Some(cmd) = &hooks.pre_generate {
182 run_hook("pre_generate", cmd)?;
183 }
184
185 pending
186 .par_iter()
187 .map(|(g, _)| g.generate(api, out_dir))
188 .collect::<Result<Vec<_>>>()?;
189
190 if let Some(cmd) = &hooks.post_generate {
191 run_hook("post_generate", cmd)?;
192 }
193
194 for (g, hash) in &pending {
195 cache::write_generator_cache(out_dir, g.name(), hash)?;
196 }
197 Ok(())
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use std::sync::atomic::{AtomicUsize, Ordering};
205 use std::sync::Arc;
206 use weaveffi_ir::ir::{Function, Module, Param, TypeRef};
207
208 #[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
211 struct TestConfig {
212 knob: Option<String>,
213 }
214
215 struct CountingGenerator {
216 name: &'static str,
217 calls: Arc<AtomicUsize>,
218 }
219
220 impl Generator for CountingGenerator {
221 type Config = TestConfig;
222
223 fn name(&self) -> &'static str {
224 self.name
225 }
226
227 fn generate(&self, _api: &Api, out_dir: &Utf8Path, _config: &Self::Config) -> Result<()> {
228 self.calls.fetch_add(1, Ordering::SeqCst);
229 let dir = out_dir.join(self.name);
230 std::fs::create_dir_all(dir.as_std_path())?;
231 std::fs::write(dir.join("output.txt").as_std_path(), "generated")?;
232 Ok(())
233 }
234 }
235
236 fn test_api() -> Api {
237 Api {
238 version: "0.1.0".to_string(),
239 modules: vec![Module {
240 name: "math".to_string(),
241 functions: vec![Function {
242 name: "add".to_string(),
243 params: vec![
244 Param {
245 name: "a".to_string(),
246 ty: TypeRef::I32,
247 mutable: false,
248 doc: None,
249 },
250 Param {
251 name: "b".to_string(),
252 ty: TypeRef::I32,
253 mutable: false,
254 doc: None,
255 },
256 ],
257 returns: Some(TypeRef::I32),
258 doc: None,
259 r#async: false,
260 cancellable: false,
261 deprecated: None,
262 since: None,
263 }],
264 structs: vec![],
265 enums: vec![],
266 callbacks: vec![],
267 listeners: vec![],
268 errors: None,
269 modules: vec![],
270 }],
271 generators: None,
272 }
273 }
274
275 fn configured(
276 name: &'static str,
277 calls: Arc<AtomicUsize>,
278 ) -> ConfiguredGenerator<CountingGenerator> {
279 ConfiguredGenerator::new(CountingGenerator { name, calls }, TestConfig::default())
280 }
281
282 #[test]
283 fn incremental_skips_when_unchanged() {
284 let dir = tempfile::tempdir().unwrap();
285 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
286 let api = test_api();
287 let hooks = OrchestratorHooks::default();
288 let calls = Arc::new(AtomicUsize::new(0));
289 let gen = configured("counting", Arc::clone(&calls));
290
291 let orch = Orchestrator::new().with_generator(&gen);
292
293 orch.run(&api, out_dir, &hooks, false).unwrap();
294 assert_eq!(calls.load(Ordering::SeqCst), 1);
295 let content_after_first =
296 std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
297
298 orch.run(&api, out_dir, &hooks, false).unwrap();
299 assert_eq!(
300 calls.load(Ordering::SeqCst),
301 1,
302 "generator should not run again"
303 );
304 let content_after_second =
305 std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
306
307 assert_eq!(content_after_first, content_after_second);
308 }
309
310 #[test]
311 fn force_bypasses_cache() {
312 let dir = tempfile::tempdir().unwrap();
313 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
314 let api = test_api();
315 let hooks = OrchestratorHooks::default();
316 let calls = Arc::new(AtomicUsize::new(0));
317 let gen = configured("counting", Arc::clone(&calls));
318
319 let orch = Orchestrator::new().with_generator(&gen);
320
321 orch.run(&api, out_dir, &hooks, false).unwrap();
322 assert_eq!(calls.load(Ordering::SeqCst), 1);
323
324 orch.run(&api, out_dir, &hooks, true).unwrap();
325 assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
326 }
327
328 #[test]
329 fn parallel_orchestrator_runs_all_generators() {
330 let dir = tempfile::tempdir().unwrap();
331 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
332 let api = test_api();
333 let hooks = OrchestratorHooks::default();
334
335 let names = ["g0", "g1", "g2", "g3", "g4", "g5"];
336 let counters: Vec<Arc<AtomicUsize>> = names
337 .iter()
338 .map(|_| Arc::new(AtomicUsize::new(0)))
339 .collect();
340 let gens: Vec<ConfiguredGenerator<CountingGenerator>> = names
341 .iter()
342 .zip(counters.iter())
343 .map(|(name, calls)| configured(name, Arc::clone(calls)))
344 .collect();
345
346 let mut orch = Orchestrator::new();
347 for g in &gens {
348 orch = orch.with_generator(g);
349 }
350
351 orch.run(&api, out_dir, &hooks, false).unwrap();
352
353 for (name, calls) in names.iter().zip(counters.iter()) {
354 assert_eq!(
355 calls.load(Ordering::SeqCst),
356 1,
357 "generator '{name}' should have run exactly once",
358 );
359 assert!(
360 out_dir.join(name).join("output.txt").exists(),
361 "generator '{name}' should have written its output",
362 );
363 }
364 }
365
366 #[test]
367 fn single_generator_cache_invalidates_independently() {
368 let dir = tempfile::tempdir().unwrap();
369 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
370 let hooks = OrchestratorHooks::default();
371
372 let c_calls = Arc::new(AtomicUsize::new(0));
373 let s_calls = Arc::new(AtomicUsize::new(0));
374 let c_gen = configured("c", Arc::clone(&c_calls));
375 let s_gen = configured("swift", Arc::clone(&s_calls));
376
377 let orch = Orchestrator::new()
378 .with_generator(&c_gen)
379 .with_generator(&s_gen);
380
381 let api = test_api();
382 orch.run(&api, out_dir, &hooks, false).unwrap();
383 assert_eq!(c_calls.load(Ordering::SeqCst), 1);
384 assert_eq!(s_calls.load(Ordering::SeqCst), 1);
385
386 let mut modified = api.clone();
390 modified.modules[0].name = "math2".to_string();
391
392 let new_swift_hash =
393 cache::hash_generator_inputs(&modified, "swift", &s_gen.config_hash_input());
394 cache::write_generator_cache(out_dir, "swift", &new_swift_hash).unwrap();
395
396 orch.run(&modified, out_dir, &hooks, false).unwrap();
397 assert_eq!(
398 c_calls.load(Ordering::SeqCst),
399 2,
400 "C generator should re-run because its cache entry no longer matches",
401 );
402 assert_eq!(
403 s_calls.load(Ordering::SeqCst),
404 1,
405 "Swift generator's cache matched the new API and must be skipped",
406 );
407 }
408
409 #[test]
410 fn config_change_invalidates_cache() {
411 let dir = tempfile::tempdir().unwrap();
412 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
413 let hooks = OrchestratorHooks::default();
414 let api = test_api();
415
416 let calls = Arc::new(AtomicUsize::new(0));
417 let g1 = ConfiguredGenerator::new(
418 CountingGenerator {
419 name: "counting",
420 calls: Arc::clone(&calls),
421 },
422 TestConfig::default(),
423 );
424 Orchestrator::new()
425 .with_generator(&g1)
426 .run(&api, out_dir, &hooks, false)
427 .unwrap();
428 assert_eq!(calls.load(Ordering::SeqCst), 1);
429
430 let g2 = ConfiguredGenerator::new(
432 CountingGenerator {
433 name: "counting",
434 calls: Arc::clone(&calls),
435 },
436 TestConfig {
437 knob: Some("changed".into()),
438 },
439 );
440 Orchestrator::new()
441 .with_generator(&g2)
442 .run(&api, out_dir, &hooks, false)
443 .unwrap();
444 assert_eq!(
445 calls.load(Ordering::SeqCst),
446 2,
447 "config-only change must invalidate the cache",
448 );
449 }
450}