Skip to main content

iac_forge/
render_cache.rs

1//! Content-addressed render cache.
2//!
3//! Caches backend rendering output keyed on the source IR's content
4//! hash and the backend's platform identifier. A cache hit skips
5//! the entire `generate_resource` call — rendering becomes a hashmap
6//! lookup when nothing in the IR changed.
7//!
8//! # Invariants
9//!
10//! - **Purity is required.** Cache hits short-circuit `Backend::apply`;
11//!   if a backend has observable side effects (touching the filesystem,
12//!   writing a log, reading env vars), the cache hides them. All
13//!   existing backends are pure renderers — this is safe for them.
14//! - **Schema version.** The cache key includes a schema version so
15//!   Backend trait upgrades that change output invalidate old entries
16//!   automatically. Bump `SCHEMA_VERSION` whenever the Backend contract
17//!   changes in a user-visible way.
18//! - **Deterministic inputs.** Because `content_hash` is over canonical
19//!   emission and canonical emission is deterministic (proven by
20//!   proptest), two semantically-equal IR values ALWAYS produce the
21//!   same cache key.
22//!
23//! # Usage
24//!
25//! ```no_run
26//! use iac_forge::render_cache::RenderCache;
27//! # use iac_forge::backend::Backend;
28//! # use iac_forge::morphism::{Morphism, ResourceInput};
29//! # fn example<B: Backend>(backend: &B, input: ResourceInput<'_>) {
30//! let mut cache = RenderCache::new();
31//! let artifacts = cache.render(backend, &input);
32//! // Second call with the same (backend, input) is a lookup.
33//! let artifacts2 = cache.render(backend, &input);
34//! # let _ = (artifacts, artifacts2);
35//! # }
36//! ```
37
38use std::collections::HashMap;
39
40use crate::backend::{Backend, GeneratedArtifact};
41use crate::morphism::{Morphism, ResourceInput};
42use crate::sexpr::{ContentHash, ToSExpr};
43
44/// Schema version embedded in every cache key. Bump when the Backend
45/// trait's output contract changes in a way that would invalidate
46/// cached artifacts (new mandatory field, changed path scheme, etc.).
47pub const SCHEMA_VERSION: u32 = 1;
48
49/// Cache key: (schema_version, backend_platform, ir_content_hash).
50///
51/// Three-part: the schema version guards against trait evolution, the
52/// platform name guards against backend-specific output, the IR hash
53/// identifies the input.
54#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub struct CacheKey {
56    pub schema_version: u32,
57    pub platform: String,
58    pub ir_hash: ContentHash,
59}
60
61impl CacheKey {
62    /// Construct a key for the current schema and a (backend, resource).
63    ///
64    /// Uses the source resource's `content_hash` and the backend's
65    /// `platform()` as the two variable parts.
66    #[must_use]
67    pub fn for_resource<B: Backend>(backend: &B, input: &ResourceInput<'_>) -> Self {
68        Self {
69            schema_version: SCHEMA_VERSION,
70            platform: backend.platform().to_string(),
71            ir_hash: input.resource.content_hash(),
72        }
73    }
74}
75
76/// A stat block for cache observability — useful in tests and telemetry.
77#[derive(Debug, Default, Clone, PartialEq, Eq)]
78pub struct CacheStats {
79    pub hits: u64,
80    pub misses: u64,
81}
82
83impl CacheStats {
84    #[must_use]
85    pub fn total(&self) -> u64 {
86        self.hits + self.misses
87    }
88
89    #[must_use]
90    pub fn hit_ratio(&self) -> f64 {
91        let total = self.total();
92        if total == 0 {
93            0.0
94        } else {
95            self.hits as f64 / total as f64
96        }
97    }
98}
99
100/// In-memory render cache.
101#[derive(Debug, Default)]
102pub struct RenderCache {
103    entries: HashMap<CacheKey, Vec<GeneratedArtifact>>,
104    stats: CacheStats,
105}
106
107impl RenderCache {
108    /// New empty cache.
109    #[must_use]
110    pub fn new() -> Self {
111        Self::default()
112    }
113
114    /// Render via cache: lookup by (backend, resource) content hash;
115    /// on miss, call the backend through the Morphism interface so
116    /// artifact provenance is populated, then store.
117    #[must_use]
118    pub fn render<B: Backend>(
119        &mut self,
120        backend: &B,
121        input: &ResourceInput<'_>,
122    ) -> Vec<GeneratedArtifact> {
123        let key = CacheKey::for_resource(backend, input);
124        if let Some(cached) = self.entries.get(&key) {
125            self.stats.hits += 1;
126            return cached.clone();
127        }
128        self.stats.misses += 1;
129        let rendered = <B as Morphism<_, _>>::apply(backend, input);
130        self.entries.insert(key, rendered.clone());
131        rendered
132    }
133
134    /// Current cache statistics.
135    #[must_use]
136    pub fn stats(&self) -> &CacheStats {
137        &self.stats
138    }
139
140    /// Number of entries currently cached.
141    #[must_use]
142    pub fn len(&self) -> usize {
143        self.entries.len()
144    }
145
146    /// Whether the cache is empty.
147    #[must_use]
148    pub fn is_empty(&self) -> bool {
149        self.entries.is_empty()
150    }
151
152    /// Drop all entries (preserves stats).
153    pub fn clear(&mut self) {
154        self.entries.clear();
155    }
156
157    /// Remove a specific entry. Returns whether it was present.
158    pub fn invalidate<B: Backend>(&mut self, backend: &B, input: &ResourceInput<'_>) -> bool {
159        self.entries
160            .remove(&CacheKey::for_resource(backend, input))
161            .is_some()
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::backend::{ArtifactKind, GeneratedArtifact, NamingConvention};
169    use crate::error::IacForgeError;
170    use crate::ir::{IacDataSource, IacProvider, IacResource};
171    use crate::testing::{test_provider, test_resource};
172
173    // A counting backend: tracks how many times generate_resource was called.
174    struct CountingBackend {
175        calls: std::cell::Cell<u32>,
176    }
177    impl CountingBackend {
178        fn new() -> Self {
179            Self {
180                calls: std::cell::Cell::new(0),
181            }
182        }
183    }
184    struct PlainNaming;
185    impl NamingConvention for PlainNaming {
186        fn resource_type_name(&self, r: &str, p: &str) -> String {
187            format!("{p}_{r}")
188        }
189        fn file_name(&self, r: &str, _k: &ArtifactKind) -> String {
190            format!("{r}.out")
191        }
192        fn field_name(&self, n: &str) -> String {
193            n.to_string()
194        }
195    }
196    impl Backend for CountingBackend {
197        fn platform(&self) -> &str {
198            "counting"
199        }
200        fn naming(&self) -> &dyn NamingConvention {
201            &PlainNaming
202        }
203        fn generate_resource(
204            &self,
205            r: &IacResource,
206            _p: &IacProvider,
207        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
208            self.calls.set(self.calls.get() + 1);
209            Ok(vec![GeneratedArtifact::new(
210                format!("{}.out", r.name),
211                format!("body for {}", r.name),
212                ArtifactKind::Resource,
213            )])
214        }
215        fn generate_data_source(
216            &self,
217            _d: &IacDataSource,
218            _p: &IacProvider,
219        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
220            Ok(vec![])
221        }
222        fn generate_provider(
223            &self,
224            _p: &IacProvider,
225            _r: &[IacResource],
226            _d: &[IacDataSource],
227        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
228            Ok(vec![])
229        }
230        fn generate_test(
231            &self,
232            _r: &IacResource,
233            _p: &IacProvider,
234        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
235            Ok(vec![])
236        }
237    }
238
239    fn fixture() -> (IacResource, IacProvider) {
240        (test_resource("widget"), test_provider("acme"))
241    }
242
243    // ── Basic behaviour ───────────────────────────────────────
244
245    #[test]
246    fn hit_after_miss_returns_same_artifacts() {
247        let (r, p) = fixture();
248        let input = ResourceInput {
249            resource: &r,
250            provider: &p,
251        };
252        let backend = CountingBackend::new();
253        let mut cache = RenderCache::new();
254
255        let a = cache.render(&backend, &input);
256        let b = cache.render(&backend, &input);
257        assert_eq!(a, b);
258    }
259
260    #[test]
261    fn cache_short_circuits_backend_after_first_call() {
262        let (r, p) = fixture();
263        let input = ResourceInput {
264            resource: &r,
265            provider: &p,
266        };
267        let backend = CountingBackend::new();
268        let mut cache = RenderCache::new();
269
270        cache.render(&backend, &input);
271        cache.render(&backend, &input);
272        cache.render(&backend, &input);
273        assert_eq!(
274            backend.calls.get(),
275            1,
276            "backend should only be invoked once across three cache lookups",
277        );
278    }
279
280    #[test]
281    fn stats_track_hits_and_misses() {
282        let (r, p) = fixture();
283        let input = ResourceInput {
284            resource: &r,
285            provider: &p,
286        };
287        let backend = CountingBackend::new();
288        let mut cache = RenderCache::new();
289
290        cache.render(&backend, &input);
291        cache.render(&backend, &input);
292        cache.render(&backend, &input);
293
294        assert_eq!(cache.stats().misses, 1);
295        assert_eq!(cache.stats().hits, 2);
296        assert!((cache.stats().hit_ratio() - (2.0 / 3.0)).abs() < 1e-9);
297    }
298
299    #[test]
300    fn stats_start_empty() {
301        let cache = RenderCache::new();
302        assert_eq!(cache.stats().total(), 0);
303        assert_eq!(cache.stats().hit_ratio(), 0.0);
304        assert!(cache.is_empty());
305    }
306
307    #[test]
308    fn different_resources_miss_independently() {
309        let p = test_provider("acme");
310        let r1 = test_resource("widget");
311        let r2 = test_resource("gadget");
312        let backend = CountingBackend::new();
313        let mut cache = RenderCache::new();
314
315        cache.render(
316            &backend,
317            &ResourceInput {
318                resource: &r1,
319                provider: &p,
320            },
321        );
322        cache.render(
323            &backend,
324            &ResourceInput {
325                resource: &r2,
326                provider: &p,
327            },
328        );
329        assert_eq!(backend.calls.get(), 2);
330        assert_eq!(cache.len(), 2);
331    }
332
333    #[test]
334    fn semantically_equal_resources_hit_the_same_key() {
335        // Two resources built from the same fixture helper must
336        // produce equal content hashes and therefore share a key.
337        let p = test_provider("acme");
338        let r_a = test_resource("widget");
339        let r_b = test_resource("widget");
340        let backend = CountingBackend::new();
341        let mut cache = RenderCache::new();
342
343        cache.render(
344            &backend,
345            &ResourceInput {
346                resource: &r_a,
347                provider: &p,
348            },
349        );
350        cache.render(
351            &backend,
352            &ResourceInput {
353                resource: &r_b,
354                provider: &p,
355            },
356        );
357        assert_eq!(
358            backend.calls.get(),
359            1,
360            "structurally-equal resources must share a cache key",
361        );
362    }
363
364    // ── Invalidation ──────────────────────────────────────────
365
366    #[test]
367    fn invalidate_removes_specific_entry() {
368        let (r, p) = fixture();
369        let input = ResourceInput {
370            resource: &r,
371            provider: &p,
372        };
373        let backend = CountingBackend::new();
374        let mut cache = RenderCache::new();
375
376        cache.render(&backend, &input);
377        assert_eq!(cache.len(), 1);
378        assert!(cache.invalidate(&backend, &input));
379        assert!(cache.is_empty());
380
381        // Next render is a miss again.
382        cache.render(&backend, &input);
383        assert_eq!(backend.calls.get(), 2);
384    }
385
386    #[test]
387    fn invalidate_reports_false_on_missing_entry() {
388        let (r, p) = fixture();
389        let input = ResourceInput {
390            resource: &r,
391            provider: &p,
392        };
393        let backend = CountingBackend::new();
394        let mut cache = RenderCache::new();
395        assert!(!cache.invalidate(&backend, &input));
396    }
397
398    #[test]
399    fn clear_drops_all_entries() {
400        let p = test_provider("acme");
401        let r1 = test_resource("a");
402        let r2 = test_resource("b");
403        let backend = CountingBackend::new();
404        let mut cache = RenderCache::new();
405
406        cache.render(
407            &backend,
408            &ResourceInput {
409                resource: &r1,
410                provider: &p,
411            },
412        );
413        cache.render(
414            &backend,
415            &ResourceInput {
416                resource: &r2,
417                provider: &p,
418            },
419        );
420        assert_eq!(cache.len(), 2);
421        cache.clear();
422        assert!(cache.is_empty());
423    }
424
425    // ── Key structure ─────────────────────────────────────────
426
427    #[test]
428    fn cache_key_contains_schema_version_and_platform() {
429        let (r, p) = fixture();
430        let input = ResourceInput {
431            resource: &r,
432            provider: &p,
433        };
434        let backend = CountingBackend::new();
435        let key = CacheKey::for_resource(&backend, &input);
436        assert_eq!(key.schema_version, SCHEMA_VERSION);
437        assert_eq!(key.platform, "counting");
438        assert_eq!(key.ir_hash, r.content_hash());
439    }
440
441    #[test]
442    fn cached_artifacts_carry_provenance() {
443        // Cache hits must preserve the provenance the Morphism apply
444        // populated on the first call (otherwise attestation would
445        // degrade to "unknown origin" on the second hit).
446        let (r, p) = fixture();
447        let input = ResourceInput {
448            resource: &r,
449            provider: &p,
450        };
451        let backend = CountingBackend::new();
452        let mut cache = RenderCache::new();
453
454        let first = cache.render(&backend, &input);
455        let second = cache.render(&backend, &input);
456        assert_eq!(first[0].source_hash, second[0].source_hash);
457        assert!(!first[0].source_hash.is_empty());
458        assert!(!first[0].morphism_chain.is_empty());
459    }
460
461    // ── Cross-backend isolation ──────────────────────────────
462
463    struct OtherBackend;
464    impl Backend for OtherBackend {
465        fn platform(&self) -> &str {
466            "other"
467        }
468        fn naming(&self) -> &dyn NamingConvention {
469            &PlainNaming
470        }
471        fn generate_resource(
472            &self,
473            r: &IacResource,
474            _p: &IacProvider,
475        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
476            Ok(vec![GeneratedArtifact::new(
477                format!("{}.other", r.name),
478                "other content".to_string(),
479                ArtifactKind::Resource,
480            )])
481        }
482        fn generate_data_source(
483            &self,
484            _d: &IacDataSource,
485            _p: &IacProvider,
486        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
487            Ok(vec![])
488        }
489        fn generate_provider(
490            &self,
491            _p: &IacProvider,
492            _r: &[IacResource],
493            _d: &[IacDataSource],
494        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
495            Ok(vec![])
496        }
497        fn generate_test(
498            &self,
499            _r: &IacResource,
500            _p: &IacProvider,
501        ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
502            Ok(vec![])
503        }
504    }
505
506    #[test]
507    fn different_backends_do_not_share_cache_entries() {
508        let (r, p) = fixture();
509        let input = ResourceInput {
510            resource: &r,
511            provider: &p,
512        };
513        let counting = CountingBackend::new();
514        let other = OtherBackend;
515        let mut cache = RenderCache::new();
516
517        cache.render(&counting, &input);
518        cache.render(&other, &input);
519
520        // Two distinct entries — platform is part of the key.
521        assert_eq!(cache.len(), 2);
522        assert_eq!(counting.calls.get(), 1);
523    }
524}