Skip to main content

md_tmpl_core/
cache.rs

1//! Template compilation cache for fast hot-reload.
2//!
3//! [`TemplateCache`] stores compiled templates keyed by `(path, content_hash)`.
4//! On reload it:
5//!
6//! 1. Stats the file to check its modification time (cheap syscall).
7//! 2. If the mtime matches the cached entry, returns the cached template —
8//!    **zero file I/O beyond a stat**.
9//! 3. If the mtime changed, reads the file and hashes the source.
10//! 4. If the hash still matches (e.g. whitespace-only save), returns cached.
11//! 5. Otherwise, compiles the new source, stores it, and returns it.
12//!
13//! The cache also stores compiled **include** segments so that included
14//! templates are not re-read and re-compiled on every render.
15//!
16//! An optional LRU eviction limit ([`TemplateCache::with_max_entries`])
17//! prevents unbounded memory growth in long-running processes.
18
19use std::{
20    path::{Path, PathBuf},
21    sync::{Arc, RwLock},
22    time::{Instant, SystemTime},
23};
24
25use crate::{
26    compat::HashMap,
27    compiled::{self, CompiledInlineTemplate, Segment},
28    error::TemplateError,
29    frontmatter::{self, Frontmatter},
30    types::VarDecl,
31};
32
33/// A compiled include entry, ready for rendering without re-parsing.
34#[derive(Debug, Clone)]
35pub(crate) struct CachedInclude {
36    /// Pre-compiled segment instructions.
37    pub segments: Arc<[Segment]>,
38    /// Declared variables from the included template's frontmatter.
39    pub declarations: Arc<[VarDecl]>,
40    /// Base directory for resolving nested includes.
41    pub base_dir: PathBuf,
42}
43
44/// Content-hash of a source string.
45///
46/// Uses the shared FNV-1a implementation for deterministic, cross-version
47/// stable hashing.  Same source → same hash, different source → (very
48/// likely) different hash.
49pub(crate) fn hash_source(source: &str) -> u64 {
50    crate::__private::fnv1a_hash(source.as_bytes())
51}
52
53/// A cache entry for a compiled template.
54#[derive(Debug, Clone)]
55struct CacheEntry {
56    /// Hash of the raw source (including frontmatter).
57    source_hash: u64,
58    /// File modification time at the point the source was read.
59    last_modified: SystemTime,
60    /// Last time this entry was accessed (for LRU eviction).
61    last_accessed: Instant,
62    /// Pre-compiled segment tree.
63    segments: Arc<[Segment]>,
64    /// Frontmatter declarations.
65    declarations: Arc<[VarDecl]>,
66    /// Inline template definitions.
67    inline_templates: Arc<HashMap<String, CompiledInlineTemplate>>,
68    /// Local constants.
69    consts: Arc<HashMap<String, crate::value::Value>>,
70    /// Imported constants.
71    imported_consts: Arc<HashMap<String, crate::value::Value>>,
72    /// Full parsed frontmatter.
73    frontmatter: Frontmatter,
74}
75
76/// Internal trait for generic LRU eviction across different cache entry types.
77trait HasLastAccessed {
78    fn last_accessed(&self) -> Instant;
79}
80
81impl HasLastAccessed for CacheEntry {
82    fn last_accessed(&self) -> Instant {
83        self.last_accessed
84    }
85}
86
87/// Thread-safe template compilation cache.
88///
89/// Caches compiled templates and includes by path + content hash to avoid
90/// redundant parsing during hot-reload and rendering.
91///
92/// # Usage
93///
94/// ```rust
95/// use md_tmpl_core::TemplateCache;
96///
97/// let dir = tempfile::tempdir().unwrap();
98/// let path = dir.path().join("greeting.tmpl.md");
99/// std::fs::write(
100///     &path,
101///     r#"---
102/// params:
103///   - name = str
104/// ---
105/// Hi {{ name }}!"#,
106/// )
107/// .unwrap();
108///
109/// let cache = TemplateCache::new();
110///
111/// // First load — compiles from disk.
112/// let tmpl = cache.load(&path).unwrap();
113///
114/// // Second load of same unchanged file — returns cached, zero re-parsing.
115/// let tmpl2 = cache.load(&path).unwrap();
116/// assert_eq!(tmpl.source_hash(), tmpl2.source_hash());
117/// ```
118/// Internal trait for include resolution — erases the `BuildHasher`
119/// generic so that [`Scope`](crate::scope::Scope) doesn't need to carry it.
120pub(crate) trait IncludeResolver: Send + Sync {
121    fn resolve_include(&self, path: &Path) -> Result<CachedInclude, TemplateError>;
122}
123
124/// A template compilation cache parameterised over a [`BuildHasher`](std::hash::BuildHasher)
125/// for content-addressed invalidation.
126///
127/// The default hasher is [`RandomState`](std::collections::hash_map::RandomState) (SipHash-1-3). Supply a
128/// different `BuildHasher` via [`with_hasher`](Self::with_hasher) if
129/// you need a faster or more collision-resistant hash.
130///
131/// # Examples
132///
133/// ```
134/// use md_tmpl_core::TemplateCache;
135///
136/// let dir = tempfile::tempdir().unwrap();
137/// let path = dir.path().join("greeting.tmpl.md");
138/// std::fs::write(
139///     &path,
140///     r#"---
141/// params:
142///   - name = str
143/// ---
144/// Hi {{ name }}!"#,
145/// )
146/// .unwrap();
147///
148/// let cache = TemplateCache::new();
149/// let tmpl = cache.load(&path).unwrap();
150///
151/// // Second load of same unchanged file — returns cached, zero re-parsing.
152/// let tmpl2 = cache.load(&path).unwrap();
153/// assert_eq!(tmpl.source_hash(), tmpl2.source_hash());
154/// ```
155#[derive(Clone)]
156pub struct TemplateCache<S: std::hash::BuildHasher = std::collections::hash_map::RandomState> {
157    /// Main template cache: canonical path → entry.
158    templates: Arc<RwLock<HashMap<PathBuf, CacheEntry>>>,
159    /// Include cache: canonical path → compiled include.
160    includes: Arc<RwLock<HashMap<PathBuf, IncludeCacheEntry>>>,
161    /// Hasher builder for content-addressed cache invalidation.
162    hasher: S,
163    /// Optional maximum number of entries per cache map. When set and
164    /// exceeded on insert, the least-recently-used entry is evicted.
165    max_entries: Option<usize>,
166}
167
168impl<S: std::hash::BuildHasher> std::fmt::Debug for TemplateCache<S> {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        f.debug_struct("TemplateCache")
171            .field("template_count", &self.template_count())
172            .field("include_count", &self.include_count())
173            .finish()
174    }
175}
176
177/// Cache entry for an included template file.
178#[derive(Debug, Clone)]
179struct IncludeCacheEntry {
180    source_hash: u64,
181    /// File modification time at the point the source was read.
182    last_modified: SystemTime,
183    /// Last time this entry was accessed (for LRU eviction).
184    last_accessed: Instant,
185    cached: CachedInclude,
186}
187
188impl HasLastAccessed for IncludeCacheEntry {
189    fn last_accessed(&self) -> Instant {
190        self.last_accessed
191    }
192}
193
194impl Default for TemplateCache {
195    fn default() -> Self {
196        Self::new()
197    }
198}
199
200impl TemplateCache {
201    /// Create a new empty cache using the default content hasher (`SipHash-1-3`).
202    #[must_use]
203    pub fn new() -> Self {
204        Self {
205            templates: Arc::new(RwLock::new(HashMap::new())),
206            includes: Arc::new(RwLock::new(HashMap::new())),
207            hasher: std::collections::hash_map::RandomState::new(),
208            max_entries: None,
209        }
210    }
211}
212
213impl<S: std::hash::BuildHasher> TemplateCache<S> {
214    /// Create a new empty cache with a custom [`BuildHasher`](std::hash::BuildHasher).
215    ///
216    /// The default uses `RandomState` (SipHash-1-3). Supply a
217    /// different `BuildHasher` if you need stronger collision resistance
218    /// or faster hashing (e.g. `xxHash`, `FxHash`, `AHasher`).
219    ///
220    /// # Examples
221    ///
222    /// ```
223    /// use std::{collections::hash_map::DefaultHasher, hash::BuildHasherDefault};
224    ///
225    /// use md_tmpl_core::TemplateCache;
226    ///
227    /// let cache = TemplateCache::with_hasher(BuildHasherDefault::<DefaultHasher>::default());
228    ///
229    /// let dir = tempfile::tempdir().unwrap();
230    /// let path = dir.path().join("test.tmpl.md");
231    /// std::fs::write(
232    ///     &path,
233    ///     r#"---
234    /// params: [x = str]
235    /// ---
236    /// {{ x }}"#,
237    /// )
238    /// .unwrap();
239    ///
240    /// let tmpl = cache.load(&path).unwrap();
241    /// let mut ctx = md_tmpl_core::Context::new();
242    /// ctx.set("x", "works");
243    /// assert_eq!(tmpl.render_ctx(&ctx).unwrap(), "works");
244    /// ```
245    #[must_use]
246    pub fn with_hasher(hasher: S) -> Self {
247        Self {
248            templates: Arc::new(RwLock::new(HashMap::new())),
249            includes: Arc::new(RwLock::new(HashMap::new())),
250            hasher,
251            max_entries: None,
252        }
253    }
254
255    /// Set the maximum number of entries per cache map.
256    ///
257    /// When a new entry is inserted and the cache exceeds this limit,
258    /// the least-recently-used entry is evicted. `None` (the default)
259    /// disables eviction.
260    ///
261    /// # Examples
262    ///
263    /// ```
264    /// use md_tmpl_core::TemplateCache;
265    ///
266    /// let cache = TemplateCache::new().with_max_entries(128);
267    /// ```
268    #[must_use]
269    pub fn with_max_entries(mut self, max: usize) -> Self {
270        self.max_entries = Some(max);
271        self
272    }
273
274    /// Hash a source string using this cache's hasher.
275    fn hash_content(&self, source: &str) -> u64 {
276        self.hasher.hash_one(source)
277    }
278
279    /// Load a template from file, using the cache if the source is unchanged.
280    ///
281    /// # Errors
282    ///
283    /// Returns [`TemplateError::Io`] if the file cannot be read, or
284    /// [`TemplateError::Syntax`] if compilation fails.
285    pub fn load(&self, path: &Path) -> Result<crate::Template, TemplateError> {
286        self.load_inner(path, false).map(|(tmpl, _fm)| tmpl)
287    }
288
289    /// Load a template and return frontmatter too, using the cache.
290    ///
291    /// # Errors
292    ///
293    /// Returns [`TemplateError`] on I/O or syntax errors.
294    pub fn load_with_frontmatter(
295        &self,
296        path: &Path,
297    ) -> Result<(crate::Template, Frontmatter), TemplateError> {
298        let (tmpl, fm) = self.load_inner(path, true)?;
299        // `load_inner(_, true)` always returns `Some(fm)`.
300        let fm = fm.ok_or_else(|| {
301            TemplateError::syntax("internal error: frontmatter not returned by load_inner")
302        })?;
303        Ok((tmpl, fm))
304    }
305
306    fn build_template_from_entry(
307        entry: &mut CacheEntry,
308        base_dir: Option<PathBuf>,
309        need_frontmatter: bool,
310    ) -> (crate::Template, Option<Frontmatter>) {
311        entry.last_accessed = Instant::now();
312        let tmpl = crate::Template::from_cached(crate::template::CachedTemplateData {
313            segments: entry.segments.clone(),
314            declared_variables: entry.declarations.clone(),
315            base_dir,
316            inline_templates: entry.inline_templates.clone(),
317            source_hash: entry.source_hash,
318            consts: entry.consts.clone(),
319            imported_consts: entry.imported_consts.clone(),
320            name: entry.frontmatter.name.clone(),
321            description: entry.frontmatter.description.clone(),
322        });
323        let fm = if need_frontmatter {
324            Some(entry.frontmatter.clone())
325        } else {
326            None
327        };
328        (tmpl, fm)
329    }
330
331    /// Shared implementation: stat → mtime check → (read → hash) → cache → compile → store.
332    ///
333    /// When `need_frontmatter` is false, avoids cloning the cached frontmatter.
334    fn load_inner(
335        &self,
336        path: &Path,
337        need_frontmatter: bool,
338    ) -> Result<(crate::Template, Option<Frontmatter>), TemplateError> {
339        let canonical = std::fs::canonicalize(path)?;
340        let file_mtime = std::fs::metadata(path)?
341            .modified()
342            .unwrap_or(SystemTime::UNIX_EPOCH);
343        let base_dir = path.parent().map(Path::to_path_buf);
344
345        // Fast path: if mtime matches the cached entry, skip reading the file entirely.
346        {
347            let mut cache = self
348                .templates
349                .write()
350                .unwrap_or_else(std::sync::PoisonError::into_inner);
351            if let Some(entry) = cache.get_mut(&canonical)
352                && entry.last_modified == file_mtime
353            {
354                return Ok(Self::build_template_from_entry(
355                    entry,
356                    base_dir,
357                    need_frontmatter,
358                ));
359            }
360        }
361
362        // Mtime changed (or first load) — read file and hash.
363        let source = std::fs::read_to_string(path)?;
364        let source_hash = self.hash_content(&source);
365
366        // Check if content hash still matches despite mtime change (e.g. whitespace-only save).
367        {
368            let mut cache = self
369                .templates
370                .write()
371                .unwrap_or_else(std::sync::PoisonError::into_inner);
372            if let Some(entry) = cache.get_mut(&canonical)
373                && entry.source_hash == source_hash
374            {
375                entry.last_modified = file_mtime;
376                return Ok(Self::build_template_from_entry(
377                    entry,
378                    base_dir,
379                    need_frontmatter,
380                ));
381            }
382        }
383
384        // Cache miss — compile.
385        let (fm, body) = frontmatter::parse_frontmatter(&source)?;
386        let body_str = body.to_string();
387        let (segments, inline_templates) = compiled::compile(&body_str, &fm.type_aliases)?;
388
389        let consts: HashMap<String, crate::value::Value> = fm
390            .consts
391            .iter()
392            .filter_map(|d| d.default_value.clone().map(|v| (d.name.clone(), v)))
393            .collect();
394        let consts = Arc::new(consts);
395        let imported_consts = Arc::new(fm.imported_consts.clone());
396
397        let entry = CacheEntry {
398            source_hash,
399            last_modified: file_mtime,
400            last_accessed: Instant::now(),
401            segments: Arc::from(segments),
402            declarations: Arc::from(fm.declarations.clone()),
403            inline_templates: Arc::new(inline_templates),
404            consts: consts.clone(),
405            imported_consts: imported_consts.clone(),
406            frontmatter: fm.clone(),
407        };
408
409        {
410            let mut cache = self
411                .templates
412                .write()
413                .unwrap_or_else(std::sync::PoisonError::into_inner);
414            Self::evict_lru(&mut cache, self.max_entries);
415            cache.insert(canonical, entry.clone());
416        }
417
418        let tmpl = crate::Template::from_cached(crate::template::CachedTemplateData {
419            segments: entry.segments,
420            declared_variables: entry.declarations,
421            base_dir,
422            inline_templates: entry.inline_templates,
423            source_hash,
424            consts: entry.consts,
425            imported_consts: entry.imported_consts,
426            name: entry.frontmatter.name.clone(),
427            description: entry.frontmatter.description.clone(),
428        });
429        Ok((tmpl, Some(fm)))
430    }
431
432    /// Resolve an include from cache or compile it from disk.
433    ///
434    /// Called during rendering — avoids re-reading and re-compiling
435    /// included template files that haven't changed.
436    fn resolve_include_impl(&self, include_path: &Path) -> Result<CachedInclude, TemplateError> {
437        let canonical = std::fs::canonicalize(include_path).map_err(|err| {
438            TemplateError::IncludeNotFound(format!("{}: {err}", include_path.display()))
439        })?;
440
441        let file_mtime = std::fs::metadata(include_path)
442            .and_then(|m| m.modified())
443            .unwrap_or(SystemTime::UNIX_EPOCH);
444
445        // Fast path: mtime match → skip reading the file.
446        {
447            let mut cache = self
448                .includes
449                .write()
450                .unwrap_or_else(std::sync::PoisonError::into_inner);
451            if let Some(entry) = cache.get_mut(&canonical)
452                && entry.last_modified == file_mtime
453            {
454                entry.last_accessed = Instant::now();
455                return Ok(entry.cached.clone());
456            }
457        }
458
459        // Mtime changed — read and hash.
460        let source = std::fs::read_to_string(include_path).map_err(|err| {
461            TemplateError::IncludeNotFound(format!("{}: {err}", include_path.display()))
462        })?;
463        let source_hash = self.hash_content(&source);
464
465        // Content hash still matches despite mtime change?
466        {
467            let mut cache = self
468                .includes
469                .write()
470                .unwrap_or_else(std::sync::PoisonError::into_inner);
471            if let Some(entry) = cache.get_mut(&canonical)
472                && entry.source_hash == source_hash
473            {
474                entry.last_modified = file_mtime;
475                entry.last_accessed = Instant::now();
476                return Ok(entry.cached.clone());
477            }
478        }
479
480        // Cache miss — compile.
481        let (fm, body) = frontmatter::parse_frontmatter(&source)?;
482        let (segments, _inline_templates) = compiled::compile(body, &fm.type_aliases)?;
483        let base_dir = include_path
484            .parent()
485            .unwrap_or_else(|| Path::new("."))
486            .to_path_buf();
487
488        let cached = CachedInclude {
489            segments: Arc::from(segments),
490            declarations: Arc::from(fm.declarations),
491            base_dir,
492        };
493
494        {
495            let mut cache = self
496                .includes
497                .write()
498                .unwrap_or_else(std::sync::PoisonError::into_inner);
499            Self::evict_lru(&mut cache, self.max_entries);
500            cache.insert(
501                canonical,
502                IncludeCacheEntry {
503                    source_hash,
504                    last_modified: file_mtime,
505                    last_accessed: Instant::now(),
506                    cached: cached.clone(),
507                },
508            );
509        }
510
511        Ok(cached)
512    }
513
514    /// Invalidate all cached entries (e.g. after a bulk file update).
515    pub fn clear(&self) {
516        self.templates
517            .write()
518            .unwrap_or_else(std::sync::PoisonError::into_inner)
519            .clear();
520        self.includes
521            .write()
522            .unwrap_or_else(std::sync::PoisonError::into_inner)
523            .clear();
524    }
525
526    /// Number of cached main templates.
527    #[must_use]
528    pub fn template_count(&self) -> usize {
529        self.templates
530            .read()
531            .unwrap_or_else(std::sync::PoisonError::into_inner)
532            .len()
533    }
534
535    /// Number of cached include templates.
536    #[must_use]
537    pub fn include_count(&self) -> usize {
538        self.includes
539            .read()
540            .unwrap_or_else(std::sync::PoisonError::into_inner)
541            .len()
542    }
543
544    /// Evict the oldest entries when the cache exceeds `max_entries`.
545    ///
546    /// Amortised: evicts down to 75% capacity in a single pass, so the
547    /// O(n·log n) sort runs infrequently rather than on every insert.
548    fn evict_lru<V: HasLastAccessed>(cache: &mut HashMap<PathBuf, V>, max_entries: Option<usize>) {
549        let Some(max) = max_entries else { return };
550        if cache.len() < max {
551            return;
552        }
553        // Target: keep 75% of max (at least 1).
554        let keep = (max * 3 / 4).max(1);
555        let evict_count = cache.len().saturating_sub(keep);
556        if evict_count == 0 {
557            return;
558        }
559        // Collect and sort by last_accessed (oldest first).
560        let mut entries: Vec<_> = cache
561            .iter()
562            .map(|(k, v)| (k.clone(), v.last_accessed()))
563            .collect();
564        entries.sort_unstable_by_key(|(_, t)| *t);
565        // Remove the oldest `evict_count` entries.
566        for (key, _) in entries.into_iter().take(evict_count) {
567            cache.remove(&key);
568        }
569    }
570}
571
572impl<S: std::hash::BuildHasher + Send + Sync> IncludeResolver for TemplateCache<S> {
573    fn resolve_include(&self, path: &Path) -> Result<CachedInclude, TemplateError> {
574        self.resolve_include_impl(path)
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use std::sync::atomic::AtomicUsize;
581
582    use super::*;
583
584    #[test]
585    fn cache_returns_same_template_for_unchanged_file() {
586        let dir = tempfile::tempdir().unwrap();
587        let path = dir.path().join("test.tmpl.md");
588        std::fs::write(
589            &path,
590            r"---
591params: [name = str]
592---
593Hello {{ name }}!",
594        )
595        .unwrap();
596
597        let cache = TemplateCache::new();
598        let t1 = cache.load(&path).unwrap();
599        let t2 = cache.load(&path).unwrap();
600
601        assert_eq!(t1.source_hash(), t2.source_hash());
602        assert_eq!(cache.template_count(), 1);
603    }
604
605    #[test]
606    fn cache_recompiles_on_file_change() {
607        let dir = tempfile::tempdir().unwrap();
608        let path = dir.path().join("test.tmpl.md");
609        std::fs::write(
610            &path,
611            r"---
612params: [name = str]
613---
614Hello {{ name }}!",
615        )
616        .unwrap();
617
618        let cache = TemplateCache::new();
619        let t1 = cache.load(&path).unwrap();
620
621        std::fs::write(
622            &path,
623            r"---
624params: [name = str]
625---
626Goodbye {{ name }}!",
627        )
628        .unwrap();
629        let t2 = cache.load(&path).unwrap();
630
631        assert_ne!(t1.source_hash(), t2.source_hash());
632        assert_eq!(cache.template_count(), 1); // same path, entry replaced
633    }
634
635    #[test]
636    fn cache_clear_invalidates_all() {
637        let dir = tempfile::tempdir().unwrap();
638        let path = dir.path().join("test.tmpl.md");
639        std::fs::write(
640            &path,
641            r"---
642params: []
643---
644Hi",
645        )
646        .unwrap();
647
648        let cache = TemplateCache::new();
649        cache.load(&path).unwrap();
650        assert_eq!(cache.template_count(), 1);
651
652        cache.clear();
653        assert_eq!(cache.template_count(), 0);
654    }
655
656    #[test]
657    fn include_cache_avoids_recompile() {
658        let dir = tempfile::tempdir().unwrap();
659        let path = dir.path().join("header.tmpl.md");
660        std::fs::write(
661            &path,
662            r"---
663name: header
664params: []
665---
666# Header",
667        )
668        .unwrap();
669
670        let cache = TemplateCache::new();
671        let c1 = cache.resolve_include(&path).unwrap();
672        let c2 = cache.resolve_include(&path).unwrap();
673
674        assert_eq!(c1.segments.len(), c2.segments.len());
675        assert_eq!(cache.include_count(), 1);
676    }
677
678    #[test]
679    fn load_with_frontmatter_caches() {
680        let dir = tempfile::tempdir().unwrap();
681        let path = dir.path().join("fm.tmpl.md");
682        std::fs::write(
683            &path,
684            r"---
685name: test
686params: [x = str]
687---
688{{ x }}",
689        )
690        .unwrap();
691
692        let cache = TemplateCache::new();
693        let (t1, fm1) = cache.load_with_frontmatter(&path).unwrap();
694        let (t2, fm2) = cache.load_with_frontmatter(&path).unwrap();
695
696        assert_eq!(t1.source_hash(), t2.source_hash());
697        assert_eq!(fm1.name, fm2.name);
698        assert_eq!(cache.template_count(), 1);
699    }
700
701    #[test]
702    fn render_cached_with_include() {
703        let dir = tempfile::tempdir().unwrap();
704
705        // Create a main template that includes a header.
706        std::fs::write(
707            dir.path().join("header.tmpl.md"),
708            r"---
709name: header
710params: [title = str]
711---
712# {{ title }}",
713        )
714        .unwrap();
715        let main_path = dir.path().join("main.tmpl.md");
716        std::fs::write(
717            &main_path,
718            r"---
719params: [title = str]
720---
721> {% include [header](./header.tmpl.md) with title=title %}
722
723Body",
724        )
725        .unwrap();
726
727        let cache = TemplateCache::new();
728        let tmpl = cache.load(&main_path).unwrap();
729
730        let mut ctx = crate::Context::new();
731        ctx.set("title", "Hello");
732
733        // First render — compiles include from disk.
734        let output1 = tmpl.render_ctx_cached(&ctx, &cache).unwrap();
735        assert!(output1.contains("# Hello"));
736        assert!(output1.contains("Body"));
737        assert_eq!(cache.include_count(), 1);
738
739        // Second render — include resolved from cache.
740        let output2 = tmpl.render_ctx_cached(&ctx, &cache).unwrap();
741        assert_eq!(output1, output2);
742        assert_eq!(cache.include_count(), 1); // same entry, no new compilation
743    }
744
745    #[test]
746    fn with_hasher_custom_builder() {
747        use std::hash::BuildHasherDefault;
748
749        // Use a deterministic DefaultHasher via BuildHasherDefault.
750        let cache = TemplateCache::with_hasher(BuildHasherDefault::<
751            std::collections::hash_map::DefaultHasher,
752        >::default());
753
754        let dir = tempfile::tempdir().unwrap();
755        let path = dir.path().join("custom.tmpl.md");
756        std::fs::write(
757            &path,
758            r"---
759params: [x = str]
760---
761{{ x }}",
762        )
763        .unwrap();
764
765        let tmpl = cache.load(&path).unwrap();
766        let mut ctx = crate::Context::new();
767        ctx.set("x", "works");
768        assert_eq!(tmpl.render_ctx(&ctx).unwrap(), "works");
769
770        // Cached reload works.
771        let tmpl2 = cache.load(&path).unwrap();
772        assert_eq!(tmpl.source_hash(), tmpl2.source_hash());
773    }
774
775    #[test]
776    fn eviction_removes_lru_entry() {
777        let cache = TemplateCache::new().with_max_entries(2);
778        let dir = tempfile::tempdir().unwrap();
779
780        let path_a = dir.path().join("a.tmpl.md");
781        let path_b = dir.path().join("b.tmpl.md");
782        let path_c = dir.path().join("c.tmpl.md");
783        std::fs::write(
784            &path_a,
785            "\
786---
787
788params: []
789---
790A",
791        )
792        .unwrap();
793        std::fs::write(
794            &path_b,
795            "\
796---
797
798params: []
799---
800B",
801        )
802        .unwrap();
803        std::fs::write(
804            &path_c,
805            "\
806---
807
808params: []
809---
810C",
811        )
812        .unwrap();
813
814        cache.load(&path_a).unwrap();
815        cache.load(&path_b).unwrap();
816        assert_eq!(cache.template_count(), 2);
817
818        // Loading C should evict the LRU entry (A), keeping count at 2.
819        cache.load(&path_c).unwrap();
820        assert_eq!(cache.template_count(), 2);
821    }
822
823    #[test]
824    fn no_eviction_when_max_entries_is_none() {
825        let cache = TemplateCache::new();
826        let dir = tempfile::tempdir().unwrap();
827
828        for i in 0..10 {
829            let path = dir.path().join(format!("{i}.tmpl.md"));
830            std::fs::write(
831                &path,
832                format!(
833                    "---
834params: []
835---
836{i}"
837                ),
838            )
839            .unwrap();
840            cache.load(&path).unwrap();
841        }
842        assert_eq!(cache.template_count(), 10);
843    }
844
845    /// Helper for [`concurrent_load_render_clear`]: loader thread logic.
846    fn run_loader_thread(
847        cache: &TemplateCache,
848        path: &std::path::Path,
849        successful_loads: &AtomicUsize,
850    ) {
851        use std::sync::atomic::Ordering;
852        if let Ok(tmpl) = cache.load(path) {
853            // Verify the loaded template is functional.
854            assert!(
855                !tmpl.declarations().is_empty(),
856                "loaded template must have declarations"
857            );
858            successful_loads.fetch_add(1, Ordering::Relaxed);
859        }
860        // Err is acceptable — clear() may have raced.
861    }
862
863    /// Helper for [`concurrent_load_render_clear`]: renderer thread logic.
864    fn run_renderer_thread(
865        cache: &TemplateCache,
866        path: &std::path::Path,
867        expected_idx: usize,
868        successful_renders: &AtomicUsize,
869    ) {
870        use std::sync::atomic::Ordering;
871        if let Ok(tmpl) = cache.load(path) {
872            let mut ctx = crate::Context::new();
873            ctx.set("x", "hello");
874            if let Ok(output) = tmpl.render_ctx_cached(&ctx, cache) {
875                assert!(
876                    output.contains("hello"),
877                    "rendered output must contain 'hello', got: {output}"
878                );
879                assert!(
880                    output.contains(&format!("template{expected_idx}")),
881                    "rendered output must contain template index, got: {output}"
882                );
883                successful_renders.fetch_add(1, Ordering::Relaxed);
884            }
885        }
886    }
887
888    /// Helper for [`concurrent_load_render_clear`]: clear thread logic.
889    fn run_clear_thread(
890        cache: &TemplateCache,
891        path: &std::path::Path,
892        round: usize,
893        successful_loads: &AtomicUsize,
894    ) {
895        use std::sync::atomic::Ordering;
896        if round % 5 == 0 {
897            cache.clear();
898        }
899        // Load after clear to verify cache rebuilds correctly.
900        if let Ok(tmpl) = cache.load(path) {
901            assert!(
902                !tmpl.declarations().is_empty(),
903                "reloaded template must have declarations"
904            );
905            successful_loads.fetch_add(1, Ordering::Relaxed);
906        }
907    }
908
909    /// Helper for [`concurrent_load_render_clear`]: reader thread logic.
910    fn run_reader_thread(
911        cache: &TemplateCache,
912        path: &std::path::Path,
913        paths_len: usize,
914        successful_loads: &AtomicUsize,
915    ) {
916        use std::sync::atomic::Ordering;
917        // Counts must be non-negative and bounded.
918        let tc = cache.template_count();
919        let ic = cache.include_count();
920        assert!(tc <= paths_len, "template count {tc} exceeds file count");
921        assert!(ic <= 100, "include count {ic} unexpectedly large");
922        if let Ok(tmpl) = cache.load(path) {
923            assert!(
924                !tmpl.declarations().is_empty(),
925                "loaded template must have declarations"
926            );
927            successful_loads.fetch_add(1, Ordering::Relaxed);
928        }
929    }
930
931    /// Stress-test `TemplateCache` under concurrent access.
932    ///
933    /// Spawns 8 threads that simultaneously `load`, `render_ctx_cached`,
934    /// `clear`, and query `template_count` / `include_count` in a tight
935    /// loop. The test verifies:
936    ///
937    /// - No panics (locks are never poisoned).
938    /// - No deadlocks (all threads join within the timeout).
939    /// - Rendered output is correct when rendering succeeds.
940    #[test]
941    fn concurrent_load_render_clear() {
942        use std::sync::{
943            Arc, Barrier,
944            atomic::{AtomicUsize, Ordering},
945        };
946
947        const NUM_THREADS: usize = 8;
948        const ROUNDS_PER_THREAD: usize = 50;
949
950        let dir = tempfile::tempdir().unwrap();
951
952        // Create several template files that threads will load concurrently.
953        let mut paths = Vec::new();
954        for i in 0..4 {
955            let path = dir.path().join(format!("t{i}.tmpl.md"));
956            std::fs::write(
957                &path,
958                format!(
959                    "---
960params: [x = str]
961---
962template{i}: {{{{ x }}}}"
963                ),
964            )
965            .unwrap();
966            paths.push(path);
967        }
968
969        let cache = Arc::new(TemplateCache::new());
970        let paths = Arc::new(paths);
971        let barrier = Arc::new(Barrier::new(NUM_THREADS));
972        let successful_loads = Arc::new(AtomicUsize::new(0));
973        let successful_renders = Arc::new(AtomicUsize::new(0));
974
975        let handles: Vec<_> = (0..NUM_THREADS)
976            .map(|thread_id| {
977                let cache = Arc::clone(&cache);
978                let paths = Arc::clone(&paths);
979                let barrier = Arc::clone(&barrier);
980                let successful_loads = Arc::clone(&successful_loads);
981                let successful_renders = Arc::clone(&successful_renders);
982                std::thread::spawn(move || {
983                    // All threads start simultaneously.
984                    barrier.wait();
985
986                    for round in 0..ROUNDS_PER_THREAD {
987                        let path = &paths[round % paths.len()];
988                        let expected_idx = round % paths.len();
989
990                        match thread_id % 4 {
991                            0 => run_loader_thread(&cache, path, &successful_loads),
992                            1 => {
993                                run_renderer_thread(
994                                    &cache,
995                                    path,
996                                    expected_idx,
997                                    &successful_renders,
998                                );
999                            }
1000                            2 => run_clear_thread(&cache, path, round, &successful_loads),
1001                            _ => run_reader_thread(&cache, path, paths.len(), &successful_loads),
1002                        }
1003                    }
1004                })
1005            })
1006            .collect();
1007
1008        // Join all threads — a hang here would indicate a deadlock.
1009        for handle in handles {
1010            handle.join().expect("thread must not panic");
1011        }
1012
1013        // At least some loads and renders must have succeeded.
1014        let loads = successful_loads.load(Ordering::Relaxed);
1015        let renders = successful_renders.load(Ordering::Relaxed);
1016        assert!(loads > 0, "no loads succeeded across {NUM_THREADS} threads");
1017        assert!(
1018            renders > 0,
1019            "no renders succeeded across {NUM_THREADS} threads"
1020        );
1021    }
1022}