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;
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
39/// A language code generator.
40///
41/// Generators are dispatched in parallel, so every implementation must be
42/// safe to share across threads. The associated [`Config`] type is owned
43/// by the generator crate so `weaveffi-core` does not have to know about
44/// target-specific options like `swift_module_name` or `cpp_namespace`.
45///
46/// [`Config`]: Generator::Config
47pub trait Generator: Send + Sync {
48    /// Per-target, fully-typed configuration consumed by [`generate`] and
49    /// [`output_files`]. Must round-trip through `serde_json` so the
50    /// orchestrator can hash it as part of the cache key.
51    ///
52    /// [`generate`]: Generator::generate
53    /// [`output_files`]: Generator::output_files
54    type Config: Serialize + Default + Clone + Send + Sync;
55
56    /// Stable short name for the target (`"swift"`, `"c"`, `"node"`, …).
57    /// Used as the cache file basename and the `--target` filter token.
58    fn name(&self) -> &'static str;
59
60    /// The gated IDL features this target implements. The orchestrator
61    /// refuses to run a generator against an API that uses a feature its
62    /// declared capabilities do not cover: a target either generates a
63    /// feature or fails loudly; it never silently omits one.
64    fn capabilities(&self) -> TargetCapabilities;
65
66    /// Whether the user explicitly opted in to generating this target even
67    /// though the API uses features the target does not support (for example
68    /// `generators.wasm.allow_unsupported: true`). When `true` the
69    /// orchestrator downgrades the capability failure to a loud warning and
70    /// the generator must emit an explicit unsupported surface (throwing
71    /// stubs, documentation) rather than silently omitting the feature.
72    /// Default: `false`. Opting in must always be an explicit config act.
73    fn allows_unsupported(&self, config: &Self::Config) -> bool {
74        let _ = config;
75        false
76    }
77
78    /// Render the bindings under `out_dir`.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the target cannot render the API or cannot write its
83    /// output files (for example a filesystem failure).
84    fn generate(&self, api: &Api, out_dir: &Utf8Path, config: &Self::Config) -> Result<()>;
85
86    /// Files that [`generate`](Generator::generate) would write, relative
87    /// to (or anchored under) `out_dir`. Used by `--dry-run` and `diff`.
88    /// Default implementation returns the empty list; generators override
89    /// to surface the list without doing any I/O.
90    fn output_files(&self, _api: &Api, _out_dir: &Utf8Path, _config: &Self::Config) -> Vec<String> {
91        vec![]
92    }
93
94    /// Assemble a distributable package that bundles the prebuilt native
95    /// libraries in `ctx`, anchored under `out_dir`, or `None` when this target
96    /// does not support packaging. Mirrors
97    /// [`LanguageBackend::package`](crate::backend::LanguageBackend::package);
98    /// the default returns `None`.
99    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
110/// Object-safe view of a [`Generator`] paired with a concrete config.
111///
112/// The orchestrator stores generators as `&dyn DynGenerator` so it can
113/// hold a heterogeneous set of targets whose `Config` types differ.
114/// [`ConfiguredGenerator`] is the canonical adapter.
115pub trait DynGenerator: Send + Sync {
116    /// The target's stable short name. Mirrors [`Generator::name`].
117    fn name(&self) -> &'static str;
118    /// The gated features the target implements. Mirrors
119    /// [`Generator::capabilities`].
120    fn capabilities(&self) -> TargetCapabilities;
121    /// See [`Generator::allows_unsupported`], evaluated against the bound
122    /// config.
123    fn allows_unsupported(&self) -> bool;
124    /// Render the bindings under `out_dir`, using the bound config. Mirrors
125    /// [`Generator::generate`].
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the underlying [`Generator::generate`] fails to
130    /// render or write its output.
131    fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()>;
132    /// The files [`generate`](Self::generate) would write. Mirrors
133    /// [`Generator::output_files`].
134    fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String>;
135    /// Assemble the distributable package for this target, using the bound
136    /// config. Mirrors [`Generator::package`]; returns `None` when the target
137    /// does not support packaging.
138    fn package(
139        &self,
140        api: &Api,
141        ctx: &PackageContext,
142        out_dir: &Utf8Path,
143    ) -> Option<Vec<PackagedFile>>;
144    /// Canonical-JSON encoding of the bound config, fed into the cache
145    /// hash so a config-only change invalidates the entry.
146    fn config_hash_input(&self) -> Vec<u8>;
147}
148
149/// Binds a [`Generator`] to a concrete [`Generator::Config`] value so it
150/// can be erased to `&dyn DynGenerator`.
151///
152/// ```ignore
153/// let swift = ConfiguredGenerator::new(SwiftGenerator, SwiftConfig::default());
154/// orchestrator.with_generator(&swift);
155/// ```
156pub struct ConfiguredGenerator<G: Generator> {
157    inner: G,
158    config: G::Config,
159}
160
161impl<G: Generator> ConfiguredGenerator<G> {
162    /// Pair a typed generator with the concrete config it should run under.
163    pub fn new(inner: G, config: G::Config) -> Self {
164        Self { inner, config }
165    }
166
167    /// Borrow the bound config value.
168    pub fn config(&self) -> &G::Config {
169        &self.config
170    }
171
172    /// Borrow the wrapped typed generator.
173    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/// Global hooks the orchestrator runs around the parallel codegen pass.
216#[derive(Default, Debug, Clone)]
217pub struct OrchestratorHooks {
218    /// Shell command run once before the parallel pass, only when at least one
219    /// target is out of date. `None` skips it.
220    pub pre_generate: Option<String>,
221    /// Shell command run once after every target finishes. `None` skips it.
222    pub post_generate: Option<String>,
223}
224
225/// Runs a set of configured generators: it capability-gates each target,
226/// skips the ones whose cached hash is still current, and renders the rest in
227/// parallel.
228#[derive(Default)]
229pub struct Orchestrator<'a> {
230    generators: Vec<&'a dyn DynGenerator>,
231}
232
233impl<'a> Orchestrator<'a> {
234    /// Create an orchestrator with no targets registered.
235    pub fn new() -> Self {
236        Self::default()
237    }
238
239    /// Register one erased generator to run, returning `self` for chaining.
240    pub fn with_generator(mut self, gen: &'a dyn DynGenerator) -> Self {
241        self.generators.push(gen);
242        self
243    }
244
245    /// Generate every registered target under `out_dir`.
246    ///
247    /// Gates each target against the gated features the API uses, skips targets
248    /// whose cached hash still matches (unless `force` clears the cache first),
249    /// runs the `pre_generate` and `post_generate` hooks around the parallel
250    /// pass, and records a fresh cache entry for each regenerated target.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if a selected target does not support a feature the IDL
255    /// uses (without `allow_unsupported`), the `force` cache reset fails, a
256    /// `pre_generate` or `post_generate` hook exits non-zero, any generator
257    /// fails while rendering, or a cache entry cannot be written.
258    pub fn run(
259        &self,
260        api: &Api,
261        out_dir: &Utf8Path,
262        hooks: &OrchestratorHooks,
263        force: bool,
264    ) -> Result<()> {
265        // Capability gate: every selected target must support every gated
266        // feature the API uses. Collect all violations before failing so the
267        // user sees the complete picture in one run. A generator whose config
268        // explicitly opted in via `allow_unsupported` downgrades its failure
269        // to a loud warning: the generator emits an explicit unsupported
270        // surface (throwing stubs) for the missing features instead.
271        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        // Pair each generator with its expected hash and decide individually
298        // whether it needs to run, so a single generator can be re-run while
299        // the others stay cached.
300        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    /// Test generator with a minimal config so tests don't have to depend
343    /// on any real per-language generator crate.
344    #[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    /// An API that uses listeners, so a target without listener support
435    /// trips the capability gate.
436    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        // Mutate the API in a way that affects both generators' hashes by
662        // renaming a module. Then pre-seed the Swift cache with the *new*
663        // expected hash so only the C entry stays stale and re-runs.
664        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        // Same generator, different config value: must re-run.
707        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}