Skip to main content

modo/email/
cache.rs

1use crate::Result;
2use crate::cache::LruCache;
3use std::num::NonZeroUsize;
4use std::sync::Mutex;
5
6use crate::email::source::TemplateSource;
7
8/// LRU-cached wrapper around any [`TemplateSource`].
9///
10/// On the first call for a given `(name, locale, default_locale)` triple the
11/// inner source is queried and the result is stored in the cache. Subsequent
12/// calls with the same triple return the cached value without touching the
13/// inner source.
14///
15/// The cache is bounded by `capacity` entries. When full, the least-recently
16/// used entry is evicted.
17pub struct CachedSource<S: TemplateSource> {
18    inner: S,
19    cache: Mutex<LruCache<(String, String, String), String>>,
20}
21
22impl<S: TemplateSource> CachedSource<S> {
23    /// Create a new `CachedSource` wrapping `inner` with the given LRU `capacity`.
24    ///
25    /// A `capacity` of `0` is treated as `1` to avoid a panic.
26    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    /// A mock source that counts how many times load() is called.
75    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        // First load — cache miss
111        let result = cached.load("welcome", "en", "en").unwrap();
112        assert_eq!(result, "content");
113        assert_eq!(calls.load(Ordering::SeqCst), 1);
114
115        // Second load — cache hit
116        let result = cached.load("welcome", "en", "en").unwrap();
117        assert_eq!(result, "content");
118        assert_eq!(calls.load(Ordering::SeqCst), 1); // not incremented
119    }
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); // capacity of 1
140
141        cached.load("a", "en", "en").unwrap();
142        assert_eq!(calls.load(Ordering::SeqCst), 1);
143
144        cached.load("b", "en", "en").unwrap(); // evicts "a"
145        assert_eq!(calls.load(Ordering::SeqCst), 2);
146
147        cached.load("a", "en", "en").unwrap(); // cache miss again
148        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        // capacity 0 should not panic, falls back to 1
167        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        // Different default_locale → separate cache entries → 2 inner calls
182        assert_eq!(calls.load(Ordering::SeqCst), 2);
183    }
184}