use std::time::Duration;
use snafu::Snafu;
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum MaxAgeParseError {
#[snafu(display("max_age cannot be the empty string"))]
Empty,
#[snafu(display("max_age expects a digit at byte position {position}"))]
NoDigits {
position: usize,
},
#[snafu(display("max_age integer `{number}` has no unit suffix"))]
MissingUnit {
number: String,
},
#[snafu(display("max_age unit `{unit}` is not one of s/m/h/d/w"))]
UnknownUnit {
unit: char,
},
#[snafu(display("max_age unit `{unit}` appeared more than once"))]
DuplicateUnit {
unit: char,
},
#[snafu(display("max_age `{input}` exceeds the representable duration range"))]
Overflow {
input: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MaxAge(Duration);
impl MaxAge {
pub fn parse(input: &str) -> Result<Self, MaxAgeParseError> {
if input.is_empty() {
return Err(MaxAgeParseError::Empty);
}
let bytes = input.as_bytes();
let mut idx = 0usize;
let mut seen = [false; 5];
let mut total = Duration::ZERO;
while idx < bytes.len() {
let digit_start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == digit_start {
return Err(MaxAgeParseError::NoDigits {
position: digit_start,
});
}
let number_str = &input[digit_start..idx];
if idx >= bytes.len() {
return Err(MaxAgeParseError::MissingUnit {
number: number_str.to_owned(),
});
}
let unit_byte = bytes[idx];
idx += 1;
let (slot, secs_per_unit): (usize, u64) = match unit_byte {
b's' => (0, 1),
b'm' => (1, 60),
b'h' => (2, 60 * 60),
b'd' => (3, 60 * 60 * 24),
b'w' => (4, 60 * 60 * 24 * 7),
_ => {
return Err(MaxAgeParseError::UnknownUnit {
unit: unit_byte as char,
});
}
};
if seen[slot] {
return Err(MaxAgeParseError::DuplicateUnit {
unit: unit_byte as char,
});
}
seen[slot] = true;
let n: u64 = number_str.parse().map_err(|_| MaxAgeParseError::Overflow {
input: input.to_owned(),
})?;
let term_secs =
n.checked_mul(secs_per_unit)
.ok_or_else(|| MaxAgeParseError::Overflow {
input: input.to_owned(),
})?;
total = total
.checked_add(Duration::from_secs(term_secs))
.ok_or_else(|| MaxAgeParseError::Overflow {
input: input.to_owned(),
})?;
}
Ok(Self(total))
}
#[must_use]
pub const fn as_duration(&self) -> Duration {
self.0
}
}
#[cfg(test)]
mod tests {
use super::{MaxAge, MaxAgeParseError};
use std::time::Duration;
fn dur_secs(s: u64) -> Duration {
Duration::from_secs(s)
}
#[test]
fn parse_accepts_seconds() {
assert_eq!(MaxAge::parse("45s").unwrap().as_duration(), dur_secs(45));
}
#[test]
fn parse_accepts_minutes() {
assert_eq!(
MaxAge::parse("12m").unwrap().as_duration(),
dur_secs(12 * 60)
);
}
#[test]
fn parse_accepts_hours() {
assert_eq!(
MaxAge::parse("12h").unwrap().as_duration(),
dur_secs(12 * 60 * 60),
);
}
#[test]
fn parse_accepts_days() {
assert_eq!(
MaxAge::parse("90d").unwrap().as_duration(),
dur_secs(90 * 24 * 60 * 60),
);
}
#[test]
fn parse_accepts_weeks() {
assert_eq!(
MaxAge::parse("2w").unwrap().as_duration(),
dur_secs(2 * 7 * 24 * 60 * 60),
);
}
#[test]
fn parse_accepts_multi_unit_combinations() {
assert_eq!(
MaxAge::parse("7d12h").unwrap().as_duration(),
dur_secs(7 * 24 * 60 * 60 + 12 * 60 * 60),
);
assert_eq!(
MaxAge::parse("1w2d").unwrap().as_duration(),
dur_secs((7 + 2) * 24 * 60 * 60),
);
assert_eq!(
MaxAge::parse("1h30m15s").unwrap().as_duration(),
dur_secs(60 * 60 + 30 * 60 + 15),
);
}
#[test]
fn parse_accepts_zero_valued_pair() {
assert_eq!(MaxAge::parse("0d").unwrap().as_duration(), Duration::ZERO);
assert_eq!(MaxAge::parse("0s").unwrap().as_duration(), Duration::ZERO);
}
#[test]
fn parse_rejects_empty_string() {
assert_eq!(MaxAge::parse(""), Err(MaxAgeParseError::Empty));
}
#[test]
fn parse_rejects_bare_integer() {
match MaxAge::parse("90") {
Err(MaxAgeParseError::MissingUnit { number }) => assert_eq!(number, "90"),
other => panic!("expected MissingUnit, got {other:?}"),
}
}
#[test]
fn parse_rejects_trailing_unitless_integer() {
match MaxAge::parse("12h0") {
Err(MaxAgeParseError::MissingUnit { number }) => assert_eq!(number, "0"),
other => panic!("expected MissingUnit, got {other:?}"),
}
}
#[test]
fn parse_rejects_duplicate_unit() {
match MaxAge::parse("5d3d") {
Err(MaxAgeParseError::DuplicateUnit { unit }) => assert_eq!(unit, 'd'),
other => panic!("expected DuplicateUnit, got {other:?}"),
}
}
#[test]
fn parse_rejects_unknown_unit() {
match MaxAge::parse("5x") {
Err(MaxAgeParseError::UnknownUnit { unit }) => assert_eq!(unit, 'x'),
other => panic!("expected UnknownUnit, got {other:?}"),
}
}
#[test]
fn parse_rejects_leading_non_digit() {
match MaxAge::parse("d5") {
Err(MaxAgeParseError::NoDigits { position }) => assert_eq!(position, 0),
other => panic!("expected NoDigits, got {other:?}"),
}
}
#[test]
fn parse_rejects_internal_non_digit_after_unit() {
match MaxAge::parse("1hx") {
Err(MaxAgeParseError::NoDigits { position }) => assert_eq!(position, 2),
other => panic!("expected NoDigits, got {other:?}"),
}
}
#[test]
fn parse_rejects_overflow_in_term() {
let huge = format!("{}w", u64::MAX);
match MaxAge::parse(&huge) {
Err(MaxAgeParseError::Overflow { input }) => assert_eq!(input, huge),
other => panic!("expected Overflow, got {other:?}"),
}
}
}