use std::time::Duration;
use crate::cache::{Cache, MAX_STALE};
use crate::error::{AppError, Result};
#[derive(Debug, Clone)]
pub struct Outcome<T> {
pub snapshot: T,
pub stale: bool,
pub last_error: Option<(u16, String)>,
pub cache_age: Option<Duration>,
}
impl<T> Outcome<T> {
pub fn fresh(snapshot: T) -> Self {
Self {
snapshot,
stale: false,
last_error: None,
cache_age: Some(Duration::ZERO),
}
}
pub fn cached(snapshot: T, cache: &Cache, stale: bool) -> Self {
Self {
snapshot,
stale,
last_error: cache.read_last_error(),
cache_age: cache.payload_age(),
}
}
pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Outcome<U> {
Outcome {
snapshot: f(self.snapshot),
stale: self.stale,
last_error: self.last_error,
cache_age: self.cache_age,
}
}
}
pub fn fallback<T>(
cache: &Cache,
last_error: Option<(u16, String)>,
original: AppError,
parse: impl FnOnce(&[u8]) -> Result<T>,
) -> Result<Outcome<T>> {
let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
return Err(original);
};
let Ok(snapshot) = parse(&bytes) else {
return Err(original);
};
let mut outcome = Outcome::cached(snapshot, cache, true);
if last_error.is_some() {
outcome.last_error = last_error;
}
Ok(outcome)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fixture() -> (TempDir, Cache) {
let td = TempDir::new().unwrap();
let cache = Cache::at(td.path().join("vendor"));
cache.ensure_dir().unwrap();
(td, cache)
}
fn parse_ok(bytes: &[u8]) -> Result<String> {
Ok(String::from_utf8_lossy(bytes).into_owned())
}
fn parse_fails(_: &[u8]) -> Result<String> {
Err(AppError::Schema("cached payload is not ours".into()))
}
#[test]
fn no_vendor_assembles_the_record_by_hand() {
let mut sites = Vec::new();
for file in crate::guard::rs_files_in("src") {
if file.ends_with("outcome.rs") {
continue;
}
let source = std::fs::read_to_string(&file).expect("readable module");
for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
let line = line.trim();
if line == "cache_age: Some(Duration::ZERO),"
|| line == "cache_age: cache.payload_age(),"
{
sites.push(format!("{}:{}", file.display(), n + 1));
}
}
}
assert!(
sites.is_empty(),
"build outcomes with `Outcome::fresh` or `Outcome::cached`, so the \
provenance rules live in one place. Found: {sites:#?}"
);
}
#[test]
fn a_fresh_outcome_clears_the_recorded_error() {
let out = Outcome::fresh("live");
assert_eq!(out.snapshot, "live");
assert!(!out.stale);
assert_eq!(out.last_error, None);
assert_eq!(out.cache_age, Some(Duration::ZERO));
}
#[test]
fn a_cached_outcome_carries_the_recorded_error() {
let (_td, cache) = fixture();
cache.write_last_error(500, "upstream is down");
let out = Outcome::cached("cached", &cache, true);
assert!(out.stale);
assert_eq!(out.last_error, Some((500, "upstream is down".to_string())));
}
#[test]
fn map_re_types_the_snapshot_and_keeps_the_provenance() {
let (_td, cache) = fixture();
cache.write_last_error(429, "slow down");
let out = Outcome::cached(7u8, &cache, true).map(|n| n as u32 * 2);
assert_eq!(out.snapshot, 14u32);
assert!(out.stale);
assert_eq!(out.last_error, Some((429, "slow down".to_string())));
}
#[test]
fn no_cache_returns_the_error_that_caused_the_failure() {
let (_td, cache) = fixture();
let err = fallback(
&cache,
Some((401, "Authentication failed".into())),
AppError::Http {
status: 401,
body: "Authentication failed".into(),
},
parse_ok,
)
.unwrap_err();
assert!(
matches!(err, AppError::Http { status: 401, .. }),
"got {err:?}"
);
}
#[test]
fn an_unparseable_cached_payload_also_reports_the_original_error() {
let (_td, cache) = fixture();
cache
.write_payload(b"payload from another account")
.unwrap();
let err = fallback(
&cache,
None,
AppError::Transport("network unreachable".into()),
parse_fails,
)
.unwrap_err();
assert!(
matches!(&err, AppError::Transport(m) if m == "network unreachable"),
"the cache parse failure masked the real cause: {err:?}"
);
}
#[test]
fn a_usable_cache_is_served_stale_with_the_fresher_diagnostic() {
let (_td, cache) = fixture();
cache.write_payload(b"last good").unwrap();
cache.write_last_error(500, "an older failure");
let out = fallback(
&cache,
Some((401, "Authentication failed".into())),
AppError::Http {
status: 401,
body: "raw".into(),
},
parse_ok,
)
.unwrap();
assert_eq!(out.snapshot, "last good");
assert!(out.stale);
assert_eq!(
out.last_error,
Some((401, "Authentication failed".to_string())),
"the caller's fresher diagnostic must win over the recorded one"
);
}
#[test]
fn a_silent_fallback_keeps_the_error_already_recorded() {
let (_td, cache) = fixture();
cache.write_payload(b"last good").unwrap();
cache.write_last_error(500, "an earlier failure");
let out = fallback(
&cache,
None,
AppError::Transport("network unreachable".into()),
parse_ok,
)
.unwrap();
assert_eq!(
out.last_error,
Some((500, "an earlier failure".to_string()))
);
}
}