use std::collections::HashMap;
pub const CACHE_TTL_KEY: &str = "vgi.cache.ttl";
pub const CACHE_EXPIRES_KEY: &str = "vgi.cache.expires";
pub const CACHE_NO_STORE_KEY: &str = "vgi.cache.no_store";
pub const CACHE_SCOPE_KEY: &str = "vgi.cache.scope";
pub const CACHE_ETAG_KEY: &str = "vgi.cache.etag";
pub const CACHE_LAST_MODIFIED_KEY: &str = "vgi.cache.last_modified";
pub const CACHE_REVALIDATABLE_KEY: &str = "vgi.cache.revalidatable";
pub const CACHE_STALE_WHILE_REVALIDATE_KEY: &str = "vgi.cache.stale_while_revalidate";
pub const CACHE_STALE_IF_ERROR_KEY: &str = "vgi.cache.stale_if_error";
pub const CACHE_NOT_MODIFIED_KEY: &str = "vgi.cache.not_modified";
pub const CACHE_IF_NONE_MATCH_KEY: &str = "vgi.cache.if_none_match";
pub const CACHE_IF_MODIFIED_SINCE_KEY: &str = "vgi.cache.if_modified_since";
pub const CACHE_SCOPE_CATALOG: &str = "catalog";
pub const CACHE_SCOPE_TRANSACTION: &str = "transaction";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CacheControl {
pub ttl_seconds: Option<i64>,
pub expires: Option<String>,
pub scope: String,
pub no_store: bool,
pub etag: Option<String>,
pub last_modified: Option<String>,
pub revalidatable: bool,
pub stale_while_revalidate: Option<i64>,
pub stale_if_error: Option<i64>,
pub not_modified: bool,
}
impl Default for CacheControl {
fn default() -> Self {
CacheControl {
ttl_seconds: None,
expires: None,
scope: CACHE_SCOPE_CATALOG.to_string(),
no_store: false,
etag: None,
last_modified: None,
revalidatable: false,
stale_while_revalidate: None,
stale_if_error: None,
not_modified: false,
}
}
}
impl CacheControl {
pub fn ttl(seconds: i64) -> Self {
CacheControl {
ttl_seconds: Some(seconds.max(0)),
..Default::default()
}
}
pub fn no_store() -> Self {
CacheControl {
no_store: true,
..Default::default()
}
}
pub fn with_transaction_scope(mut self) -> Self {
self.scope = CACHE_SCOPE_TRANSACTION.to_string();
self
}
pub fn with_etag(mut self, etag: impl Into<String>) -> Self {
self.etag = Some(etag.into());
self
}
pub fn with_last_modified(mut self, last_modified: impl Into<String>) -> Self {
self.last_modified = Some(last_modified.into());
self
}
pub fn with_revalidatable(mut self) -> Self {
self.revalidatable = true;
self
}
pub fn with_expires(mut self, expires: impl Into<String>) -> Self {
self.expires = Some(expires.into());
self
}
pub fn with_stale_while_revalidate(mut self, seconds: i64) -> Self {
self.stale_while_revalidate = Some(seconds.max(0));
self
}
pub fn with_stale_if_error(mut self, seconds: i64) -> Self {
self.stale_if_error = Some(seconds.max(0));
self
}
pub fn with_not_modified(mut self) -> Self {
self.not_modified = true;
self
}
pub fn to_metadata(&self) -> HashMap<String, String> {
let mut md = HashMap::new();
if let Some(ttl) = self.ttl_seconds {
md.insert(CACHE_TTL_KEY.to_string(), ttl.to_string());
}
if let Some(expires) = &self.expires {
md.insert(CACHE_EXPIRES_KEY.to_string(), expires.clone());
}
if self.no_store {
md.insert(CACHE_NO_STORE_KEY.to_string(), "1".to_string());
}
md.insert(CACHE_SCOPE_KEY.to_string(), self.scope.clone());
if let Some(etag) = &self.etag {
md.insert(CACHE_ETAG_KEY.to_string(), etag.clone());
}
if let Some(lm) = &self.last_modified {
md.insert(CACHE_LAST_MODIFIED_KEY.to_string(), lm.clone());
}
if self.revalidatable {
md.insert(CACHE_REVALIDATABLE_KEY.to_string(), "1".to_string());
}
if let Some(s) = self.stale_while_revalidate {
md.insert(CACHE_STALE_WHILE_REVALIDATE_KEY.to_string(), s.to_string());
}
if let Some(s) = self.stale_if_error {
md.insert(CACHE_STALE_IF_ERROR_KEY.to_string(), s.to_string());
}
if self.not_modified {
md.insert(CACHE_NOT_MODIFIED_KEY.to_string(), "1".to_string());
}
md
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ConditionalRequest {
pub if_none_match: Option<String>,
pub if_modified_since: Option<String>,
}
impl ConditionalRequest {
pub fn is_conditional(&self) -> bool {
self.if_none_match.is_some() || self.if_modified_since.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ttl_renders_ttl_and_default_scope() {
let md = CacheControl::ttl(300).to_metadata();
assert_eq!(md[CACHE_TTL_KEY], "300");
assert_eq!(md[CACHE_SCOPE_KEY], CACHE_SCOPE_CATALOG);
assert!(!md.contains_key(CACHE_NO_STORE_KEY));
assert!(!md.contains_key(CACHE_REVALIDATABLE_KEY));
}
#[test]
fn negative_ttl_clamps_to_zero() {
assert_eq!(CacheControl::ttl(-5).ttl_seconds, Some(0));
}
#[test]
fn no_store_omits_freshness_and_sets_flag() {
let md = CacheControl::no_store().to_metadata();
assert_eq!(md[CACHE_NO_STORE_KEY], "1");
assert!(!md.contains_key(CACHE_TTL_KEY));
}
#[test]
fn transaction_scope_renders() {
let md = CacheControl::ttl(300)
.with_transaction_scope()
.to_metadata();
assert_eq!(md[CACHE_SCOPE_KEY], CACHE_SCOPE_TRANSACTION);
}
#[test]
fn revalidatable_contract_renders_etag_and_flags() {
let md = CacheControl::ttl(0)
.with_etag("\"rev-v1\"")
.with_revalidatable()
.to_metadata();
assert_eq!(md[CACHE_TTL_KEY], "0");
assert_eq!(md[CACHE_ETAG_KEY], "\"rev-v1\"");
assert_eq!(md[CACHE_REVALIDATABLE_KEY], "1");
assert!(!md.contains_key(CACHE_NOT_MODIFIED_KEY));
}
#[test]
fn not_modified_renders() {
let md = CacheControl::ttl(0)
.with_etag("\"rev-v1\"")
.with_revalidatable()
.with_not_modified()
.to_metadata();
assert_eq!(md[CACHE_NOT_MODIFIED_KEY], "1");
}
#[test]
fn stale_windows_render_as_seconds() {
let md = CacheControl::ttl(10)
.with_stale_while_revalidate(5)
.with_stale_if_error(7)
.with_expires("2026-01-01T00:00:00Z")
.with_last_modified("2025-01-01T00:00:00Z")
.to_metadata();
assert_eq!(md[CACHE_STALE_WHILE_REVALIDATE_KEY], "5");
assert_eq!(md[CACHE_STALE_IF_ERROR_KEY], "7");
assert_eq!(md[CACHE_EXPIRES_KEY], "2026-01-01T00:00:00Z");
assert_eq!(md[CACHE_LAST_MODIFIED_KEY], "2025-01-01T00:00:00Z");
}
#[test]
fn conditional_request_detects_validators() {
assert!(!ConditionalRequest::default().is_conditional());
assert!(ConditionalRequest {
if_none_match: Some("\"x\"".into()),
..Default::default()
}
.is_conditional());
}
}