clnrm_template/
cache.rs

1//! Template caching and hot-reload system
2//!
3//! Provides caching for compiled templates and hot-reload functionality
4//! for development and dynamic template loading.
5
6use crate::context::TemplateContext;
7use crate::error::Result;
8use crate::renderer::TemplateRenderer;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, RwLock};
12use std::time::{Duration, SystemTime};
13
14/// Template cache for compiled templates and metadata
15///
16/// Caches compiled Tera templates and tracks file modification times
17/// for hot-reload functionality.
18#[derive(Debug)]
19pub struct TemplateCache {
20    /// Cached compiled templates (template_name -> compiled_template)
21    templates: Arc<RwLock<HashMap<String, CachedTemplate>>>,
22    /// File modification times for hot-reload
23    file_mtimes: Arc<RwLock<HashMap<PathBuf, SystemTime>>>,
24    /// Cache statistics
25    stats: Arc<RwLock<CacheStats>>,
26    /// Hot-reload enabled
27    hot_reload: bool,
28    /// Cache TTL (time-to-live)
29    ttl: Duration,
30}
31
32/// Cached template with metadata
33#[derive(Debug, Clone)]
34struct CachedTemplate {
35    /// Template content
36    content: String,
37    /// Last modification time
38    #[allow(dead_code)]
39    modified: SystemTime,
40    /// Compilation time
41    compiled_at: SystemTime,
42    /// Template size (for cache management)
43    size: usize,
44}
45
46/// Cache statistics for monitoring and optimization
47#[derive(Debug, Clone, Default)]
48pub struct CacheStats {
49    /// Total cache hits
50    pub hits: u64,
51    /// Total cache misses
52    pub misses: u64,
53    /// Templates evicted due to TTL
54    pub evictions: u64,
55    /// Total cache size (bytes)
56    pub total_size: usize,
57    /// Number of templates in cache
58    pub template_count: usize,
59}
60
61impl TemplateCache {
62    /// Create new template cache
63    ///
64    /// # Arguments
65    /// * `hot_reload` - Enable hot-reload for file changes
66    /// * `ttl` - Cache time-to-live duration
67    pub fn new(hot_reload: bool, ttl: Duration) -> Self {
68        Self {
69            templates: Arc::new(RwLock::new(HashMap::new())),
70            file_mtimes: Arc::new(RwLock::new(HashMap::new())),
71            stats: Arc::new(RwLock::new(CacheStats::default())),
72            hot_reload,
73            ttl,
74        }
75    }
76
77    /// Create cache with common settings (1 hour TTL, hot-reload enabled)
78    pub fn with_defaults() -> Self {
79        Self::new(true, Duration::from_secs(3600))
80    }
81
82    /// Get template from cache or compile if not cached/missing
83    ///
84    /// # Arguments
85    /// * `template_name` - Name of template
86    /// * `template_content` - Template content
87    /// * `file_path` - Optional file path for hot-reload
88    pub fn get_or_compile(
89        &self,
90        template_name: &str,
91        template_content: &str,
92        file_path: Option<&Path>,
93    ) -> Result<String> {
94        // Check if template is in cache and still valid
95        if let Some(cached) = self.templates.read().unwrap().get(template_name) {
96            if self.is_cache_valid(cached, file_path)? {
97                // Cache hit
98                self.record_hit();
99                return Ok(cached.content.clone());
100            }
101        }
102
103        // Cache miss or invalid - compile template
104        self.record_miss();
105
106        let compiled = self.compile_template(template_content)?;
107
108        // Cache the compiled template
109        self.cache_template(template_name, template_content, &compiled)?;
110
111        // Update file modification time for hot-reload
112        if let Some(path) = file_path {
113            if let Ok(metadata) = std::fs::metadata(path) {
114                if let Ok(mtime) = metadata.modified() {
115                    self.file_mtimes
116                        .write()
117                        .unwrap()
118                        .insert(path.to_path_buf(), mtime);
119                }
120            }
121        }
122
123        Ok(compiled)
124    }
125
126    /// Check if cached template is still valid
127    fn is_cache_valid(&self, cached: &CachedTemplate, file_path: Option<&Path>) -> Result<bool> {
128        // Check TTL
129        let age = SystemTime::now()
130            .duration_since(cached.compiled_at)
131            .unwrap_or(Duration::from_secs(0));
132
133        if age > self.ttl {
134            return Ok(false);
135        }
136
137        // Check file modification time if hot-reload is enabled
138        if self.hot_reload {
139            if let Some(path) = file_path {
140                if let Ok(metadata) = std::fs::metadata(path) {
141                    if let Ok(mtime) = metadata.modified() {
142                        if let Some(cached_mtime) = self.file_mtimes.read().unwrap().get(path) {
143                            if mtime > *cached_mtime {
144                                return Ok(false); // File was modified
145                            }
146                        }
147                    }
148                }
149            }
150        }
151
152        Ok(true)
153    }
154
155    /// Compile template content
156    fn compile_template(&self, content: &str) -> Result<String> {
157        // For now, just return the content as-is
158        // In a real implementation, this would compile Tera templates
159        Ok(content.to_string())
160    }
161
162    /// Cache compiled template
163    fn cache_template(&self, name: &str, _content: &str, compiled: &str) -> Result<()> {
164        let now = SystemTime::now();
165        let cached = CachedTemplate {
166            content: compiled.to_string(),
167            modified: now,
168            compiled_at: now,
169            size: compiled.len(),
170        };
171
172        // Update cache
173        self.templates
174            .write()
175            .unwrap()
176            .insert(name.to_string(), cached);
177
178        // Update stats
179        let mut stats = self.stats.write().unwrap();
180        stats.total_size += compiled.len();
181        stats.template_count += 1;
182
183        Ok(())
184    }
185
186    /// Record cache hit
187    fn record_hit(&self) {
188        self.stats.write().unwrap().hits += 1;
189    }
190
191    /// Record cache miss
192    fn record_miss(&self) {
193        self.stats.write().unwrap().misses += 1;
194    }
195
196    /// Get cache statistics
197    pub fn stats(&self) -> CacheStats {
198        self.stats.read().unwrap().clone()
199    }
200
201    /// Clear cache
202    pub fn clear(&self) {
203        self.templates.write().unwrap().clear();
204        self.file_mtimes.write().unwrap().clear();
205
206        let mut stats = self.stats.write().unwrap();
207        stats.total_size = 0;
208        stats.template_count = 0;
209        stats.evictions = 0;
210    }
211
212    /// Evict expired templates
213    pub fn evict_expired(&self) -> usize {
214        let now = SystemTime::now();
215        let mut templates = self.templates.write().unwrap();
216        let mut file_mtimes = self.file_mtimes.write().unwrap();
217        let mut stats = self.stats.write().unwrap();
218
219        let initial_count = templates.len();
220        templates.retain(|_name, cached| {
221            let age = now
222                .duration_since(cached.compiled_at)
223                .unwrap_or(Duration::from_secs(0));
224            if age > self.ttl {
225                // Template expired
226                stats.total_size -= cached.size;
227                stats.evictions += 1;
228                false
229            } else {
230                true
231            }
232        });
233
234        // Clean up file modification times for non-existent templates
235        file_mtimes.retain(|path, _| {
236            // Check if file still exists
237            path.exists()
238        });
239
240        stats.template_count = templates.len();
241        initial_count - templates.len()
242    }
243}
244
245/// Cached template renderer with hot-reload support
246///
247/// Combines TemplateRenderer with TemplateCache for optimal performance
248/// and development experience.
249pub struct CachedRenderer {
250    /// Base template renderer
251    renderer: TemplateRenderer,
252    /// Template cache
253    cache: TemplateCache,
254}
255
256impl CachedRenderer {
257    /// Create new cached renderer
258    ///
259    /// # Arguments
260    /// * `context` - Template context
261    /// * `hot_reload` - Enable hot-reload
262    pub fn new(context: TemplateContext, hot_reload: bool) -> Result<Self> {
263        let renderer = TemplateRenderer::new()?.with_context(context);
264        let cache = TemplateCache::new(hot_reload, Duration::from_secs(3600));
265
266        Ok(Self { renderer, cache })
267    }
268
269    /// Render template with caching
270    ///
271    /// # Arguments
272    /// * `template` - Template content
273    /// * `name` - Template name for caching
274    /// * `file_path` - Optional file path for hot-reload
275    pub fn render_cached(
276        &mut self,
277        template: &str,
278        name: &str,
279        file_path: Option<&Path>,
280    ) -> Result<String> {
281        // Try to get from cache first
282        if let Ok(cached) = self.cache.get_or_compile(name, template, file_path) {
283            return Ok(cached);
284        }
285
286        // Fall back to direct rendering if caching fails
287        self.renderer.render_str(template, name)
288    }
289
290    /// Get cache statistics
291    pub fn cache_stats(&self) -> CacheStats {
292        self.cache.stats()
293    }
294
295    /// Clear template cache
296    pub fn clear_cache(&self) {
297        self.cache.clear();
298    }
299
300    /// Evict expired templates from cache
301    pub fn evict_expired(&self) -> usize {
302        self.cache.evict_expired()
303    }
304
305    /// Access the underlying template renderer
306    pub fn renderer(&self) -> &TemplateRenderer {
307        &self.renderer
308    }
309
310    /// Access the underlying template renderer mutably
311    pub fn renderer_mut(&mut self) -> &mut TemplateRenderer {
312        &mut self.renderer
313    }
314}
315
316/// Hot-reload watcher for template files
317///
318/// Monitors template directories for file changes and triggers cache invalidation.
319/// Useful for development environments where templates change frequently.
320pub struct HotReloadWatcher {
321    /// Watched directories
322    watched_dirs: Vec<PathBuf>,
323    /// Cache to invalidate
324    #[allow(dead_code)]
325    cache: Arc<TemplateCache>,
326    /// File watcher (simplified implementation)
327    _watcher: Option<Box<dyn Watcher>>,
328}
329
330impl HotReloadWatcher {
331    /// Create new hot-reload watcher
332    ///
333    /// # Arguments
334    /// * `cache` - Template cache to invalidate on changes
335    pub fn new(cache: Arc<TemplateCache>) -> Self {
336        Self {
337            watched_dirs: Vec::new(),
338            cache,
339            _watcher: None,
340        }
341    }
342
343    /// Add directory to watch
344    ///
345    /// # Arguments
346    /// * `path` - Directory path to watch for template changes
347    pub fn watch_directory<P: AsRef<Path>>(mut self, path: P) -> Self {
348        self.watched_dirs.push(path.as_ref().to_path_buf());
349        self
350    }
351
352    /// Start watching for file changes
353    ///
354    /// This is a simplified implementation. In a real implementation,
355    /// this would use a proper file watcher like `notify` or `inotify`.
356    pub fn start(self) -> Result<()> {
357        // For now, just log that we're watching
358        // Real implementation would set up file system watchers
359        Ok(())
360    }
361
362    /// Stop watching (no-op in simplified implementation)
363    pub fn stop(&self) -> Result<()> {
364        Ok(())
365    }
366}
367
368// Placeholder trait for file watcher
369trait Watcher {}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use tempfile::tempdir;
375
376    #[test]
377    fn test_template_cache_basic() {
378        let cache = TemplateCache::default();
379        let template = "Hello {{ name }}";
380
381        // First access should be a miss
382        let result = cache.get_or_compile("test", template, None).unwrap();
383        assert_eq!(result, template);
384
385        let stats = cache.stats();
386        assert_eq!(stats.misses, 1);
387        assert_eq!(stats.hits, 0);
388
389        // Second access should be a hit
390        let result = cache.get_or_compile("test", template, None).unwrap();
391        assert_eq!(result, template);
392
393        let stats = cache.stats();
394        assert_eq!(stats.misses, 1);
395        assert_eq!(stats.hits, 1);
396    }
397
398    #[test]
399    fn test_cached_renderer() {
400        let context = TemplateContext::with_defaults();
401        let mut renderer = CachedRenderer::new(context, false).unwrap();
402
403        let template = "service = \"{{ svc }}\"";
404        let result = renderer.render_cached(template, "test", None).unwrap();
405        assert_eq!(result, template);
406
407        let stats = renderer.cache_stats();
408        assert_eq!(stats.misses, 1);
409    }
410
411    #[test]
412    fn test_cache_eviction() {
413        let cache = TemplateCache::new(false, Duration::from_millis(1));
414
415        // Add template
416        cache.get_or_compile("test", "Hello", None).unwrap();
417
418        // Wait for TTL to expire
419        std::thread::sleep(Duration::from_millis(10));
420
421        // Should be evicted
422        let evicted = cache.evict_expired();
423        assert_eq!(evicted, 1);
424    }
425}