ngdp_cache/
cached_cdn_client.rs

1//! Cached wrapper for CDN client
2//!
3//! This module provides a caching layer for CdnClient that stores CDN content
4//! files locally in ~/.cache/ngdp/cdn/ preserving the CDN path structure.
5//!
6//! The cache structure mirrors the CDN paths:
7//! - `{cdn_path}/config/{first2}/{next2}/{hash}` - Configuration files
8//! - `{cdn_path}/data/{first2}/{next2}/{hash}` - Data files
9//! - `{cdn_path}/patch/{first2}/{next2}/{hash}` - Patch files
10//!
11//! Where `{cdn_path}` is extracted from the path parameter (e.g., "tpr/wow").
12//!
13//! Content files are immutable (addressed by hash), so once cached they never expire.
14//! This significantly reduces bandwidth usage and improves performance when
15//! accessing the same content multiple times.
16//!
17//! # Example
18//!
19//! ```no_run
20//! use ngdp_cache::cached_cdn_client::CachedCdnClient;
21//!
22//! # #[tokio::main]
23//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
24//! // Create a cached client
25//! let client = CachedCdnClient::new().await?;
26//!
27//! // Download content - first time fetches from CDN
28//! // The CDN path "tpr/wow" will be preserved in the cache structure
29//! let response = client.download(
30//!     "blzddist1-a.akamaihd.net",
31//!     "tpr/wow/config",  // Config files use /config suffix
32//!     "2e9c1e3b5f5a0c9d9e8f1234567890ab",
33//! ).await?;
34//! let data = response.bytes().await?;
35//!
36//! // File is cached at: ~/.cache/ngdp/cdn/tpr/wow/config/2e/9c/{hash}
37//!
38//! // Subsequent calls use cache - no network request!
39//! let response2 = client.download(
40//!     "blzddist1-a.akamaihd.net",
41//!     "tpr/wow/config",
42//!     "2e9c1e3b5f5a0c9d9e8f1234567890ab",
43//! ).await?;
44//! # Ok(())
45//! # }
46//! ```
47
48use bytes::Bytes;
49use reqwest::Response;
50use std::path::PathBuf;
51use tokio::io::AsyncRead;
52use tracing::debug;
53
54use ngdp_cdn::CdnClient;
55
56use crate::{CdnCache, Result};
57
58/// Type of CDN content based on path
59#[derive(Debug, Clone, Copy, PartialEq)]
60enum ContentType {
61    Config,
62    Data,
63    Patch,
64}
65
66impl ContentType {
67    /// Determine content type from CDN path
68    fn from_path(path: &str) -> Self {
69        let path_lower = path.to_lowercase();
70        if path_lower.contains("/config") || path_lower.ends_with("config") {
71            Self::Config
72        } else if path_lower.contains("/patch") || path_lower.ends_with("patch") {
73            Self::Patch
74        } else {
75            Self::Data
76        }
77    }
78}
79
80/// A caching wrapper around CdnClient
81pub struct CachedCdnClient {
82    /// The underlying CDN client
83    client: CdnClient,
84    /// Base cache directory
85    cache_base_dir: PathBuf,
86    /// Whether caching is enabled
87    enabled: bool,
88}
89
90impl CachedCdnClient {
91    /// Create a new cached CDN client
92    pub async fn new() -> Result<Self> {
93        let client = CdnClient::new()?;
94        let cache_base_dir = crate::get_cache_dir()?.join("cdn");
95        crate::ensure_dir(&cache_base_dir).await?;
96
97        debug!("Initialized cached CDN client");
98
99        Ok(Self {
100            client,
101            cache_base_dir,
102            enabled: true,
103        })
104    }
105
106    /// Create a new cached client for a specific product
107    pub async fn for_product(product: &str) -> Result<Self> {
108        let client = CdnClient::new()?;
109        let cache_base_dir = crate::get_cache_dir()?.join("cdn").join(product);
110        crate::ensure_dir(&cache_base_dir).await?;
111
112        debug!("Initialized cached CDN client for product '{}'", product);
113
114        Ok(Self {
115            client,
116            cache_base_dir,
117            enabled: true,
118        })
119    }
120
121    /// Create a new cached client with custom cache directory
122    pub async fn with_cache_dir(cache_dir: PathBuf) -> Result<Self> {
123        let client = CdnClient::new()?;
124        crate::ensure_dir(&cache_dir).await?;
125
126        Ok(Self {
127            client,
128            cache_base_dir: cache_dir,
129            enabled: true,
130        })
131    }
132
133    /// Create from an existing CDN client
134    pub async fn with_client(client: CdnClient) -> Result<Self> {
135        let cache_base_dir = crate::get_cache_dir()?.join("cdn");
136        crate::ensure_dir(&cache_base_dir).await?;
137
138        Ok(Self {
139            client,
140            cache_base_dir,
141            enabled: true,
142        })
143    }
144
145    /// Add a primary CDN host
146    pub fn add_primary_host(&self, host: impl Into<String>) {
147        self.client.add_primary_host(host);
148    }
149
150    /// Add multiple primary CDN hosts
151    pub fn add_primary_hosts(&self, hosts: impl IntoIterator<Item = impl Into<String>>) {
152        self.client.add_primary_hosts(hosts);
153    }
154
155    /// Add a fallback CDN host
156    pub fn add_fallback_host(&self, host: impl Into<String>) {
157        self.client.add_fallback_host(host);
158    }
159
160    /// Add multiple fallback CDN hosts
161    pub fn add_fallback_hosts(&self, hosts: impl IntoIterator<Item = impl Into<String>>) {
162        self.client.add_fallback_hosts(hosts);
163    }
164
165    /// Set primary CDN hosts, replacing any existing ones
166    pub fn set_primary_hosts(&self, hosts: impl IntoIterator<Item = impl Into<String>>) {
167        self.client.set_primary_hosts(hosts);
168    }
169
170    /// Get all configured hosts (primary first, then fallback)
171    pub fn get_all_hosts(&self) -> Vec<String> {
172        self.client.get_all_hosts()
173    }
174
175    /// Enable or disable caching
176    pub fn set_caching_enabled(&mut self, enabled: bool) {
177        self.enabled = enabled;
178    }
179
180    /// Get the cache directory
181    pub fn cache_dir(&self) -> &PathBuf {
182        &self.cache_base_dir
183    }
184
185    /// Get or create a cache for a specific CDN path
186    async fn get_cache_for_path(&self, cdn_path: &str) -> Result<CdnCache> {
187        // Use the CDN path as-is - don't try to extract a base path
188        let mut cache = CdnCache::with_base_dir(self.cache_base_dir.clone()).await?;
189        cache.set_cdn_path(Some(cdn_path.to_string()));
190        Ok(cache)
191    }
192
193    /// Check if content is cached
194    async fn is_cached(&self, path: &str, hash: &str) -> Result<bool> {
195        let cache = self.get_cache_for_path(path).await?;
196        let content_type = ContentType::from_path(path);
197        Ok(match content_type {
198            ContentType::Config => cache.has_config(hash).await,
199            ContentType::Data => cache.has_data(hash).await,
200            ContentType::Patch => cache.has_patch(hash).await,
201        })
202    }
203
204    /// Read content from cache
205    async fn read_from_cache(&self, path: &str, hash: &str) -> Result<Bytes> {
206        let cache = self.get_cache_for_path(path).await?;
207        let content_type = ContentType::from_path(path);
208        let data = match content_type {
209            ContentType::Config => cache.read_config(hash).await?,
210            ContentType::Data => cache.read_data(hash).await?,
211            ContentType::Patch => cache.read_patch(hash).await?,
212        };
213        Ok(Bytes::from(data))
214    }
215
216    /// Write content to cache
217    async fn write_to_cache(&self, path: &str, hash: &str, data: &[u8]) -> Result<()> {
218        let cache = self.get_cache_for_path(path).await?;
219        let content_type = ContentType::from_path(path);
220        match content_type {
221            ContentType::Config => cache.write_config(hash, data).await?,
222            ContentType::Data => cache.write_data(hash, data).await?,
223            ContentType::Patch => cache.write_patch(hash, data).await?,
224        };
225        Ok(())
226    }
227
228    /// Make a basic request to a CDN URL
229    ///
230    /// This method does not use caching as it's for arbitrary URLs.
231    /// Use `download` for hash-based content that should be cached.
232    pub async fn request(&self, url: &str) -> Result<Response> {
233        Ok(self.client.request(url).await?)
234    }
235
236    /// Download content from CDN by hash with caching
237    ///
238    /// If caching is enabled and the content exists in cache, it will be returned
239    /// without making a network request. Otherwise, the content is downloaded
240    /// from the CDN and stored in cache for future use.
241    pub async fn download(&self, cdn_host: &str, path: &str, hash: &str) -> Result<CachedResponse> {
242        // Check cache first if enabled
243        if self.enabled && self.is_cached(path, hash).await? {
244            debug!("Cache hit for CDN {}/{}", path, hash);
245            let data = self.read_from_cache(path, hash).await?;
246            return Ok(CachedResponse::from_cache(data));
247        }
248
249        // Cache miss - download from CDN
250        debug!("Cache miss for CDN {}/{}, fetching from server", path, hash);
251        let response = self.client.download(cdn_host, path, hash).await?;
252
253        // Get the response body
254        let data = response.bytes().await?;
255
256        // Cache the content if enabled
257        if self.enabled {
258            if let Err(e) = self.write_to_cache(path, hash, &data).await {
259                debug!("Failed to write to CDN cache: {}", e);
260            }
261        }
262
263        Ok(CachedResponse::from_network(data))
264    }
265
266    /// Stream download content from CDN with caching
267    ///
268    /// For large files, this method allows streaming the content while still
269    /// benefiting from caching. If the content is cached, it opens the file
270    /// for streaming. Otherwise, it downloads and caches the content first.
271    pub async fn download_stream(
272        &self,
273        cdn_host: &str,
274        path: &str,
275        hash: &str,
276    ) -> Result<Box<dyn AsyncRead + Unpin + Send>> {
277        // For data files, we can use the streaming API
278        if ContentType::from_path(path) == ContentType::Data {
279            // Check if cached
280            let cache = self.get_cache_for_path(path).await?;
281            if self.enabled && cache.has_data(hash).await {
282                debug!("Cache hit for CDN {}/{} (streaming)", path, hash);
283                let file = cache.open_data(hash).await?;
284                return Ok(Box::new(file));
285            }
286        }
287
288        // For non-data files or cache misses, download the full content first
289        let response = self.download(cdn_host, path, hash).await?;
290        let data = response.bytes().await?;
291
292        // Return a cursor over the bytes
293        Ok(Box::new(std::io::Cursor::new(data.to_vec())))
294    }
295
296    /// Get the size of cached content without reading it
297    pub async fn cached_size(&self, path: &str, hash: &str) -> Result<Option<u64>> {
298        if !self.enabled || !self.is_cached(path, hash).await? {
299            return Ok(None);
300        }
301
302        // Only data files support efficient size checking
303        if ContentType::from_path(path) == ContentType::Data {
304            let cache = self.get_cache_for_path(path).await?;
305            Ok(Some(cache.data_size(hash).await?))
306        } else {
307            // For other types, we need to read the full content
308            let data = self.read_from_cache(path, hash).await?;
309            Ok(Some(data.len() as u64))
310        }
311    }
312
313    /// Clear all cached content
314    ///
315    /// This removes all cached CDN content from disk.
316    /// Use with caution as it will require re-downloading all content.
317    pub async fn clear_cache(&self) -> Result<()> {
318        if tokio::fs::metadata(&self.cache_base_dir).await.is_ok() {
319            tokio::fs::remove_dir_all(&self.cache_base_dir).await?;
320        }
321        Ok(())
322    }
323
324    /// Get cache statistics
325    pub async fn cache_stats(&self) -> Result<CacheStats> {
326        let mut stats = CacheStats::default();
327
328        // Count files and calculate sizes for each content type
329        for entry in walkdir::WalkDir::new(&self.cache_base_dir)
330            .into_iter()
331            .flatten()
332        {
333            if entry.file_type().is_file() {
334                if let Ok(metadata) = entry.metadata() {
335                    stats.total_files += 1;
336                    stats.total_size += metadata.len();
337
338                    let path = entry.path();
339                    if path.to_string_lossy().contains("config") {
340                        stats.config_files += 1;
341                        stats.config_size += metadata.len();
342                    } else if path.to_string_lossy().contains("patch") {
343                        stats.patch_files += 1;
344                        stats.patch_size += metadata.len();
345                    } else if path.to_string_lossy().contains("data") {
346                        stats.data_files += 1;
347                        stats.data_size += metadata.len();
348                    }
349                }
350            }
351        }
352
353        Ok(stats)
354    }
355
356    /// Download BuildConfig from CDN with caching
357    ///
358    /// BuildConfig files are stored at `{path}/config/{hash}`
359    pub async fn download_build_config(
360        &self,
361        cdn_host: &str,
362        path: &str,
363        hash: &str,
364    ) -> Result<CachedResponse> {
365        let config_path = format!("{}/config", path.trim_end_matches('/'));
366        self.download(cdn_host, &config_path, hash).await
367    }
368
369    /// Download CDNConfig from CDN with caching
370    ///
371    /// CDNConfig files are stored at `{path}/config/{hash}`
372    pub async fn download_cdn_config(
373        &self,
374        cdn_host: &str,
375        path: &str,
376        hash: &str,
377    ) -> Result<CachedResponse> {
378        let config_path = format!("{}/config", path.trim_end_matches('/'));
379        self.download(cdn_host, &config_path, hash).await
380    }
381
382    /// Download ProductConfig from CDN with caching
383    ///
384    /// ProductConfig files are stored at `{config_path}/{hash}`
385    /// Note: This uses the config_path from CDN response, not the regular path
386    pub async fn download_product_config(
387        &self,
388        cdn_host: &str,
389        config_path: &str,
390        hash: &str,
391    ) -> Result<CachedResponse> {
392        self.download(cdn_host, config_path, hash).await
393    }
394
395    /// Download KeyRing from CDN with caching
396    ///
397    /// KeyRing files are stored at `{path}/config/{hash}`
398    pub async fn download_key_ring(
399        &self,
400        cdn_host: &str,
401        path: &str,
402        hash: &str,
403    ) -> Result<CachedResponse> {
404        let config_path = format!("{}/config", path.trim_end_matches('/'));
405        self.download(cdn_host, &config_path, hash).await
406    }
407
408    /// Download data file from CDN with caching
409    ///
410    /// Data files are stored at `{path}/data/{hash}`
411    pub async fn download_data(
412        &self,
413        cdn_host: &str,
414        path: &str,
415        hash: &str,
416    ) -> Result<CachedResponse> {
417        let data_path = format!("{}/data", path.trim_end_matches('/'));
418        self.download(cdn_host, &data_path, hash).await
419    }
420
421    /// Download patch file from CDN with caching
422    ///
423    /// Patch files are stored at `{path}/patch/{hash}`
424    pub async fn download_patch(
425        &self,
426        cdn_host: &str,
427        path: &str,
428        hash: &str,
429    ) -> Result<CachedResponse> {
430        let patch_path = format!("{}/patch", path.trim_end_matches('/'));
431        self.download(cdn_host, &patch_path, hash).await
432    }
433}
434
435/// Response wrapper that indicates whether content came from cache
436pub struct CachedResponse {
437    /// The response data
438    data: Bytes,
439    /// Whether this response came from cache
440    from_cache: bool,
441}
442
443impl CachedResponse {
444    /// Create a response from cache
445    fn from_cache(data: Bytes) -> Self {
446        Self {
447            data,
448            from_cache: true,
449        }
450    }
451
452    /// Create a response from network
453    fn from_network(data: Bytes) -> Self {
454        Self {
455            data,
456            from_cache: false,
457        }
458    }
459
460    /// Check if this response came from cache
461    pub fn is_from_cache(&self) -> bool {
462        self.from_cache
463    }
464
465    /// Get the response data as bytes
466    pub async fn bytes(self) -> Result<Bytes> {
467        Ok(self.data)
468    }
469
470    /// Get the response data as text
471    pub async fn text(self) -> Result<String> {
472        Ok(String::from_utf8(self.data.to_vec())?)
473    }
474
475    /// Get the content length
476    pub fn content_length(&self) -> usize {
477        self.data.len()
478    }
479}
480
481/// Cache statistics
482#[derive(Debug, Default, Clone)]
483pub struct CacheStats {
484    /// Total number of cached files
485    pub total_files: u64,
486    /// Total size of cached files in bytes
487    pub total_size: u64,
488    /// Number of cached config files
489    pub config_files: u64,
490    /// Size of cached config files in bytes
491    pub config_size: u64,
492    /// Number of cached data files
493    pub data_files: u64,
494    /// Size of cached data files in bytes
495    pub data_size: u64,
496    /// Number of cached patch files
497    pub patch_files: u64,
498    /// Size of cached patch files in bytes
499    pub patch_size: u64,
500}
501
502impl CacheStats {
503    /// Get total size in human-readable format
504    pub fn total_size_human(&self) -> String {
505        format_bytes(self.total_size)
506    }
507
508    /// Get config size in human-readable format
509    pub fn config_size_human(&self) -> String {
510        format_bytes(self.config_size)
511    }
512
513    /// Get data size in human-readable format
514    pub fn data_size_human(&self) -> String {
515        format_bytes(self.data_size)
516    }
517
518    /// Get patch size in human-readable format
519    pub fn patch_size_human(&self) -> String {
520        format_bytes(self.patch_size)
521    }
522}
523
524/// Format bytes as human-readable string
525fn format_bytes(bytes: u64) -> String {
526    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
527    let mut size = bytes as f64;
528    let mut unit_idx = 0;
529
530    while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
531        size /= 1024.0;
532        unit_idx += 1;
533    }
534
535    if unit_idx == 0 {
536        format!("{} {}", size as u64, UNITS[unit_idx])
537    } else {
538        format!("{:.2} {}", size, UNITS[unit_idx])
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use tempfile::TempDir;
546
547    #[tokio::test]
548    async fn test_cached_cdn_client_creation() {
549        let client = CachedCdnClient::new().await.unwrap();
550        assert!(client.enabled);
551    }
552
553    #[tokio::test]
554    async fn test_content_type_detection() {
555        assert_eq!(
556            ContentType::from_path("tpr/configs/data/config"),
557            ContentType::Config
558        );
559        assert_eq!(
560            ContentType::from_path("tpr/wow/config"),
561            ContentType::Config
562        );
563        assert_eq!(ContentType::from_path("config"), ContentType::Config);
564        assert_eq!(ContentType::from_path("tpr/wow/data"), ContentType::Data);
565        assert_eq!(ContentType::from_path("tpr/wow/patch"), ContentType::Patch);
566        assert_eq!(ContentType::from_path("tpr/wow"), ContentType::Data);
567    }
568
569    #[tokio::test]
570    async fn test_cache_enabling() {
571        let mut client = CachedCdnClient::new().await.unwrap();
572
573        client.set_caching_enabled(false);
574        assert!(!client.enabled);
575
576        client.set_caching_enabled(true);
577        assert!(client.enabled);
578    }
579
580    #[tokio::test]
581    async fn test_format_bytes() {
582        assert_eq!(format_bytes(0), "0 B");
583        assert_eq!(format_bytes(1023), "1023 B");
584        assert_eq!(format_bytes(1024), "1.00 KB");
585        assert_eq!(format_bytes(1536), "1.50 KB");
586        assert_eq!(format_bytes(1048576), "1.00 MB");
587        assert_eq!(format_bytes(1073741824), "1.00 GB");
588    }
589
590    #[tokio::test]
591    async fn test_cache_with_temp_dir() {
592        let temp_dir = TempDir::new().unwrap();
593        let client = CachedCdnClient::with_cache_dir(temp_dir.path().to_path_buf())
594            .await
595            .unwrap();
596
597        assert_eq!(client.cache_dir(), &temp_dir.path().to_path_buf());
598    }
599}