1use std::future::Future;
9use std::path::Path;
10
11use crate::error::{Error, Result};
12use crate::model::Catalog;
13
14pub const TTL_SECS: i64 = 6 * 60 * 60;
16
17pub async fn load(client: &reqwest::Client, force_refresh: bool) -> Result<Catalog> {
22 let path = crate::paths::catalog_cache_file()?;
23 resolve(&path, now_secs(), force_refresh, || refresh(client)).await
24}
25
26async fn refresh(client: &reqwest::Client) -> Result<Catalog> {
28 let mut episodes = super::feed::fetch(client).await?;
29 let enrichment = super::enrich::enrich(client, &mut episodes).await;
30 Ok(Catalog {
31 episodes,
32 info: enrichment.info,
33 fetched_at: now_secs(),
34 enriched: enrichment.applied,
35 })
36}
37
38async fn resolve<F, Fut>(path: &Path, now: i64, force_refresh: bool, refresh: F) -> Result<Catalog>
40where
41 F: FnOnce() -> Fut,
42 Fut: Future<Output = Result<Catalog>>,
43{
44 let cached = read(path);
45
46 if !force_refresh
47 && let Some(catalog) = &cached
48 && catalog.age_secs(now) < TTL_SECS
49 {
50 return Ok(catalog.clone());
51 }
52
53 match refresh().await {
54 Ok(catalog) => {
55 if let Err(error) = write(path, &catalog) {
57 tracing::debug!("the catalog cache could not be written: {error}");
58 }
59 Ok(catalog)
60 }
61 Err(error) => match cached {
63 Some(catalog) => {
64 tracing::warn!(
65 "serving a catalog {} seconds old: {error}",
66 catalog.age_secs(now)
67 );
68 Ok(catalog)
69 }
70 None => Err(Error::CatalogUnavailable(format!(
71 "{error}, and no cached catalog is available"
72 ))),
73 },
74 }
75}
76
77pub fn read(path: &Path) -> Option<Catalog> {
79 let text = std::fs::read_to_string(path).ok()?;
80 match serde_json::from_str(&text) {
81 Ok(catalog) => Some(catalog),
82 Err(error) => {
83 tracing::debug!("{} could not be parsed: {error}", path.display());
84 None
85 }
86 }
87}
88
89pub fn write(path: &Path, catalog: &Catalog) -> Result<()> {
94 if let Some(parent) = path.parent() {
95 std::fs::create_dir_all(parent)?;
96 }
97 let document = serde_json::to_string_pretty(catalog).map_err(|error| {
98 Error::Internal(format!("The catalog could not be serialised: {error}"))
99 })?;
100 let partial = path.with_extension("json.tmp");
101 std::fs::write(&partial, document)?;
102 std::fs::rename(&partial, path)?;
103 Ok(())
104}
105
106fn now_secs() -> i64 {
107 std::time::SystemTime::now()
108 .duration_since(std::time::UNIX_EPOCH)
109 .map(|elapsed| elapsed.as_secs() as i64)
110 .unwrap_or(0)
111}
112
113#[cfg(test)]
114mod tests {
115 use std::cell::Cell;
116
117 use super::*;
118 use crate::model::Episode;
119
120 const NOW: i64 = 1_800_000_000;
121
122 fn catalog(fetched_at: i64, title: &str) -> Catalog {
123 Catalog {
124 info: Vec::new(),
125 episodes: vec![Episode {
126 bundle_title: None,
127 special: false,
128 title: title.into(),
129 link: "https://musicforprogramming.net/one".into(),
130 enclosure_url: "https://datashat.net/one.mp3".into(),
131 byte_len: 1,
132 duration_secs: 2,
133 published_at: 3,
134 slug: None,
135 order: None,
136 tracklist: None,
137 body: None,
138 links: None,
139 }],
140 fetched_at,
141 enriched: false,
142 }
143 }
144
145 struct Network<'a> {
148 result: Result<Catalog>,
149 calls: &'a Cell<usize>,
150 }
151
152 impl Network<'_> {
153 fn run(self) -> impl Future<Output = Result<Catalog>> {
154 self.calls.set(self.calls.get() + 1);
155 std::future::ready(self.result)
156 }
157 }
158
159 fn unreachable() -> Error {
160 Error::CatalogUnavailable("the feed is unreachable: connection refused".into())
161 }
162
163 #[tokio::test]
164 async fn a_fresh_cache_is_served_without_touching_the_network() {
165 let root = tempfile::tempdir().unwrap();
166 let path = root.path().join("catalog.json");
167 write(&path, &catalog(NOW - 3_600, "cached")).unwrap();
168 let calls = Cell::new(0);
169
170 let resolved = resolve(&path, NOW, false, || {
171 Network {
172 result: Ok(catalog(NOW, "network")),
173 calls: &calls,
174 }
175 .run()
176 })
177 .await
178 .unwrap();
179
180 assert_eq!(resolved.episodes[0].title, "cached");
181 assert_eq!(calls.get(), 0);
182 }
183
184 #[tokio::test]
185 async fn a_stale_cache_triggers_a_refresh_that_overwrites_it() {
186 let root = tempfile::tempdir().unwrap();
187 let path = root.path().join("catalog.json");
188 write(&path, &catalog(NOW - 7 * 3_600, "cached")).unwrap();
189 let calls = Cell::new(0);
190
191 let resolved = resolve(&path, NOW, false, || {
192 Network {
193 result: Ok(catalog(NOW, "network")),
194 calls: &calls,
195 }
196 .run()
197 })
198 .await
199 .unwrap();
200
201 assert_eq!(resolved.episodes[0].title, "network");
202 assert_eq!(calls.get(), 1);
203 assert_eq!(read(&path).unwrap().episodes[0].title, "network");
204 }
205
206 #[tokio::test]
207 async fn the_ttl_boundary_is_six_hours() {
208 assert_eq!(TTL_SECS, 6 * 60 * 60);
209 let root = tempfile::tempdir().unwrap();
210 let calls = Cell::new(0);
211
212 for (age, expected_calls) in [(TTL_SECS - 1, 0), (TTL_SECS, 1)] {
213 let path = root.path().join(format!("catalog-{age}.json"));
214 write(&path, &catalog(NOW - age, "cached")).unwrap();
215 calls.set(0);
216
217 resolve(&path, NOW, false, || {
218 Network {
219 result: Ok(catalog(NOW, "network")),
220 calls: &calls,
221 }
222 .run()
223 })
224 .await
225 .unwrap();
226
227 assert_eq!(calls.get(), expected_calls, "at age {age}");
228 }
229 }
230
231 #[tokio::test]
232 async fn a_stale_cache_is_served_when_the_refresh_fails() {
233 let root = tempfile::tempdir().unwrap();
234 let path = root.path().join("catalog.json");
235 let fetched_at = NOW - 3 * 86_400;
236 write(&path, &catalog(fetched_at, "cached")).unwrap();
237 let calls = Cell::new(0);
238
239 let resolved = resolve(&path, NOW, false, || {
240 Network {
241 result: Err(unreachable()),
242 calls: &calls,
243 }
244 .run()
245 })
246 .await
247 .unwrap();
248
249 assert_eq!(resolved.episodes[0].title, "cached");
250 assert_eq!(resolved.age_secs(NOW), 3 * 86_400);
251 assert_eq!(calls.get(), 1);
252 }
253
254 #[tokio::test]
255 async fn a_refresh_failure_with_no_cache_names_the_feed_as_unreachable() {
256 let root = tempfile::tempdir().unwrap();
257 let calls = Cell::new(0);
258
259 let error = resolve(&root.path().join("catalog.json"), NOW, false, || {
260 Network {
261 result: Err(unreachable()),
262 calls: &calls,
263 }
264 .run()
265 })
266 .await
267 .unwrap_err();
268
269 assert_eq!(error.code(), crate::error::ErrorCode::CatalogUnavailable);
270 assert!(error.to_string().contains("unreachable"));
271 assert!(error.to_string().contains("no cached catalog is available"));
272 }
273
274 #[tokio::test]
275 async fn a_forced_refresh_ignores_a_fresh_cache() {
276 let root = tempfile::tempdir().unwrap();
277 let path = root.path().join("catalog.json");
278 write(&path, &catalog(NOW - 600, "cached")).unwrap();
279 let calls = Cell::new(0);
280
281 let resolved = resolve(&path, NOW, true, || {
282 Network {
283 result: Ok(catalog(NOW, "network")),
284 calls: &calls,
285 }
286 .run()
287 })
288 .await
289 .unwrap();
290
291 assert_eq!(resolved.episodes[0].title, "network");
292 assert_eq!(calls.get(), 1);
293 assert_eq!(read(&path).unwrap().episodes[0].title, "network");
294 }
295
296 #[tokio::test]
297 async fn a_failed_forced_refresh_leaves_the_cache_file_untouched() {
298 let root = tempfile::tempdir().unwrap();
299 let path = root.path().join("catalog.json");
300 write(&path, &catalog(NOW - 600, "cached")).unwrap();
301 let before = std::fs::read(&path).unwrap();
302 let calls = Cell::new(0);
303
304 let resolved = resolve(&path, NOW, true, || {
305 Network {
306 result: Err(unreachable()),
307 calls: &calls,
308 }
309 .run()
310 })
311 .await
312 .unwrap();
313
314 assert_eq!(resolved.episodes[0].title, "cached");
315 assert_eq!(std::fs::read(&path).unwrap(), before);
316 }
317
318 #[tokio::test]
319 async fn a_corrupt_cache_file_is_a_miss_rather_than_an_error() {
320 let root = tempfile::tempdir().unwrap();
321 let path = root.path().join("catalog.json");
322 std::fs::write(&path, b"{ this is not json").unwrap();
323 let calls = Cell::new(0);
324
325 assert!(read(&path).is_none());
326
327 let resolved = resolve(&path, NOW, false, || {
328 Network {
329 result: Ok(catalog(NOW, "network")),
330 calls: &calls,
331 }
332 .run()
333 })
334 .await
335 .unwrap();
336
337 assert_eq!(resolved.episodes[0].title, "network");
338 assert_eq!(calls.get(), 1);
339 }
340
341 #[test]
342 fn a_missing_cache_file_is_a_miss_rather_than_an_error() {
343 let root = tempfile::tempdir().unwrap();
344 assert!(read(&root.path().join("nothing.json")).is_none());
345 }
346
347 #[test]
350 fn a_written_catalog_replaces_the_last_one_and_leaves_no_partial_file() {
351 let root = tempfile::tempdir().unwrap();
352 let path = root.path().join("catalog.json");
353
354 write(&path, &catalog(NOW, "first")).unwrap();
355 write(&path, &catalog(NOW, "second")).unwrap();
356
357 assert_eq!(read(&path).unwrap().episodes[0].title, "second");
358 let entries: Vec<String> = std::fs::read_dir(root.path())
359 .unwrap()
360 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
361 .collect();
362 assert_eq!(entries, ["catalog.json"]);
363 }
364
365 #[test]
366 fn a_written_catalog_round_trips_through_the_cache_file() {
367 let root = tempfile::tempdir().unwrap();
368 let path = root.path().join("nested").join("catalog.json");
369 let original = catalog(NOW, "cached");
370
371 write(&path, &original).unwrap();
372
373 assert_eq!(read(&path).unwrap(), original);
374 }
375}