1use 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#[derive(Debug, Clone)]
36pub(crate) struct CachedInclude {
37 pub segments: Arc<[Segment]>,
39 pub declarations: Arc<[VarDecl]>,
41 pub base_dir: PathBuf,
43 pub consts: HashMap<String, Value>,
45 pub imported_consts: HashMap<String, Value>,
47}
48
49pub(crate) fn hash_source(source: &str) -> u64 {
55 crate::__private::fnv1a_hash(source.as_bytes())
56}
57
58#[derive(Debug, Clone)]
60struct CacheEntry {
61 source_hash: u64,
63 last_modified: SystemTime,
65 last_accessed: Instant,
67 segments: Arc<[Segment]>,
69 declarations: Arc<[VarDecl]>,
71 inline_templates: Arc<HashMap<String, CompiledInlineTemplate>>,
73 consts: Arc<HashMap<String, crate::value::Value>>,
75 imported_consts: Arc<HashMap<String, crate::value::Value>>,
77 frontmatter: Frontmatter,
79}
80
81trait 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
92pub(crate) trait IncludeResolver: Send + Sync {
126 fn resolve_include(&self, path: &Path) -> Result<CachedInclude, TemplateError>;
127}
128
129#[derive(Clone)]
161pub struct TemplateCache<S: std::hash::BuildHasher = std::collections::hash_map::RandomState> {
162 templates: Arc<RwLock<HashMap<PathBuf, CacheEntry>>>,
164 includes: Arc<RwLock<HashMap<PathBuf, IncludeCacheEntry>>>,
166 hasher: S,
168 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#[derive(Debug, Clone)]
184struct IncludeCacheEntry {
185 source_hash: u64,
186 last_modified: SystemTime,
188 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 #[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 #[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 #[must_use]
274 pub fn with_max_entries(mut self, max: usize) -> Self {
275 self.max_entries = Some(max);
276 self
277 }
278
279 fn hash_content(&self, source: &str) -> u64 {
281 self.hasher.hash_one(source)
282 }
283
284 pub fn load(&self, path: &Path) -> Result<crate::Template, TemplateError> {
291 self.load_inner(path, false).map(|(tmpl, _fm)| tmpl)
292 }
293
294 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 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 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 {
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 let source = std::fs::read_to_string(path)?;
369 let source_hash = self.hash_content(&source);
370
371 {
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 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 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 {
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 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 {
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 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 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 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 #[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 #[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 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 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 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 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); }
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 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 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 let output2 = tmpl.render_ctx_cached(&ctx, &cache).unwrap();
763 assert_eq!(output1, output2);
764 assert_eq!(cache.include_count(), 1); }
766
767 #[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 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 let out2 = tmpl.render_ctx_cached(&ctx, &cache).unwrap();
810 assert_eq!(out1, out2, "cached render must match uncached render");
811 }
812
813 #[test]
817 fn cached_include_preserves_imported_consts() {
818 let dir = tempfile::tempdir().unwrap();
819
820 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 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 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 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 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 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 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 fn run_loader_thread(
981 cache: &TemplateCache,
982 path: &std::path::Path,
983 successful_loads: &AtomicUsize,
984 ) {
985 use std::sync::atomic::Ordering;
986 if let Ok(tmpl) = cache.load(path) {
988 assert!(
990 !tmpl.declarations().is_empty(),
991 "loaded template must have declarations"
992 );
993 successful_loads.fetch_add(1, Ordering::Relaxed);
994 }
995 }
997
998 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 if let Ok(tmpl) = cache.load(path) {
1008 let mut ctx = crate::Context::new();
1009 ctx.set("x", "hello");
1010 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 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 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 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 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 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 #[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 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 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 for handle in handles {
1149 handle.join().expect("thread must not panic");
1150 }
1151
1152 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}