1use crate::error::CacheResult;
4use std::time::Duration;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum CacheBackend {
9 Redis,
11 Memcached,
13}
14
15#[derive(Clone)]
21pub struct CacheConfig {
22 pub backend: CacheBackend,
24
25 pub url: String,
27
28 pub key_prefix: Option<String>,
30
31 pub default_ttl: Option<Duration>,
33
34 pub connection_timeout: Duration,
36
37 pub operation_timeout: Duration,
39
40 pub max_connections: usize,
42}
43
44impl std::fmt::Debug for CacheConfig {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_struct("CacheConfig")
47 .field("backend", &self.backend)
48 .field("url", &redact_url_for_logging(&self.url))
49 .field("key_prefix", &self.key_prefix)
50 .field("default_ttl", &self.default_ttl)
51 .field("connection_timeout", &self.connection_timeout)
52 .field("operation_timeout", &self.operation_timeout)
53 .field("max_connections", &self.max_connections)
54 .finish()
55 }
56}
57
58pub(crate) fn redact_url_for_logging(url: &str) -> String {
65 if let Some(scheme_end) = url.find("://") {
66 let after_scheme = scheme_end + 3;
67 let authority_end = url[after_scheme..]
68 .find(['/', '?', '#'])
69 .map(|i| after_scheme + i)
70 .unwrap_or(url.len());
71 if let Some(at) = url[after_scheme..authority_end].rfind('@') {
72 let host_start = after_scheme + at + 1;
73 return format!("{}***@{}", &url[..after_scheme], &url[host_start..]);
74 }
75 }
76 url.to_string()
77}
78
79impl CacheConfig {
80 pub fn redis(url: impl Into<String>) -> CacheResult<Self> {
94 Ok(Self {
95 backend: CacheBackend::Redis,
96 url: url.into(),
97 key_prefix: None,
98 default_ttl: None,
99 connection_timeout: Duration::from_secs(5),
100 operation_timeout: Duration::from_secs(3),
101 max_connections: 10,
102 })
103 }
104
105 pub fn memcached(url: impl Into<String>) -> CacheResult<Self> {
119 Ok(Self {
120 backend: CacheBackend::Memcached,
121 url: url.into(),
122 key_prefix: None,
123 default_ttl: None,
124 connection_timeout: Duration::from_secs(5),
125 operation_timeout: Duration::from_secs(3),
126 max_connections: 10,
127 })
128 }
129
130 pub fn with_key_prefix(mut self, prefix: impl Into<String>) -> Self {
132 self.key_prefix = Some(prefix.into());
133 self
134 }
135
136 pub fn with_default_ttl(mut self, ttl: Duration) -> Self {
138 self.default_ttl = Some(ttl);
139 self
140 }
141
142 pub fn with_connection_timeout(mut self, timeout: Duration) -> Self {
144 self.connection_timeout = timeout;
145 self
146 }
147
148 pub fn with_operation_timeout(mut self, timeout: Duration) -> Self {
150 self.operation_timeout = timeout;
151 self
152 }
153
154 pub fn with_max_connections(mut self, max: usize) -> Self {
156 self.max_connections = max;
157 self
158 }
159
160 pub fn build_key(&self, key: &str) -> String {
162 match &self.key_prefix {
163 Some(prefix) => format!("{}:{}", prefix, key),
164 None => key.to_string(),
165 }
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn debug_redacts_url_credentials() {
175 let config = CacheConfig::redis("redis://admin:s3cr@t@cache.internal:6379/2").unwrap();
176 let rendered = format!("{config:?}");
177 assert!(!rendered.contains("s3cr"), "password leaked: {rendered}");
178 assert!(!rendered.contains("admin"), "username leaked: {rendered}");
179 assert!(rendered.contains("redis://***@cache.internal:6379/2"));
180 }
181
182 #[test]
183 fn redact_url_leaves_credential_free_urls_untouched() {
184 assert_eq!(
185 redact_url_for_logging("redis://localhost:6379"),
186 "redis://localhost:6379"
187 );
188 assert_eq!(
189 redact_url_for_logging("memcache://:pw@h:11211?timeout=1"),
190 "memcache://***@h:11211?timeout=1"
191 );
192 assert_eq!(
194 redact_url_for_logging("redis://h:6379/0?x=a@b"),
195 "redis://h:6379/0?x=a@b"
196 );
197 }
198
199 #[test]
200 fn test_redis_config() {
201 let config = CacheConfig::redis("redis://localhost:6379").unwrap();
202 assert_eq!(config.backend, CacheBackend::Redis);
203 assert_eq!(config.url, "redis://localhost:6379");
204 }
205
206 #[test]
207 fn test_memcached_config() {
208 let config = CacheConfig::memcached("memcache://localhost:11211").unwrap();
209 assert_eq!(config.backend, CacheBackend::Memcached);
210 assert_eq!(config.url, "memcache://localhost:11211");
211 }
212
213 #[test]
214 fn test_config_builder() {
215 let config = CacheConfig::redis("redis://localhost:6379")
216 .unwrap()
217 .with_key_prefix("app")
218 .with_default_ttl(Duration::from_secs(300))
219 .with_max_connections(20);
220
221 assert_eq!(config.key_prefix, Some("app".to_string()));
222 assert_eq!(config.default_ttl, Some(Duration::from_secs(300)));
223 assert_eq!(config.max_connections, 20);
224 }
225
226 #[test]
227 fn test_build_key_with_prefix() {
228 let config = CacheConfig::redis("redis://localhost:6379")
229 .unwrap()
230 .with_key_prefix("myapp");
231
232 assert_eq!(config.build_key("user:123"), "myapp:user:123");
233 }
234
235 #[test]
236 fn test_build_key_without_prefix() {
237 let config = CacheConfig::redis("redis://localhost:6379").unwrap();
238 assert_eq!(config.build_key("user:123"), "user:123");
239 }
240
241 #[test]
242 fn test_redis_config_with_auth() {
243 let config = CacheConfig::redis("redis://:password@localhost:6379/1").unwrap();
244 assert_eq!(config.backend, CacheBackend::Redis);
245 assert!(config.url.contains("password"));
246 }
247
248 #[test]
249 fn test_memcached_config_with_multiple_servers() {
250 let config = CacheConfig::memcached("memcache://server1:11211,server2:11211").unwrap();
251 assert_eq!(config.backend, CacheBackend::Memcached);
252 }
253
254 #[test]
255 fn test_config_default_values() {
256 let config = CacheConfig::redis("redis://localhost:6379").unwrap();
257 assert_eq!(config.max_connections, 10);
258 assert_eq!(config.key_prefix, None);
259 assert_eq!(config.default_ttl, None);
260 }
261
262 #[test]
263 fn test_config_with_custom_max_connections() {
264 let config = CacheConfig::redis("redis://localhost:6379")
265 .unwrap()
266 .with_max_connections(50);
267 assert_eq!(config.max_connections, 50);
268 }
269
270 #[test]
271 fn test_config_with_ttl_zero() {
272 let config = CacheConfig::redis("redis://localhost:6379")
273 .unwrap()
274 .with_default_ttl(Duration::from_secs(0));
275 assert_eq!(config.default_ttl, Some(Duration::from_secs(0)));
276 }
277
278 #[test]
279 fn test_config_with_long_ttl() {
280 let one_day = Duration::from_secs(86400);
281 let config = CacheConfig::redis("redis://localhost:6379")
282 .unwrap()
283 .with_default_ttl(one_day);
284 assert_eq!(config.default_ttl, Some(one_day));
285 }
286
287 #[test]
288 fn test_build_key_with_empty_key() {
289 let config = CacheConfig::redis("redis://localhost:6379")
290 .unwrap()
291 .with_key_prefix("app");
292 assert_eq!(config.build_key(""), "app:");
293 }
294
295 #[test]
296 fn test_build_key_with_special_characters() {
297 let config = CacheConfig::redis("redis://localhost:6379")
298 .unwrap()
299 .with_key_prefix("app");
300 assert_eq!(config.build_key("user:123:token"), "app:user:123:token");
301 }
302
303 #[test]
304 fn test_config_backend_display() {
305 let redis = CacheBackend::Redis;
306 let memcached = CacheBackend::Memcached;
307
308 let _ = format!("{:?}", redis);
310 let _ = format!("{:?}", memcached);
311 }
312
313 #[test]
314 fn test_config_chaining() {
315 let config = CacheConfig::redis("redis://localhost:6379")
316 .unwrap()
317 .with_key_prefix("test")
318 .with_default_ttl(Duration::from_secs(100))
319 .with_max_connections(15);
320
321 assert_eq!(config.key_prefix, Some("test".to_string()));
322 assert_eq!(config.default_ttl, Some(Duration::from_secs(100)));
323 assert_eq!(config.max_connections, 15);
324 }
325
326 #[test]
327 fn test_config_clone() {
328 let config1 = CacheConfig::redis("redis://localhost:6379")
329 .unwrap()
330 .with_key_prefix("app");
331 let config2 = config1.clone();
332
333 assert_eq!(config1.url, config2.url);
334 assert_eq!(config1.key_prefix, config2.key_prefix);
335 }
336
337 #[test]
338 fn test_redis_config_with_db_number() {
339 let config = CacheConfig::redis("redis://localhost:6379/5").unwrap();
340 assert!(config.url.ends_with("/5"));
341 }
342
343 #[test]
344 fn test_build_key_consistency() {
345 let config = CacheConfig::redis("redis://localhost:6379")
346 .unwrap()
347 .with_key_prefix("app");
348
349 let key1 = config.build_key("test");
350 let key2 = config.build_key("test");
351 assert_eq!(key1, key2);
352 }
353}