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
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum Provenance {
223 Live,
225 TimedOut {
228 cached: bool,
230 },
231 FetchFailed {
235 cached: bool,
237 },
238}
239
240#[cfg(feature = "direct-http")]
263pub async fn load_live<T: SpecTransport>(
264 transport: &T,
265 config: &CacheConfig<'_>,
266 reducer: &ReducerConfig<'_>,
267 deadline: Duration,
268) -> (Surface, Provenance) {
269 let existing = read(config);
270 let cached = existing.is_some();
271 let fallback = |existing: Option<Cached>| {
272 existing
273 .map(|cached| cached.surface(reducer))
274 .unwrap_or_default()
275 };
276
277 match tokio::time::timeout(deadline, revalidate(transport, config, existing.as_ref())).await {
278 Ok(Some(fresh)) => {
279 write(config, &fresh);
280 (fresh.surface(reducer), Provenance::Live)
281 }
282 Ok(None) => (fallback(existing), Provenance::FetchFailed { cached }),
283 Err(_elapsed) => (fallback(existing), Provenance::TimedOut { cached }),
284 }
285}
286
287pub async fn load<T: SpecTransport>(
291 transport: &T,
292 config: &CacheConfig<'_>,
293 reducer: &ReducerConfig<'_>,
294) -> Surface {
295 let existing = read(config);
296
297 if let Some(cached) = &existing {
298 if cached.is_fresh(now(), config.revalidate_after) {
299 return cached.surface(reducer);
300 }
301 }
302
303 match revalidate(transport, config, existing.as_ref()).await {
304 Some(fresh) => {
305 write(config, &fresh);
306 fresh.surface(reducer)
307 }
308 None => {
309 existing
313 .map(|cached| cached.surface(reducer))
314 .unwrap_or_default()
315 }
316 }
317}
318
319async fn revalidate<T: SpecTransport>(
321 transport: &T,
322 config: &CacheConfig<'_>,
323 existing: Option<&Cached>,
324) -> Option<Cached> {
325 let document = match transport.fetch_discovery().await {
326 Ok(document) => document,
327 Err(error) => {
328 tracing::debug!(%error, "could not reach cassette discovery");
329 return None;
330 }
331 };
332 let discovery: Discovery = match serde_json::from_value(document) {
333 Ok(discovery) => discovery,
334 Err(error) => {
335 tracing::debug!(%error, "could not read the cassette discovery document");
336 return None;
337 }
338 };
339
340 for problem in &discovery.problems {
341 tracing::debug!(
345 subject = %problem.subject,
346 reason = %problem.reason,
347 "the server refused a configured cassette",
348 );
349 }
350
351 let mut specs: BTreeMap<String, CachedSpec> = BTreeMap::new();
352 for entry in &discovery.cassettes {
353 if !entry.has_spec() {
354 continue;
355 }
356 let previous = existing.and_then(|cached| cached.specs.get(&entry.name));
357 let etag = previous.and_then(|spec| spec.etag.as_deref());
358
359 match transport.fetch_spec(&entry.openapi_path, etag).await {
360 Ok(SpecFetch::Unchanged) => {
361 if let Some(previous) = previous {
362 specs.insert(entry.name.clone(), previous.clone());
363 }
364 }
365 Ok(SpecFetch::Fetched { document, etag }) => {
366 specs.insert(entry.name.clone(), CachedSpec { etag, document });
367 }
368 Err(error) => {
369 tracing::debug!(
372 cassette = %entry.name,
373 %error,
374 "could not fetch a cassette's OpenAPI document",
375 );
376 if let Some(previous) = previous {
377 specs.insert(entry.name.clone(), previous.clone());
378 }
379 }
380 }
381 }
382
383 Some(Cached {
384 base: config.key.to_owned(),
385 revalidated_at: now(),
386 discovery,
387 specs,
388 })
389}
390
391#[cfg(test)]
392#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
393mod tests {
394 use super::*;
395 use crate::cassettes::discovery::DiscoveryEntry;
396 use serde_json::json;
397
398 const REVALIDATE_AFTER: Duration = Duration::from_secs(600);
400
401 const RESERVED: ReducerConfig<'static> = ReducerConfig {
402 reserved_flags: &["tapes-url", "body", "help", "verbose"],
403 };
404
405 fn config(key: &str) -> CacheConfig<'_> {
406 CacheConfig {
407 app_dir_name: "tapesctl/cassettes",
408 env_override_var: "TAPESCTL_CACHE_DIR",
409 revalidate_after: REVALIDATE_AFTER,
410 key,
411 }
412 }
413
414 struct NeverAnswers;
418
419 impl crate::transport::SpecTransport for NeverAnswers {
420 type Error = std::convert::Infallible;
421
422 async fn fetch_discovery(&self) -> Result<Value, Self::Error> {
423 std::future::pending().await
424 }
425
426 async fn fetch_spec(
427 &self,
428 _path: &str,
429 _etag: Option<&str>,
430 ) -> Result<crate::transport::SpecFetch, Self::Error> {
431 unreachable!("the probe failed; no request may be made");
432 }
433
434 async fn execute(&self, _call: &crate::transport::Call<'_>) -> Result<Value, Self::Error> {
435 unreachable!("the probe failed; no request may be made");
436 }
437 }
438
439 #[tokio::test]
440 async fn load_live_times_out_against_a_transport_that_never_answers() {
441 let unique = format!("live-timeout-{}", std::process::id());
447 let started = std::time::Instant::now();
448 let (surface, provenance) = load_live(
449 &NeverAnswers,
450 &config(&unique),
451 &RESERVED,
452 Duration::from_millis(300),
453 )
454 .await;
455 assert!(surface.is_empty());
456 assert_eq!(provenance, Provenance::TimedOut { cached: false });
457 assert!(
458 started.elapsed() < Duration::from_secs(5),
459 "the deadline must actually bound the wait"
460 );
461 }
462
463 fn entry(name: &str) -> DiscoveryEntry {
464 DiscoveryEntry {
465 name: name.to_owned(),
466 route_prefix: format!("/v1/cassettes/{name}"),
467 openapi_path: format!("/v1/cassettes/{name}/openapi.json"),
468 openapi_status: "fresh".to_owned(),
469 ..Default::default()
470 }
471 }
472
473 fn hello_document(name: &str) -> Value {
474 json!({"paths": {format!("/v1/cassettes/{name}/hello"): {
475 "get": {"operationId": "getHello"}
476 }}})
477 }
478
479 fn cached(base: &str, name: &str, at: u64) -> Cached {
480 Cached {
481 base: base.to_owned(),
482 revalidated_at: at,
483 discovery: Discovery {
484 contract_version: "v1".to_owned(),
485 cassettes: vec![entry(name)],
486 problems: Vec::new(),
487 },
488 specs: BTreeMap::from([(
489 name.to_owned(),
490 CachedSpec {
491 etag: Some("\"sha256:abc\"".to_owned()),
492 document: hello_document(name),
493 },
494 )]),
495 }
496 }
497
498 #[test]
499 fn a_cached_entry_reduces_to_the_generated_surface() {
500 let surface = cached("http://a", "hello-world", 0).surface(&RESERVED);
501 assert_eq!(surface.cassettes.len(), 1);
502 assert_eq!(surface.cassettes[0].methods[0].name, "get-hello");
503 }
504
505 #[test]
506 fn a_cassette_with_no_cached_document_generates_no_noun() {
507 let mut entry = cached("http://a", "hello-world", 0);
509 entry.specs.clear();
510 assert!(entry.surface(&RESERVED).is_empty());
511 }
512
513 #[test]
514 fn freshness_expires_after_the_revalidation_window() {
515 let entry = cached("http://a", "hello-world", 1_000);
516 assert!(entry.is_fresh(1_000, REVALIDATE_AFTER));
517 assert!(entry.is_fresh(1_000 + REVALIDATE_AFTER.as_secs() - 1, REVALIDATE_AFTER));
518 assert!(!entry.is_fresh(1_000 + REVALIDATE_AFTER.as_secs(), REVALIDATE_AFTER));
519 }
520
521 #[test]
522 fn a_clock_that_moved_backwards_expires_rather_than_pinning_the_surface() {
523 let entry = cached("http://a", "hello-world", 5_000);
524 assert!(!entry.is_fresh(1_000, REVALIDATE_AFTER));
525 }
526
527 #[test]
528 fn two_base_urls_get_two_cache_files() {
529 let a = cache_path(&config("http://one.example")).unwrap();
531 let b = cache_path(&config("http://two.example")).unwrap();
532 assert_ne!(a, b);
533 }
534
535 #[test]
536 fn urls_that_sanitize_alike_still_get_different_files() {
537 let a = cache_path(&config("http://a-b.example")).unwrap();
540 let b = cache_path(&config("http://a.b-example")).unwrap();
541 assert_ne!(a, b);
542 }
543
544 #[test]
545 fn the_file_name_hash_is_stable_across_builds() {
546 assert_eq!(fnv1a(""), 0xcbf2_9ce4_8422_2325);
549 assert_eq!(
550 fnv1a("http://127.0.0.1:8081/"),
551 fnv1a("http://127.0.0.1:8081/")
552 );
553 assert_ne!(fnv1a("a"), fnv1a("b"));
554 }
555
556 #[test]
557 fn the_file_name_is_byte_identical_to_the_pre_extraction_layout() {
558 let path = cache_path(&CacheConfig {
566 env_override_var: "CASSETTE_CLIENT_TEST_UNSET_VAR",
567 ..config("http://127.0.0.1:8081/")
568 })
569 .unwrap();
570 assert_eq!(
571 path.file_name().unwrap().to_str().unwrap(),
572 "http___127_0_0_1_8081_-709aba2490ce417e.json",
573 );
574 assert!(path.parent().unwrap().ends_with("tapesctl/cassettes"));
575 }
576
577 #[test]
578 fn the_cached_serde_shape_is_byte_compatible_with_the_pre_extraction_format() {
579 let cached = cached("http://a", "hello-world", 42);
581 let encoded = serde_json::to_value(&cached).unwrap();
582 assert_eq!(
583 encoded,
584 json!({
585 "base": "http://a",
586 "revalidated_at": 42,
587 "discovery": {
588 "contract_version": "v1",
589 "cassettes": [{
590 "name": "hello-world",
591 "version": null,
592 "display_name": null,
593 "description": null,
594 "route_prefix": "/v1/cassettes/hello-world",
595 "openapi_path": "/v1/cassettes/hello-world/openapi.json",
596 "openapi_status": "fresh",
597 "manifest_digest": ""
598 }],
599 "problems": []
600 },
601 "specs": {
602 "hello-world": {
603 "etag": "\"sha256:abc\"",
604 "document": hello_document("hello-world")
605 }
606 }
607 }),
608 );
609 let decoded: Cached = serde_json::from_value(encoded).unwrap();
610 assert_eq!(decoded.base, cached.base);
611 }
612}