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.4.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                    r#async: false,
407                    cancellable: false,
408                    deprecated: None,
409                    since: None,
410                }],
411                structs: vec![],
412                enums: vec![],
413                callbacks: vec![],
414                listeners: vec![],
415                errors: None,
416                modules: vec![],
417            }],
418            generators: None,
419            package: None,
420        }
421    }
422
423    fn configured(
424        name: &'static str,
425        calls: Arc<AtomicUsize>,
426    ) -> ConfiguredGenerator<CountingGenerator> {
427        ConfiguredGenerator::new(
428            CountingGenerator {
429                name,
430                calls,
431                caps: TargetCapabilities::full(),
432            },
433            TestConfig::default(),
434        )
435    }
436
437    /// An API that uses listeners, so a target without listener support
438    /// trips the capability gate.
439    fn listener_api() -> Api {
440        let mut api = test_api();
441        api.modules[0].listeners = vec![weaveffi_ir::ir::ListenerDef {
442            name: "on_change".to_string(),
443            event_callback: "OnChange".to_string(),
444            doc: None,
445        }];
446        api.modules[0].callbacks = vec![weaveffi_ir::ir::CallbackDef {
447            name: "OnChange".to_string(),
448            params: vec![],
449            doc: None,
450        }];
451        api
452    }
453
454    fn partial(
455        calls: Arc<AtomicUsize>,
456        allow_unsupported: bool,
457    ) -> ConfiguredGenerator<CountingGenerator> {
458        ConfiguredGenerator::new(
459            CountingGenerator {
460                name: "partial",
461                calls,
462                caps: TargetCapabilities {
463                    callbacks: false,
464                    listeners: false,
465                    ..TargetCapabilities::full()
466                },
467            },
468            TestConfig {
469                knob: None,
470                allow_unsupported,
471            },
472        )
473    }
474
475    #[test]
476    fn capability_gate_blocks_unsupported_target() {
477        let dir = tempfile::tempdir().unwrap();
478        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
479        let calls = Arc::new(AtomicUsize::new(0));
480        let gen = partial(Arc::clone(&calls), false);
481
482        let err = Orchestrator::new()
483            .with_generator(&gen)
484            .run(
485                &listener_api(),
486                out_dir,
487                &OrchestratorHooks::default(),
488                false,
489            )
490            .unwrap_err();
491
492        let msg = err.to_string();
493        assert!(msg.contains("target 'partial' does not support"), "{msg}");
494        assert!(msg.contains("math.on_change"), "{msg}");
495        assert!(msg.contains("allow_unsupported"), "{msg}");
496        assert_eq!(
497            calls.load(Ordering::SeqCst),
498            0,
499            "gated generator must not run"
500        );
501    }
502
503    #[test]
504    fn allow_unsupported_downgrades_gate_to_warning() {
505        let dir = tempfile::tempdir().unwrap();
506        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
507        let calls = Arc::new(AtomicUsize::new(0));
508        let gen = partial(Arc::clone(&calls), true);
509
510        Orchestrator::new()
511            .with_generator(&gen)
512            .run(
513                &listener_api(),
514                out_dir,
515                &OrchestratorHooks::default(),
516                false,
517            )
518            .expect("allow_unsupported must let generation proceed");
519
520        assert_eq!(calls.load(Ordering::SeqCst), 1, "generator should run");
521    }
522
523    #[test]
524    fn allow_unsupported_does_not_relax_other_targets() {
525        let dir = tempfile::tempdir().unwrap();
526        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
527        let opted_calls = Arc::new(AtomicUsize::new(0));
528        let strict_calls = Arc::new(AtomicUsize::new(0));
529        let opted = partial(Arc::clone(&opted_calls), true);
530        let strict = ConfiguredGenerator::new(
531            CountingGenerator {
532                name: "strict",
533                calls: Arc::clone(&strict_calls),
534                caps: TargetCapabilities {
535                    listeners: false,
536                    ..TargetCapabilities::full()
537                },
538            },
539            TestConfig::default(),
540        );
541
542        let err = Orchestrator::new()
543            .with_generator(&opted)
544            .with_generator(&strict)
545            .run(
546                &listener_api(),
547                out_dir,
548                &OrchestratorHooks::default(),
549                false,
550            )
551            .unwrap_err();
552
553        let msg = err.to_string();
554        assert!(msg.contains("target 'strict'"), "{msg}");
555        assert!(!msg.contains("target 'partial'"), "{msg}");
556        assert_eq!(opted_calls.load(Ordering::SeqCst), 0);
557        assert_eq!(strict_calls.load(Ordering::SeqCst), 0);
558    }
559
560    #[test]
561    fn incremental_skips_when_unchanged() {
562        let dir = tempfile::tempdir().unwrap();
563        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
564        let api = test_api();
565        let hooks = OrchestratorHooks::default();
566        let calls = Arc::new(AtomicUsize::new(0));
567        let gen = configured("counting", Arc::clone(&calls));
568
569        let orch = Orchestrator::new().with_generator(&gen);
570
571        orch.run(&api, out_dir, &hooks, false).unwrap();
572        assert_eq!(calls.load(Ordering::SeqCst), 1);
573        let content_after_first =
574            std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
575
576        orch.run(&api, out_dir, &hooks, false).unwrap();
577        assert_eq!(
578            calls.load(Ordering::SeqCst),
579            1,
580            "generator should not run again"
581        );
582        let content_after_second =
583            std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
584
585        assert_eq!(content_after_first, content_after_second);
586    }
587
588    #[test]
589    fn force_bypasses_cache() {
590        let dir = tempfile::tempdir().unwrap();
591        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
592        let api = test_api();
593        let hooks = OrchestratorHooks::default();
594        let calls = Arc::new(AtomicUsize::new(0));
595        let gen = configured("counting", Arc::clone(&calls));
596
597        let orch = Orchestrator::new().with_generator(&gen);
598
599        orch.run(&api, out_dir, &hooks, false).unwrap();
600        assert_eq!(calls.load(Ordering::SeqCst), 1);
601
602        orch.run(&api, out_dir, &hooks, true).unwrap();
603        assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
604    }
605
606    #[test]
607    fn parallel_orchestrator_runs_all_generators() {
608        let dir = tempfile::tempdir().unwrap();
609        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
610        let api = test_api();
611        let hooks = OrchestratorHooks::default();
612
613        let names = ["g0", "g1", "g2", "g3", "g4", "g5"];
614        let counters: Vec<Arc<AtomicUsize>> = names
615            .iter()
616            .map(|_| Arc::new(AtomicUsize::new(0)))
617            .collect();
618        let gens: Vec<ConfiguredGenerator<CountingGenerator>> = names
619            .iter()
620            .zip(counters.iter())
621            .map(|(name, calls)| configured(name, Arc::clone(calls)))
622            .collect();
623
624        let mut orch = Orchestrator::new();
625        for g in &gens {
626            orch = orch.with_generator(g);
627        }
628
629        orch.run(&api, out_dir, &hooks, false).unwrap();
630
631        for (name, calls) in names.iter().zip(counters.iter()) {
632            assert_eq!(
633                calls.load(Ordering::SeqCst),
634                1,
635                "generator '{name}' should have run exactly once",
636            );
637            assert!(
638                out_dir.join(name).join("output.txt").exists(),
639                "generator '{name}' should have written its output",
640            );
641        }
642    }
643
644    #[test]
645    fn single_generator_cache_invalidates_independently() {
646        let dir = tempfile::tempdir().unwrap();
647        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
648        let hooks = OrchestratorHooks::default();
649
650        let c_calls = Arc::new(AtomicUsize::new(0));
651        let s_calls = Arc::new(AtomicUsize::new(0));
652        let c_gen = configured("c", Arc::clone(&c_calls));
653        let s_gen = configured("swift", Arc::clone(&s_calls));
654
655        let orch = Orchestrator::new()
656            .with_generator(&c_gen)
657            .with_generator(&s_gen);
658
659        let api = test_api();
660        orch.run(&api, out_dir, &hooks, false).unwrap();
661        assert_eq!(c_calls.load(Ordering::SeqCst), 1);
662        assert_eq!(s_calls.load(Ordering::SeqCst), 1);
663
664        // Mutate the API in a way that affects both generators' hashes by
665        // renaming a module. Then pre-seed the Swift cache with the *new*
666        // expected hash so only the C entry stays stale and re-runs.
667        let mut modified = api.clone();
668        modified.modules[0].name = "math2".to_string();
669
670        let new_swift_hash =
671            cache::hash_generator_inputs(&modified, "swift", &s_gen.config_hash_input());
672        cache::write_generator_cache(out_dir, "swift", &new_swift_hash).unwrap();
673
674        orch.run(&modified, out_dir, &hooks, false).unwrap();
675        assert_eq!(
676            c_calls.load(Ordering::SeqCst),
677            2,
678            "C generator should re-run because its cache entry no longer matches",
679        );
680        assert_eq!(
681            s_calls.load(Ordering::SeqCst),
682            1,
683            "Swift generator's cache matched the new API and must be skipped",
684        );
685    }
686
687    #[test]
688    fn config_change_invalidates_cache() {
689        let dir = tempfile::tempdir().unwrap();
690        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
691        let hooks = OrchestratorHooks::default();
692        let api = test_api();
693
694        let calls = Arc::new(AtomicUsize::new(0));
695        let g1 = ConfiguredGenerator::new(
696            CountingGenerator {
697                name: "counting",
698                calls: Arc::clone(&calls),
699                caps: TargetCapabilities::full(),
700            },
701            TestConfig::default(),
702        );
703        Orchestrator::new()
704            .with_generator(&g1)
705            .run(&api, out_dir, &hooks, false)
706            .unwrap();
707        assert_eq!(calls.load(Ordering::SeqCst), 1);
708
709        // Same generator, different config value: must re-run.
710        let g2 = ConfiguredGenerator::new(
711            CountingGenerator {
712                name: "counting",
713                calls: Arc::clone(&calls),
714                caps: TargetCapabilities::full(),
715            },
716            TestConfig {
717                knob: Some("changed".into()),
718                allow_unsupported: false,
719            },
720        );
721        Orchestrator::new()
722            .with_generator(&g2)
723            .run(&api, out_dir, &hooks, false)
724            .unwrap();
725        assert_eq!(
726            calls.load(Ordering::SeqCst),
727            2,
728            "config-only change must invalidate the cache",
729        );
730    }
731}