Skip to main content

weaveffi_core/codegen/
mod.rs

1//! Generator trait, dyn-erasure wrapper, and orchestration.
2//!
3//! Each language target implements [`Generator`] with its own associated
4//! `Config` type. The orchestrator works on the object-safe [`DynGenerator`]
5//! trait, which erases the concrete config and is what tests and the CLI
6//! pass into [`Orchestrator::with_generator`]. The recommended way to
7//! produce a `&dyn DynGenerator` is to build a [`ConfiguredGenerator`]
8//! that pairs a typed generator with its concrete config value.
9
10use 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;
21pub mod writer;
22
23pub use writer::CodeWriter;
24
25fn run_hook(label: &str, cmd: &str) -> Result<()> {
26    let status = if cfg!(target_os = "windows") {
27        std::process::Command::new("cmd")
28            .args(["/C", cmd])
29            .status()?
30    } else {
31        std::process::Command::new("sh")
32            .arg("-c")
33            .arg(cmd)
34            .status()?
35    };
36    if !status.success() {
37        bail!("{label} hook failed with {status}");
38    }
39    Ok(())
40}
41
42/// A language code generator.
43///
44/// Generators are dispatched in parallel, so every implementation must be
45/// safe to share across threads. The associated [`Config`] type is owned
46/// by the generator crate so `weaveffi-core` does not have to know about
47/// target-specific options like `swift_module_name` or `cpp_namespace`.
48///
49/// [`Config`]: Generator::Config
50pub trait Generator: Send + Sync {
51    /// Per-target, fully-typed configuration consumed by [`generate`] and
52    /// [`output_files`]. Must round-trip through `serde_json` so the
53    /// orchestrator can hash it as part of the cache key.
54    ///
55    /// [`generate`]: Generator::generate
56    /// [`output_files`]: Generator::output_files
57    type Config: Serialize + Default + Clone + Send + Sync;
58
59    /// Stable short name for the target (`"swift"`, `"c"`, `"node"`, …).
60    /// Used as the cache file basename and the `--target` filter token.
61    fn name(&self) -> &'static str;
62
63    /// The gated IDL features this target implements. The orchestrator
64    /// refuses to run a generator against an API that uses a feature its
65    /// declared capabilities do not cover: a target either generates a
66    /// feature or fails loudly; it never silently omits one.
67    fn capabilities(&self) -> TargetCapabilities;
68
69    /// Whether the user explicitly opted in to generating this target even
70    /// though the API uses features the target does not support (for example
71    /// `generators.wasm.allow_unsupported: true`). When `true` the
72    /// orchestrator downgrades the capability failure to a loud warning and
73    /// the generator must emit an explicit unsupported surface (throwing
74    /// stubs, documentation) rather than silently omitting the feature.
75    /// Default: `false`. Opting in must always be an explicit config act.
76    fn allows_unsupported(&self, config: &Self::Config) -> bool {
77        let _ = config;
78        false
79    }
80
81    /// Render the bindings under `out_dir`.
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if the target cannot render the API or cannot write its
86    /// output files (for example a filesystem failure).
87    fn generate(&self, api: &Api, out_dir: &Utf8Path, config: &Self::Config) -> Result<()>;
88
89    /// Files that [`generate`](Generator::generate) would write, relative
90    /// to (or anchored under) `out_dir`. Used by `--dry-run` and `diff`.
91    /// Default implementation returns the empty list; generators override
92    /// to surface the list without doing any I/O.
93    fn output_files(&self, _api: &Api, _out_dir: &Utf8Path, _config: &Self::Config) -> Vec<String> {
94        vec![]
95    }
96
97    /// Assemble a distributable package that bundles the prebuilt native
98    /// libraries in `ctx`, anchored under `out_dir`, or `None` when this target
99    /// does not support packaging. Mirrors
100    /// [`LanguageBackend::package`](crate::backend::LanguageBackend::package);
101    /// the default returns `None`.
102    fn package(
103        &self,
104        _api: &Api,
105        _ctx: &PackageContext,
106        _out_dir: &Utf8Path,
107        _config: &Self::Config,
108    ) -> Option<Vec<PackagedFile>> {
109        None
110    }
111}
112
113/// Object-safe view of a [`Generator`] paired with a concrete config.
114///
115/// The orchestrator stores generators as `&dyn DynGenerator` so it can
116/// hold a heterogeneous set of targets whose `Config` types differ.
117/// [`ConfiguredGenerator`] is the canonical adapter.
118pub trait DynGenerator: Send + Sync {
119    /// The target's stable short name. Mirrors [`Generator::name`].
120    fn name(&self) -> &'static str;
121    /// The gated features the target implements. Mirrors
122    /// [`Generator::capabilities`].
123    fn capabilities(&self) -> TargetCapabilities;
124    /// See [`Generator::allows_unsupported`], evaluated against the bound
125    /// config.
126    fn allows_unsupported(&self) -> bool;
127    /// Render the bindings under `out_dir`, using the bound config. Mirrors
128    /// [`Generator::generate`].
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the underlying [`Generator::generate`] fails to
133    /// render or write its output.
134    fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()>;
135    /// The files [`generate`](Self::generate) would write. Mirrors
136    /// [`Generator::output_files`].
137    fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String>;
138    /// Assemble the distributable package for this target, using the bound
139    /// config. Mirrors [`Generator::package`]; returns `None` when the target
140    /// does not support packaging.
141    fn package(
142        &self,
143        api: &Api,
144        ctx: &PackageContext,
145        out_dir: &Utf8Path,
146    ) -> Option<Vec<PackagedFile>>;
147    /// Canonical-JSON encoding of the bound config, fed into the cache
148    /// hash so a config-only change invalidates the entry.
149    fn config_hash_input(&self) -> Vec<u8>;
150}
151
152/// Binds a [`Generator`] to a concrete [`Generator::Config`] value so it
153/// can be erased to `&dyn DynGenerator`.
154///
155/// ```ignore
156/// let swift = ConfiguredGenerator::new(SwiftGenerator, SwiftConfig::default());
157/// orchestrator.with_generator(&swift);
158/// ```
159pub struct ConfiguredGenerator<G: Generator> {
160    inner: G,
161    config: G::Config,
162}
163
164impl<G: Generator> ConfiguredGenerator<G> {
165    /// Pair a typed generator with the concrete config it should run under.
166    pub fn new(inner: G, config: G::Config) -> Self {
167        Self { inner, config }
168    }
169
170    /// Borrow the bound config value.
171    pub fn config(&self) -> &G::Config {
172        &self.config
173    }
174
175    /// Borrow the wrapped typed generator.
176    pub fn inner(&self) -> &G {
177        &self.inner
178    }
179}
180
181impl<G: Generator> DynGenerator for ConfiguredGenerator<G> {
182    fn name(&self) -> &'static str {
183        self.inner.name()
184    }
185
186    fn capabilities(&self) -> TargetCapabilities {
187        self.inner.capabilities()
188    }
189
190    fn allows_unsupported(&self) -> bool {
191        self.inner.allows_unsupported(&self.config)
192    }
193
194    fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()> {
195        self.inner.generate(api, out_dir, &self.config)
196    }
197
198    fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String> {
199        self.inner.output_files(api, out_dir, &self.config)
200    }
201
202    fn package(
203        &self,
204        api: &Api,
205        ctx: &PackageContext,
206        out_dir: &Utf8Path,
207    ) -> Option<Vec<PackagedFile>> {
208        self.inner.package(api, ctx, out_dir, &self.config)
209    }
210
211    fn config_hash_input(&self) -> Vec<u8> {
212        let value =
213            serde_json::to_value(&self.config).expect("generator config should serialize to JSON");
214        serde_json::to_vec(&value).expect("JSON Value should serialize")
215    }
216}
217
218/// Global hooks the orchestrator runs around the parallel codegen pass.
219#[derive(Default, Debug, Clone)]
220pub struct OrchestratorHooks {
221    /// Shell command run once before the parallel pass, only when at least one
222    /// target is out of date. `None` skips it.
223    pub pre_generate: Option<String>,
224    /// Shell command run once after every target finishes. `None` skips it.
225    pub post_generate: Option<String>,
226}
227
228/// Runs a set of configured generators: it capability-gates each target,
229/// skips the ones whose cached hash is still current, and renders the rest in
230/// parallel.
231#[derive(Default)]
232pub struct Orchestrator<'a> {
233    generators: Vec<&'a dyn DynGenerator>,
234}
235
236impl<'a> Orchestrator<'a> {
237    /// Create an orchestrator with no targets registered.
238    pub fn new() -> Self {
239        Self::default()
240    }
241
242    /// Register one erased generator to run, returning `self` for chaining.
243    pub fn with_generator(mut self, gen: &'a dyn DynGenerator) -> Self {
244        self.generators.push(gen);
245        self
246    }
247
248    /// Generate every registered target under `out_dir`.
249    ///
250    /// Gates each target against the gated features the API uses, skips targets
251    /// whose cached hash still matches (unless `force` clears the cache first),
252    /// runs the `pre_generate` and `post_generate` hooks around the parallel
253    /// pass, and records a fresh cache entry for each regenerated target.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if a selected target does not support a feature the IDL
258    /// uses (without `allow_unsupported`), the `force` cache reset fails, a
259    /// `pre_generate` or `post_generate` hook exits non-zero, any generator
260    /// fails while rendering, or a cache entry cannot be written.
261    pub fn run(
262        &self,
263        api: &Api,
264        out_dir: &Utf8Path,
265        hooks: &OrchestratorHooks,
266        force: bool,
267    ) -> Result<()> {
268        // Capability gate: every selected target must support every gated
269        // feature the API uses. Collect all violations before failing so the
270        // user sees the complete picture in one run. A generator whose config
271        // explicitly opted in via `allow_unsupported` downgrades its failure
272        // to a loud warning: the generator emits an explicit unsupported
273        // surface (throwing stubs) for the missing features instead.
274        let mut violations: Vec<String> = Vec::new();
275        for g in &self.generators {
276            let Err(err) = capabilities::check(api, g.name(), &g.capabilities()) else {
277                continue;
278            };
279            if g.allows_unsupported() {
280                eprintln!(
281                    "warning: target '{}' does not support every feature this IDL uses; \
282                     generating anyway because allow_unsupported is set:",
283                    g.name()
284                );
285                for (feature, locations) in &err.violations {
286                    eprintln!("  - {feature} (used by: {})", locations.join(", "));
287                }
288            } else {
289                violations.push(err.to_string());
290            }
291        }
292        if !violations.is_empty() {
293            bail!("{}", violations.join("\n"));
294        }
295
296        if force {
297            cache::invalidate_all(out_dir)?;
298        }
299
300        // Pair each generator with its expected hash and decide individually
301        // whether it needs to run, so a single generator can be re-run while
302        // the others stay cached.
303        let mut pending: Vec<(&'a dyn DynGenerator, String)> = Vec::new();
304        for &g in &self.generators {
305            let cfg_bytes = g.config_hash_input();
306            let hash = cache::hash_generator_inputs(api, g.name(), &cfg_bytes);
307            let cached = cache::read_generator_cache(out_dir, g.name());
308            if cached.as_deref() != Some(hash.as_str()) {
309                pending.push((g, hash));
310            }
311        }
312
313        if pending.is_empty() {
314            println!("No changes detected, skipping code generation.");
315            return Ok(());
316        }
317
318        if let Some(cmd) = &hooks.pre_generate {
319            run_hook("pre_generate", cmd)?;
320        }
321
322        pending
323            .par_iter()
324            .map(|(g, _)| g.generate(api, out_dir))
325            .collect::<Result<Vec<_>>>()?;
326
327        if let Some(cmd) = &hooks.post_generate {
328            run_hook("post_generate", cmd)?;
329        }
330
331        for (g, hash) in &pending {
332            cache::write_generator_cache(out_dir, g.name(), hash)?;
333        }
334        Ok(())
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use std::sync::atomic::{AtomicUsize, Ordering};
342    use std::sync::Arc;
343    use weaveffi_ir::ir::{Function, Module, Param, TypeRef};
344
345    /// Test generator with a minimal config so tests don't have to depend
346    /// on any real per-language generator crate.
347    #[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
348    struct TestConfig {
349        knob: Option<String>,
350        allow_unsupported: bool,
351    }
352
353    struct CountingGenerator {
354        name: &'static str,
355        calls: Arc<AtomicUsize>,
356        caps: TargetCapabilities,
357    }
358
359    impl Generator for CountingGenerator {
360        type Config = TestConfig;
361
362        fn name(&self) -> &'static str {
363            self.name
364        }
365
366        fn capabilities(&self) -> TargetCapabilities {
367            self.caps
368        }
369
370        fn allows_unsupported(&self, config: &Self::Config) -> bool {
371            config.allow_unsupported
372        }
373
374        fn generate(&self, _api: &Api, out_dir: &Utf8Path, _config: &Self::Config) -> Result<()> {
375            self.calls.fetch_add(1, Ordering::SeqCst);
376            let dir = out_dir.join(self.name);
377            std::fs::create_dir_all(dir.as_std_path())?;
378            std::fs::write(dir.join("output.txt").as_std_path(), "generated")?;
379            Ok(())
380        }
381    }
382
383    fn test_api() -> Api {
384        Api {
385            version: "0.5.0".to_string(),
386            modules: vec![Module {
387                name: "math".to_string(),
388                functions: vec![Function {
389                    name: "add".to_string(),
390                    params: vec![
391                        Param {
392                            name: "a".to_string(),
393                            ty: TypeRef::I32,
394                            mutable: false,
395                            doc: None,
396                        },
397                        Param {
398                            name: "b".to_string(),
399                            ty: TypeRef::I32,
400                            mutable: false,
401                            doc: None,
402                        },
403                    ],
404                    returns: Some(TypeRef::I32),
405                    doc: None,
406                    throws: false,
407                    r#async: false,
408                    cancellable: false,
409                    deprecated: None,
410                    since: None,
411                }],
412                interfaces: vec![],
413                structs: vec![],
414                enums: vec![],
415                callbacks: vec![],
416                listeners: vec![],
417                errors: None,
418                modules: vec![],
419            }],
420            generators: None,
421            package: None,
422        }
423    }
424
425    fn configured(
426        name: &'static str,
427        calls: Arc<AtomicUsize>,
428    ) -> ConfiguredGenerator<CountingGenerator> {
429        ConfiguredGenerator::new(
430            CountingGenerator {
431                name,
432                calls,
433                caps: TargetCapabilities::full(),
434            },
435            TestConfig::default(),
436        )
437    }
438
439    /// An API that uses listeners, so a target without listener support
440    /// trips the capability gate.
441    fn listener_api() -> Api {
442        let mut api = test_api();
443        api.modules[0].listeners = vec![weaveffi_ir::ir::ListenerDef {
444            name: "on_change".to_string(),
445            event_callback: "OnChange".to_string(),
446            doc: None,
447        }];
448        api.modules[0].callbacks = vec![weaveffi_ir::ir::CallbackDef {
449            name: "OnChange".to_string(),
450            params: vec![],
451            doc: None,
452        }];
453        api
454    }
455
456    fn partial(
457        calls: Arc<AtomicUsize>,
458        allow_unsupported: bool,
459    ) -> ConfiguredGenerator<CountingGenerator> {
460        ConfiguredGenerator::new(
461            CountingGenerator {
462                name: "partial",
463                calls,
464                caps: TargetCapabilities {
465                    callbacks: false,
466                    listeners: false,
467                    ..TargetCapabilities::full()
468                },
469            },
470            TestConfig {
471                knob: None,
472                allow_unsupported,
473            },
474        )
475    }
476
477    #[test]
478    fn capability_gate_blocks_unsupported_target() {
479        let dir = tempfile::tempdir().unwrap();
480        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
481        let calls = Arc::new(AtomicUsize::new(0));
482        let gen = partial(Arc::clone(&calls), false);
483
484        let err = Orchestrator::new()
485            .with_generator(&gen)
486            .run(
487                &listener_api(),
488                out_dir,
489                &OrchestratorHooks::default(),
490                false,
491            )
492            .unwrap_err();
493
494        let msg = err.to_string();
495        assert!(msg.contains("target 'partial' does not support"), "{msg}");
496        assert!(msg.contains("math.on_change"), "{msg}");
497        assert!(msg.contains("allow_unsupported"), "{msg}");
498        assert_eq!(
499            calls.load(Ordering::SeqCst),
500            0,
501            "gated generator must not run"
502        );
503    }
504
505    #[test]
506    fn allow_unsupported_downgrades_gate_to_warning() {
507        let dir = tempfile::tempdir().unwrap();
508        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
509        let calls = Arc::new(AtomicUsize::new(0));
510        let gen = partial(Arc::clone(&calls), true);
511
512        Orchestrator::new()
513            .with_generator(&gen)
514            .run(
515                &listener_api(),
516                out_dir,
517                &OrchestratorHooks::default(),
518                false,
519            )
520            .expect("allow_unsupported must let generation proceed");
521
522        assert_eq!(calls.load(Ordering::SeqCst), 1, "generator should run");
523    }
524
525    #[test]
526    fn allow_unsupported_does_not_relax_other_targets() {
527        let dir = tempfile::tempdir().unwrap();
528        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
529        let opted_calls = Arc::new(AtomicUsize::new(0));
530        let strict_calls = Arc::new(AtomicUsize::new(0));
531        let opted = partial(Arc::clone(&opted_calls), true);
532        let strict = ConfiguredGenerator::new(
533            CountingGenerator {
534                name: "strict",
535                calls: Arc::clone(&strict_calls),
536                caps: TargetCapabilities {
537                    listeners: false,
538                    ..TargetCapabilities::full()
539                },
540            },
541            TestConfig::default(),
542        );
543
544        let err = Orchestrator::new()
545            .with_generator(&opted)
546            .with_generator(&strict)
547            .run(
548                &listener_api(),
549                out_dir,
550                &OrchestratorHooks::default(),
551                false,
552            )
553            .unwrap_err();
554
555        let msg = err.to_string();
556        assert!(msg.contains("target 'strict'"), "{msg}");
557        assert!(!msg.contains("target 'partial'"), "{msg}");
558        assert_eq!(opted_calls.load(Ordering::SeqCst), 0);
559        assert_eq!(strict_calls.load(Ordering::SeqCst), 0);
560    }
561
562    #[test]
563    fn incremental_skips_when_unchanged() {
564        let dir = tempfile::tempdir().unwrap();
565        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
566        let api = test_api();
567        let hooks = OrchestratorHooks::default();
568        let calls = Arc::new(AtomicUsize::new(0));
569        let gen = configured("counting", Arc::clone(&calls));
570
571        let orch = Orchestrator::new().with_generator(&gen);
572
573        orch.run(&api, out_dir, &hooks, false).unwrap();
574        assert_eq!(calls.load(Ordering::SeqCst), 1);
575        let content_after_first =
576            std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
577
578        orch.run(&api, out_dir, &hooks, false).unwrap();
579        assert_eq!(
580            calls.load(Ordering::SeqCst),
581            1,
582            "generator should not run again"
583        );
584        let content_after_second =
585            std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
586
587        assert_eq!(content_after_first, content_after_second);
588    }
589
590    #[test]
591    fn force_bypasses_cache() {
592        let dir = tempfile::tempdir().unwrap();
593        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
594        let api = test_api();
595        let hooks = OrchestratorHooks::default();
596        let calls = Arc::new(AtomicUsize::new(0));
597        let gen = configured("counting", Arc::clone(&calls));
598
599        let orch = Orchestrator::new().with_generator(&gen);
600
601        orch.run(&api, out_dir, &hooks, false).unwrap();
602        assert_eq!(calls.load(Ordering::SeqCst), 1);
603
604        orch.run(&api, out_dir, &hooks, true).unwrap();
605        assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
606    }
607
608    #[test]
609    fn parallel_orchestrator_runs_all_generators() {
610        let dir = tempfile::tempdir().unwrap();
611        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
612        let api = test_api();
613        let hooks = OrchestratorHooks::default();
614
615        let names = ["g0", "g1", "g2", "g3", "g4", "g5"];
616        let counters: Vec<Arc<AtomicUsize>> = names
617            .iter()
618            .map(|_| Arc::new(AtomicUsize::new(0)))
619            .collect();
620        let gens: Vec<ConfiguredGenerator<CountingGenerator>> = names
621            .iter()
622            .zip(counters.iter())
623            .map(|(name, calls)| configured(name, Arc::clone(calls)))
624            .collect();
625
626        let mut orch = Orchestrator::new();
627        for g in &gens {
628            orch = orch.with_generator(g);
629        }
630
631        orch.run(&api, out_dir, &hooks, false).unwrap();
632
633        for (name, calls) in names.iter().zip(counters.iter()) {
634            assert_eq!(
635                calls.load(Ordering::SeqCst),
636                1,
637                "generator '{name}' should have run exactly once",
638            );
639            assert!(
640                out_dir.join(name).join("output.txt").exists(),
641                "generator '{name}' should have written its output",
642            );
643        }
644    }
645
646    #[test]
647    fn single_generator_cache_invalidates_independently() {
648        let dir = tempfile::tempdir().unwrap();
649        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
650        let hooks = OrchestratorHooks::default();
651
652        let c_calls = Arc::new(AtomicUsize::new(0));
653        let s_calls = Arc::new(AtomicUsize::new(0));
654        let c_gen = configured("c", Arc::clone(&c_calls));
655        let s_gen = configured("swift", Arc::clone(&s_calls));
656
657        let orch = Orchestrator::new()
658            .with_generator(&c_gen)
659            .with_generator(&s_gen);
660
661        let api = test_api();
662        orch.run(&api, out_dir, &hooks, false).unwrap();
663        assert_eq!(c_calls.load(Ordering::SeqCst), 1);
664        assert_eq!(s_calls.load(Ordering::SeqCst), 1);
665
666        // Mutate the API in a way that affects both generators' hashes by
667        // renaming a module. Then pre-seed the Swift cache with the *new*
668        // expected hash so only the C entry stays stale and re-runs.
669        let mut modified = api.clone();
670        modified.modules[0].name = "math2".to_string();
671
672        let new_swift_hash =
673            cache::hash_generator_inputs(&modified, "swift", &s_gen.config_hash_input());
674        cache::write_generator_cache(out_dir, "swift", &new_swift_hash).unwrap();
675
676        orch.run(&modified, out_dir, &hooks, false).unwrap();
677        assert_eq!(
678            c_calls.load(Ordering::SeqCst),
679            2,
680            "C generator should re-run because its cache entry no longer matches",
681        );
682        assert_eq!(
683            s_calls.load(Ordering::SeqCst),
684            1,
685            "Swift generator's cache matched the new API and must be skipped",
686        );
687    }
688
689    #[test]
690    fn config_change_invalidates_cache() {
691        let dir = tempfile::tempdir().unwrap();
692        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
693        let hooks = OrchestratorHooks::default();
694        let api = test_api();
695
696        let calls = Arc::new(AtomicUsize::new(0));
697        let g1 = ConfiguredGenerator::new(
698            CountingGenerator {
699                name: "counting",
700                calls: Arc::clone(&calls),
701                caps: TargetCapabilities::full(),
702            },
703            TestConfig::default(),
704        );
705        Orchestrator::new()
706            .with_generator(&g1)
707            .run(&api, out_dir, &hooks, false)
708            .unwrap();
709        assert_eq!(calls.load(Ordering::SeqCst), 1);
710
711        // Same generator, different config value: must re-run.
712        let g2 = ConfiguredGenerator::new(
713            CountingGenerator {
714                name: "counting",
715                calls: Arc::clone(&calls),
716                caps: TargetCapabilities::full(),
717            },
718            TestConfig {
719                knob: Some("changed".into()),
720                allow_unsupported: false,
721            },
722        );
723        Orchestrator::new()
724            .with_generator(&g2)
725            .run(&api, out_dir, &hooks, false)
726            .unwrap();
727        assert_eq!(
728            calls.load(Ordering::SeqCst),
729            2,
730            "config-only change must invalidate the cache",
731        );
732    }
733}