Skip to main content

specter/
cache.rs

1use crate::response::Response;
2use http::Method;
3use std::time::{Duration, SystemTime};
4
5#[derive(Debug, Clone)]
6pub struct CacheEntry {
7    pub response: Response,
8    pub expires: SystemTime,
9    pub etag: Option<String>,
10    pub last_modified: Option<String>,
11}
12
13#[derive(Debug)]
14pub enum CacheStatus {
15    /// Response is fresh and can be used directly.
16    Fresh(Response),
17    /// Response is stale but can be validated using conditional headers.
18    /// (Response, ETag, Last-Modified)
19    Revalidate(Response, Option<String>, Option<String>),
20    /// Cache miss.
21    Miss,
22}
23
24pub struct HttpCache {
25    // In-memory cache
26    entries: std::collections::HashMap<String, CacheEntry>,
27}
28
29impl Default for HttpCache {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl HttpCache {
36    pub fn new() -> Self {
37        Self {
38            entries: std::collections::HashMap::new(),
39        }
40    }
41
42    pub fn get(&self, method: &Method, url: &str) -> CacheStatus {
43        if method != Method::GET {
44            return CacheStatus::Miss;
45        }
46
47        if let Some(entry) = self.entries.get(url) {
48            if entry.expires > SystemTime::now() {
49                return CacheStatus::Fresh(entry.response.clone());
50            } else {
51                // Stale, check if revalidation is possible
52                if entry.etag.is_some() || entry.last_modified.is_some() {
53                    return CacheStatus::Revalidate(
54                        entry.response.clone(),
55                        entry.etag.clone(),
56                        entry.last_modified.clone(),
57                    );
58                }
59            }
60        }
61        CacheStatus::Miss
62    }
63
64    pub fn store(&mut self, url: &str, response: &Response) {
65        // Parse Cache-Control
66        if let Some(cc) = response.get_header("cache-control") {
67            if cc.contains("no-store") {
68                return;
69            }
70
71            // Determine TTL (simplified Max-Age parsing)
72            // Look for "max-age=N"
73            let ttl = if let Some(pos) = cc.find("max-age=") {
74                let start = pos + 8;
75                let end = cc[start..].find(',').map(|i| start + i).unwrap_or(cc.len());
76                cc[start..end].trim().parse::<u64>().unwrap_or(0)
77            } else {
78                0
79            };
80
81            if ttl == 0 {
82                // No implicit caching for now unless heuristics added
83                return;
84            }
85
86            let expires = SystemTime::now() + Duration::from_secs(ttl);
87
88            let etag = response.get_header("etag").map(|s| s.to_string());
89            let last_modified = response.get_header("last-modified").map(|s| s.to_string());
90
91            let entry = CacheEntry {
92                response: response.clone(),
93                expires,
94                etag,
95                last_modified,
96            };
97            self.entries.insert(url.to_string(), entry);
98        }
99    }
100}