use std::fmt;
use serde::Deserialize;
use crate::clock::Clock;
use crate::manifest::Command;
const UNITS: [(char, u64); 4] = [('s', 1), ('m', 60), ('h', 3_600), ('d', 86_400)];
const GRAMMAR: &str =
"expected a positive whole number and one unit — s, m, h or d (e.g. 30m, 12h, 7d)";
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(try_from = "String")]
pub struct Ttl {
secs: u64,
text: String,
}
impl Ttl {
pub fn parse(text: &str) -> Result<Self, String> {
let refuse = |why: &str| format!("invalid ttl `{text}`: {why}");
let unit = text.chars().last().ok_or_else(|| refuse(GRAMMAR))?;
let digits = text.strip_suffix(unit).unwrap_or_default();
let scale = UNITS
.iter()
.find(|(name, _)| *name == unit)
.map(|(_, scale)| *scale)
.ok_or_else(|| refuse(GRAMMAR))?;
let malformed = digits.is_empty()
|| !digits.bytes().all(|byte| byte.is_ascii_digit())
|| (digits.starts_with('0') && digits != "0");
if malformed {
return Err(refuse(GRAMMAR));
}
let count: u64 = digits.parse().map_err(|_| refuse("too large"))?;
if count == 0 {
return Err(refuse(
"a zero ttl is never fresh; drop the rule instead of memoizing it",
));
}
let secs = count
.checked_mul(scale)
.ok_or_else(|| refuse("too large"))?;
Ok(Self {
secs,
text: text.to_owned(),
})
}
#[must_use]
pub const fn secs(&self) -> u64 {
self.secs
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.text
}
#[must_use]
pub const fn expires_at(&self, ran_at: u64) -> u64 {
ran_at.saturating_add(self.secs)
}
#[must_use]
pub const fn is_expired(&self, ran_at: u64, now: Clock) -> bool {
now.now_secs() >= self.expires_at(ran_at)
}
}
impl TryFrom<String> for Ttl {
type Error = String;
fn try_from(text: String) -> Result<Self, Self::Error> {
Self::parse(&text)
}
}
impl fmt::Display for Ttl {
fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result {
out.write_str(&self.text)
}
}
#[must_use]
pub fn time_only(rule: &Command) -> bool {
rule.ttl.is_some() && rule.inputs.is_empty()
}
#[must_use]
pub fn outlived(ttl: Option<&Ttl>, ok: bool, ran_at: u64, now: Clock) -> Option<&Ttl> {
ttl.filter(|ttl| ok && ttl.is_expired(ran_at, now))
}
#[cfg(test)]
mod tests {
use super::{Ttl, outlived};
use crate::clock::Clock;
fn secs(text: &str) -> u64 {
Ttl::parse(text).expect("valid ttl").secs()
}
#[test]
fn each_unit_scales_its_count() {
assert_eq!(secs("45s"), 45);
assert_eq!(secs("30m"), 30 * 60);
assert_eq!(secs("12h"), 12 * 3_600);
assert_eq!(secs("7d"), 7 * 86_400);
assert_eq!(secs("1s"), 1, "the smallest legal ttl");
}
#[test]
fn the_spelling_is_kept_for_display() {
let ttl = Ttl::parse("24h").expect("valid");
assert_eq!(ttl.as_str(), "24h");
assert_eq!(ttl.to_string(), "24h");
}
#[test]
fn anything_outside_the_grammar_is_refused_naming_it() {
for bad in [
"", "h", "10", "1h30m", "1.5h", "-1h", "+1h", "01h", "00h", "1H", "1 h", " 1h", "1h ",
"1w", "1ms", "1y", "1ч", "١h",
] {
let err = Ttl::parse(bad).expect_err("malformed ttl is refused");
assert!(err.contains(&format!("`{bad}`")), "names the value: {err}");
assert!(err.contains("s, m, h or d"), "teaches the grammar: {err}");
}
}
#[test]
fn zero_is_refused_with_its_own_reason() {
for zero in ["0s", "0m", "0h", "0d"] {
let err = Ttl::parse(zero).expect_err("zero ttl is refused");
assert!(err.contains("zero"), "{err}");
}
}
#[test]
fn a_ttl_past_a_timestamp_is_refused_rather_than_wrapped() {
for huge in [
"99999999999999999999d",
"999999999999999999d",
"18446744073709551616s",
] {
let err = Ttl::parse(huge).expect_err("overflowing ttl is refused");
assert!(err.contains("too large"), "{err}");
}
assert_eq!(
secs("18446744073709551615s"),
u64::MAX,
"the largest legal ttl"
);
}
#[test]
fn deserializes_through_the_same_parser() {
let ttl: Ttl = serde_yaml_ng::from_str("12h").expect("valid");
assert_eq!(ttl.secs(), 12 * 3_600);
let err = serde_yaml_ng::from_str::<Ttl>("0h").expect_err("zero refused");
assert!(err.to_string().contains("invalid ttl `0h`"), "{err}");
}
#[test]
fn a_record_expires_once_its_age_reaches_the_ttl() {
let ttl = Ttl::parse("1h").expect("valid");
let ran_at = 1_000;
assert_eq!(ttl.expires_at(ran_at), 4_600);
assert!(
!ttl.is_expired(ran_at, Clock::pinned(ran_at)),
"just written"
);
assert!(
!ttl.is_expired(ran_at, Clock::pinned(4_599)),
"one second inside"
);
assert!(ttl.is_expired(ran_at, Clock::pinned(4_600)), "at the ttl");
assert!(ttl.is_expired(ran_at, Clock::pinned(90_000)), "long past");
assert!(
!ttl.is_expired(ran_at, Clock::pinned(10)),
"a record from the future has age zero"
);
assert_eq!(
ttl.expires_at(u64::MAX),
u64::MAX,
"a far-future stamp saturates"
);
}
#[test]
fn outlived_names_the_ttl_only_for_an_expired_pass() {
let ttl = Ttl::parse("1h").expect("valid");
let late = Clock::pinned(10_000);
assert_eq!(outlived(Some(&ttl), true, 0, late), Some(&ttl));
assert_eq!(outlived(Some(&ttl), true, 9_000, late), None, "inside");
assert_eq!(outlived(Some(&ttl), false, 0, late), None, "a failure");
assert_eq!(outlived(None, true, 0, late), None, "no ttl");
}
}