Skip to main content

shardline_cache/
redis.rs

1use std::{fmt, num::NonZeroU64};
2
3use redis::AsyncCommands;
4use shardline_protocol::SecretBytes;
5
6use crate::{
7    AsyncReconstructionCache, ReconstructionCacheError, ReconstructionCacheFuture,
8    ReconstructionCacheKey,
9};
10
11const RECONSTRUCTION_CACHE_PREFIX: &str = "shardline:reconstruction:v1";
12
13/// TLS material for a Redis connection.
14///
15/// Configure a root certificate for private certificate authorities. Configure
16/// a client certificate and key together when the Redis server requires mTLS.
17#[derive(Clone, Default, PartialEq, Eq)]
18pub struct RedisTlsConfig {
19    root_cert: Option<SecretBytes>,
20    client_cert: Option<SecretBytes>,
21    client_key: Option<SecretBytes>,
22}
23
24impl RedisTlsConfig {
25    /// Creates TLS configuration with an optional PEM-encoded root certificate.
26    #[must_use]
27    pub const fn new(root_cert: Option<SecretBytes>) -> Self {
28        Self {
29            root_cert,
30            client_cert: None,
31            client_key: None,
32        }
33    }
34
35    /// Adds a PEM-encoded client certificate and private key for mTLS.
36    #[must_use]
37    pub fn with_client_identity(
38        mut self,
39        client_cert: SecretBytes,
40        client_key: SecretBytes,
41    ) -> Self {
42        self.client_cert = Some(client_cert);
43        self.client_key = Some(client_key);
44        self
45    }
46
47    const fn is_empty(&self) -> bool {
48        self.root_cert.is_none() && self.client_cert.is_none() && self.client_key.is_none()
49    }
50
51    fn into_redis_tls_certificates(
52        self,
53    ) -> Result<redis::TlsCertificates, ReconstructionCacheError> {
54        let client_tls = match (self.client_cert, self.client_key) {
55            (None, None) => None,
56            (Some(client_cert), Some(client_key)) => Some(redis::ClientTlsConfig {
57                client_cert: client_cert.expose_secret().to_vec(),
58                client_key: client_key.expose_secret().to_vec(),
59            }),
60            (Some(_), None) | (None, Some(_)) => {
61                return Err(ReconstructionCacheError::IncompleteRedisTlsClientIdentity);
62            }
63        };
64
65        Ok(redis::TlsCertificates {
66            client_tls,
67            root_cert: self.root_cert.map(|c| c.expose_secret().to_vec()),
68        })
69    }
70}
71
72impl fmt::Debug for RedisTlsConfig {
73    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74        formatter
75            .debug_struct("RedisTlsConfig")
76            .field("root_cert", &self.root_cert)
77            .field("client_cert", &self.client_cert)
78            .field("client_key", &self.client_key)
79            .finish()
80    }
81}
82
83/// Redis-backed reconstruction cache adapter.
84pub struct RedisReconstructionCache {
85    client: redis::Client,
86    ttl_seconds: NonZeroU64,
87}
88
89impl Clone for RedisReconstructionCache {
90    fn clone(&self) -> Self {
91        Self {
92            client: self.client.clone(),
93            ttl_seconds: self.ttl_seconds,
94        }
95    }
96}
97
98impl fmt::Debug for RedisReconstructionCache {
99    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100        formatter
101            .debug_struct("RedisReconstructionCache")
102            .field("client", &"***")
103            .field("ttl_seconds", &self.ttl_seconds)
104            .finish()
105    }
106}
107
108impl RedisReconstructionCache {
109    /// Creates a Redis-backed reconstruction cache adapter.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`ReconstructionCacheError`] when the URL is empty or invalid.
114    pub fn new(redis_url: &str, ttl_seconds: NonZeroU64) -> Result<Self, ReconstructionCacheError> {
115        Self::new_with_tls(redis_url, ttl_seconds, RedisTlsConfig::default())
116    }
117
118    /// Creates a Redis-backed reconstruction cache adapter with TLS or mTLS material.
119    ///
120    /// The Redis URL must use the `rediss://` scheme whenever TLS material is
121    /// supplied. An empty config still permits `rediss://` URLs that use the
122    /// platform trust store.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`ReconstructionCacheError`] when the URL or TLS material is invalid.
127    pub fn new_with_tls(
128        redis_url: &str,
129        ttl_seconds: NonZeroU64,
130        tls_config: RedisTlsConfig,
131    ) -> Result<Self, ReconstructionCacheError> {
132        if redis_url.trim().is_empty() {
133            return Err(ReconstructionCacheError::EmptyRedisUrl);
134        }
135
136        if redis_url.trim_start().starts_with("rediss://") || !tls_config.is_empty() {
137            install_rustls_crypto_provider();
138        }
139
140        let client = if tls_config.is_empty() {
141            redis::Client::open(redis_url)?
142        } else {
143            redis::Client::build_with_tls(redis_url, tls_config.into_redis_tls_certificates()?)?
144        };
145
146        Ok(Self {
147            client,
148            ttl_seconds,
149        })
150    }
151
152    /// Returns a new multiplexed async connection from the client pool.
153    ///
154    /// The underlying `redis::Client` manages connection pooling internally,
155    /// so this is cheap to call per-operation.
156    async fn get_connection(
157        &self,
158    ) -> Result<redis::aio::MultiplexedConnection, ReconstructionCacheError> {
159        Ok(self.client.get_multiplexed_async_connection().await?)
160    }
161
162    pub(crate) fn redis_key(key: &ReconstructionCacheKey) -> String {
163        let scope = key.repository_scope().map_or_else(
164            || "global".to_owned(),
165            |scope| {
166                let revision = scope
167                    .revision()
168                    .map_or_else(|| "head".to_owned(), encode_component);
169                format!(
170                    "{}:{}:{}:{}",
171                    scope.provider(),
172                    encode_component(scope.owner()),
173                    encode_component(scope.repo()),
174                    revision
175                )
176            },
177        );
178        let content = key
179            .content_hash()
180            .map_or_else(|| "latest".to_owned(), encode_component);
181
182        format!(
183            "{RECONSTRUCTION_CACHE_PREFIX}:{scope}:{content}:{}",
184            encode_component(key.file_id())
185        )
186    }
187}
188
189fn install_rustls_crypto_provider() {
190    let _already_installed = rustls::crypto::ring::default_provider()
191        .install_default()
192        .is_err();
193}
194
195impl AsyncReconstructionCache for RedisReconstructionCache {
196    fn ready(&self) -> ReconstructionCacheFuture<'_, ()> {
197        Box::pin(async move {
198            let mut connection = self.get_connection().await?;
199            let _pong: String = redis::cmd("PING").query_async(&mut connection).await?;
200            Ok(())
201        })
202    }
203
204    fn get<'operation>(
205        &'operation self,
206        key: &'operation ReconstructionCacheKey,
207    ) -> ReconstructionCacheFuture<'operation, Option<Vec<u8>>> {
208        Box::pin(async move {
209            let mut connection = self.get_connection().await?;
210            let redis_key = Self::redis_key(key);
211            let value: Option<Vec<u8>> = connection.get(redis_key).await?;
212            Ok(value)
213        })
214    }
215
216    fn put<'operation>(
217        &'operation self,
218        key: &'operation ReconstructionCacheKey,
219        payload: &'operation [u8],
220    ) -> ReconstructionCacheFuture<'operation, ()> {
221        Box::pin(async move {
222            let mut connection = self.get_connection().await?;
223            let redis_key = Self::redis_key(key);
224            let ttl_seconds = self.ttl_seconds.get();
225            let _: () = connection
226                .set_ex(redis_key, payload.to_vec(), ttl_seconds)
227                .await?;
228            Ok(())
229        })
230    }
231
232    fn delete<'operation>(
233        &'operation self,
234        key: &'operation ReconstructionCacheKey,
235    ) -> ReconstructionCacheFuture<'operation, bool> {
236        Box::pin(async move {
237            let mut connection = self.get_connection().await?;
238            let redis_key = Self::redis_key(key);
239            let deleted: usize = connection.del(redis_key).await?;
240            Ok(deleted > 0)
241        })
242    }
243}
244
245fn encode_component(value: &str) -> String {
246    hex::encode(value.as_bytes())
247}
248
249#[cfg(test)]
250mod tests {
251    use std::{env::var as env_var, error::Error as StdError, num::NonZeroU64};
252
253    use redis::AsyncCommands;
254
255    use super::{RECONSTRUCTION_CACHE_PREFIX, RedisReconstructionCache, RedisTlsConfig};
256    use crate::{AsyncReconstructionCache, ReconstructionCacheKey};
257    use shardline_protocol::{RepositoryProvider, RepositoryScope, SecretBytes};
258
259    #[test]
260    fn redis_cache_debug_redacts_connection_url() {
261        let ttl_seconds = NonZeroU64::new(60).unwrap_or(NonZeroU64::MIN);
262        let cache = RedisReconstructionCache::new(
263            "redis://:cache-secret@cache.example.test:6379/0",
264            ttl_seconds,
265        );
266        assert!(cache.is_ok());
267        let Ok(cache) = cache else {
268            return;
269        };
270
271        let rendered = format!("{cache:?}");
272
273        assert!(!rendered.contains("cache-secret"));
274        assert!(rendered.contains("***"));
275    }
276
277    #[test]
278    fn redis_cache_rejects_empty_url() {
279        let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
280        let result = RedisReconstructionCache::new("", ttl_seconds);
281        assert!(matches!(
282            result,
283            Err(crate::ReconstructionCacheError::EmptyRedisUrl)
284        ));
285    }
286
287    #[test]
288    fn redis_tls_config_debug_redacts_certificate_material() {
289        let tls = RedisTlsConfig::new(Some(SecretBytes::from_slice(b"root-secret")))
290            .with_client_identity(
291                SecretBytes::from_slice(b"client-secret"),
292                SecretBytes::from_slice(b"key-secret"),
293            );
294
295        let rendered = format!("{tls:?}");
296
297        assert!(!rendered.contains("root-secret"));
298        assert!(!rendered.contains("client-secret"));
299        assert!(!rendered.contains("key-secret"));
300        assert!(rendered.contains("***"));
301    }
302
303    #[test]
304    fn redis_tls_client_identity_must_include_certificate_and_key() {
305        let tls = RedisTlsConfig {
306            root_cert: None,
307            client_cert: Some(SecretBytes::from_slice(b"certificate")),
308            client_key: None,
309        };
310        let result =
311            RedisReconstructionCache::new_with_tls("rediss://localhost:6379", NonZeroU64::MIN, tls);
312
313        assert!(matches!(
314            result,
315            Err(crate::ReconstructionCacheError::IncompleteRedisTlsClientIdentity)
316        ));
317    }
318
319    #[tokio::test]
320    async fn redis_cache_roundtrips_payload_when_live_url_is_available() {
321        let Some(redis_url) = env_var("STEXS_REDIS_CACHE_TEST_URL").ok() else {
322            return;
323        };
324
325        let ttl_seconds = NonZeroU64::new(60).unwrap_or(NonZeroU64::MIN);
326        let cache = RedisReconstructionCache::new(&redis_url, ttl_seconds);
327        assert!(cache.is_ok());
328        let Ok(cache) = cache else {
329            return;
330        };
331        let initial_flush = flush_matching_keys(&redis_url).await;
332        assert!(initial_flush.is_ok());
333        let key = ReconstructionCacheKey::latest("asset.bin", None);
334
335        let put = cache.put(&key, b"payload").await;
336        assert!(put.is_ok());
337        let value = cache.get(&key).await;
338        assert!(value.is_ok());
339        assert_eq!(value.ok().flatten(), Some(b"payload".to_vec()));
340
341        let final_flush = flush_matching_keys(&redis_url).await;
342        assert!(final_flush.is_ok());
343    }
344
345    async fn flush_matching_keys(redis_url: &str) -> Result<(), Box<dyn StdError>> {
346        let client = redis::Client::open(redis_url)?;
347        let mut connection = client.get_multiplexed_async_connection().await?;
348        let pattern = format!("{RECONSTRUCTION_CACHE_PREFIX}:*");
349        let keys: Vec<String> = redis::cmd("KEYS")
350            .arg(&pattern)
351            .query_async(&mut connection)
352            .await?;
353        if !keys.is_empty() {
354            let _: usize = connection.del(keys).await?;
355        }
356        Ok(())
357    }
358
359    #[allow(clippy::unwrap_used)]
360    #[tokio::test]
361    async fn redis_cache_ready_succeeds_when_live_url_is_available() {
362        let Some(redis_url) = env_var("STEXS_REDIS_CACHE_TEST_URL").ok() else {
363            return;
364        };
365
366        let ttl_seconds = NonZeroU64::new(60).unwrap_or(NonZeroU64::MIN);
367        let cache = RedisReconstructionCache::new(&redis_url, ttl_seconds).unwrap();
368        let result = cache.ready().await;
369        assert!(result.is_ok());
370    }
371
372    // ── Repository scope key serialization ────────────────────────────────
373
374    #[test]
375    fn redis_key_format_with_scope() {
376        let scope = RepositoryScope::new(
377            RepositoryProvider::GitHub,
378            "my-org",
379            "my-repo",
380            Some("main"),
381        );
382        assert!(scope.is_ok());
383        let Ok(scope) = scope else {
384            return;
385        };
386        let key = ReconstructionCacheKey::latest("file.bin", Some(&scope));
387        let redis_key = RedisReconstructionCache::redis_key(&key);
388        // The provider ("github") is NOT hex-encoded (comes from provider_token directly).
389        // owner, repo, and revision ARE hex-encoded (via encode_component).
390        // file_id and content_hash ARE also hex-encoded.
391        let owner_hex = hex::encode("my-org");
392        let repo_hex = hex::encode("my-repo");
393        let revision_hex = hex::encode("main");
394        let file_hex = hex::encode("file.bin");
395        assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
396        assert!(redis_key.contains(&format!(":github:{owner_hex}:{repo_hex}:{revision_hex}:")));
397        assert!(redis_key.ends_with(&format!(":latest:{file_hex}")));
398    }
399
400    #[test]
401    fn redis_key_format_without_scope() {
402        let key = ReconstructionCacheKey::latest("file.bin", None);
403        let redis_key = RedisReconstructionCache::redis_key(&key);
404        assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
405        assert!(redis_key.contains(":global:"));
406    }
407
408    #[test]
409    fn redis_key_format_with_content_hash() {
410        let key = ReconstructionCacheKey::version("file.bin", "abc123", None);
411        let redis_key = RedisReconstructionCache::redis_key(&key);
412        let hash_hex = hex::encode("abc123");
413        let file_hex = hex::encode("file.bin");
414        assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
415        assert!(redis_key.ends_with(&format!(":{hash_hex}:{file_hex}")));
416    }
417
418    #[test]
419    fn redis_key_format_with_scope_no_revision() {
420        let scope = RepositoryScope::new(RepositoryProvider::GitLab, "group", "project", None);
421        assert!(scope.is_ok());
422        let Ok(scope) = scope else {
423            return;
424        };
425        let key = ReconstructionCacheKey::latest("doc.pdf", Some(&scope));
426        let redis_key = RedisReconstructionCache::redis_key(&key);
427        let owner_hex = hex::encode("group");
428        let repo_hex = hex::encode("project");
429        assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
430        assert!(redis_key.contains(&format!(":gitlab:{owner_hex}:{repo_hex}:")));
431        assert!(redis_key.contains(":head:"));
432    }
433
434    // ── Additional provider key formats ─────────────────────────────────
435
436    #[test]
437    fn redis_key_format_gitea_provider() {
438        let scope =
439            RepositoryScope::new(RepositoryProvider::Gitea, "gitea-org", "gitea-repo", None);
440        assert!(scope.is_ok());
441        let Ok(scope) = scope else {
442            return;
443        };
444        let key = ReconstructionCacheKey::version("f1.bin", "hash1", Some(&scope));
445        let redis_key = RedisReconstructionCache::redis_key(&key);
446        let owner_hex = hex::encode("gitea-org");
447        let repo_hex = hex::encode("gitea-repo");
448        let hash_hex = hex::encode("hash1");
449        let file_hex = hex::encode("f1.bin");
450        assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
451        assert!(redis_key.contains(&format!(":gitea:{owner_hex}:{repo_hex}:head:")));
452        assert!(redis_key.ends_with(&format!(":{hash_hex}:{file_hex}")));
453    }
454
455    #[test]
456    fn redis_key_format_codeberg_provider() {
457        let scope = RepositoryScope::new(RepositoryProvider::Codeberg, "user", "repo", Some("v2"));
458        assert!(scope.is_ok());
459        let Ok(scope) = scope else {
460            return;
461        };
462        let key = ReconstructionCacheKey::latest("data.bin", Some(&scope));
463        let redis_key = RedisReconstructionCache::redis_key(&key);
464        let owner_hex = hex::encode("user");
465        let repo_hex = hex::encode("repo");
466        let rev_hex = hex::encode("v2");
467        assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
468        assert!(redis_key.contains(&format!(":codeberg:{owner_hex}:{repo_hex}:{rev_hex}:")));
469    }
470
471    #[test]
472    fn redis_key_format_generic_provider() {
473        let scope = RepositoryScope::new(RepositoryProvider::Generic, "ns", "repo-name", None);
474        assert!(scope.is_ok());
475        let Ok(scope) = scope else {
476            return;
477        };
478        let key = ReconstructionCacheKey::latest("generic.bin", Some(&scope));
479        let redis_key = RedisReconstructionCache::redis_key(&key);
480        let owner_hex = hex::encode("ns");
481        let repo_hex = hex::encode("repo-name");
482        assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
483        assert!(redis_key.contains(&format!(":generic:{owner_hex}:{repo_hex}:head:")));
484    }
485
486    // ── Edge cases ──────────────────────────────────────────────────────
487
488    #[test]
489    fn redis_key_format_special_characters_in_names() {
490        let scope = RepositoryScope::new(
491            RepositoryProvider::GitHub,
492            "my-org/team",
493            "asset.repo_v2",
494            Some("feature/branch"),
495        );
496        assert!(scope.is_ok());
497        let Ok(scope) = scope else {
498            return;
499        };
500        let key = ReconstructionCacheKey::latest("my file (1).bin", Some(&scope));
501        let redis_key = RedisReconstructionCache::redis_key(&key);
502        let owner_hex = hex::encode("my-org/team");
503        let repo_hex = hex::encode("asset.repo_v2");
504        let rev_hex = hex::encode("feature/branch");
505        let file_hex = hex::encode("my file (1).bin");
506        assert!(redis_key.contains(&format!(":github:{owner_hex}:{repo_hex}:{rev_hex}:")));
507        assert!(redis_key.ends_with(&format!(":latest:{file_hex}")));
508    }
509
510    #[test]
511    fn redis_key_format_empty_file_id() {
512        let key = ReconstructionCacheKey::latest("", None);
513        let redis_key = RedisReconstructionCache::redis_key(&key);
514        assert!(redis_key.ends_with(":latest:"));
515    }
516
517    #[test]
518    fn redis_key_format_empty_content_hash() {
519        let key = ReconstructionCacheKey::version("f", "", None);
520        let redis_key = RedisReconstructionCache::redis_key(&key);
521        // Empty content hash is still hex-encoded as an empty string.
522        let hash_hex = hex::encode("");
523        assert!(redis_key.contains(&format!(":{hash_hex}:")));
524    }
525
526    // ── Redis URL validation ─────────────────────────────────────────────
527
528    #[test]
529    fn redis_cache_rejects_missing_scheme() {
530        let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
531        let result = RedisReconstructionCache::new("localhost:6379", ttl_seconds);
532        assert!(result.is_err());
533    }
534
535    #[test]
536    fn redis_cache_rejects_invalid_port() {
537        let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
538        let result = RedisReconstructionCache::new("redis://localhost:abc", ttl_seconds);
539        assert!(result.is_err());
540    }
541
542    #[test]
543    fn redis_cache_rejects_whitespace_only_url() {
544        let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
545        let result = RedisReconstructionCache::new("   ", ttl_seconds);
546        assert!(matches!(
547            result,
548            Err(crate::ReconstructionCacheError::EmptyRedisUrl)
549        ));
550    }
551
552    #[test]
553    fn redis_cache_rejects_url_with_only_newline() {
554        let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
555        let result = RedisReconstructionCache::new("\n", ttl_seconds);
556        assert!(matches!(
557            result,
558            Err(crate::ReconstructionCacheError::EmptyRedisUrl)
559        ));
560    }
561
562    #[test]
563    fn redis_cache_rejects_url_with_tabs() {
564        let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
565        let result = RedisReconstructionCache::new("\t\t", ttl_seconds);
566        assert!(matches!(
567            result,
568            Err(crate::ReconstructionCacheError::EmptyRedisUrl)
569        ));
570    }
571
572    #[test]
573    fn redis_cache_accepts_valid_redis_url() {
574        let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
575        let result = RedisReconstructionCache::new("redis://127.0.0.1:6379", ttl_seconds);
576        assert!(result.is_ok());
577    }
578
579    #[test]
580    fn redis_cache_accepts_unix_socket_url() {
581        let ttl_seconds = NonZeroU64::new(3600).unwrap_or(NonZeroU64::MIN);
582        let result = RedisReconstructionCache::new("redis+unix:///tmp/redis.sock", ttl_seconds);
583        assert!(result.is_ok());
584    }
585
586    // ── encode_component ───────────────────────────────────────────────────
587
588    #[test]
589    fn encode_component_hex_encodes_input() {
590        assert_eq!(super::encode_component("hello"), hex::encode("hello"));
591    }
592
593    #[test]
594    fn encode_component_handles_special_characters() {
595        assert_eq!(
596            super::encode_component("file name!"),
597            hex::encode("file name!")
598        );
599        assert_eq!(super::encode_component("a/b:c"), hex::encode("a/b:c"));
600    }
601
602    #[test]
603    fn encode_component_handles_unicode() {
604        assert_eq!(super::encode_component("héllo"), hex::encode("héllo"));
605    }
606
607    #[test]
608    fn encode_component_empty_string() {
609        assert_eq!(super::encode_component(""), hex::encode(""));
610    }
611
612    #[test]
613    fn encode_component_handles_control_characters_in_string() {
614        // encode_component takes &str so we can pass strings with escaped chars
615        assert_eq!(super::encode_component("a\tb"), hex::encode("a\tb"));
616        assert_eq!(
617            super::encode_component("line1\nline2"),
618            hex::encode("line1\nline2")
619        );
620    }
621
622    // ── Clone ────────────────────────────────────────────────────────────
623
624    #[allow(clippy::unwrap_used)]
625    #[test]
626    fn redis_cache_clone_produces_independent_cache() {
627        let ttl = NonZeroU64::new(60).unwrap();
628        let cache = RedisReconstructionCache::new("redis://localhost", ttl).unwrap();
629        let cloned = cache.clone();
630        // Both should have the same client URL (redacted in Debug)
631        let debug_original = format!("{cache:?}");
632        let debug_cloned = format!("{cloned:?}");
633        assert_eq!(debug_original, debug_cloned);
634        // Verify ttl is preserved
635        assert_eq!(cache.ttl_seconds, cloned.ttl_seconds);
636    }
637
638    // ── RedisReconstructionCache::new edge cases ─────────────────────────
639
640    #[allow(clippy::unwrap_used)]
641    #[test]
642    fn redis_cache_new_with_redis_scheme() {
643        let ttl = NonZeroU64::new(60).unwrap();
644        assert!(RedisReconstructionCache::new("redis://localhost", ttl).is_ok());
645    }
646
647    #[allow(clippy::unwrap_used)]
648    #[test]
649    fn redis_cache_new_with_unix_scheme() {
650        let ttl = NonZeroU64::new(60).unwrap();
651        assert!(RedisReconstructionCache::new("redis+unix:///run/redis.sock", ttl).is_ok());
652    }
653
654    // ── More redis_key format edge cases ──────────────────────────────────
655
656    #[test]
657    fn redis_key_format_special_chars_in_names() {
658        let scope = RepositoryScope::new(
659            RepositoryProvider::GitHub,
660            "org.name",
661            "repo-name",
662            Some("rev_sion"),
663        );
664        assert!(scope.is_ok());
665        if let Ok(scope) = scope {
666            let key = ReconstructionCacheKey::latest("file.bin", Some(&scope));
667            let redis_key = RedisReconstructionCache::redis_key(&key);
668            assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
669            assert!(redis_key.contains(hex::encode("org.name").as_str()));
670            assert!(redis_key.contains(hex::encode("repo-name").as_str()));
671            assert!(redis_key.contains(hex::encode("rev_sion").as_str()));
672        }
673    }
674
675    #[test]
676    fn redis_key_format_long_file_id() {
677        let long_id = "a".repeat(1000);
678        let key = ReconstructionCacheKey::latest(&long_id, None);
679        let redis_key = RedisReconstructionCache::redis_key(&key);
680        assert!(redis_key.starts_with("shardline:reconstruction:v1:global:latest:"));
681        assert!(redis_key.ends_with(&hex::encode(&long_id)));
682        assert_eq!(
683            redis_key.len(),
684            "shardline:reconstruction:v1:global:latest:".len() + hex::encode(&long_id).len()
685        );
686    }
687
688    #[test]
689    fn redis_key_format_long_content_hash() {
690        let long_hash = "b".repeat(200);
691        let key = ReconstructionCacheKey::version("f", &long_hash, None);
692        let redis_key = RedisReconstructionCache::redis_key(&key);
693        assert!(redis_key.starts_with("shardline:reconstruction:v1:global:"));
694        assert!(redis_key.contains(&hex::encode(&long_hash)));
695    }
696
697    #[test]
698    fn redis_key_format_with_all_providers_and_revision() {
699        let providers = [
700            (RepositoryProvider::GitHub, "github"),
701            (RepositoryProvider::GitLab, "gitlab"),
702            (RepositoryProvider::Gitea, "gitea"),
703            (RepositoryProvider::Codeberg, "codeberg"),
704            (RepositoryProvider::Generic, "generic"),
705        ];
706        for (provider, expected) in &providers {
707            let scope = RepositoryScope::new(*provider, "owner", "repo", Some("main"));
708            assert!(scope.is_ok());
709            if let Ok(scope) = scope {
710                let key = ReconstructionCacheKey::latest("f.bin", Some(&scope));
711                let redis_key = RedisReconstructionCache::redis_key(&key);
712                assert!(
713                    redis_key.contains(expected),
714                    "expected provider token {expected} in {redis_key}"
715                );
716            }
717        }
718    }
719
720    #[test]
721    fn redis_key_format_version_no_scope_all_providers() {
722        let providers = [
723            RepositoryProvider::GitHub,
724            RepositoryProvider::GitLab,
725            RepositoryProvider::Gitea,
726            RepositoryProvider::Codeberg,
727            RepositoryProvider::Generic,
728        ];
729        for provider in &providers {
730            let scope = RepositoryScope::new(*provider, "ns", "proj", None);
731            assert!(scope.is_ok());
732            if let Ok(scope) = scope {
733                let key = ReconstructionCacheKey::version("file.bin", "hash1", Some(&scope));
734                let redis_key = RedisReconstructionCache::redis_key(&key);
735                assert!(redis_key.starts_with("shardline:reconstruction:v1:"));
736                // Without revision, "head" is used
737                assert!(
738                    redis_key.contains(":head:"),
739                    "expected :head: in key for {provider:?}"
740                );
741            }
742        }
743    }
744}