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 }
274 }
275
276 fn configured(
277 name: &'static str,
278 calls: Arc<AtomicUsize>,
279 ) -> ConfiguredGenerator<CountingGenerator> {
280 ConfiguredGenerator::new(CountingGenerator { name, calls }, TestConfig::default())
281 }
282
283 #[test]
284 fn incremental_skips_when_unchanged() {
285 let dir = tempfile::tempdir().unwrap();
286 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
287 let api = test_api();
288 let hooks = OrchestratorHooks::default();
289 let calls = Arc::new(AtomicUsize::new(0));
290 let gen = configured("counting", Arc::clone(&calls));
291
292 let orch = Orchestrator::new().with_generator(&gen);
293
294 orch.run(&api, out_dir, &hooks, false).unwrap();
295 assert_eq!(calls.load(Ordering::SeqCst), 1);
296 let content_after_first =
297 std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
298
299 orch.run(&api, out_dir, &hooks, false).unwrap();
300 assert_eq!(
301 calls.load(Ordering::SeqCst),
302 1,
303 "generator should not run again"
304 );
305 let content_after_second =
306 std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
307
308 assert_eq!(content_after_first, content_after_second);
309 }
310
311 #[test]
312 fn force_bypasses_cache() {
313 let dir = tempfile::tempdir().unwrap();
314 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
315 let api = test_api();
316 let hooks = OrchestratorHooks::default();
317 let calls = Arc::new(AtomicUsize::new(0));
318 let gen = configured("counting", Arc::clone(&calls));
319
320 let orch = Orchestrator::new().with_generator(&gen);
321
322 orch.run(&api, out_dir, &hooks, false).unwrap();
323 assert_eq!(calls.load(Ordering::SeqCst), 1);
324
325 orch.run(&api, out_dir, &hooks, true).unwrap();
326 assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
327 }
328
329 #[test]
330 fn parallel_orchestrator_runs_all_generators() {
331 let dir = tempfile::tempdir().unwrap();
332 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
333 let api = test_api();
334 let hooks = OrchestratorHooks::default();
335
336 let names = ["g0", "g1", "g2", "g3", "g4", "g5"];
337 let counters: Vec<Arc<AtomicUsize>> = names
338 .iter()
339 .map(|_| Arc::new(AtomicUsize::new(0)))
340 .collect();
341 let gens: Vec<ConfiguredGenerator<CountingGenerator>> = names
342 .iter()
343 .zip(counters.iter())
344 .map(|(name, calls)| configured(name, Arc::clone(calls)))
345 .collect();
346
347 let mut orch = Orchestrator::new();
348 for g in &gens {
349 orch = orch.with_generator(g);
350 }
351
352 orch.run(&api, out_dir, &hooks, false).unwrap();
353
354 for (name, calls) in names.iter().zip(counters.iter()) {
355 assert_eq!(
356 calls.load(Ordering::SeqCst),
357 1,
358 "generator '{name}' should have run exactly once",
359 );
360 assert!(
361 out_dir.join(name).join("output.txt").exists(),
362 "generator '{name}' should have written its output",
363 );
364 }
365 }
366
367 #[test]
368 fn single_generator_cache_invalidates_independently() {
369 let dir = tempfile::tempdir().unwrap();
370 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
371 let hooks = OrchestratorHooks::default();
372
373 let c_calls = Arc::new(AtomicUsize::new(0));
374 let s_calls = Arc::new(AtomicUsize::new(0));
375 let c_gen = configured("c", Arc::clone(&c_calls));
376 let s_gen = configured("swift", Arc::clone(&s_calls));
377
378 let orch = Orchestrator::new()
379 .with_generator(&c_gen)
380 .with_generator(&s_gen);
381
382 let api = test_api();
383 orch.run(&api, out_dir, &hooks, false).unwrap();
384 assert_eq!(c_calls.load(Ordering::SeqCst), 1);
385 assert_eq!(s_calls.load(Ordering::SeqCst), 1);
386
387 let mut modified = api.clone();
391 modified.modules[0].name = "math2".to_string();
392
393 let new_swift_hash =
394 cache::hash_generator_inputs(&modified, "swift", &s_gen.config_hash_input());
395 cache::write_generator_cache(out_dir, "swift", &new_swift_hash).unwrap();
396
397 orch.run(&modified, out_dir, &hooks, false).unwrap();
398 assert_eq!(
399 c_calls.load(Ordering::SeqCst),
400 2,
401 "C generator should re-run because its cache entry no longer matches",
402 );
403 assert_eq!(
404 s_calls.load(Ordering::SeqCst),
405 1,
406 "Swift generator's cache matched the new API and must be skipped",
407 );
408 }
409
410 #[test]
411 fn config_change_invalidates_cache() {
412 let dir = tempfile::tempdir().unwrap();
413 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
414 let hooks = OrchestratorHooks::default();
415 let api = test_api();
416
417 let calls = Arc::new(AtomicUsize::new(0));
418 let g1 = ConfiguredGenerator::new(
419 CountingGenerator {
420 name: "counting",
421 calls: Arc::clone(&calls),
422 },
423 TestConfig::default(),
424 );
425 Orchestrator::new()
426 .with_generator(&g1)
427 .run(&api, out_dir, &hooks, false)
428 .unwrap();
429 assert_eq!(calls.load(Ordering::SeqCst), 1);
430
431 let g2 = ConfiguredGenerator::new(
433 CountingGenerator {
434 name: "counting",
435 calls: Arc::clone(&calls),
436 },
437 TestConfig {
438 knob: Some("changed".into()),
439 },
440 );
441 Orchestrator::new()
442 .with_generator(&g2)
443 .run(&api, out_dir, &hooks, false)
444 .unwrap();
445 assert_eq!(
446 calls.load(Ordering::SeqCst),
447 2,
448 "config-only change must invalidate the cache",
449 );
450 }
451}