ricecoder_generation/templates/
cache.rs

1//! Template caching for improved performance
2//!
3//! Provides in-memory caching of parsed templates with file change detection
4//! and cache statistics.
5
6use crate::templates::error::TemplateError;
7use crate::templates::parser::ParsedTemplate;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::time::UNIX_EPOCH;
11
12/// Statistics about the template cache
13#[derive(Debug, Clone)]
14pub struct CacheStats {
15    /// Total number of templates in cache
16    pub total_templates: usize,
17    /// Number of cache hits
18    pub hits: u64,
19    /// Number of cache misses
20    pub misses: u64,
21    /// Total size of cached templates in bytes
22    pub total_size_bytes: usize,
23}
24
25impl CacheStats {
26    /// Calculate cache hit rate as a percentage
27    pub fn hit_rate(&self) -> f64 {
28        let total = self.hits + self.misses;
29        if total == 0 {
30            0.0
31        } else {
32            (self.hits as f64 / total as f64) * 100.0
33        }
34    }
35}
36
37/// Cached template entry with metadata
38#[derive(Debug, Clone)]
39struct CacheEntry {
40    /// Parsed template
41    template: ParsedTemplate,
42    /// File path (if loaded from file)
43    file_path: Option<PathBuf>,
44    /// Last modified time of the file
45    last_modified: Option<u64>,
46    /// Size of the template content in bytes
47    size_bytes: usize,
48}
49
50/// Template cache with file change detection
51pub struct TemplateCache {
52    /// Cache storage
53    cache: HashMap<String, CacheEntry>,
54    /// Cache statistics
55    stats: CacheStats,
56}
57
58impl TemplateCache {
59    /// Create a new template cache
60    pub fn new() -> Self {
61        Self {
62            cache: HashMap::new(),
63            stats: CacheStats {
64                total_templates: 0,
65                hits: 0,
66                misses: 0,
67                total_size_bytes: 0,
68            },
69        }
70    }
71
72    /// Get a cached template by key
73    ///
74    /// # Arguments
75    /// * `key` - Cache key (usually template name or path)
76    ///
77    /// # Returns
78    /// Cached template if found and valid, None otherwise
79    pub fn get(&mut self, key: &str) -> Option<ParsedTemplate> {
80        if let Some(entry) = self.cache.get(key) {
81            // Check if file has been modified (if it's file-backed)
82            if let Some(file_path) = &entry.file_path {
83                if let Ok(modified_time) = self.get_file_modified_time(file_path) {
84                    if Some(modified_time) != entry.last_modified {
85                        // File has been modified, invalidate cache entry
86                        self.cache.remove(key);
87                        self.stats.misses += 1;
88                        return None;
89                    }
90                }
91            }
92
93            self.stats.hits += 1;
94            Some(entry.template.clone())
95        } else {
96            self.stats.misses += 1;
97            None
98        }
99    }
100
101    /// Insert a template into the cache
102    ///
103    /// # Arguments
104    /// * `key` - Cache key
105    /// * `template` - Parsed template to cache
106    pub fn insert(&mut self, key: String, template: ParsedTemplate) {
107        self.insert_with_file(key, template, None);
108    }
109
110    /// Insert a template into the cache with file path for change detection
111    ///
112    /// # Arguments
113    /// * `key` - Cache key
114    /// * `template` - Parsed template to cache
115    /// * `file_path` - Path to the template file (for change detection)
116    pub fn insert_with_file(
117        &mut self,
118        key: String,
119        template: ParsedTemplate,
120        file_path: Option<PathBuf>,
121    ) {
122        let size_bytes = template.elements.len() * 8; // Rough estimate
123        let last_modified = file_path
124            .as_ref()
125            .and_then(|p| self.get_file_modified_time(p).ok());
126
127        let entry = CacheEntry {
128            template,
129            file_path,
130            last_modified,
131            size_bytes,
132        };
133
134        if !self.cache.contains_key(&key) {
135            self.stats.total_templates += 1;
136            self.stats.total_size_bytes += size_bytes;
137        }
138
139        self.cache.insert(key, entry);
140    }
141
142    /// Remove a template from the cache
143    pub fn remove(&mut self, key: &str) -> Option<ParsedTemplate> {
144        if let Some(entry) = self.cache.remove(key) {
145            self.stats.total_templates = self.stats.total_templates.saturating_sub(1);
146            self.stats.total_size_bytes =
147                self.stats.total_size_bytes.saturating_sub(entry.size_bytes);
148            Some(entry.template)
149        } else {
150            None
151        }
152    }
153
154    /// Clear all cached templates
155    pub fn clear(&mut self) {
156        self.cache.clear();
157        self.stats.total_templates = 0;
158        self.stats.total_size_bytes = 0;
159    }
160
161    /// Invalidate cache entries for a specific file
162    pub fn invalidate_file(&mut self, file_path: &Path) {
163        let keys_to_remove: Vec<String> = self
164            .cache
165            .iter()
166            .filter(|(_, entry)| entry.file_path.as_ref().is_some_and(|p| p == file_path))
167            .map(|(k, _)| k.clone())
168            .collect();
169
170        for key in keys_to_remove {
171            self.remove(&key);
172        }
173    }
174
175    /// Get cache statistics
176    pub fn stats(&self) -> CacheStats {
177        self.stats.clone()
178    }
179
180    /// Check if a key exists in the cache
181    pub fn contains(&self, key: &str) -> bool {
182        self.cache.contains_key(key)
183    }
184
185    /// Get the number of cached templates
186    pub fn len(&self) -> usize {
187        self.cache.len()
188    }
189
190    /// Check if cache is empty
191    pub fn is_empty(&self) -> bool {
192        self.cache.is_empty()
193    }
194
195    /// Get file modification time as Unix timestamp
196    fn get_file_modified_time(&self, path: &Path) -> Result<u64, TemplateError> {
197        let metadata = std::fs::metadata(path).map_err(TemplateError::IoError)?;
198
199        let modified = metadata.modified().map_err(TemplateError::IoError)?;
200
201        let duration = modified.duration_since(UNIX_EPOCH).map_err(|_| {
202            TemplateError::RenderError("Invalid file modification time".to_string())
203        })?;
204
205        Ok(duration.as_secs())
206    }
207}
208
209impl Default for TemplateCache {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::templates::parser::TemplateElement;
219
220    fn create_test_template() -> ParsedTemplate {
221        ParsedTemplate {
222            elements: vec![TemplateElement::Text("test".to_string())],
223            placeholders: vec![],
224            placeholder_names: Default::default(),
225        }
226    }
227
228    #[test]
229    fn test_cache_creation() {
230        let cache = TemplateCache::new();
231        assert!(cache.is_empty());
232        assert_eq!(cache.len(), 0);
233    }
234
235    #[test]
236    fn test_cache_insert_and_get() {
237        let mut cache = TemplateCache::new();
238        let template = create_test_template();
239
240        cache.insert("test".to_string(), template.clone());
241        assert_eq!(cache.len(), 1);
242
243        let retrieved = cache.get("test");
244        assert!(retrieved.is_some());
245    }
246
247    #[test]
248    fn test_cache_miss() {
249        let mut cache = TemplateCache::new();
250        let retrieved = cache.get("nonexistent");
251        assert!(retrieved.is_none());
252        assert_eq!(cache.stats().misses, 1);
253    }
254
255    #[test]
256    fn test_cache_hit() {
257        let mut cache = TemplateCache::new();
258        let template = create_test_template();
259
260        cache.insert("test".to_string(), template);
261        let _ = cache.get("test");
262        assert_eq!(cache.stats().hits, 1);
263    }
264
265    #[test]
266    fn test_cache_remove() {
267        let mut cache = TemplateCache::new();
268        let template = create_test_template();
269
270        cache.insert("test".to_string(), template);
271        assert_eq!(cache.len(), 1);
272
273        let removed = cache.remove("test");
274        assert!(removed.is_some());
275        assert_eq!(cache.len(), 0);
276    }
277
278    #[test]
279    fn test_cache_clear() {
280        let mut cache = TemplateCache::new();
281        let template = create_test_template();
282
283        cache.insert("test1".to_string(), template.clone());
284        cache.insert("test2".to_string(), template);
285
286        assert_eq!(cache.len(), 2);
287        cache.clear();
288        assert_eq!(cache.len(), 0);
289    }
290
291    #[test]
292    fn test_cache_contains() {
293        let mut cache = TemplateCache::new();
294        let template = create_test_template();
295
296        cache.insert("test".to_string(), template);
297        assert!(cache.contains("test"));
298        assert!(!cache.contains("nonexistent"));
299    }
300
301    #[test]
302    fn test_cache_stats_hit_rate() {
303        let mut cache = TemplateCache::new();
304        let template = create_test_template();
305
306        cache.insert("test".to_string(), template);
307
308        // 2 hits, 1 miss
309        let _ = cache.get("test");
310        let _ = cache.get("test");
311        let _ = cache.get("nonexistent");
312
313        let stats = cache.stats();
314        assert_eq!(stats.hits, 2);
315        assert_eq!(stats.misses, 1);
316        assert!((stats.hit_rate() - 66.66).abs() < 1.0); // Approximately 66.66%
317    }
318
319    #[test]
320    fn test_cache_multiple_templates() {
321        let mut cache = TemplateCache::new();
322        let template = create_test_template();
323
324        cache.insert("test1".to_string(), template.clone());
325        cache.insert("test2".to_string(), template.clone());
326        cache.insert("test3".to_string(), template);
327
328        assert_eq!(cache.len(), 3);
329        assert!(cache.contains("test1"));
330        assert!(cache.contains("test2"));
331        assert!(cache.contains("test3"));
332    }
333
334    #[test]
335    fn test_cache_stats_total_templates() {
336        let mut cache = TemplateCache::new();
337        let template = create_test_template();
338
339        cache.insert("test1".to_string(), template.clone());
340        cache.insert("test2".to_string(), template);
341
342        let stats = cache.stats();
343        assert_eq!(stats.total_templates, 2);
344    }
345
346    #[test]
347    fn test_cache_stats_zero_hit_rate() {
348        let cache = TemplateCache::new();
349        let stats = cache.stats();
350        assert_eq!(stats.hit_rate(), 0.0);
351    }
352
353    #[test]
354    fn test_cache_insert_with_file() {
355        let mut cache = TemplateCache::new();
356        let template = create_test_template();
357        let path = PathBuf::from("test.tmpl");
358
359        cache.insert_with_file("test".to_string(), template, Some(path.clone()));
360        assert!(cache.contains("test"));
361    }
362}