eure_env/cache/
meta.rs

1//! Cache metadata types.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6/// Metadata for a cached file.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct CacheMeta {
9    /// Original URL
10    pub url: String,
11    /// When the file was fetched
12    pub fetched_at: DateTime<Utc>,
13    /// When the file was last used
14    pub last_used_at: DateTime<Utc>,
15    /// HTTP ETag header (for conditional GET)
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub etag: Option<String>,
18    /// HTTP Last-Modified header (for conditional GET)
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub last_modified: Option<String>,
21    /// SHA256 hash of content
22    pub content_sha256: String,
23    /// File size in bytes
24    pub size_bytes: u64,
25}
26
27/// Result of checking cache freshness.
28#[derive(Debug, Clone, Serialize)]
29#[serde(tag = "action")]
30pub enum CacheAction {
31    /// No cache exists, fetch fresh.
32    #[serde(rename = "fetch")]
33    Fetch,
34    /// Cache is fresh, use it directly.
35    #[serde(rename = "use_cached")]
36    UseCached,
37    /// Cache is stale, revalidate with conditional headers.
38    #[serde(rename = "revalidate")]
39    Revalidate {
40        /// Headers to send for conditional GET.
41        headers: ConditionalHeaders,
42    },
43}
44
45/// Conditional GET headers.
46#[derive(Debug, Clone, Default, Serialize)]
47pub struct ConditionalHeaders {
48    /// If-None-Match header value (ETag).
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub if_none_match: Option<String>,
51    /// If-Modified-Since header value.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub if_modified_since: Option<String>,
54}
55
56impl CacheMeta {
57    /// Create a new CacheMeta with current timestamp.
58    pub fn new(
59        url: String,
60        etag: Option<String>,
61        last_modified: Option<String>,
62        content_sha256: String,
63        size_bytes: u64,
64    ) -> Self {
65        let now = Utc::now();
66        Self {
67            url,
68            fetched_at: now,
69            last_used_at: now,
70            etag,
71            last_modified,
72            content_sha256,
73            size_bytes,
74        }
75    }
76
77    /// Update last_used_at to current time.
78    pub fn touch(&mut self) {
79        self.last_used_at = Utc::now();
80    }
81
82    /// Check if this cache entry is fresh based on max_age.
83    ///
84    /// Returns the appropriate action to take.
85    pub fn check_freshness(&self, max_age_secs: u32) -> CacheAction {
86        let now = Utc::now();
87        let age = now.signed_duration_since(self.last_used_at);
88        let max_age = chrono::TimeDelta::seconds(max_age_secs as i64);
89
90        if age < max_age {
91            return CacheAction::UseCached;
92        }
93
94        // Cache is stale, need revalidation
95        if self.etag.is_some() || self.last_modified.is_some() {
96            CacheAction::Revalidate {
97                headers: ConditionalHeaders {
98                    if_none_match: self.etag.clone(),
99                    if_modified_since: self.last_modified.clone(),
100                },
101            }
102        } else {
103            // No conditional headers available, fetch fresh
104            CacheAction::Fetch
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn test_cache_meta_new() {
115        let meta = CacheMeta::new(
116            "https://example.com/schema.eure".to_string(),
117            Some("\"abc123\"".to_string()),
118            Some("Mon, 01 Jan 2024 00:00:00 GMT".to_string()),
119            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
120            1024,
121        );
122
123        assert_eq!(meta.url, "https://example.com/schema.eure");
124        assert_eq!(meta.etag, Some("\"abc123\"".to_string()));
125        assert_eq!(
126            meta.last_modified,
127            Some("Mon, 01 Jan 2024 00:00:00 GMT".to_string())
128        );
129        assert_eq!(meta.size_bytes, 1024);
130        // fetched_at and last_used_at should be the same initially
131        assert_eq!(meta.fetched_at, meta.last_used_at);
132    }
133
134    #[test]
135    fn test_cache_meta_serde_roundtrip() {
136        let meta = CacheMeta::new(
137            "https://example.com/schema.eure".to_string(),
138            Some("\"abc123\"".to_string()),
139            None,
140            "deadbeef".to_string(),
141            512,
142        );
143
144        let json = serde_json::to_string(&meta).unwrap();
145        let parsed: CacheMeta = serde_json::from_str(&json).unwrap();
146
147        assert_eq!(parsed.url, meta.url);
148        assert_eq!(parsed.etag, meta.etag);
149        assert_eq!(parsed.last_modified, meta.last_modified);
150        assert_eq!(parsed.content_sha256, meta.content_sha256);
151        assert_eq!(parsed.size_bytes, meta.size_bytes);
152    }
153
154    #[test]
155    fn test_check_freshness_fresh() {
156        let meta = CacheMeta::new(
157            "https://example.com/schema.eure".to_string(),
158            None,
159            None,
160            "hash".to_string(),
161            100,
162        );
163
164        // With a large max_age, cache should be fresh
165        match meta.check_freshness(3600) {
166            CacheAction::UseCached => {}
167            other => panic!("Expected UseCached, got {:?}", other),
168        }
169    }
170
171    #[test]
172    fn test_check_freshness_stale_with_etag() {
173        let mut meta = CacheMeta::new(
174            "https://example.com/schema.eure".to_string(),
175            Some("\"abc123\"".to_string()),
176            None,
177            "hash".to_string(),
178            100,
179        );
180
181        // Set last_used_at to the past
182        meta.last_used_at = Utc::now() - chrono::TimeDelta::hours(2);
183
184        // With a small max_age, cache should need revalidation
185        match meta.check_freshness(60) {
186            CacheAction::Revalidate { headers } => {
187                assert_eq!(headers.if_none_match, Some("\"abc123\"".to_string()));
188                assert_eq!(headers.if_modified_since, None);
189            }
190            other => panic!("Expected Revalidate, got {:?}", other),
191        }
192    }
193
194    #[test]
195    fn test_check_freshness_stale_no_headers() {
196        let mut meta = CacheMeta::new(
197            "https://example.com/schema.eure".to_string(),
198            None,
199            None,
200            "hash".to_string(),
201            100,
202        );
203
204        // Set last_used_at to the past
205        meta.last_used_at = Utc::now() - chrono::TimeDelta::hours(2);
206
207        // Without conditional headers, should fetch fresh
208        match meta.check_freshness(60) {
209            CacheAction::Fetch => {}
210            other => panic!("Expected Fetch, got {:?}", other),
211        }
212    }
213}