1use std::collections::BTreeMap;
41use std::path::PathBuf;
42use std::time::{Duration, SystemTime, UNIX_EPOCH};
43
44use serde::{Deserialize, Serialize};
45use serde_json::Value;
46
47use crate::cassettes::discovery::Discovery;
48use crate::cassettes::spec::{self, ReducerConfig, Surface};
49use crate::transport::{SpecFetch, SpecTransport};
50
51#[derive(Debug, Clone, Copy)]
53pub struct CacheConfig<'a> {
54 pub app_dir_name: &'a str,
56 pub env_override_var: &'a str,
59 pub revalidate_after: Duration,
66 pub key: &'a str,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CachedSpec {
74 #[serde(default)]
76 pub etag: Option<String>,
77 pub document: Value,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Cached {
89 pub base: String,
92 pub revalidated_at: u64,
94 pub discovery: Discovery,
96 pub specs: BTreeMap<String, CachedSpec>,
98}
99
100impl Cached {
101 #[must_use]
103 pub fn surface(&self, reducer: &ReducerConfig<'_>) -> Surface {
104 let cassettes = self
105 .discovery
106 .cassettes
107 .iter()
108 .filter_map(|entry| {
109 let cached = self.specs.get(&entry.name)?;
110 Some(spec::reduce(
111 &entry.name,
112 entry.description.clone(),
113 &cached.document,
114 reducer,
115 ))
116 })
117 .collect();
118 Surface { cassettes }
119 }
120
121 #[must_use]
123 pub fn is_fresh(&self, now: u64, revalidate_after: Duration) -> bool {
124 now >= self.revalidated_at && now - self.revalidated_at < revalidate_after.as_secs()
128 }
129}
130
131fn now() -> u64 {
133 SystemTime::now()
134 .duration_since(UNIX_EPOCH)
135 .map_or(0, |d| d.as_secs())
136}
137
138fn cache_dir(config: &CacheConfig<'_>) -> Option<PathBuf> {
140 if let Ok(raw) = std::env::var(config.env_override_var) {
141 if !raw.trim().is_empty() {
142 return Some(PathBuf::from(raw));
143 }
144 }
145 Some(dirs::cache_dir()?.join(config.app_dir_name))
146}
147
148fn cache_path(config: &CacheConfig<'_>) -> Option<PathBuf> {
153 let readable: String = config
154 .key
155 .chars()
156 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
157 .collect();
158 let trimmed: String = readable.chars().take(48).collect();
159 Some(cache_dir(config)?.join(format!("{trimmed}-{:016x}.json", fnv1a(config.key))))
160}
161
162fn fnv1a(input: &str) -> u64 {
167 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
168 for byte in input.as_bytes() {
169 hash ^= u64::from(*byte);
170 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
171 }
172 hash
173}
174
175#[must_use]
178pub fn read(config: &CacheConfig<'_>) -> Option<Cached> {
179 let path = cache_path(config)?;
180 let raw = std::fs::read(&path).ok()?;
181 let cached: Cached = serde_json::from_slice(&raw).ok()?;
182 (cached.base == config.key).then_some(cached)
185}
186
187pub fn write(config: &CacheConfig<'_>, cached: &Cached) {
193 let Some(path) = cache_path(config) else {
194 return;
195 };
196 let Some(parent) = path.parent() else {
197 return;
198 };
199 if let Err(error) = std::fs::create_dir_all(parent) {
200 tracing::debug!(%error, "could not create the cassette cache directory");
201 return;
202 }
203 let Ok(encoded) = serde_json::to_vec(cached) else {
204 return;
205 };
206
207 let temporary = path.with_extension(format!("{}.tmp", std::process::id()));
208 if let Err(error) = std::fs::write(&temporary, &encoded) {
209 tracing::debug!(%error, "could not write the cassette cache");
210 return;
211 }
212 if let Err(error) = std::fs::rename(&temporary, &path) {
213 tracing::debug!(%error, "could not install the cassette cache");
214 let _ = std::fs::remove_file(&temporary);
215 }
216}
217
218pub async fn load<T: SpecTransport>(
222 transport: &T,
223 config: &CacheConfig<'_>,
224 reducer: &ReducerConfig<'_>,
225) -> Surface {
226 let existing = read(config);
227
228 if let Some(cached) = &existing {
229 if cached.is_fresh(now(), config.revalidate_after) {
230 return cached.surface(reducer);
231 }
232 }
233
234 match revalidate(transport, config, existing.as_ref()).await {
235 Some(fresh) => {
236 write(config, &fresh);
237 fresh.surface(reducer)
238 }
239 None => {
240 existing
244 .map(|cached| cached.surface(reducer))
245 .unwrap_or_default()
246 }
247 }
248}
249
250async fn revalidate<T: SpecTransport>(
252 transport: &T,
253 config: &CacheConfig<'_>,
254 existing: Option<&Cached>,
255) -> Option<Cached> {
256 let document = match transport.fetch_discovery().await {
257 Ok(document) => document,
258 Err(error) => {
259 tracing::debug!(%error, "could not reach cassette discovery");
260 return None;
261 }
262 };
263 let discovery: Discovery = match serde_json::from_value(document) {
264 Ok(discovery) => discovery,
265 Err(error) => {
266 tracing::debug!(%error, "could not read the cassette discovery document");
267 return None;
268 }
269 };
270
271 for problem in &discovery.problems {
272 tracing::debug!(
276 subject = %problem.subject,
277 reason = %problem.reason,
278 "the server refused a configured cassette",
279 );
280 }
281
282 let mut specs: BTreeMap<String, CachedSpec> = BTreeMap::new();
283 for entry in &discovery.cassettes {
284 if !entry.has_spec() {
285 continue;
286 }
287 let previous = existing.and_then(|cached| cached.specs.get(&entry.name));
288 let etag = previous.and_then(|spec| spec.etag.as_deref());
289
290 match transport.fetch_spec(&entry.openapi_path, etag).await {
291 Ok(SpecFetch::Unchanged) => {
292 if let Some(previous) = previous {
293 specs.insert(entry.name.clone(), previous.clone());
294 }
295 }
296 Ok(SpecFetch::Fetched { document, etag }) => {
297 specs.insert(entry.name.clone(), CachedSpec { etag, document });
298 }
299 Err(error) => {
300 tracing::debug!(
303 cassette = %entry.name,
304 %error,
305 "could not fetch a cassette's OpenAPI document",
306 );
307 if let Some(previous) = previous {
308 specs.insert(entry.name.clone(), previous.clone());
309 }
310 }
311 }
312 }
313
314 Some(Cached {
315 base: config.key.to_owned(),
316 revalidated_at: now(),
317 discovery,
318 specs,
319 })
320}
321
322#[cfg(test)]
323#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
324mod tests {
325 use super::*;
326 use crate::cassettes::discovery::DiscoveryEntry;
327 use serde_json::json;
328
329 const REVALIDATE_AFTER: Duration = Duration::from_secs(600);
331
332 const RESERVED: ReducerConfig<'static> = ReducerConfig {
333 reserved_flags: &["tapes-url", "body", "help", "verbose"],
334 };
335
336 fn config(key: &str) -> CacheConfig<'_> {
337 CacheConfig {
338 app_dir_name: "tapesctl/cassettes",
339 env_override_var: "TAPESCTL_CACHE_DIR",
340 revalidate_after: REVALIDATE_AFTER,
341 key,
342 }
343 }
344
345 fn entry(name: &str) -> DiscoveryEntry {
346 DiscoveryEntry {
347 name: name.to_owned(),
348 route_prefix: format!("/v1/cassettes/{name}"),
349 openapi_path: format!("/v1/cassettes/{name}/openapi.json"),
350 openapi_status: "fresh".to_owned(),
351 ..Default::default()
352 }
353 }
354
355 fn hello_document(name: &str) -> Value {
356 json!({"paths": {format!("/v1/cassettes/{name}/hello"): {
357 "get": {"operationId": "getHello"}
358 }}})
359 }
360
361 fn cached(base: &str, name: &str, at: u64) -> Cached {
362 Cached {
363 base: base.to_owned(),
364 revalidated_at: at,
365 discovery: Discovery {
366 contract_version: "v1".to_owned(),
367 cassettes: vec![entry(name)],
368 problems: Vec::new(),
369 },
370 specs: BTreeMap::from([(
371 name.to_owned(),
372 CachedSpec {
373 etag: Some("\"sha256:abc\"".to_owned()),
374 document: hello_document(name),
375 },
376 )]),
377 }
378 }
379
380 #[test]
381 fn a_cached_entry_reduces_to_the_generated_surface() {
382 let surface = cached("http://a", "hello-world", 0).surface(&RESERVED);
383 assert_eq!(surface.cassettes.len(), 1);
384 assert_eq!(surface.cassettes[0].methods[0].name, "get-hello");
385 }
386
387 #[test]
388 fn a_cassette_with_no_cached_document_generates_no_noun() {
389 let mut entry = cached("http://a", "hello-world", 0);
391 entry.specs.clear();
392 assert!(entry.surface(&RESERVED).is_empty());
393 }
394
395 #[test]
396 fn freshness_expires_after_the_revalidation_window() {
397 let entry = cached("http://a", "hello-world", 1_000);
398 assert!(entry.is_fresh(1_000, REVALIDATE_AFTER));
399 assert!(entry.is_fresh(1_000 + REVALIDATE_AFTER.as_secs() - 1, REVALIDATE_AFTER));
400 assert!(!entry.is_fresh(1_000 + REVALIDATE_AFTER.as_secs(), REVALIDATE_AFTER));
401 }
402
403 #[test]
404 fn a_clock_that_moved_backwards_expires_rather_than_pinning_the_surface() {
405 let entry = cached("http://a", "hello-world", 5_000);
406 assert!(!entry.is_fresh(1_000, REVALIDATE_AFTER));
407 }
408
409 #[test]
410 fn two_base_urls_get_two_cache_files() {
411 let a = cache_path(&config("http://one.example")).unwrap();
413 let b = cache_path(&config("http://two.example")).unwrap();
414 assert_ne!(a, b);
415 }
416
417 #[test]
418 fn urls_that_sanitize_alike_still_get_different_files() {
419 let a = cache_path(&config("http://a-b.example")).unwrap();
422 let b = cache_path(&config("http://a.b-example")).unwrap();
423 assert_ne!(a, b);
424 }
425
426 #[test]
427 fn the_file_name_hash_is_stable_across_builds() {
428 assert_eq!(fnv1a(""), 0xcbf2_9ce4_8422_2325);
431 assert_eq!(
432 fnv1a("http://127.0.0.1:8081/"),
433 fnv1a("http://127.0.0.1:8081/")
434 );
435 assert_ne!(fnv1a("a"), fnv1a("b"));
436 }
437
438 #[test]
439 fn the_file_name_is_byte_identical_to_the_pre_extraction_layout() {
440 let path = cache_path(&CacheConfig {
448 env_override_var: "CASSETTE_CLIENT_TEST_UNSET_VAR",
449 ..config("http://127.0.0.1:8081/")
450 })
451 .unwrap();
452 assert_eq!(
453 path.file_name().unwrap().to_str().unwrap(),
454 "http___127_0_0_1_8081_-709aba2490ce417e.json",
455 );
456 assert!(path.parent().unwrap().ends_with("tapesctl/cassettes"));
457 }
458
459 #[test]
460 fn the_cached_serde_shape_is_byte_compatible_with_the_pre_extraction_format() {
461 let cached = cached("http://a", "hello-world", 42);
463 let encoded = serde_json::to_value(&cached).unwrap();
464 assert_eq!(
465 encoded,
466 json!({
467 "base": "http://a",
468 "revalidated_at": 42,
469 "discovery": {
470 "contract_version": "v1",
471 "cassettes": [{
472 "name": "hello-world",
473 "version": null,
474 "display_name": null,
475 "description": null,
476 "route_prefix": "/v1/cassettes/hello-world",
477 "openapi_path": "/v1/cassettes/hello-world/openapi.json",
478 "openapi_status": "fresh",
479 "manifest_digest": ""
480 }],
481 "problems": []
482 },
483 "specs": {
484 "hello-world": {
485 "etag": "\"sha256:abc\"",
486 "document": hello_document("hello-world")
487 }
488 }
489 }),
490 );
491 let decoded: Cached = serde_json::from_value(encoded).unwrap();
492 assert_eq!(decoded.base, cached.base);
493 }
494}