1use std::collections::HashMap;
39
40use crate::backend::{Backend, GeneratedArtifact};
41use crate::morphism::{Morphism, ResourceInput};
42use crate::sexpr::{ContentHash, ToSExpr};
43
44pub const SCHEMA_VERSION: u32 = 1;
48
49#[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 #[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#[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#[derive(Debug, Default)]
102pub struct RenderCache {
103 entries: HashMap<CacheKey, Vec<GeneratedArtifact>>,
104 stats: CacheStats,
105}
106
107impl RenderCache {
108 #[must_use]
110 pub fn new() -> Self {
111 Self::default()
112 }
113
114 #[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 #[must_use]
136 pub fn stats(&self) -> &CacheStats {
137 &self.stats
138 }
139
140 #[must_use]
142 pub fn len(&self) -> usize {
143 self.entries.len()
144 }
145
146 #[must_use]
148 pub fn is_empty(&self) -> bool {
149 self.entries.is_empty()
150 }
151
152 pub fn clear(&mut self) {
154 self.entries.clear();
155 }
156
157 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 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 #[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 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 #[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 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 #[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 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 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 assert_eq!(cache.len(), 2);
522 assert_eq!(counting.calls.get(), 1);
523 }
524}