1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct CacheMeta {
9 pub url: String,
11 pub fetched_at: DateTime<Utc>,
13 pub last_used_at: DateTime<Utc>,
15 #[serde(skip_serializing_if = "Option::is_none")]
17 pub etag: Option<String>,
18 #[serde(skip_serializing_if = "Option::is_none")]
20 pub last_modified: Option<String>,
21 pub content_sha256: String,
23 pub size_bytes: u64,
25}
26
27#[derive(Debug, Clone, Serialize)]
29#[serde(tag = "action")]
30pub enum CacheAction {
31 #[serde(rename = "fetch")]
33 Fetch,
34 #[serde(rename = "use_cached")]
36 UseCached,
37 #[serde(rename = "revalidate")]
39 Revalidate {
40 headers: ConditionalHeaders,
42 },
43}
44
45#[derive(Debug, Clone, Default, Serialize)]
47pub struct ConditionalHeaders {
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub if_none_match: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub if_modified_since: Option<String>,
54}
55
56impl CacheMeta {
57 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 pub fn touch(&mut self) {
79 self.last_used_at = Utc::now();
80 }
81
82 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 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 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 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 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 meta.last_used_at = Utc::now() - chrono::TimeDelta::hours(2);
183
184 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 meta.last_used_at = Utc::now() - chrono::TimeDelta::hours(2);
206
207 match meta.check_freshness(60) {
209 CacheAction::Fetch => {}
210 other => panic!("Expected Fetch, got {:?}", other),
211 }
212 }
213}