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.5.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                    throws: false,
198                    r#async: false,
199                    cancellable: false,
200                    deprecated: None,
201                    since: None,
202                }],
203                interfaces: vec![],
204                structs: vec![],
205                enums: vec![],
206                callbacks: vec![],
207                listeners: vec![],
208                errors: None,
209                modules: vec![],
210            }],
211            generators: None,
212            package: None,
213        }
214    }
215
216    struct CountingGenerator {
217        name: &'static str,
218        calls: Arc<AtomicUsize>,
219    }
220
221    impl Generator for CountingGenerator {
222        type Config = TestConfig;
223
224        fn name(&self) -> &'static str {
225            self.name
226        }
227
228        fn capabilities(&self) -> crate::capabilities::TargetCapabilities {
229            crate::capabilities::TargetCapabilities::full()
230        }
231
232        fn generate(
233            &self,
234            _api: &Api,
235            out_dir: &Utf8Path,
236            _config: &Self::Config,
237        ) -> anyhow::Result<()> {
238            self.calls.fetch_add(1, Ordering::SeqCst);
239            let dir = out_dir.join(self.name);
240            std::fs::create_dir_all(dir.as_std_path())?;
241            std::fs::write(dir.join("output.txt").as_std_path(), "generated")?;
242            Ok(())
243        }
244    }
245
246    fn configured(
247        name: &'static str,
248        calls: Arc<AtomicUsize>,
249        cfg: TestConfig,
250    ) -> ConfiguredGenerator<CountingGenerator> {
251        ConfiguredGenerator::new(CountingGenerator { name, calls }, cfg)
252    }
253
254    #[test]
255    fn hash_deterministic() {
256        let api = minimal_api();
257        let h1 = hash_api(&api);
258        let h2 = hash_api(&api);
259        assert_eq!(h1, h2);
260        assert_eq!(h1.len(), 64);
261    }
262
263    #[test]
264    fn hash_is_deterministic_across_runs() {
265        let mut api = minimal_api();
266        let mut generators = std::collections::BTreeMap::new();
267        let mut swift = toml::value::Table::new();
268        swift.insert(
269            "module_name".into(),
270            toml::Value::String("MySwiftModule".into()),
271        );
272        generators.insert("swift".into(), toml::Value::Table(swift));
273        let mut android = toml::value::Table::new();
274        android.insert(
275            "package".into(),
276            toml::Value::String("com.example.app".into()),
277        );
278        generators.insert("android".into(), toml::Value::Table(android));
279        api.generators = Some(generators);
280
281        let baseline = hash_api(&api);
282        for _ in 0..100 {
283            assert_eq!(
284                hash_api(&api),
285                baseline,
286                "hash_api must produce identical output on every call"
287            );
288        }
289    }
290
291    #[test]
292    fn hash_changes_on_modification() {
293        let mut api = minimal_api();
294        let h1 = hash_api(&api);
295
296        api.modules[0].functions.push(Function {
297            name: "subtract".to_string(),
298            params: vec![
299                Param {
300                    name: "a".to_string(),
301                    ty: TypeRef::I32,
302                    mutable: false,
303                    doc: None,
304                },
305                Param {
306                    name: "b".to_string(),
307                    ty: TypeRef::I32,
308                    mutable: false,
309                    doc: None,
310                },
311            ],
312            returns: Some(TypeRef::I32),
313            doc: None,
314            throws: false,
315            r#async: false,
316            cancellable: false,
317            deprecated: None,
318            since: None,
319        });
320        let h2 = hash_api(&api);
321
322        assert_ne!(h1, h2);
323    }
324
325    #[test]
326    fn per_generator_hash_includes_name() {
327        let api = minimal_api();
328        let h_c = hash_api_for_generator(&api, "c");
329        let h_swift = hash_api_for_generator(&api, "swift");
330        assert_ne!(h_c, h_swift);
331        assert_eq!(h_c.len(), 64);
332    }
333
334    #[test]
335    fn per_generator_hash_deterministic() {
336        let api = minimal_api();
337        assert_eq!(
338            hash_api_for_generator(&api, "c"),
339            hash_api_for_generator(&api, "c"),
340        );
341    }
342
343    #[test]
344    fn per_generator_cache_round_trip() {
345        let dir = tempfile::tempdir().unwrap();
346        let dir_path = Utf8Path::from_path(dir.path()).unwrap();
347
348        let hash = hash_api_for_generator(&minimal_api(), "c");
349        write_generator_cache(dir_path, "c", &hash).unwrap();
350
351        let read_back = read_generator_cache(dir_path, "c");
352        assert_eq!(read_back, Some(hash));
353        assert_eq!(read_generator_cache(dir_path, "swift"), None);
354    }
355
356    #[test]
357    fn read_generator_cache_returns_none_when_missing() {
358        let dir = tempfile::tempdir().unwrap();
359        let dir_path = Utf8Path::from_path(dir.path()).unwrap();
360        assert_eq!(read_generator_cache(dir_path, "c"), None);
361    }
362
363    #[test]
364    fn invalidate_all_clears_cache() {
365        let dir = tempfile::tempdir().unwrap();
366        let dir_path = Utf8Path::from_path(dir.path()).unwrap();
367        write_generator_cache(dir_path, "c", "abc").unwrap();
368        write_generator_cache(dir_path, "swift", "def").unwrap();
369
370        invalidate_all(dir_path).unwrap();
371        assert_eq!(read_generator_cache(dir_path, "c"), None);
372        assert_eq!(read_generator_cache(dir_path, "swift"), None);
373    }
374
375    #[test]
376    fn legacy_cache_file_is_replaced_by_directory() {
377        let dir = tempfile::tempdir().unwrap();
378        let dir_path = Utf8Path::from_path(dir.path()).unwrap();
379        std::fs::write(dir_path.join(CACHE_DIR), "stale-global-hash").unwrap();
380        assert!(dir_path.join(CACHE_DIR).is_file());
381
382        write_generator_cache(dir_path, "c", "fresh-hash").unwrap();
383
384        assert!(dir_path.join(CACHE_DIR).is_dir());
385        assert_eq!(
386            read_generator_cache(dir_path, "c"),
387            Some("fresh-hash".to_string())
388        );
389    }
390
391    #[test]
392    fn cache_file_written_after_generate() {
393        let dir = tempfile::tempdir().unwrap();
394        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
395        let api = minimal_api();
396        let hooks = OrchestratorHooks::default();
397        let calls = Arc::new(AtomicUsize::new(0));
398        let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
399
400        let orch = Orchestrator::new().with_generator(&gen);
401        orch.run(&api, out_dir, &hooks, false).unwrap();
402
403        assert!(out_dir.join(CACHE_DIR).join("counting.hash").exists());
404        assert_eq!(calls.load(Ordering::SeqCst), 1);
405    }
406
407    #[test]
408    fn cache_prevents_regeneration() {
409        let dir = tempfile::tempdir().unwrap();
410        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
411        let api = minimal_api();
412        let hooks = OrchestratorHooks::default();
413        let calls = Arc::new(AtomicUsize::new(0));
414        let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
415
416        let orch = Orchestrator::new().with_generator(&gen);
417        orch.run(&api, out_dir, &hooks, false).unwrap();
418        assert_eq!(calls.load(Ordering::SeqCst), 1);
419
420        orch.run(&api, out_dir, &hooks, false).unwrap();
421        assert_eq!(
422            calls.load(Ordering::SeqCst),
423            1,
424            "second run should skip generation"
425        );
426    }
427
428    #[test]
429    fn cache_invalidated_on_api_change() {
430        let dir = tempfile::tempdir().unwrap();
431        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
432        let api = minimal_api();
433        let hooks = OrchestratorHooks::default();
434        let calls = Arc::new(AtomicUsize::new(0));
435        let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
436
437        let orch = Orchestrator::new().with_generator(&gen);
438        orch.run(&api, out_dir, &hooks, false).unwrap();
439        assert_eq!(calls.load(Ordering::SeqCst), 1);
440
441        let mut modified_api = api;
442        modified_api.modules[0].functions.push(Function {
443            name: "subtract".to_string(),
444            params: vec![
445                Param {
446                    name: "a".to_string(),
447                    ty: TypeRef::I32,
448                    mutable: false,
449                    doc: None,
450                },
451                Param {
452                    name: "b".to_string(),
453                    ty: TypeRef::I32,
454                    mutable: false,
455                    doc: None,
456                },
457            ],
458            returns: Some(TypeRef::I32),
459            doc: None,
460            throws: false,
461            r#async: false,
462            cancellable: false,
463            deprecated: None,
464            since: None,
465        });
466
467        orch.run(&modified_api, out_dir, &hooks, false).unwrap();
468        assert_eq!(
469            calls.load(Ordering::SeqCst),
470            2,
471            "changed API should trigger regeneration"
472        );
473    }
474
475    #[test]
476    fn force_flag_bypasses_cache() {
477        let dir = tempfile::tempdir().unwrap();
478        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
479        let api = minimal_api();
480        let hooks = OrchestratorHooks::default();
481        let calls = Arc::new(AtomicUsize::new(0));
482        let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
483
484        let orch = Orchestrator::new().with_generator(&gen);
485        orch.run(&api, out_dir, &hooks, true).unwrap();
486        assert_eq!(calls.load(Ordering::SeqCst), 1);
487
488        orch.run(&api, out_dir, &hooks, true).unwrap();
489        assert_eq!(
490            calls.load(Ordering::SeqCst),
491            2,
492            "force=true should bypass cache"
493        );
494    }
495
496    #[test]
497    fn legacy_cache_file_ignored_on_first_run() {
498        let dir = tempfile::tempdir().unwrap();
499        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
500        std::fs::write(out_dir.join(CACHE_DIR), "stale-legacy").unwrap();
501
502        let api = minimal_api();
503        let hooks = OrchestratorHooks::default();
504        let calls = Arc::new(AtomicUsize::new(0));
505        let gen = configured("counting", Arc::clone(&calls), TestConfig::default());
506
507        let orch = Orchestrator::new().with_generator(&gen);
508        orch.run(&api, out_dir, &hooks, false).unwrap();
509        assert_eq!(
510            calls.load(Ordering::SeqCst),
511            1,
512            "legacy single-file cache must not skip first run"
513        );
514        assert!(out_dir.join(CACHE_DIR).is_dir());
515    }
516
517    #[test]
518    fn single_generator_cache_invalidates_independently() {
519        let dir = tempfile::tempdir().unwrap();
520        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
521        let hooks = OrchestratorHooks::default();
522        let c_calls = Arc::new(AtomicUsize::new(0));
523        let s_calls = Arc::new(AtomicUsize::new(0));
524        let c_gen = configured("c", Arc::clone(&c_calls), TestConfig::default());
525        let s_gen = configured("swift", Arc::clone(&s_calls), TestConfig::default());
526        let orch = Orchestrator::new()
527            .with_generator(&c_gen)
528            .with_generator(&s_gen);
529
530        let api = minimal_api();
531        orch.run(&api, out_dir, &hooks, false).unwrap();
532        assert_eq!(c_calls.load(Ordering::SeqCst), 1);
533        assert_eq!(s_calls.load(Ordering::SeqCst), 1);
534
535        // Invalidate only the C generator's cache; the API itself is unchanged.
536        std::fs::remove_file(out_dir.join(CACHE_DIR).join("c.hash")).unwrap();
537
538        orch.run(&api, out_dir, &hooks, false).unwrap();
539        assert_eq!(
540            c_calls.load(Ordering::SeqCst),
541            2,
542            "C generator should re-run after its cache entry was removed"
543        );
544        assert_eq!(
545            s_calls.load(Ordering::SeqCst),
546            1,
547            "Swift generator's cache is intact and must be skipped"
548        );
549    }
550
551    #[test]
552    fn hash_generator_inputs_changes_when_config_bytes_change() {
553        let api = minimal_api();
554        let base = config_bytes(&TestConfig::default());
555
556        let changed = config_bytes(&TestConfig {
557            knob: Some("flipped".into()),
558        });
559
560        assert_ne!(
561            hash_generator_inputs(&api, "c", &base),
562            hash_generator_inputs(&api, "c", &changed),
563            "changing config bytes must change the per-generator hash"
564        );
565    }
566
567    #[test]
568    fn hash_generator_inputs_includes_cli_version() {
569        let api = minimal_api();
570        let cfg = config_bytes(&TestConfig::default());
571
572        // Compute the canonical hash, then compute the digest the same way
573        // but pretend a different CLI version produced it. The two must
574        // differ; otherwise upgrades silently leave stale output.
575        let real = hash_generator_inputs(&api, "c", &cfg);
576
577        let api_value = serde_json::to_value(&api).unwrap();
578        let api_json = serde_json::to_string(&api_value).unwrap();
579
580        let mut h = Sha256::new();
581        h.update(b"v1\0");
582        h.update(b"0.0.0-pretend-old\0");
583        h.update(b"c\0");
584        h.update(api_json.as_bytes());
585        h.update(b"\0");
586        h.update(&cfg);
587        let pretend = format!("{:x}", h.finalize());
588
589        assert_ne!(
590            real, pretend,
591            "CLI_VERSION must be part of the cache key so an upgrade invalidates it"
592        );
593        assert_eq!(CLI_VERSION, env!("CARGO_PKG_VERSION"));
594    }
595
596    #[test]
597    fn cache_invalidated_on_config_only_change() {
598        let dir = tempfile::tempdir().unwrap();
599        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
600        let api = minimal_api();
601        let hooks = OrchestratorHooks::default();
602
603        let calls = Arc::new(AtomicUsize::new(0));
604        let gen = configured("c", Arc::clone(&calls), TestConfig::default());
605        Orchestrator::new()
606            .with_generator(&gen)
607            .run(&api, out_dir, &hooks, false)
608            .unwrap();
609        assert_eq!(calls.load(Ordering::SeqCst), 1);
610
611        // Re-run with the *same* IR but a changed generator config.
612        let gen2 = configured(
613            "c",
614            Arc::clone(&calls),
615            TestConfig {
616                knob: Some("changed".into()),
617            },
618        );
619        Orchestrator::new()
620            .with_generator(&gen2)
621            .run(&api, out_dir, &hooks, false)
622            .unwrap();
623        assert_eq!(
624            calls.load(Ordering::SeqCst),
625            2,
626            "changing generator config must invalidate the cache and re-run the generator"
627        );
628
629        // A third run with the same `changed` config should hit the cache again.
630        Orchestrator::new()
631            .with_generator(&gen2)
632            .run(&api, out_dir, &hooks, false)
633            .unwrap();
634        assert_eq!(
635            calls.load(Ordering::SeqCst),
636            2,
637            "running with the same config twice should not regenerate"
638        );
639    }
640
641    #[test]
642    fn cache_invalidated_when_pre_generated_hash_has_wrong_version() {
643        let dir = tempfile::tempdir().unwrap();
644        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
645        let api = minimal_api();
646        let hooks = OrchestratorHooks::default();
647        let calls = Arc::new(AtomicUsize::new(0));
648        let gen = configured("c", Arc::clone(&calls), TestConfig::default());
649        let orch = Orchestrator::new().with_generator(&gen);
650
651        // Pre-seed the cache with a hash that was computed with the
652        // legacy IR-only function. The orchestrator now keys on
653        // `hash_generator_inputs`, so the stale entry must not match
654        // and the generator must re-run.
655        let stale = hash_api_for_generator(&api, "c");
656        write_generator_cache(out_dir, "c", &stale).unwrap();
657
658        orch.run(&api, out_dir, &hooks, false).unwrap();
659        assert_eq!(
660            calls.load(Ordering::SeqCst),
661            1,
662            "legacy IR-only hash must not satisfy the new cache key shape"
663        );
664    }
665}