1use crate::Result;
2use crate::cache::LruCache;
3use std::num::NonZeroUsize;
4use std::sync::Mutex;
5
6use crate::email::source::TemplateSource;
7
8pub struct CachedSource<S: TemplateSource> {
18 inner: S,
19 cache: Mutex<LruCache<(String, String, String), String>>,
20}
21
22impl<S: TemplateSource> CachedSource<S> {
23 pub fn new(inner: S, capacity: usize) -> Self {
27 let cap = NonZeroUsize::new(capacity).unwrap_or(NonZeroUsize::new(1).unwrap());
28 Self {
29 inner,
30 cache: Mutex::new(LruCache::new(cap)),
31 }
32 }
33}
34
35impl<S: TemplateSource> TemplateSource for CachedSource<S> {
36 fn load(&self, name: &str, locale: &str, default_locale: &str) -> Result<String> {
37 let key = (
38 name.to_string(),
39 locale.to_string(),
40 default_locale.to_string(),
41 );
42
43 {
44 let mut cache = self
45 .cache
46 .lock()
47 .expect("email template cache lock poisoned");
48 if let Some(cached) = cache.get(&key) {
49 return Ok(cached.clone());
50 }
51 }
52
53 let content = self.inner.load(name, locale, default_locale)?;
54
55 {
56 let mut cache = self
57 .cache
58 .lock()
59 .expect("email template cache lock poisoned");
60 cache.put(key, content.clone());
61 }
62
63 Ok(content)
64 }
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70 use std::collections::HashMap;
71 use std::sync::Arc;
72 use std::sync::atomic::{AtomicUsize, Ordering};
73
74 struct CountingSource {
76 calls: Arc<AtomicUsize>,
77 templates: HashMap<String, String>,
78 }
79
80 impl CountingSource {
81 fn new(templates: HashMap<String, String>) -> (Self, Arc<AtomicUsize>) {
82 let calls = Arc::new(AtomicUsize::new(0));
83 (
84 Self {
85 calls: calls.clone(),
86 templates,
87 },
88 calls,
89 )
90 }
91 }
92
93 impl TemplateSource for CountingSource {
94 fn load(&self, name: &str, _locale: &str, _default_locale: &str) -> Result<String> {
95 self.calls.fetch_add(1, Ordering::SeqCst);
96 self.templates
97 .get(name)
98 .cloned()
99 .ok_or_else(|| crate::Error::not_found(format!("not found: {name}")))
100 }
101 }
102
103 #[test]
104 fn cache_hit_avoids_inner_call() {
105 let mut templates = HashMap::new();
106 templates.insert("welcome".into(), "content".into());
107 let (source, calls) = CountingSource::new(templates);
108 let cached = CachedSource::new(source, 10);
109
110 let result = cached.load("welcome", "en", "en").unwrap();
112 assert_eq!(result, "content");
113 assert_eq!(calls.load(Ordering::SeqCst), 1);
114
115 let result = cached.load("welcome", "en", "en").unwrap();
117 assert_eq!(result, "content");
118 assert_eq!(calls.load(Ordering::SeqCst), 1); }
120
121 #[test]
122 fn cache_different_locales_are_separate_entries() {
123 let mut templates = HashMap::new();
124 templates.insert("welcome".into(), "content".into());
125 let (source, calls) = CountingSource::new(templates);
126 let cached = CachedSource::new(source, 10);
127
128 cached.load("welcome", "en", "en").unwrap();
129 cached.load("welcome", "uk", "en").unwrap();
130 assert_eq!(calls.load(Ordering::SeqCst), 2);
131 }
132
133 #[test]
134 fn cache_eviction_on_capacity() {
135 let mut templates = HashMap::new();
136 templates.insert("a".into(), "content_a".into());
137 templates.insert("b".into(), "content_b".into());
138 let (source, calls) = CountingSource::new(templates);
139 let cached = CachedSource::new(source, 1); cached.load("a", "en", "en").unwrap();
142 assert_eq!(calls.load(Ordering::SeqCst), 1);
143
144 cached.load("b", "en", "en").unwrap(); assert_eq!(calls.load(Ordering::SeqCst), 2);
146
147 cached.load("a", "en", "en").unwrap(); assert_eq!(calls.load(Ordering::SeqCst), 3);
149 }
150
151 #[test]
152 fn cache_propagates_errors() {
153 let templates = HashMap::new();
154 let (source, _) = CountingSource::new(templates);
155 let cached = CachedSource::new(source, 10);
156
157 let result = cached.load("missing", "en", "en");
158 assert!(result.is_err());
159 }
160
161 #[test]
162 fn cache_capacity_zero_uses_one() {
163 let mut templates = HashMap::new();
164 templates.insert("a".into(), "content".into());
165 let (source, _) = CountingSource::new(templates);
166 let cached = CachedSource::new(source, 0);
168 let result = cached.load("a", "en", "en").unwrap();
169 assert_eq!(result, "content");
170 }
171
172 #[test]
173 fn cache_different_default_locales_are_separate() {
174 let mut templates = HashMap::new();
175 templates.insert("t".into(), "content".into());
176 let (source, calls) = CountingSource::new(templates);
177 let cached = CachedSource::new(source, 10);
178
179 cached.load("t", "fr", "en").unwrap();
180 cached.load("t", "fr", "de").unwrap();
181 assert_eq!(calls.load(Ordering::SeqCst), 2);
183 }
184}