1use crate::file::FileStore;
17use crate::memory::MemoryStore;
18use crate::redis::{RedisConfig, RedisStore};
19use crate::store::Cache;
20use rustlavel_core::{Config, Error, Result};
21use std::path::PathBuf;
22use std::sync::Arc;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Driver {
27 Memory,
30 File,
32 Redis,
34}
35
36impl Driver {
37 pub fn parse(name: &str) -> Result<Driver> {
42 match name.trim().to_ascii_lowercase().as_str() {
43 "memory" | "array" => Ok(Driver::Memory),
44 "file" => Ok(Driver::File),
45 "redis" => Ok(Driver::Redis),
46 other => Err(Error::msg(format!(
47 "`{other}` is not a cache driver. Set cache.driver to one of: memory, file, redis."
48 ))),
49 }
50 }
51
52 pub fn name(&self) -> &'static str {
53 match self {
54 Driver::Memory => "memory",
55 Driver::File => "file",
56 Driver::Redis => "redis",
57 }
58 }
59}
60
61#[derive(Debug, Clone)]
62pub struct CacheConfig {
63 pub driver: Driver,
64 pub path: PathBuf,
66 pub url: String,
68 pub prefix: String,
71 pub sweep_interval: std::time::Duration,
73}
74
75impl Default for CacheConfig {
76 fn default() -> Self {
77 CacheConfig {
78 driver: Driver::Memory,
79 path: PathBuf::from("storage/cache"),
84 url: String::new(),
85 prefix: String::new(),
86 sweep_interval: std::time::Duration::from_secs(60),
87 }
88 }
89}
90
91impl CacheConfig {
92 pub fn from_app_config(config: &Config) -> Result<Self> {
94 Ok(CacheConfig {
95 driver: Driver::parse(&config.string("cache.driver", "memory"))?,
96 path: PathBuf::from(config.string("cache.path", "storage/cache")),
97 url: config.string("cache.url", ""),
98 prefix: config.string("cache.prefix", ""),
99 ..CacheConfig::default()
100 })
101 }
102}
103
104#[derive(Clone)]
110pub struct CacheStore {
111 inner: Arc<dyn Cache>,
112}
113
114impl CacheStore {
115 pub fn from_config(config: &Config) -> Result<Self> {
121 CacheStore::build(&CacheConfig::from_app_config(config)?)
122 }
123
124 pub fn build(settings: &CacheConfig) -> Result<Self> {
125 let store: Arc<dyn Cache> = match settings.driver {
126 Driver::Memory => Arc::new(MemoryStore::with_options(
127 settings.prefix.clone(),
128 settings.sweep_interval,
129 )),
130 Driver::File => {
131 Arc::new(FileStore::with_prefix(&settings.path, settings.prefix.clone())?)
132 }
133 Driver::Redis => {
134 let redis = if settings.url.is_empty() {
135 RedisConfig::from_app_config(&Config::new())?
136 } else {
137 RedisConfig::from_url(&settings.url)?
138 };
139 Arc::new(RedisStore::new(redis, settings.prefix.clone()))
140 }
141 };
142
143 Ok(CacheStore { inner: store })
144 }
145
146 pub fn from_driver(store: impl Cache) -> Self {
148 CacheStore { inner: Arc::new(store) }
149 }
150
151 pub fn driver_handle(&self) -> Arc<dyn Cache> {
153 Arc::clone(&self.inner)
154 }
155
156 pub async fn verify(&self) -> Result<()> {
158 let key = "__rustlavel_cache_probe";
159 self.put(key, rustlavel_core::Json::from(1), std::time::Duration::from_secs(5)).await?;
160 self.forget(key).await?;
161 Ok(())
162 }
163}
164
165impl Cache for CacheStore {
168 fn driver(&self) -> &'static str {
169 self.inner.driver()
170 }
171
172 fn get<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<Option<rustlavel_core::Json>>> {
173 self.inner.get(key)
174 }
175
176 fn put<'a>(
177 &'a self,
178 key: &'a str,
179 value: rustlavel_core::Json,
180 ttl: std::time::Duration,
181 ) -> crate::store::BoxFuture<'a, Result<()>> {
182 self.inner.put(key, value, ttl)
183 }
184
185 fn forever<'a>(
186 &'a self,
187 key: &'a str,
188 value: rustlavel_core::Json,
189 ) -> crate::store::BoxFuture<'a, Result<()>> {
190 self.inner.forever(key, value)
191 }
192
193 fn forget<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<bool>> {
194 self.inner.forget(key)
195 }
196
197 fn flush(&self) -> crate::store::BoxFuture<'_, Result<()>> {
198 self.inner.flush()
199 }
200
201 fn has<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<bool>> {
202 self.inner.has(key)
203 }
204
205 fn increment<'a>(&'a self, key: &'a str, by: i64) -> crate::store::BoxFuture<'a, Result<i64>> {
206 self.inner.increment(key, by)
207 }
208
209 fn decrement<'a>(&'a self, key: &'a str, by: i64) -> crate::store::BoxFuture<'a, Result<i64>> {
210 self.inner.decrement(key, by)
211 }
212
213 fn increment_within<'a>(
214 &'a self,
215 key: &'a str,
216 by: i64,
217 ttl: std::time::Duration,
218 ) -> crate::store::BoxFuture<'a, Result<i64>> {
219 self.inner.increment_within(key, by, ttl)
220 }
221
222 fn ttl<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<Option<std::time::Duration>>> {
223 self.inner.ttl(key)
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::store::CacheExt;
231 use rustlavel_core::Json;
232 use std::time::Duration;
233
234 #[test]
235 fn a_driver_typo_names_the_valid_choices() {
236 let error = Driver::parse("redsi").unwrap_err().to_string();
237 assert!(error.contains("memory, file, redis"), "got: {error}");
238 }
239
240 #[test]
241 fn driver_names_are_case_insensitive_and_array_means_memory() {
242 assert_eq!(Driver::parse("Redis").unwrap(), Driver::Redis);
243 assert_eq!(Driver::parse(" file ").unwrap(), Driver::File);
244 assert_eq!(Driver::parse("array").unwrap(), Driver::Memory);
246 }
247
248 #[tokio::test]
249 async fn the_factory_defaults_to_the_memory_driver() {
250 let store = CacheStore::from_config(&Config::new()).unwrap();
251
252 assert_eq!(store.driver(), "memory");
253 store.forever("k", Json::from(1)).await.unwrap();
254 assert_eq!(store.get("k").await.unwrap(), Some(Json::from(1)));
255 }
256
257 #[tokio::test]
258 async fn the_factory_builds_the_file_driver_at_the_configured_path() {
259 let directory = std::env::temp_dir()
260 .join(format!("rustlavel-cache-factory-{}", std::process::id()));
261 let _ = std::fs::remove_dir_all(&directory);
262
263 let config = Config::new();
264 config.set("cache.driver", "file");
265 config.set("cache.path", directory.to_string_lossy().to_string());
266
267 let store = CacheStore::from_config(&config).unwrap();
268 assert_eq!(store.driver(), "file");
269
270 store.forever("on-disk", Json::from("yes")).await.unwrap();
271 assert!(directory.exists());
272 assert_eq!(store.get("on-disk").await.unwrap(), Some(Json::from("yes")));
273
274 let _ = std::fs::remove_dir_all(&directory);
275 }
276
277 #[test]
278 fn the_factory_builds_the_redis_driver_without_connecting() {
279 let config = Config::new();
280 config.set("cache.driver", "redis");
281 config.set("cache.url", "redis://127.0.0.1:1/0");
282
283 let store = CacheStore::from_config(&config).unwrap();
286 assert_eq!(store.driver(), "redis");
287 }
288
289 #[test]
290 fn a_malformed_redis_url_is_refused_at_boot() {
291 let config = Config::new();
292 config.set("cache.driver", "redis");
293 config.set("cache.url", "http://not-redis");
294
295 assert!(CacheStore::from_config(&config).is_err());
296 }
297
298 #[tokio::test]
299 async fn the_configured_prefix_reaches_the_driver() {
300 let config = Config::new();
301 config.set("cache.prefix", "tenant-a:");
302 let prefixed = CacheStore::from_config(&config).unwrap();
303
304 let bare = CacheStore::from_driver(MemoryStore::new());
305
306 prefixed.forever("who", Json::from("a")).await.unwrap();
307 assert_eq!(bare.get("who").await.unwrap(), None);
309 assert_eq!(prefixed.get("who").await.unwrap(), Some(Json::from("a")));
310 }
311
312 #[tokio::test]
313 async fn a_store_handle_supports_the_full_cache_api_including_remember() {
314 let store = CacheStore::from_driver(MemoryStore::new());
315
316 let value = store
317 .remember("expensive", Duration::from_secs(60), || async { Ok(Json::from(99)) })
318 .await
319 .unwrap();
320
321 assert_eq!(value, Json::from(99));
322 assert_eq!(store.pull("expensive").await.unwrap(), Some(Json::from(99)));
323 assert!(!store.has("expensive").await.unwrap());
324 store.verify().await.unwrap();
325 }
326}