Skip to main content

weaveffi_core/
cache.rs

1//! Content-hashing and per-generator caching for skip-if-unchanged builds.
2
3use anyhow::{Context, Result};
4use camino::Utf8Path;
5use sha2::{Digest, Sha256};
6use weaveffi_ir::ir::Api;
7
8const CACHE_DIR: &str = ".weaveffi-cache";
9
10/// Version string baked into every cache entry. Bumping the WeaveFFI CLI
11/// version automatically invalidates every cache file so users never see
12/// stale generator output after an upgrade.
13pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
14
15/// Serialize the API to canonical JSON and return its SHA-256 hex digest.
16///
17/// The IR is first serialized to a `serde_json::Value`, whose `Object`
18/// representation is backed by a `BTreeMap` (when the `preserve_order`
19/// feature is not enabled). Re-serializing that `Value` therefore emits
20/// keys in deterministic, lexicographic order regardless of the iteration
21/// order of any source maps. This guarantees that two runs over the same
22/// IR always produce the same hash.
23///
24/// # Panics
25///
26/// Panics if `api` cannot be serialized to JSON. This does not happen for a
27/// well-formed [`Api`], whose IR is plain serializable data.
28pub fn hash_api(api: &Api) -> String {
29    let value = serde_json::to_value(api).expect("Api serialization should not fail");
30    let json = serde_json::to_string(&value).expect("Value serialization should not fail");
31    let hash = Sha256::digest(json.as_bytes());
32    format!("{hash:x}")
33}
34
35/// Return the SHA-256 hex digest of the API content keyed by `generator_name`.
36///
37/// Kept for tests and direct callers that only need an IR-keyed digest;
38/// the orchestrator goes through [`hash_generator_inputs`] so that config
39/// and CLI version changes invalidate the cache too.
40///
41/// # Panics
42///
43/// Panics if `api` cannot be serialized to JSON, which does not happen for a
44/// well-formed [`Api`].
45pub fn hash_api_for_generator(api: &Api, generator_name: &str) -> String {
46    let value = serde_json::to_value(api).expect("Api serialization should not fail");
47    let json = serde_json::to_string(&value).expect("Value serialization should not fail");
48    let mut hasher = Sha256::new();
49    hasher.update(generator_name.as_bytes());
50    hasher.update(b":");
51    hasher.update(json.as_bytes());
52    let hash = hasher.finalize();
53    format!("{hash:x}")
54}
55
56/// Return the SHA-256 hex digest of every input that affects a single
57/// generator's output: the canonical IR, the generator's name, the
58/// generator's typed config (already serialized to canonical JSON bytes
59/// by the caller via [`crate::codegen::DynGenerator::config_hash_input`]),
60/// and the CLI version.
61///
62/// This is the cache key the orchestrator stores under
63/// `{out_dir}/.weaveffi-cache/{generator_name}.hash`, so any change to
64/// the IR, generator config, or CLI version invalidates that entry and
65/// triggers a re-run.
66///
67/// # Panics
68///
69/// Panics if `api` cannot be serialized to JSON, which does not happen for a
70/// well-formed [`Api`]. The `config_bytes` are already serialized by the
71/// caller and are hashed as-is.
72pub fn hash_generator_inputs(api: &Api, generator_name: &str, config_bytes: &[u8]) -> String {
73    let api_value = serde_json::to_value(api).expect("Api serialization should not fail");
74    let api_json = serde_json::to_string(&api_value).expect("Value serialization should not fail");
75
76    let mut hasher = Sha256::new();
77    hasher.update(b"v1\0");
78    hasher.update(CLI_VERSION.as_bytes());
79    hasher.update(b"\0");
80    hasher.update(generator_name.as_bytes());
81    hasher.update(b"\0");
82    hasher.update(api_json.as_bytes());
83    hasher.update(b"\0");
84    hasher.update(config_bytes);
85    let hash = hasher.finalize();
86    format!("{hash:x}")
87}
88
89/// Read the persisted hash for `generator_name` from `out_dir/.weaveffi-cache/`.
90///
91/// Returns `None` when no cache entry exists yet (or it is empty).
92pub fn read_generator_cache(out_dir: &Utf8Path, generator_name: &str) -> Option<String> {
93    let path = out_dir
94        .join(CACHE_DIR)
95        .join(format!("{generator_name}.hash"));
96    std::fs::read_to_string(path)
97        .ok()
98        .map(|s| s.trim().to_string())
99        .filter(|s| !s.is_empty())
100}
101
102/// Persist `hash` as the cache entry for `generator_name`.
103///
104/// Removes a stale legacy `.weaveffi-cache` regular file (written by older
105/// CLI versions that used a single global cache) before creating the new
106/// per-generator directory layout.
107///
108/// # Errors
109///
110/// Returns an error if the legacy cache file cannot be removed, the cache
111/// directory cannot be created, or the hash file cannot be written.
112pub fn write_generator_cache(out_dir: &Utf8Path, generator_name: &str, hash: &str) -> Result<()> {
113    let cache_dir = out_dir.join(CACHE_DIR);
114    migrate_legacy_cache(out_dir)?;
115    std::fs::create_dir_all(cache_dir.as_std_path())
116        .with_context(|| format!("failed to create cache directory: {cache_dir}"))?;
117    let path = cache_dir.join(format!("{generator_name}.hash"));
118    std::fs::write(path.as_std_path(), hash)
119        .with_context(|| format!("failed to write cache file: {path}"))?;
120    Ok(())
121}
122
123/// Delete every persisted cache entry under `out_dir/.weaveffi-cache/`.
124///
125/// Called when `--force` is used so subsequent runs always regenerate.
126///
127/// # Errors
128///
129/// Returns an error if the cache directory (or a stale legacy cache file)
130/// exists but cannot be removed.
131pub fn invalidate_all(out_dir: &Utf8Path) -> Result<()> {
132    let cache_dir = out_dir.join(CACHE_DIR);
133    if cache_dir.is_dir() {
134        std::fs::remove_dir_all(cache_dir.as_std_path())
135            .with_context(|| format!("failed to remove cache directory: {cache_dir}"))?;
136    } else if cache_dir.exists() {
137        std::fs::remove_file(cache_dir.as_std_path())
138            .with_context(|| format!("failed to remove legacy cache file: {cache_dir}"))?;
139    }
140    Ok(())
141}
142
143/// Remove a stale legacy single-file cache so we can create the new
144/// per-generator directory in its place.
145fn migrate_legacy_cache(out_dir: &Utf8Path) -> Result<()> {
146    let cache_path = out_dir.join(CACHE_DIR);
147    if cache_path.is_file() {
148        std::fs::remove_file(cache_path.as_std_path())
149            .with_context(|| format!("failed to remove legacy cache file: {cache_path}"))?;
150    }
151    Ok(())
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::codegen::{ConfiguredGenerator, Generator, Orchestrator, OrchestratorHooks};
158    use std::sync::atomic::{AtomicUsize, Ordering};
159    use std::sync::Arc;
160    use weaveffi_ir::ir::{Function, Module, Param, TypeRef};
161
162    /// Minimal serde-able config so the cache tests can exercise the
163    /// orchestrator without depending on any real per-language config.
164    #[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
165    struct TestConfig {
166        knob: Option<String>,
167    }
168
169    fn config_bytes(c: &TestConfig) -> Vec<u8> {
170        let v = serde_json::to_value(c).unwrap();
171        serde_json::to_vec(&v).unwrap()
172    }
173
174    fn minimal_api() -> Api {
175        Api {
176            version: "0.4.0".to_string(),
177            modules: vec![Module {
178                name: "math".to_string(),
179                functions: vec![Function {
180                    name: "add".to_string(),
181                    params: vec![
182                        Param {
183                            name: "a".to_string(),
184                            ty: TypeRef::I32,
185                            mutable: false,
186                            doc: None,
187                        },
188                        Param {
189                            name: "b".to_string(),
190                            ty: TypeRef::I32,
191                            mutable: false,
192                            doc: None,
193                        },
194                    ],
195                    returns: Some(TypeRef::I32),
196                    doc: None,
197                    r#async: false,
198                    cancellable: false,
199                    deprecated: None,
200                    since: None,
201                }],
202                structs: vec![],
203                enums: vec![],
204                callbacks: vec![],
205                listeners: vec![],
206                errors: None,
207                modules: vec![],
208            }],
209            generators: None,
210            package: None,
211        }
212    }
213
214    struct CountingGenerator {
215        name: &'static str,
216        calls: Arc<AtomicUsize>,
217    }
218
219    impl Generator for CountingGenerator {
220        type Config = TestConfig;
221
222        fn name(&self) -> &'static str {
223            self.name
224        }
225
226        fn capabilities(&self) -> crate::capabilities::TargetCapabilities {
227            crate::capabilities::TargetCapabilities::full()
228        }
229
230        fn generate(
231            &self,
232            _api: &Api,
233            out_dir: &Utf8Path,
234            _config: &Self::Config,
235        ) -> anyhow::Result<()> {
236            self.calls.fetch_add(1, Ordering::SeqCst);
237            let dir = out_dir.join(self.name);
238            std::fs::create_dir_all(dir.as_std_path())?;
239            std::fs::write(dir.join("output.txt").as_std_path(), "generated")?;
240            Ok(())
241        }
242    }
243
244    fn configured(
245        name: &'static str,
246        calls: Arc<AtomicUsize>,
247        cfg: TestConfig,
248    ) -> ConfiguredGenerator<CountingGenerator> {
249        ConfiguredGenerator::new(CountingGenerator { name, calls }, cfg)
250    }
251
252    #[test]
253    fn hash_deterministic() {
254        let api = minimal_api();
255        let h1 = hash_api(&api);
256        let h2 = hash_api(&api);
257        assert_eq!(h1, h2);
258        assert_eq!(h1.len(), 64);
259    }
260
261    #[test]
262    fn hash_is_deterministic_across_runs() {
263        let mut api = minimal_api();
264        let mut generators = std::collections::BTreeMap::new();
265        let mut swift = toml::value::Table::new();
266        swift.insert(
267            "module_name".into(),
268            toml::Value::String("MySwiftModule".into()),
269        );
270        generators.insert("swift".into(), toml::Value::Table(swift));
271        let mut android = toml::value::Table::new();
272        android.insert(
273            "package".into(),
274            toml::Value::String("com.example.app".into()),
275        );
276        generators.insert("android".into(), toml::Value::Table(android));
277        api.generators = Some(generators);
278
279        let baseline = hash_api(&api);
280        for _ in 0..100 {
281            assert_eq!(
282                hash_api(&api),
283                baseline,
284                "hash_api must produce identical output on every call"
285            );
286        }
287    }
288
289    #[test]
290    fn hash_changes_on_modification() {
291        let mut api = minimal_api();
292        let h1 = hash_api(&api);
293
294        api.modules[0].functions.push(Function {
295            name: "subtract".to_string(),
296            params: vec![
297                Param {
298                    name: "a".to_string(),
299                    ty: TypeRef::I32,
300                    mutable: false,
301                    doc: None,
302                },
303                Param {
304                    name: "b".to_string(),
305                    ty: TypeRef::I32,
306                    mutable: false,
307                    doc: None,
308                },
309            ],
310            returns: Some(TypeRef::I32),
311            doc: None,
312            r#async: false,
313            cancellable: false,
314            deprecated: None,
315            since: None,
316        });
317        let h2 = hash_api(&api);
318
319        assert_ne!(h1, h2);
320    }
321
322    #[test]
323    fn per_generator_hash_includes_name() {
324        let api = minimal_api();
325        let h_c = hash_api_for_generator(&api, "c");
326        let h_swift = hash_api_for_generator(&api, "swift");
327        assert_ne!(h_c, h_swift);
328        assert_eq!(h_c.len(), 64);
329    }
330
331    #[test]
332    fn per_generator_hash_deterministic() {
333        let api = minimal_api();
334        assert_eq!(
335            hash_api_for_generator(&api, "c"),
336            hash_api_for_generator(&api, "c"),
337        );
338    }
339
340    #[test]
341    fn per_generator_cache_round_trip() {
342        let dir = tempfile::tempdir().unwrap();
343        let dir_path = Utf8Path::from_path(dir.path()).unwrap();
344
345        let hash = hash_api_for_generator(&minimal_api(), "c");
346        write_generator_cache(dir_path, "c", &hash).unwrap();
347
348        let read_back = read_generator_cache(dir_path, "c");
349        assert_eq!(read_back, Some(hash));
350        assert_eq!(read_generator_cache(dir_path, "swift"), None);
351    }
352
353    #[test]
354    fn read_generator_cache_returns_none_when_missing() {
355        let dir = tempfile::tempdir().unwrap();
356        let dir_path = Utf8Path::from_path(dir.path()).unwrap();
357        assert_eq!(read_generator_cache(dir_path, "c"), None);
358    }
359
360    #[test]
361    fn invalidate_all_clears_cache() {
362        let dir = tempfile::tempdir().unwrap();
363        let dir_path = Utf8Path::from_path(dir.path()).unwrap();
364        write_generator_cache(dir_path, "c", "abc").unwrap();
365        write_generator_cache(dir_path, "swift", "def").unwrap();
366
367        invalidate_all(dir_path).unwrap();
368        assert_eq!(read_generator_cache(dir_path, "c"), None);
369        assert_eq!(read_generator_cache(dir_path, "swift"), None);
370    }
371
372    #[test]
373    fn legacy_cache_file_is_replaced_by_directory() {
374        let dir = tempfile::tempdir().unwrap();
375        let dir_path = Utf8Path::from_path(dir.path()).unwrap();
376        std::fs::write(dir_path.join(CACHE_DIR), "stale-global-hash").unwrap();
377        assert!(dir_path.join(CACHE_DIR).is_file());
378
379        write_generator_cache(dir_path, "c", "fresh-hash").unwrap();
380
381        assert!(dir_path.join(CACHE_DIR).is_dir());
382        assert_eq!(
383            read_generator_cache(dir_path, "c"),
384            Some("fresh-hash".to_string())
385        );
386    }
387
388    #[test]
389    fn cache_file_written_after_generate() {
390        let dir = tempfile::tempdir().unwrap();
391        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
392        let api = minimal_api();
393        let hooks = OrchestratorHooks::default();
394        let calls = Arc::new(AtomicUsize::new(0));
395        let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
396
397        let orch = Orchestrator::new().with_generator(&gen);
398        orch.run(&api, out_dir, &hooks, false).unwrap();
399
400        assert!(out_dir.join(CACHE_DIR).join("counting.hash").exists());
401        assert_eq!(calls.load(Ordering::SeqCst), 1);
402    }
403
404    #[test]
405    fn cache_prevents_regeneration() {
406        let dir = tempfile::tempdir().unwrap();
407        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
408        let api = minimal_api();
409        let hooks = OrchestratorHooks::default();
410        let calls = Arc::new(AtomicUsize::new(0));
411        let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
412
413        let orch = Orchestrator::new().with_generator(&gen);
414        orch.run(&api, out_dir, &hooks, false).unwrap();
415        assert_eq!(calls.load(Ordering::SeqCst), 1);
416
417        orch.run(&api, out_dir, &hooks, false).unwrap();
418        assert_eq!(
419            calls.load(Ordering::SeqCst),
420            1,
421            "second run should skip generation"
422        );
423    }
424
425    #[test]
426    fn cache_invalidated_on_api_change() {
427        let dir = tempfile::tempdir().unwrap();
428        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
429        let api = minimal_api();
430        let hooks = OrchestratorHooks::default();
431        let calls = Arc::new(AtomicUsize::new(0));
432        let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
433
434        let orch = Orchestrator::new().with_generator(&gen);
435        orch.run(&api, out_dir, &hooks, false).unwrap();
436        assert_eq!(calls.load(Ordering::SeqCst), 1);
437
438        let mut modified_api = api;
439        modified_api.modules[0].functions.push(Function {
440            name: "subtract".to_string(),
441            params: vec![
442                Param {
443                    name: "a".to_string(),
444                    ty: TypeRef::I32,
445                    mutable: false,
446                    doc: None,
447                },
448                Param {
449                    name: "b".to_string(),
450                    ty: TypeRef::I32,
451                    mutable: false,
452                    doc: None,
453                },
454            ],
455            returns: Some(TypeRef::I32),
456            doc: None,
457            r#async: false,
458            cancellable: false,
459            deprecated: None,
460            since: None,
461        });
462
463        orch.run(&modified_api, out_dir, &hooks, false).unwrap();
464        assert_eq!(
465            calls.load(Ordering::SeqCst),
466            2,
467            "changed API should trigger regeneration"
468        );
469    }
470
471    #[test]
472    fn force_flag_bypasses_cache() {
473        let dir = tempfile::tempdir().unwrap();
474        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
475        let api = minimal_api();
476        let hooks = OrchestratorHooks::default();
477        let calls = Arc::new(AtomicUsize::new(0));
478        let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
479
480        let orch = Orchestrator::new().with_generator(&gen);
481        orch.run(&api, out_dir, &hooks, true).unwrap();
482        assert_eq!(calls.load(Ordering::SeqCst), 1);
483
484        orch.run(&api, out_dir, &hooks, true).unwrap();
485        assert_eq!(
486            calls.load(Ordering::SeqCst),
487            2,
488            "force=true should bypass cache"
489        );
490    }
491
492    #[test]
493    fn legacy_cache_file_ignored_on_first_run() {
494        let dir = tempfile::tempdir().unwrap();
495        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
496        std::fs::write(out_dir.join(CACHE_DIR), "stale-legacy").unwrap();
497
498        let api = minimal_api();
499        let hooks = OrchestratorHooks::default();
500        let calls = Arc::new(AtomicUsize::new(0));
501        let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
502
503        let orch = Orchestrator::new().with_generator(&gen);
504        orch.run(&api, out_dir, &hooks, false).unwrap();
505        assert_eq!(
506            calls.load(Ordering::SeqCst),
507            1,
508            "legacy single-file cache must not skip first run"
509        );
510        assert!(out_dir.join(CACHE_DIR).is_dir());
511    }
512
513    #[test]
514    fn single_generator_cache_invalidates_independently() {
515        let dir = tempfile::tempdir().unwrap();
516        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
517        let hooks = OrchestratorHooks::default();
518        let c_calls = Arc::new(AtomicUsize::new(0));
519        let s_calls = Arc::new(AtomicUsize::new(0));
520        let c_gen = configured("c", Arc::clone(&c_calls), TestConfig::default());
521        let s_gen = configured("swift", Arc::clone(&s_calls), TestConfig::default());
522        let orch = Orchestrator::new()
523            .with_generator(&c_gen)
524            .with_generator(&s_gen);
525
526        let api = minimal_api();
527        orch.run(&api, out_dir, &hooks, false).unwrap();
528        assert_eq!(c_calls.load(Ordering::SeqCst), 1);
529        assert_eq!(s_calls.load(Ordering::SeqCst), 1);
530
531        // Invalidate only the C generator's cache; the API itself is unchanged.
532        std::fs::remove_file(out_dir.join(CACHE_DIR).join("c.hash")).unwrap();
533
534        orch.run(&api, out_dir, &hooks, false).unwrap();
535        assert_eq!(
536            c_calls.load(Ordering::SeqCst),
537            2,
538            "C generator should re-run after its cache entry was removed"
539        );
540        assert_eq!(
541            s_calls.load(Ordering::SeqCst),
542            1,
543            "Swift generator's cache is intact and must be skipped"
544        );
545    }
546
547    #[test]
548    fn hash_generator_inputs_changes_when_config_bytes_change() {
549        let api = minimal_api();
550        let base = config_bytes(&TestConfig::default());
551
552        let changed = config_bytes(&TestConfig {
553            knob: Some("flipped".into()),
554        });
555
556        assert_ne!(
557            hash_generator_inputs(&api, "c", &base),
558            hash_generator_inputs(&api, "c", &changed),
559            "changing config bytes must change the per-generator hash"
560        );
561    }
562
563    #[test]
564    fn hash_generator_inputs_includes_cli_version() {
565        let api = minimal_api();
566        let cfg = config_bytes(&TestConfig::default());
567
568        // Compute the canonical hash, then compute the digest the same way
569        // but pretend a different CLI version produced it. The two must
570        // differ; otherwise upgrades silently leave stale output.
571        let real = hash_generator_inputs(&api, "c", &cfg);
572
573        let api_value = serde_json::to_value(&api).unwrap();
574        let api_json = serde_json::to_string(&api_value).unwrap();
575
576        let mut h = Sha256::new();
577        h.update(b"v1\0");
578        h.update(b"0.0.0-pretend-old\0");
579        h.update(b"c\0");
580        h.update(api_json.as_bytes());
581        h.update(b"\0");
582        h.update(&cfg);
583        let pretend = format!("{:x}", h.finalize());
584
585        assert_ne!(
586            real, pretend,
587            "CLI_VERSION must be part of the cache key so an upgrade invalidates it"
588        );
589        assert_eq!(CLI_VERSION, env!("CARGO_PKG_VERSION"));
590    }
591
592    #[test]
593    fn cache_invalidated_on_config_only_change() {
594        let dir = tempfile::tempdir().unwrap();
595        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
596        let api = minimal_api();
597        let hooks = OrchestratorHooks::default();
598
599        let calls = Arc::new(AtomicUsize::new(0));
600        let gen = configured("c", Arc::clone(&calls), TestConfig::default());
601        Orchestrator::new()
602            .with_generator(&gen)
603            .run(&api, out_dir, &hooks, false)
604            .unwrap();
605        assert_eq!(calls.load(Ordering::SeqCst), 1);
606
607        // Re-run with the *same* IR but a changed generator config.
608        let gen2 = configured(
609            "c",
610            Arc::clone(&calls),
611            TestConfig {
612                knob: Some("changed".into()),
613            },
614        );
615        Orchestrator::new()
616            .with_generator(&gen2)
617            .run(&api, out_dir, &hooks, false)
618            .unwrap();
619        assert_eq!(
620            calls.load(Ordering::SeqCst),
621            2,
622            "changing generator config must invalidate the cache and re-run the generator"
623        );
624
625        // A third run with the same `changed` config should hit the cache again.
626        Orchestrator::new()
627            .with_generator(&gen2)
628            .run(&api, out_dir, &hooks, false)
629            .unwrap();
630        assert_eq!(
631            calls.load(Ordering::SeqCst),
632            2,
633            "running with the same config twice should not regenerate"
634        );
635    }
636
637    #[test]
638    fn cache_invalidated_when_pre_generated_hash_has_wrong_version() {
639        let dir = tempfile::tempdir().unwrap();
640        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
641        let api = minimal_api();
642        let hooks = OrchestratorHooks::default();
643        let calls = Arc::new(AtomicUsize::new(0));
644        let gen = configured("c", Arc::clone(&calls), TestConfig::default());
645        let orch = Orchestrator::new().with_generator(&gen);
646
647        // Pre-seed the cache with a hash that was computed with the
648        // legacy IR-only function. The orchestrator now keys on
649        // `hash_generator_inputs`, so the stale entry must not match
650        // and the generator must re-run.
651        let stale = hash_api_for_generator(&api, "c");
652        write_generator_cache(out_dir, "c", &stale).unwrap();
653
654        orch.run(&api, out_dir, &hooks, false).unwrap();
655        assert_eq!(
656            calls.load(Ordering::SeqCst),
657            1,
658            "legacy IR-only hash must not satisfy the new cache key shape"
659        );
660    }
661}