Skip to main content

crates_docs/tools/docs/cache/
mod.rs

1//! Document cache module
2//!
3//! Provides document-specific cache service with support for independent TTL configuration
4//! for crate docs, search results, and item docs.
5//!
6//! # Cache key format
7//!
8//! - Crate documentation: `crate:{name}` or `crate:{name}:{version}`
9//! - Search results: `search:{query}:{limit}`
10//! - Item documentation: `item:{crate}:{path}` or `item:{crate}:{version}:{path}`
11//!
12//! # Examples
13//!
14//! ```rust,no_run
15//! use std::sync::Arc;
16//! use crates_docs::tools::docs::cache::{DocCache, DocCacheTtl};
17//! use crates_docs::cache::memory::MemoryCache;
18//!
19//! let cache = Arc::new(MemoryCache::new(1000));
20//! let doc_cache = DocCache::new(cache);
21//! ```
22
23mod key;
24mod stats;
25mod ttl;
26
27use crate::cache::Cache;
28use std::sync::Arc;
29
30// Re-export public types
31pub use key::CacheKeyGenerator;
32pub use stats::CacheStats;
33pub use ttl::DocCacheTtl;
34
35/// Document cache service
36///
37/// Provides document-specific cache operations, supports crate docs, search results, and item docs.
38///
39/// # Fields
40///
41/// - `cache`: Underlying cache instance
42/// - `ttl`: TTL configuration
43/// - `stats`: Cache statistics
44#[derive(Clone)]
45pub struct DocCache {
46    cache: Arc<dyn Cache>,
47    ttl: DocCacheTtl,
48    stats: CacheStats,
49}
50
51impl DocCache {
52    /// Create new document cache (with default TTL)
53    ///
54    /// # Arguments
55    ///
56    /// * `cache` - cache instance
57    ///
58    /// # Examples
59    ///
60    /// ```rust,no_run
61    /// use std::sync::Arc;
62    /// use crates_docs::tools::docs::cache::DocCache;
63    /// use crates_docs::cache::memory::MemoryCache;
64    ///
65    /// let cache = Arc::new(MemoryCache::new(1000));
66    /// let doc_cache = DocCache::new(cache);
67    /// ```
68    pub fn new(cache: Arc<dyn Cache>) -> Self {
69        Self {
70            cache,
71            ttl: DocCacheTtl::default(),
72            stats: CacheStats::new(),
73        }
74    }
75
76    /// Create new document cache (with custom TTL)
77    ///
78    /// # Arguments
79    ///
80    /// * `cache` - cache instance
81    /// * `ttl` - TTL configuration
82    ///
83    /// # Examples
84    ///
85    /// ```rust,no_run
86    /// use std::sync::Arc;
87    /// use crates_docs::tools::docs::cache::{DocCache, DocCacheTtl};
88    /// use crates_docs::cache::memory::MemoryCache;
89    ///
90    /// let cache = Arc::new(MemoryCache::new(1000));
91    /// let ttl = DocCacheTtl::with_jitter(
92    ///     7200,  // crate_docs_secs
93    ///     600,   // search_results_secs
94    ///     3600,  // item_docs_secs
95    ///     0.1,   // jitter_ratio
96    /// );
97    /// let doc_cache = DocCache::with_ttl(cache, ttl);
98    /// ```
99    pub fn with_ttl(cache: Arc<dyn Cache>, ttl: DocCacheTtl) -> Self {
100        Self {
101            cache,
102            ttl,
103            stats: CacheStats::new(),
104        }
105    }
106
107    /// Get cached crate documentation
108    ///
109    /// # Arguments
110    ///
111    /// * `crate_name` - crate name
112    /// * `version` - Optional version
113    ///
114    /// # Returns
115    ///
116    /// Returns document content if cache hit; otherwise returns `None`
117    #[tracing::instrument(skip(self), fields(crate = crate_name, version = version), level = "trace")]
118    pub async fn get_crate_docs(
119        &self,
120        crate_name: &str,
121        version: Option<&str>,
122    ) -> Option<Arc<str>> {
123        let key = CacheKeyGenerator::crate_cache_key(crate_name, version);
124        let result = self.cache.get(&key).await;
125        let is_hit = result.is_some();
126        if is_hit {
127            self.stats.record_hit();
128            tracing::span!(
129                tracing::Level::TRACE,
130                "cache",
131                op = "get",
132                hit = true,
133                crate = crate_name
134            )
135            .in_scope(|| {
136                tracing::trace!("Cache hit for crate docs");
137            });
138        } else {
139            self.stats.record_miss();
140            tracing::span!(
141                tracing::Level::TRACE,
142                "cache",
143                op = "get",
144                hit = false,
145                crate = crate_name
146            )
147            .in_scope(|| {
148                tracing::trace!("Cache miss for crate docs");
149            });
150        }
151        result
152    }
153
154    /// Set crate document cache
155    ///
156    /// # Arguments
157    ///
158    /// * `crate_name` - crate name
159    /// * `version` - Optional version
160    /// * `content` - Document content
161    ///
162    /// # Errors
163    ///
164    /// Returns error if cache operation fails
165    #[tracing::instrument(skip(self, content), fields(crate = crate_name, version = version), err, level = "trace")]
166    pub async fn set_crate_docs(
167        &self,
168        crate_name: &str,
169        version: Option<&str>,
170        content: String,
171    ) -> crate::error::Result<()> {
172        let key = CacheKeyGenerator::crate_cache_key(crate_name, version);
173        let ttl = self.ttl.crate_docs_duration();
174        self.cache.set(key, content, Some(ttl)).await?;
175        self.stats.record_set();
176        tracing::trace!(ttl_secs = ttl.as_secs(), "Crate docs cached");
177        Ok(())
178    }
179
180    /// Get cached crate HTML
181    ///
182    /// Returns `Arc<str>` to avoid unnecessary cloning on cache hits.
183    /// The caller can clone if an owned String is needed.
184    #[tracing::instrument(skip(self), fields(crate = crate_name, version = version), level = "trace")]
185    pub async fn get_crate_html(
186        &self,
187        crate_name: &str,
188        version: Option<&str>,
189    ) -> Option<Arc<str>> {
190        let key = CacheKeyGenerator::crate_html_cache_key(crate_name, version);
191        let result = self.cache.get(&key).await;
192        let is_hit = result.is_some();
193        if is_hit {
194            self.stats.record_hit();
195            tracing::span!(
196                tracing::Level::TRACE,
197                "cache",
198                op = "get_html",
199                hit = true,
200                crate = crate_name
201            )
202            .in_scope(|| {
203                tracing::trace!("Cache hit for crate HTML");
204            });
205        } else {
206            self.stats.record_miss();
207            tracing::span!(
208                tracing::Level::TRACE,
209                "cache",
210                op = "get_html",
211                hit = false,
212                crate = crate_name
213            )
214            .in_scope(|| {
215                tracing::trace!("Cache miss for crate HTML");
216            });
217        }
218        result
219    }
220
221    /// Set crate HTML cache
222    ///
223    /// # Errors
224    ///
225    /// Returns error if cache operation fails
226    #[tracing::instrument(skip(self, content), fields(crate = crate_name, version = version), err, level = "trace")]
227    pub async fn set_crate_html(
228        &self,
229        crate_name: &str,
230        version: Option<&str>,
231        content: String,
232    ) -> crate::error::Result<()> {
233        let key = CacheKeyGenerator::crate_html_cache_key(crate_name, version);
234        let ttl = self.ttl.crate_docs_duration();
235        self.cache.set(key, content, Some(ttl)).await?;
236        self.stats.record_set();
237        tracing::trace!(ttl_secs = ttl.as_secs(), "Crate HTML cached");
238        Ok(())
239    }
240
241    /// Get cached search results
242    ///
243    /// # Arguments
244    ///
245    /// * `query` - Search query
246    /// * `limit` - Result count limit
247    /// * `sort` - Optional search sort order
248    ///
249    /// # Returns
250    ///
251    /// Returns search results if cache hit;otherwise returns `None`
252    #[tracing::instrument(skip(self), fields(query, limit, sort), level = "trace")]
253    pub async fn get_search_results(
254        &self,
255        query: &str,
256        limit: u32,
257        sort: Option<&str>,
258    ) -> Option<Arc<str>> {
259        let key = CacheKeyGenerator::search_cache_key(query, limit, sort);
260        let result = self.cache.get(&key).await;
261        let is_hit = result.is_some();
262        if is_hit {
263            self.stats.record_hit();
264            tracing::span!(
265                tracing::Level::TRACE,
266                "cache",
267                op = "get_search",
268                hit = true
269            )
270            .in_scope(|| {
271                tracing::trace!("Cache hit for search results");
272            });
273        } else {
274            self.stats.record_miss();
275            tracing::span!(
276                tracing::Level::TRACE,
277                "cache",
278                op = "get_search",
279                hit = false
280            )
281            .in_scope(|| {
282                tracing::trace!("Cache miss for search results");
283            });
284        }
285        result
286    }
287
288    /// Set search results cache
289    ///
290    /// # Arguments
291    ///
292    /// * `query` - Search query
293    /// * `limit` - Result count limit
294    /// * `sort` - Optional search sort order
295    /// * `content` - search result content
296    ///
297    /// # Errors
298    ///
299    /// Returns error if cache operation fails
300    #[tracing::instrument(skip(self, content), fields(query, limit, sort), err, level = "trace")]
301    pub async fn set_search_results(
302        &self,
303        query: &str,
304        limit: u32,
305        sort: Option<&str>,
306        content: String,
307    ) -> crate::error::Result<()> {
308        let key = CacheKeyGenerator::search_cache_key(query, limit, sort);
309        let ttl = self.ttl.search_results_duration();
310        self.cache.set(key, content, Some(ttl)).await?;
311        self.stats.record_set();
312        tracing::trace!(ttl_secs = ttl.as_secs(), "Search results cached");
313        Ok(())
314    }
315
316    /// Get cached item docs
317    ///
318    /// # Arguments
319    ///
320    /// * `crate_name` - crate name
321    /// * `item_path` - Item path
322    /// * `version` - Optional version
323    ///
324    /// # Returns
325    ///
326    /// Returns item docs if cache hit;otherwise returns `None`
327    #[tracing::instrument(skip(self), fields(crate = crate_name, item = item_path, version), level = "trace")]
328    pub async fn get_item_docs(
329        &self,
330        crate_name: &str,
331        item_path: &str,
332        version: Option<&str>,
333    ) -> Option<Arc<str>> {
334        let key = CacheKeyGenerator::item_cache_key(crate_name, item_path, version);
335        let result = self.cache.get(&key).await;
336        let is_hit = result.is_some();
337        if is_hit {
338            self.stats.record_hit();
339            tracing::span!(tracing::Level::TRACE, "cache", op = "get_item", hit = true).in_scope(
340                || {
341                    tracing::trace!("Cache hit for item docs");
342                },
343            );
344        } else {
345            self.stats.record_miss();
346            tracing::span!(tracing::Level::TRACE, "cache", op = "get_item", hit = false).in_scope(
347                || {
348                    tracing::trace!("Cache miss for item docs");
349                },
350            );
351        }
352        result
353    }
354
355    /// Set item docs cache
356    ///
357    /// # Arguments
358    ///
359    /// * `crate_name` - crate name
360    /// * `item_path` - Item path
361    /// * `version` - Optional version
362    /// * `content` - Document content
363    ///
364    /// # Errors
365    ///
366    /// Returns error if cache operation fails
367    #[tracing::instrument(skip(self, content), fields(crate = crate_name, item = item_path, version), err, level = "trace")]
368    pub async fn set_item_docs(
369        &self,
370        crate_name: &str,
371        item_path: &str,
372        version: Option<&str>,
373        content: String,
374    ) -> crate::error::Result<()> {
375        let key = CacheKeyGenerator::item_cache_key(crate_name, item_path, version);
376        let ttl = self.ttl.item_docs_duration();
377        self.cache.set(key, content, Some(ttl)).await?;
378        self.stats.record_set();
379        tracing::trace!(ttl_secs = ttl.as_secs(), "Item docs cached");
380        Ok(())
381    }
382
383    /// Get cached item HTML
384    ///
385    /// Returns `Arc<str>` to avoid unnecessary cloning on cache hits.
386    /// The caller can clone if an owned String is needed.
387    #[tracing::instrument(skip(self), fields(crate = crate_name, item = item_path, version), level = "trace")]
388    pub async fn get_item_html(
389        &self,
390        crate_name: &str,
391        item_path: &str,
392        version: Option<&str>,
393    ) -> Option<Arc<str>> {
394        let key = CacheKeyGenerator::item_html_cache_key(crate_name, item_path, version);
395        let result = self.cache.get(&key).await;
396        let is_hit = result.is_some();
397        if is_hit {
398            self.stats.record_hit();
399            tracing::span!(
400                tracing::Level::TRACE,
401                "cache",
402                op = "get_item_html",
403                hit = true
404            )
405            .in_scope(|| {
406                tracing::trace!("Cache hit for item HTML");
407            });
408        } else {
409            self.stats.record_miss();
410            tracing::span!(
411                tracing::Level::TRACE,
412                "cache",
413                op = "get_item_html",
414                hit = false
415            )
416            .in_scope(|| {
417                tracing::trace!("Cache miss for item HTML");
418            });
419        }
420        result
421    }
422
423    /// Set item HTML cache
424    ///
425    /// # Errors
426    ///
427    /// Returns error if cache operation fails
428    #[tracing::instrument(skip(self, content), fields(crate = crate_name, item = item_path, version), err, level = "trace")]
429    pub async fn set_item_html(
430        &self,
431        crate_name: &str,
432        item_path: &str,
433        version: Option<&str>,
434        content: String,
435    ) -> crate::error::Result<()> {
436        let key = CacheKeyGenerator::item_html_cache_key(crate_name, item_path, version);
437        let ttl = self.ttl.item_docs_duration();
438        self.cache.set(key, content, Some(ttl)).await?;
439        self.stats.record_set();
440        tracing::trace!(ttl_secs = ttl.as_secs(), "Item HTML cached");
441        Ok(())
442    }
443
444    /// Clear cache
445    ///
446    /// # Errors
447    ///
448    /// Returns error if cache operation fails
449    #[tracing::instrument(skip(self), err, level = "trace")]
450    pub async fn clear(&self) -> crate::error::Result<()> {
451        tracing::trace!("Clearing all doc cache entries");
452        self.cache.clear().await
453    }
454
455    /// Get cache statistics
456    #[must_use]
457    pub fn stats(&self) -> &CacheStats {
458        &self.stats
459    }
460
461    /// Get TTL configuration
462    #[must_use]
463    pub fn ttl(&self) -> &DocCacheTtl {
464        &self.ttl
465    }
466}
467
468impl Default for DocCache {
469    fn default() -> Self {
470        let cache = Arc::new(crate::cache::memory::MemoryCache::new(1000));
471        Self::new(cache)
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::cache::memory::MemoryCache;
479
480    #[tokio::test]
481    async fn test_doc_cache() {
482        let memory_cache = MemoryCache::new(100);
483        let cache = Arc::new(memory_cache);
484        let doc_cache = DocCache::new(cache);
485
486        // Test crate document cache
487        doc_cache
488            .set_crate_docs("serde", Some("1.0"), "Test docs".to_string())
489            .await
490            .expect("set_crate_docs should succeed");
491        let cached = doc_cache.get_crate_docs("serde", Some("1.0")).await;
492        assert_eq!(
493            cached.as_ref().map(std::convert::AsRef::as_ref),
494            Some("Test docs")
495        );
496
497        // Test search results cache
498        doc_cache
499            .set_search_results(
500                "web framework",
501                10,
502                Some("relevance"),
503                "Search results".to_string(),
504            )
505            .await
506            .expect("set_search_results should succeed");
507        let search_cached = doc_cache
508            .get_search_results("web framework", 10, Some("relevance"))
509            .await;
510        assert_eq!(
511            search_cached.as_ref().map(std::convert::AsRef::as_ref),
512            Some("Search results")
513        );
514
515        // Test item docs cache
516        doc_cache
517            .set_item_docs(
518                "serde",
519                "serde::Serialize",
520                Some("1.0"),
521                "Item docs".to_string(),
522            )
523            .await
524            .expect("set_item_docs should succeed");
525        let item_cached = doc_cache
526            .get_item_docs("serde", "serde::Serialize", Some("1.0"))
527            .await;
528        assert_eq!(
529            item_cached.as_ref().map(std::convert::AsRef::as_ref),
530            Some("Item docs")
531        );
532
533        // Test clear
534        doc_cache.clear().await.expect("clear should succeed");
535        let cleared = doc_cache.get_crate_docs("serde", Some("1.0")).await;
536        assert_eq!(cleared, None);
537    }
538
539    #[tokio::test]
540    async fn test_doc_cache_getters_preserve_shared_ownership() {
541        let memory_cache = Arc::new(MemoryCache::new(100));
542        let doc_cache = DocCache::new(memory_cache.clone());
543
544        doc_cache
545            .set_crate_docs("serde", Some("1.0"), "Test docs".to_string())
546            .await
547            .expect("set_crate_docs should succeed");
548
549        let key = CacheKeyGenerator::crate_cache_key("serde", Some("1.0"));
550        let cached_from_doc_cache = doc_cache
551            .get_crate_docs("serde", Some("1.0"))
552            .await
553            .expect("doc cache should return cached docs");
554        let cached_from_backend = memory_cache
555            .get(&key)
556            .await
557            .expect("backend cache should return cached docs");
558
559        assert!(Arc::ptr_eq(&cached_from_doc_cache, &cached_from_backend));
560    }
561
562    #[tokio::test]
563    async fn test_doc_cache_with_ttl() {
564        let memory_cache = MemoryCache::new(100);
565        let cache = Arc::new(memory_cache);
566
567        let mut ttl = DocCacheTtl::default();
568        ttl.crate_docs_secs = 7200;
569        ttl.search_results_secs = 600;
570        ttl.item_docs_secs = 3600;
571        ttl.set_jitter_ratio(0.0); // Disable jitter for predictable tests
572
573        let doc_cache = DocCache::with_ttl(cache, ttl);
574
575        assert_eq!(doc_cache.ttl().crate_docs_secs, 7200);
576        assert_eq!(doc_cache.ttl().search_results_secs, 600);
577        assert_eq!(doc_cache.ttl().item_docs_secs, 3600);
578    }
579
580    #[tokio::test]
581    async fn test_doc_cache_stats() {
582        let memory_cache = MemoryCache::new(100);
583        let cache = Arc::new(memory_cache);
584        let doc_cache = DocCache::new(cache);
585
586        // Record a hit
587        doc_cache
588            .set_crate_docs("serde", None, "docs".to_string())
589            .await
590            .ok();
591        doc_cache.get_crate_docs("serde", None).await;
592
593        // Record a miss
594        doc_cache.get_crate_docs("nonexistent", None).await;
595
596        assert_eq!(doc_cache.stats().hits(), 1);
597        assert_eq!(doc_cache.stats().misses(), 1);
598        assert_eq!(doc_cache.stats().sets(), 1);
599    }
600
601    #[test]
602    fn test_doc_cache_default() {
603        let doc_cache = DocCache::default();
604        assert_eq!(doc_cache.ttl().crate_docs_secs, 3600);
605        assert_eq!(doc_cache.ttl().search_results_secs, 300);
606        assert_eq!(doc_cache.ttl().item_docs_secs, 1800);
607    }
608}