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