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/framework/cache"),
80 url: String::new(),
81 prefix: String::new(),
82 sweep_interval: std::time::Duration::from_secs(60),
83 }
84 }
85}
86
87impl CacheConfig {
88 pub fn from_app_config(config: &Config) -> Result<Self> {
90 Ok(CacheConfig {
91 driver: Driver::parse(&config.string("cache.driver", "memory"))?,
92 path: PathBuf::from(config.string("cache.path", "storage/framework/cache")),
93 url: config.string("cache.url", ""),
94 prefix: config.string("cache.prefix", ""),
95 ..CacheConfig::default()
96 })
97 }
98}
99
100#[derive(Clone)]
106pub struct CacheStore {
107 inner: Arc<dyn Cache>,
108}
109
110impl CacheStore {
111 pub fn from_config(config: &Config) -> Result<Self> {
117 CacheStore::build(&CacheConfig::from_app_config(config)?)
118 }
119
120 pub fn build(settings: &CacheConfig) -> Result<Self> {
121 let store: Arc<dyn Cache> = match settings.driver {
122 Driver::Memory => Arc::new(MemoryStore::with_options(
123 settings.prefix.clone(),
124 settings.sweep_interval,
125 )),
126 Driver::File => {
127 Arc::new(FileStore::with_prefix(&settings.path, settings.prefix.clone())?)
128 }
129 Driver::Redis => {
130 let redis = if settings.url.is_empty() {
131 RedisConfig::from_app_config(&Config::new())?
132 } else {
133 RedisConfig::from_url(&settings.url)?
134 };
135 Arc::new(RedisStore::new(redis, settings.prefix.clone()))
136 }
137 };
138
139 Ok(CacheStore { inner: store })
140 }
141
142 pub fn from_driver(store: impl Cache) -> Self {
144 CacheStore { inner: Arc::new(store) }
145 }
146
147 pub fn driver_handle(&self) -> Arc<dyn Cache> {
149 Arc::clone(&self.inner)
150 }
151
152 pub async fn verify(&self) -> Result<()> {
154 let key = "__rustlavel_cache_probe";
155 self.put(key, rustlavel_core::Json::from(1), std::time::Duration::from_secs(5)).await?;
156 self.forget(key).await?;
157 Ok(())
158 }
159}
160
161impl Cache for CacheStore {
164 fn driver(&self) -> &'static str {
165 self.inner.driver()
166 }
167
168 fn get<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<Option<rustlavel_core::Json>>> {
169 self.inner.get(key)
170 }
171
172 fn put<'a>(
173 &'a self,
174 key: &'a str,
175 value: rustlavel_core::Json,
176 ttl: std::time::Duration,
177 ) -> crate::store::BoxFuture<'a, Result<()>> {
178 self.inner.put(key, value, ttl)
179 }
180
181 fn forever<'a>(
182 &'a self,
183 key: &'a str,
184 value: rustlavel_core::Json,
185 ) -> crate::store::BoxFuture<'a, Result<()>> {
186 self.inner.forever(key, value)
187 }
188
189 fn forget<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<bool>> {
190 self.inner.forget(key)
191 }
192
193 fn flush(&self) -> crate::store::BoxFuture<'_, Result<()>> {
194 self.inner.flush()
195 }
196
197 fn has<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<bool>> {
198 self.inner.has(key)
199 }
200
201 fn increment<'a>(&'a self, key: &'a str, by: i64) -> crate::store::BoxFuture<'a, Result<i64>> {
202 self.inner.increment(key, by)
203 }
204
205 fn decrement<'a>(&'a self, key: &'a str, by: i64) -> crate::store::BoxFuture<'a, Result<i64>> {
206 self.inner.decrement(key, by)
207 }
208
209 fn increment_within<'a>(
210 &'a self,
211 key: &'a str,
212 by: i64,
213 ttl: std::time::Duration,
214 ) -> crate::store::BoxFuture<'a, Result<i64>> {
215 self.inner.increment_within(key, by, ttl)
216 }
217
218 fn ttl<'a>(&'a self, key: &'a str) -> crate::store::BoxFuture<'a, Result<Option<std::time::Duration>>> {
219 self.inner.ttl(key)
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::store::CacheExt;
227 use rustlavel_core::Json;
228 use std::time::Duration;
229
230 #[test]
231 fn a_driver_typo_names_the_valid_choices() {
232 let error = Driver::parse("redsi").unwrap_err().to_string();
233 assert!(error.contains("memory, file, redis"), "got: {error}");
234 }
235
236 #[test]
237 fn driver_names_are_case_insensitive_and_array_means_memory() {
238 assert_eq!(Driver::parse("Redis").unwrap(), Driver::Redis);
239 assert_eq!(Driver::parse(" file ").unwrap(), Driver::File);
240 assert_eq!(Driver::parse("array").unwrap(), Driver::Memory);
242 }
243
244 #[tokio::test]
245 async fn the_factory_defaults_to_the_memory_driver() {
246 let store = CacheStore::from_config(&Config::new()).unwrap();
247
248 assert_eq!(store.driver(), "memory");
249 store.forever("k", Json::from(1)).await.unwrap();
250 assert_eq!(store.get("k").await.unwrap(), Some(Json::from(1)));
251 }
252
253 #[tokio::test]
254 async fn the_factory_builds_the_file_driver_at_the_configured_path() {
255 let directory = std::env::temp_dir()
256 .join(format!("rustlavel-cache-factory-{}", std::process::id()));
257 let _ = std::fs::remove_dir_all(&directory);
258
259 let config = Config::new();
260 config.set("cache.driver", "file");
261 config.set("cache.path", directory.to_string_lossy().to_string());
262
263 let store = CacheStore::from_config(&config).unwrap();
264 assert_eq!(store.driver(), "file");
265
266 store.forever("on-disk", Json::from("yes")).await.unwrap();
267 assert!(directory.exists());
268 assert_eq!(store.get("on-disk").await.unwrap(), Some(Json::from("yes")));
269
270 let _ = std::fs::remove_dir_all(&directory);
271 }
272
273 #[test]
274 fn the_factory_builds_the_redis_driver_without_connecting() {
275 let config = Config::new();
276 config.set("cache.driver", "redis");
277 config.set("cache.url", "redis://127.0.0.1:1/0");
278
279 let store = CacheStore::from_config(&config).unwrap();
282 assert_eq!(store.driver(), "redis");
283 }
284
285 #[test]
286 fn a_malformed_redis_url_is_refused_at_boot() {
287 let config = Config::new();
288 config.set("cache.driver", "redis");
289 config.set("cache.url", "http://not-redis");
290
291 assert!(CacheStore::from_config(&config).is_err());
292 }
293
294 #[tokio::test]
295 async fn the_configured_prefix_reaches_the_driver() {
296 let config = Config::new();
297 config.set("cache.prefix", "tenant-a:");
298 let prefixed = CacheStore::from_config(&config).unwrap();
299
300 let bare = CacheStore::from_driver(MemoryStore::new());
301
302 prefixed.forever("who", Json::from("a")).await.unwrap();
303 assert_eq!(bare.get("who").await.unwrap(), None);
305 assert_eq!(prefixed.get("who").await.unwrap(), Some(Json::from("a")));
306 }
307
308 #[tokio::test]
309 async fn a_store_handle_supports_the_full_cache_api_including_remember() {
310 let store = CacheStore::from_driver(MemoryStore::new());
311
312 let value = store
313 .remember("expensive", Duration::from_secs(60), || async { Ok(Json::from(99)) })
314 .await
315 .unwrap();
316
317 assert_eq!(value, Json::from(99));
318 assert_eq!(store.pull("expensive").await.unwrap(), Some(Json::from(99)));
319 assert!(!store.has("expensive").await.unwrap());
320 store.verify().await.unwrap();
321 }
322}