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