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};
18use crate::package::{PackageContext, PackagedFile};
19
20pub mod common;
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<()>;
85
86 fn output_files(&self, _api: &Api, _out_dir: &Utf8Path, _config: &Self::Config) -> Vec<String> {
91 vec![]
92 }
93
94 fn package(
100 &self,
101 _api: &Api,
102 _ctx: &PackageContext,
103 _out_dir: &Utf8Path,
104 _config: &Self::Config,
105 ) -> Option<Vec<PackagedFile>> {
106 None
107 }
108}
109
110pub trait DynGenerator: Send + Sync {
116 fn name(&self) -> &'static str;
118 fn capabilities(&self) -> TargetCapabilities;
121 fn allows_unsupported(&self) -> bool;
124 fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()>;
132 fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String>;
135 fn package(
139 &self,
140 api: &Api,
141 ctx: &PackageContext,
142 out_dir: &Utf8Path,
143 ) -> Option<Vec<PackagedFile>>;
144 fn config_hash_input(&self) -> Vec<u8>;
147}
148
149pub struct ConfiguredGenerator<G: Generator> {
157 inner: G,
158 config: G::Config,
159}
160
161impl<G: Generator> ConfiguredGenerator<G> {
162 pub fn new(inner: G, config: G::Config) -> Self {
164 Self { inner, config }
165 }
166
167 pub fn config(&self) -> &G::Config {
169 &self.config
170 }
171
172 pub fn inner(&self) -> &G {
174 &self.inner
175 }
176}
177
178impl<G: Generator> DynGenerator for ConfiguredGenerator<G> {
179 fn name(&self) -> &'static str {
180 self.inner.name()
181 }
182
183 fn capabilities(&self) -> TargetCapabilities {
184 self.inner.capabilities()
185 }
186
187 fn allows_unsupported(&self) -> bool {
188 self.inner.allows_unsupported(&self.config)
189 }
190
191 fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()> {
192 self.inner.generate(api, out_dir, &self.config)
193 }
194
195 fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String> {
196 self.inner.output_files(api, out_dir, &self.config)
197 }
198
199 fn package(
200 &self,
201 api: &Api,
202 ctx: &PackageContext,
203 out_dir: &Utf8Path,
204 ) -> Option<Vec<PackagedFile>> {
205 self.inner.package(api, ctx, out_dir, &self.config)
206 }
207
208 fn config_hash_input(&self) -> Vec<u8> {
209 let value =
210 serde_json::to_value(&self.config).expect("generator config should serialize to JSON");
211 serde_json::to_vec(&value).expect("JSON Value should serialize")
212 }
213}
214
215#[derive(Default, Debug, Clone)]
217pub struct OrchestratorHooks {
218 pub pre_generate: Option<String>,
221 pub post_generate: Option<String>,
223}
224
225#[derive(Default)]
229pub struct Orchestrator<'a> {
230 generators: Vec<&'a dyn DynGenerator>,
231}
232
233impl<'a> Orchestrator<'a> {
234 pub fn new() -> Self {
236 Self::default()
237 }
238
239 pub fn with_generator(mut self, gen: &'a dyn DynGenerator) -> Self {
241 self.generators.push(gen);
242 self
243 }
244
245 pub fn run(
259 &self,
260 api: &Api,
261 out_dir: &Utf8Path,
262 hooks: &OrchestratorHooks,
263 force: bool,
264 ) -> Result<()> {
265 let mut violations: Vec<String> = Vec::new();
272 for g in &self.generators {
273 let Err(err) = capabilities::check(api, g.name(), &g.capabilities()) else {
274 continue;
275 };
276 if g.allows_unsupported() {
277 eprintln!(
278 "warning: target '{}' does not support every feature this IDL uses; \
279 generating anyway because allow_unsupported is set:",
280 g.name()
281 );
282 for (feature, locations) in &err.violations {
283 eprintln!(" - {feature} (used by: {})", locations.join(", "));
284 }
285 } else {
286 violations.push(err.to_string());
287 }
288 }
289 if !violations.is_empty() {
290 bail!("{}", violations.join("\n"));
291 }
292
293 if force {
294 cache::invalidate_all(out_dir)?;
295 }
296
297 let mut pending: Vec<(&'a dyn DynGenerator, String)> = Vec::new();
301 for &g in &self.generators {
302 let cfg_bytes = g.config_hash_input();
303 let hash = cache::hash_generator_inputs(api, g.name(), &cfg_bytes);
304 let cached = cache::read_generator_cache(out_dir, g.name());
305 if cached.as_deref() != Some(hash.as_str()) {
306 pending.push((g, hash));
307 }
308 }
309
310 if pending.is_empty() {
311 println!("No changes detected, skipping code generation.");
312 return Ok(());
313 }
314
315 if let Some(cmd) = &hooks.pre_generate {
316 run_hook("pre_generate", cmd)?;
317 }
318
319 pending
320 .par_iter()
321 .map(|(g, _)| g.generate(api, out_dir))
322 .collect::<Result<Vec<_>>>()?;
323
324 if let Some(cmd) = &hooks.post_generate {
325 run_hook("post_generate", cmd)?;
326 }
327
328 for (g, hash) in &pending {
329 cache::write_generator_cache(out_dir, g.name(), hash)?;
330 }
331 Ok(())
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use std::sync::atomic::{AtomicUsize, Ordering};
339 use std::sync::Arc;
340 use weaveffi_ir::ir::{Function, Module, Param, TypeRef};
341
342 #[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
345 struct TestConfig {
346 knob: Option<String>,
347 allow_unsupported: bool,
348 }
349
350 struct CountingGenerator {
351 name: &'static str,
352 calls: Arc<AtomicUsize>,
353 caps: TargetCapabilities,
354 }
355
356 impl Generator for CountingGenerator {
357 type Config = TestConfig;
358
359 fn name(&self) -> &'static str {
360 self.name
361 }
362
363 fn capabilities(&self) -> TargetCapabilities {
364 self.caps
365 }
366
367 fn allows_unsupported(&self, config: &Self::Config) -> bool {
368 config.allow_unsupported
369 }
370
371 fn generate(&self, _api: &Api, out_dir: &Utf8Path, _config: &Self::Config) -> Result<()> {
372 self.calls.fetch_add(1, Ordering::SeqCst);
373 let dir = out_dir.join(self.name);
374 std::fs::create_dir_all(dir.as_std_path())?;
375 std::fs::write(dir.join("output.txt").as_std_path(), "generated")?;
376 Ok(())
377 }
378 }
379
380 fn test_api() -> Api {
381 Api {
382 version: "0.4.0".to_string(),
383 modules: vec![Module {
384 name: "math".to_string(),
385 functions: vec![Function {
386 name: "add".to_string(),
387 params: vec![
388 Param {
389 name: "a".to_string(),
390 ty: TypeRef::I32,
391 mutable: false,
392 doc: None,
393 },
394 Param {
395 name: "b".to_string(),
396 ty: TypeRef::I32,
397 mutable: false,
398 doc: None,
399 },
400 ],
401 returns: Some(TypeRef::I32),
402 doc: None,
403 r#async: false,
404 cancellable: false,
405 deprecated: None,
406 since: None,
407 }],
408 structs: vec![],
409 enums: vec![],
410 callbacks: vec![],
411 listeners: vec![],
412 errors: None,
413 modules: vec![],
414 }],
415 generators: None,
416 package: None,
417 }
418 }
419
420 fn configured(
421 name: &'static str,
422 calls: Arc<AtomicUsize>,
423 ) -> ConfiguredGenerator<CountingGenerator> {
424 ConfiguredGenerator::new(
425 CountingGenerator {
426 name,
427 calls,
428 caps: TargetCapabilities::full(),
429 },
430 TestConfig::default(),
431 )
432 }
433
434 fn listener_api() -> Api {
437 let mut api = test_api();
438 api.modules[0].listeners = vec![weaveffi_ir::ir::ListenerDef {
439 name: "on_change".to_string(),
440 event_callback: "OnChange".to_string(),
441 doc: None,
442 }];
443 api.modules[0].callbacks = vec![weaveffi_ir::ir::CallbackDef {
444 name: "OnChange".to_string(),
445 params: vec![],
446 doc: None,
447 }];
448 api
449 }
450
451 fn partial(
452 calls: Arc<AtomicUsize>,
453 allow_unsupported: bool,
454 ) -> ConfiguredGenerator<CountingGenerator> {
455 ConfiguredGenerator::new(
456 CountingGenerator {
457 name: "partial",
458 calls,
459 caps: TargetCapabilities {
460 callbacks: false,
461 listeners: false,
462 ..TargetCapabilities::full()
463 },
464 },
465 TestConfig {
466 knob: None,
467 allow_unsupported,
468 },
469 )
470 }
471
472 #[test]
473 fn capability_gate_blocks_unsupported_target() {
474 let dir = tempfile::tempdir().unwrap();
475 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
476 let calls = Arc::new(AtomicUsize::new(0));
477 let gen = partial(Arc::clone(&calls), false);
478
479 let err = Orchestrator::new()
480 .with_generator(&gen)
481 .run(
482 &listener_api(),
483 out_dir,
484 &OrchestratorHooks::default(),
485 false,
486 )
487 .unwrap_err();
488
489 let msg = err.to_string();
490 assert!(msg.contains("target 'partial' does not support"), "{msg}");
491 assert!(msg.contains("math.on_change"), "{msg}");
492 assert!(msg.contains("allow_unsupported"), "{msg}");
493 assert_eq!(
494 calls.load(Ordering::SeqCst),
495 0,
496 "gated generator must not run"
497 );
498 }
499
500 #[test]
501 fn allow_unsupported_downgrades_gate_to_warning() {
502 let dir = tempfile::tempdir().unwrap();
503 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
504 let calls = Arc::new(AtomicUsize::new(0));
505 let gen = partial(Arc::clone(&calls), true);
506
507 Orchestrator::new()
508 .with_generator(&gen)
509 .run(
510 &listener_api(),
511 out_dir,
512 &OrchestratorHooks::default(),
513 false,
514 )
515 .expect("allow_unsupported must let generation proceed");
516
517 assert_eq!(calls.load(Ordering::SeqCst), 1, "generator should run");
518 }
519
520 #[test]
521 fn allow_unsupported_does_not_relax_other_targets() {
522 let dir = tempfile::tempdir().unwrap();
523 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
524 let opted_calls = Arc::new(AtomicUsize::new(0));
525 let strict_calls = Arc::new(AtomicUsize::new(0));
526 let opted = partial(Arc::clone(&opted_calls), true);
527 let strict = ConfiguredGenerator::new(
528 CountingGenerator {
529 name: "strict",
530 calls: Arc::clone(&strict_calls),
531 caps: TargetCapabilities {
532 listeners: false,
533 ..TargetCapabilities::full()
534 },
535 },
536 TestConfig::default(),
537 );
538
539 let err = Orchestrator::new()
540 .with_generator(&opted)
541 .with_generator(&strict)
542 .run(
543 &listener_api(),
544 out_dir,
545 &OrchestratorHooks::default(),
546 false,
547 )
548 .unwrap_err();
549
550 let msg = err.to_string();
551 assert!(msg.contains("target 'strict'"), "{msg}");
552 assert!(!msg.contains("target 'partial'"), "{msg}");
553 assert_eq!(opted_calls.load(Ordering::SeqCst), 0);
554 assert_eq!(strict_calls.load(Ordering::SeqCst), 0);
555 }
556
557 #[test]
558 fn incremental_skips_when_unchanged() {
559 let dir = tempfile::tempdir().unwrap();
560 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
561 let api = test_api();
562 let hooks = OrchestratorHooks::default();
563 let calls = Arc::new(AtomicUsize::new(0));
564 let gen = configured("counting", Arc::clone(&calls));
565
566 let orch = Orchestrator::new().with_generator(&gen);
567
568 orch.run(&api, out_dir, &hooks, false).unwrap();
569 assert_eq!(calls.load(Ordering::SeqCst), 1);
570 let content_after_first =
571 std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
572
573 orch.run(&api, out_dir, &hooks, false).unwrap();
574 assert_eq!(
575 calls.load(Ordering::SeqCst),
576 1,
577 "generator should not run again"
578 );
579 let content_after_second =
580 std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
581
582 assert_eq!(content_after_first, content_after_second);
583 }
584
585 #[test]
586 fn force_bypasses_cache() {
587 let dir = tempfile::tempdir().unwrap();
588 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
589 let api = test_api();
590 let hooks = OrchestratorHooks::default();
591 let calls = Arc::new(AtomicUsize::new(0));
592 let gen = configured("counting", Arc::clone(&calls));
593
594 let orch = Orchestrator::new().with_generator(&gen);
595
596 orch.run(&api, out_dir, &hooks, false).unwrap();
597 assert_eq!(calls.load(Ordering::SeqCst), 1);
598
599 orch.run(&api, out_dir, &hooks, true).unwrap();
600 assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
601 }
602
603 #[test]
604 fn parallel_orchestrator_runs_all_generators() {
605 let dir = tempfile::tempdir().unwrap();
606 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
607 let api = test_api();
608 let hooks = OrchestratorHooks::default();
609
610 let names = ["g0", "g1", "g2", "g3", "g4", "g5"];
611 let counters: Vec<Arc<AtomicUsize>> = names
612 .iter()
613 .map(|_| Arc::new(AtomicUsize::new(0)))
614 .collect();
615 let gens: Vec<ConfiguredGenerator<CountingGenerator>> = names
616 .iter()
617 .zip(counters.iter())
618 .map(|(name, calls)| configured(name, Arc::clone(calls)))
619 .collect();
620
621 let mut orch = Orchestrator::new();
622 for g in &gens {
623 orch = orch.with_generator(g);
624 }
625
626 orch.run(&api, out_dir, &hooks, false).unwrap();
627
628 for (name, calls) in names.iter().zip(counters.iter()) {
629 assert_eq!(
630 calls.load(Ordering::SeqCst),
631 1,
632 "generator '{name}' should have run exactly once",
633 );
634 assert!(
635 out_dir.join(name).join("output.txt").exists(),
636 "generator '{name}' should have written its output",
637 );
638 }
639 }
640
641 #[test]
642 fn single_generator_cache_invalidates_independently() {
643 let dir = tempfile::tempdir().unwrap();
644 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
645 let hooks = OrchestratorHooks::default();
646
647 let c_calls = Arc::new(AtomicUsize::new(0));
648 let s_calls = Arc::new(AtomicUsize::new(0));
649 let c_gen = configured("c", Arc::clone(&c_calls));
650 let s_gen = configured("swift", Arc::clone(&s_calls));
651
652 let orch = Orchestrator::new()
653 .with_generator(&c_gen)
654 .with_generator(&s_gen);
655
656 let api = test_api();
657 orch.run(&api, out_dir, &hooks, false).unwrap();
658 assert_eq!(c_calls.load(Ordering::SeqCst), 1);
659 assert_eq!(s_calls.load(Ordering::SeqCst), 1);
660
661 let mut modified = api.clone();
665 modified.modules[0].name = "math2".to_string();
666
667 let new_swift_hash =
668 cache::hash_generator_inputs(&modified, "swift", &s_gen.config_hash_input());
669 cache::write_generator_cache(out_dir, "swift", &new_swift_hash).unwrap();
670
671 orch.run(&modified, out_dir, &hooks, false).unwrap();
672 assert_eq!(
673 c_calls.load(Ordering::SeqCst),
674 2,
675 "C generator should re-run because its cache entry no longer matches",
676 );
677 assert_eq!(
678 s_calls.load(Ordering::SeqCst),
679 1,
680 "Swift generator's cache matched the new API and must be skipped",
681 );
682 }
683
684 #[test]
685 fn config_change_invalidates_cache() {
686 let dir = tempfile::tempdir().unwrap();
687 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
688 let hooks = OrchestratorHooks::default();
689 let api = test_api();
690
691 let calls = Arc::new(AtomicUsize::new(0));
692 let g1 = ConfiguredGenerator::new(
693 CountingGenerator {
694 name: "counting",
695 calls: Arc::clone(&calls),
696 caps: TargetCapabilities::full(),
697 },
698 TestConfig::default(),
699 );
700 Orchestrator::new()
701 .with_generator(&g1)
702 .run(&api, out_dir, &hooks, false)
703 .unwrap();
704 assert_eq!(calls.load(Ordering::SeqCst), 1);
705
706 let g2 = ConfiguredGenerator::new(
708 CountingGenerator {
709 name: "counting",
710 calls: Arc::clone(&calls),
711 caps: TargetCapabilities::full(),
712 },
713 TestConfig {
714 knob: Some("changed".into()),
715 allow_unsupported: false,
716 },
717 );
718 Orchestrator::new()
719 .with_generator(&g2)
720 .run(&api, out_dir, &hooks, false)
721 .unwrap();
722 assert_eq!(
723 calls.load(Ordering::SeqCst),
724 2,
725 "config-only change must invalidate the cache",
726 );
727 }
728}