Skip to main content

armature_cache/
config.rs

1//! Cache configuration types.
2
3use crate::error::CacheResult;
4use std::time::Duration;
5
6/// Cache backend type.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum CacheBackend {
9    /// Redis backend
10    Redis,
11    /// Memcached backend
12    Memcached,
13}
14
15/// Cache configuration.
16///
17/// The `Debug` impl redacts any `user:password@` userinfo embedded in
18/// [`url`](Self::url), so a config can be logged or `{:?}`-printed without
19/// leaking backend credentials.
20#[derive(Clone)]
21pub struct CacheConfig {
22    /// Cache backend type
23    pub backend: CacheBackend,
24
25    /// Connection URL
26    pub url: String,
27
28    /// Key prefix for all cache keys
29    pub key_prefix: Option<String>,
30
31    /// Default TTL for cache entries
32    pub default_ttl: Option<Duration>,
33
34    /// Connection timeout
35    pub connection_timeout: Duration,
36
37    /// Operation timeout
38    pub operation_timeout: Duration,
39
40    /// Maximum number of connections (for connection pools)
41    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
58/// Strip the userinfo (`user:pass@`) from a connection URL so it can be
59/// logged safely. Never log [`CacheConfig::url`] without passing it through
60/// this first: it may embed the backend password.
61///
62/// The *last* `@` inside the authority is used, so an unencoded `@` in the
63/// password does not leave part of the credentials behind.
64pub(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    /// Create a new Redis cache configuration.
81    ///
82    /// # Arguments
83    ///
84    /// * `url` - Redis connection URL (e.g., "redis://localhost:6379")
85    ///
86    /// # Examples
87    ///
88    /// ```
89    /// use armature_cache::CacheConfig;
90    ///
91    /// let config = CacheConfig::redis("redis://localhost:6379").unwrap();
92    /// ```
93    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    /// Create a new Memcached cache configuration.
106    ///
107    /// # Arguments
108    ///
109    /// * `url` - Memcached connection URL (e.g., "memcache://localhost:11211")
110    ///
111    /// # Examples
112    ///
113    /// ```
114    /// use armature_cache::CacheConfig;
115    ///
116    /// let config = CacheConfig::memcached("memcache://localhost:11211").unwrap();
117    /// ```
118    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    /// Set the key prefix.
131    pub fn with_key_prefix(mut self, prefix: impl Into<String>) -> Self {
132        self.key_prefix = Some(prefix.into());
133        self
134    }
135
136    /// Set the default TTL.
137    pub fn with_default_ttl(mut self, ttl: Duration) -> Self {
138        self.default_ttl = Some(ttl);
139        self
140    }
141
142    /// Set the connection timeout.
143    pub fn with_connection_timeout(mut self, timeout: Duration) -> Self {
144        self.connection_timeout = timeout;
145        self
146    }
147
148    /// Set the operation timeout.
149    pub fn with_operation_timeout(mut self, timeout: Duration) -> Self {
150        self.operation_timeout = timeout;
151        self
152    }
153
154    /// Set the maximum number of connections.
155    pub fn with_max_connections(mut self, max: usize) -> Self {
156        self.max_connections = max;
157        self
158    }
159
160    /// Build the final key with prefix if configured.
161    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        // An `@` after the authority (path/query) is not userinfo.
193        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        // Just verify they can be formatted
309        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}