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