1use anyhow::{bail, Result};
11use camino::Utf8Path;
12use rayon::prelude::*;
13use serde::Serialize;
14use weaveffi_ir::ir::Api;
15
16use crate::cache;
17use crate::capabilities::{self, TargetCapabilities};
18
19pub mod common;
20pub mod writer;
21
22fn run_hook(label: &str, cmd: &str) -> Result<()> {
23 let status = if cfg!(target_os = "windows") {
24 std::process::Command::new("cmd")
25 .args(["/C", cmd])
26 .status()?
27 } else {
28 std::process::Command::new("sh")
29 .arg("-c")
30 .arg(cmd)
31 .status()?
32 };
33 if !status.success() {
34 bail!("{label} hook failed with {status}");
35 }
36 Ok(())
37}
38
39pub trait Generator: Send + Sync {
48 type Config: Serialize + Default + Clone + Send + Sync;
55
56 fn name(&self) -> &'static str;
59
60 fn capabilities(&self) -> TargetCapabilities;
65
66 fn allows_unsupported(&self, config: &Self::Config) -> bool {
74 let _ = config;
75 false
76 }
77
78 fn generate(&self, api: &Api, out_dir: &Utf8Path, config: &Self::Config) -> Result<()>;
80
81 fn output_files(&self, _api: &Api, _out_dir: &Utf8Path, _config: &Self::Config) -> Vec<String> {
86 vec![]
87 }
88}
89
90pub trait DynGenerator: Send + Sync {
96 fn name(&self) -> &'static str;
97 fn capabilities(&self) -> TargetCapabilities;
98 fn allows_unsupported(&self) -> bool;
101 fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()>;
102 fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String>;
103 fn config_hash_input(&self) -> Vec<u8>;
106}
107
108pub struct ConfiguredGenerator<G: Generator> {
116 inner: G,
117 config: G::Config,
118}
119
120impl<G: Generator> ConfiguredGenerator<G> {
121 pub fn new(inner: G, config: G::Config) -> Self {
122 Self { inner, config }
123 }
124
125 pub fn config(&self) -> &G::Config {
126 &self.config
127 }
128
129 pub fn inner(&self) -> &G {
130 &self.inner
131 }
132}
133
134impl<G: Generator> DynGenerator for ConfiguredGenerator<G> {
135 fn name(&self) -> &'static str {
136 self.inner.name()
137 }
138
139 fn capabilities(&self) -> TargetCapabilities {
140 self.inner.capabilities()
141 }
142
143 fn allows_unsupported(&self) -> bool {
144 self.inner.allows_unsupported(&self.config)
145 }
146
147 fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()> {
148 self.inner.generate(api, out_dir, &self.config)
149 }
150
151 fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String> {
152 self.inner.output_files(api, out_dir, &self.config)
153 }
154
155 fn config_hash_input(&self) -> Vec<u8> {
156 let value =
157 serde_json::to_value(&self.config).expect("generator config should serialize to JSON");
158 serde_json::to_vec(&value).expect("JSON Value should serialize")
159 }
160}
161
162#[derive(Default, Debug, Clone)]
164pub struct OrchestratorHooks {
165 pub pre_generate: Option<String>,
166 pub post_generate: Option<String>,
167}
168
169#[derive(Default)]
170pub struct Orchestrator<'a> {
171 generators: Vec<&'a dyn DynGenerator>,
172}
173
174impl<'a> Orchestrator<'a> {
175 pub fn new() -> Self {
176 Self::default()
177 }
178
179 pub fn with_generator(mut self, gen: &'a dyn DynGenerator) -> Self {
180 self.generators.push(gen);
181 self
182 }
183
184 pub fn run(
185 &self,
186 api: &Api,
187 out_dir: &Utf8Path,
188 hooks: &OrchestratorHooks,
189 force: bool,
190 ) -> Result<()> {
191 let mut violations: Vec<String> = Vec::new();
198 for g in &self.generators {
199 let Err(err) = capabilities::check(api, g.name(), &g.capabilities()) else {
200 continue;
201 };
202 if g.allows_unsupported() {
203 eprintln!(
204 "warning: target '{}' does not support every feature this IDL uses; \
205 generating anyway because allow_unsupported is set:",
206 g.name()
207 );
208 for (feature, locations) in &err.violations {
209 eprintln!(" - {feature} (used by: {})", locations.join(", "));
210 }
211 } else {
212 violations.push(err.to_string());
213 }
214 }
215 if !violations.is_empty() {
216 bail!("{}", violations.join("\n"));
217 }
218
219 if force {
220 cache::invalidate_all(out_dir)?;
221 }
222
223 let mut pending: Vec<(&'a dyn DynGenerator, String)> = Vec::new();
227 for &g in &self.generators {
228 let cfg_bytes = g.config_hash_input();
229 let hash = cache::hash_generator_inputs(api, g.name(), &cfg_bytes);
230 let cached = cache::read_generator_cache(out_dir, g.name());
231 if cached.as_deref() != Some(hash.as_str()) {
232 pending.push((g, hash));
233 }
234 }
235
236 if pending.is_empty() {
237 println!("No changes detected, skipping code generation.");
238 return Ok(());
239 }
240
241 if let Some(cmd) = &hooks.pre_generate {
242 run_hook("pre_generate", cmd)?;
243 }
244
245 pending
246 .par_iter()
247 .map(|(g, _)| g.generate(api, out_dir))
248 .collect::<Result<Vec<_>>>()?;
249
250 if let Some(cmd) = &hooks.post_generate {
251 run_hook("post_generate", cmd)?;
252 }
253
254 for (g, hash) in &pending {
255 cache::write_generator_cache(out_dir, g.name(), hash)?;
256 }
257 Ok(())
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use std::sync::atomic::{AtomicUsize, Ordering};
265 use std::sync::Arc;
266 use weaveffi_ir::ir::{Function, Module, Param, TypeRef};
267
268 #[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
271 struct TestConfig {
272 knob: Option<String>,
273 allow_unsupported: bool,
274 }
275
276 struct CountingGenerator {
277 name: &'static str,
278 calls: Arc<AtomicUsize>,
279 caps: TargetCapabilities,
280 }
281
282 impl Generator for CountingGenerator {
283 type Config = TestConfig;
284
285 fn name(&self) -> &'static str {
286 self.name
287 }
288
289 fn capabilities(&self) -> TargetCapabilities {
290 self.caps
291 }
292
293 fn allows_unsupported(&self, config: &Self::Config) -> bool {
294 config.allow_unsupported
295 }
296
297 fn generate(&self, _api: &Api, out_dir: &Utf8Path, _config: &Self::Config) -> Result<()> {
298 self.calls.fetch_add(1, Ordering::SeqCst);
299 let dir = out_dir.join(self.name);
300 std::fs::create_dir_all(dir.as_std_path())?;
301 std::fs::write(dir.join("output.txt").as_std_path(), "generated")?;
302 Ok(())
303 }
304 }
305
306 fn test_api() -> Api {
307 Api {
308 version: "0.3.0".to_string(),
309 modules: vec![Module {
310 name: "math".to_string(),
311 functions: vec![Function {
312 name: "add".to_string(),
313 params: vec![
314 Param {
315 name: "a".to_string(),
316 ty: TypeRef::I32,
317 mutable: false,
318 doc: None,
319 },
320 Param {
321 name: "b".to_string(),
322 ty: TypeRef::I32,
323 mutable: false,
324 doc: None,
325 },
326 ],
327 returns: Some(TypeRef::I32),
328 doc: None,
329 r#async: false,
330 cancellable: false,
331 deprecated: None,
332 since: None,
333 }],
334 structs: vec![],
335 enums: vec![],
336 callbacks: vec![],
337 listeners: vec![],
338 errors: None,
339 modules: vec![],
340 }],
341 generators: None,
342 package: None,
343 }
344 }
345
346 fn configured(
347 name: &'static str,
348 calls: Arc<AtomicUsize>,
349 ) -> ConfiguredGenerator<CountingGenerator> {
350 ConfiguredGenerator::new(
351 CountingGenerator {
352 name,
353 calls,
354 caps: TargetCapabilities::full(),
355 },
356 TestConfig::default(),
357 )
358 }
359
360 fn listener_api() -> Api {
363 let mut api = test_api();
364 api.modules[0].listeners = vec![weaveffi_ir::ir::ListenerDef {
365 name: "on_change".to_string(),
366 event_callback: "OnChange".to_string(),
367 doc: None,
368 }];
369 api.modules[0].callbacks = vec![weaveffi_ir::ir::CallbackDef {
370 name: "OnChange".to_string(),
371 params: vec![],
372 doc: None,
373 }];
374 api
375 }
376
377 fn partial(
378 calls: Arc<AtomicUsize>,
379 allow_unsupported: bool,
380 ) -> ConfiguredGenerator<CountingGenerator> {
381 ConfiguredGenerator::new(
382 CountingGenerator {
383 name: "partial",
384 calls,
385 caps: TargetCapabilities {
386 callbacks: false,
387 listeners: false,
388 ..TargetCapabilities::full()
389 },
390 },
391 TestConfig {
392 knob: None,
393 allow_unsupported,
394 },
395 )
396 }
397
398 #[test]
399 fn capability_gate_blocks_unsupported_target() {
400 let dir = tempfile::tempdir().unwrap();
401 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
402 let calls = Arc::new(AtomicUsize::new(0));
403 let gen = partial(Arc::clone(&calls), false);
404
405 let err = Orchestrator::new()
406 .with_generator(&gen)
407 .run(
408 &listener_api(),
409 out_dir,
410 &OrchestratorHooks::default(),
411 false,
412 )
413 .unwrap_err();
414
415 let msg = err.to_string();
416 assert!(msg.contains("target 'partial' does not support"), "{msg}");
417 assert!(msg.contains("math.on_change"), "{msg}");
418 assert!(msg.contains("allow_unsupported"), "{msg}");
419 assert_eq!(
420 calls.load(Ordering::SeqCst),
421 0,
422 "gated generator must not run"
423 );
424 }
425
426 #[test]
427 fn allow_unsupported_downgrades_gate_to_warning() {
428 let dir = tempfile::tempdir().unwrap();
429 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
430 let calls = Arc::new(AtomicUsize::new(0));
431 let gen = partial(Arc::clone(&calls), true);
432
433 Orchestrator::new()
434 .with_generator(&gen)
435 .run(
436 &listener_api(),
437 out_dir,
438 &OrchestratorHooks::default(),
439 false,
440 )
441 .expect("allow_unsupported must let generation proceed");
442
443 assert_eq!(calls.load(Ordering::SeqCst), 1, "generator should run");
444 }
445
446 #[test]
447 fn allow_unsupported_does_not_relax_other_targets() {
448 let dir = tempfile::tempdir().unwrap();
449 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
450 let opted_calls = Arc::new(AtomicUsize::new(0));
451 let strict_calls = Arc::new(AtomicUsize::new(0));
452 let opted = partial(Arc::clone(&opted_calls), true);
453 let strict = ConfiguredGenerator::new(
454 CountingGenerator {
455 name: "strict",
456 calls: Arc::clone(&strict_calls),
457 caps: TargetCapabilities {
458 listeners: false,
459 ..TargetCapabilities::full()
460 },
461 },
462 TestConfig::default(),
463 );
464
465 let err = Orchestrator::new()
466 .with_generator(&opted)
467 .with_generator(&strict)
468 .run(
469 &listener_api(),
470 out_dir,
471 &OrchestratorHooks::default(),
472 false,
473 )
474 .unwrap_err();
475
476 let msg = err.to_string();
477 assert!(msg.contains("target 'strict'"), "{msg}");
478 assert!(!msg.contains("target 'partial'"), "{msg}");
479 assert_eq!(opted_calls.load(Ordering::SeqCst), 0);
480 assert_eq!(strict_calls.load(Ordering::SeqCst), 0);
481 }
482
483 #[test]
484 fn incremental_skips_when_unchanged() {
485 let dir = tempfile::tempdir().unwrap();
486 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
487 let api = test_api();
488 let hooks = OrchestratorHooks::default();
489 let calls = Arc::new(AtomicUsize::new(0));
490 let gen = configured("counting", Arc::clone(&calls));
491
492 let orch = Orchestrator::new().with_generator(&gen);
493
494 orch.run(&api, out_dir, &hooks, false).unwrap();
495 assert_eq!(calls.load(Ordering::SeqCst), 1);
496 let content_after_first =
497 std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
498
499 orch.run(&api, out_dir, &hooks, false).unwrap();
500 assert_eq!(
501 calls.load(Ordering::SeqCst),
502 1,
503 "generator should not run again"
504 );
505 let content_after_second =
506 std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
507
508 assert_eq!(content_after_first, content_after_second);
509 }
510
511 #[test]
512 fn force_bypasses_cache() {
513 let dir = tempfile::tempdir().unwrap();
514 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
515 let api = test_api();
516 let hooks = OrchestratorHooks::default();
517 let calls = Arc::new(AtomicUsize::new(0));
518 let gen = configured("counting", Arc::clone(&calls));
519
520 let orch = Orchestrator::new().with_generator(&gen);
521
522 orch.run(&api, out_dir, &hooks, false).unwrap();
523 assert_eq!(calls.load(Ordering::SeqCst), 1);
524
525 orch.run(&api, out_dir, &hooks, true).unwrap();
526 assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
527 }
528
529 #[test]
530 fn parallel_orchestrator_runs_all_generators() {
531 let dir = tempfile::tempdir().unwrap();
532 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
533 let api = test_api();
534 let hooks = OrchestratorHooks::default();
535
536 let names = ["g0", "g1", "g2", "g3", "g4", "g5"];
537 let counters: Vec<Arc<AtomicUsize>> = names
538 .iter()
539 .map(|_| Arc::new(AtomicUsize::new(0)))
540 .collect();
541 let gens: Vec<ConfiguredGenerator<CountingGenerator>> = names
542 .iter()
543 .zip(counters.iter())
544 .map(|(name, calls)| configured(name, Arc::clone(calls)))
545 .collect();
546
547 let mut orch = Orchestrator::new();
548 for g in &gens {
549 orch = orch.with_generator(g);
550 }
551
552 orch.run(&api, out_dir, &hooks, false).unwrap();
553
554 for (name, calls) in names.iter().zip(counters.iter()) {
555 assert_eq!(
556 calls.load(Ordering::SeqCst),
557 1,
558 "generator '{name}' should have run exactly once",
559 );
560 assert!(
561 out_dir.join(name).join("output.txt").exists(),
562 "generator '{name}' should have written its output",
563 );
564 }
565 }
566
567 #[test]
568 fn single_generator_cache_invalidates_independently() {
569 let dir = tempfile::tempdir().unwrap();
570 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
571 let hooks = OrchestratorHooks::default();
572
573 let c_calls = Arc::new(AtomicUsize::new(0));
574 let s_calls = Arc::new(AtomicUsize::new(0));
575 let c_gen = configured("c", Arc::clone(&c_calls));
576 let s_gen = configured("swift", Arc::clone(&s_calls));
577
578 let orch = Orchestrator::new()
579 .with_generator(&c_gen)
580 .with_generator(&s_gen);
581
582 let api = test_api();
583 orch.run(&api, out_dir, &hooks, false).unwrap();
584 assert_eq!(c_calls.load(Ordering::SeqCst), 1);
585 assert_eq!(s_calls.load(Ordering::SeqCst), 1);
586
587 let mut modified = api.clone();
591 modified.modules[0].name = "math2".to_string();
592
593 let new_swift_hash =
594 cache::hash_generator_inputs(&modified, "swift", &s_gen.config_hash_input());
595 cache::write_generator_cache(out_dir, "swift", &new_swift_hash).unwrap();
596
597 orch.run(&modified, out_dir, &hooks, false).unwrap();
598 assert_eq!(
599 c_calls.load(Ordering::SeqCst),
600 2,
601 "C generator should re-run because its cache entry no longer matches",
602 );
603 assert_eq!(
604 s_calls.load(Ordering::SeqCst),
605 1,
606 "Swift generator's cache matched the new API and must be skipped",
607 );
608 }
609
610 #[test]
611 fn config_change_invalidates_cache() {
612 let dir = tempfile::tempdir().unwrap();
613 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
614 let hooks = OrchestratorHooks::default();
615 let api = test_api();
616
617 let calls = Arc::new(AtomicUsize::new(0));
618 let g1 = ConfiguredGenerator::new(
619 CountingGenerator {
620 name: "counting",
621 calls: Arc::clone(&calls),
622 caps: TargetCapabilities::full(),
623 },
624 TestConfig::default(),
625 );
626 Orchestrator::new()
627 .with_generator(&g1)
628 .run(&api, out_dir, &hooks, false)
629 .unwrap();
630 assert_eq!(calls.load(Ordering::SeqCst), 1);
631
632 let g2 = ConfiguredGenerator::new(
634 CountingGenerator {
635 name: "counting",
636 calls: Arc::clone(&calls),
637 caps: TargetCapabilities::full(),
638 },
639 TestConfig {
640 knob: Some("changed".into()),
641 allow_unsupported: false,
642 },
643 );
644 Orchestrator::new()
645 .with_generator(&g2)
646 .run(&api, out_dir, &hooks, false)
647 .unwrap();
648 assert_eq!(
649 calls.load(Ordering::SeqCst),
650 2,
651 "config-only change must invalidate the cache",
652 );
653 }
654}