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