use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ValidationError(pub String);
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ValidationError {}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
#[non_exhaustive]
pub struct Id(String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
#[non_exhaustive]
pub struct UTCDate(String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
#[non_exhaustive]
pub struct Date(String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
#[non_exhaustive]
pub struct State(String);
macro_rules! impl_string_newtype {
($T:ident) => {
impl fmt::Display for $T {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for $T {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for $T {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl AsRef<str> for $T {
fn as_ref(&self) -> &str {
&self.0
}
}
impl PartialEq<str> for $T {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for $T {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl std::borrow::Borrow<str> for $T {
fn borrow(&self) -> &str {
&self.0
}
}
impl $T {
pub fn into_inner(self) -> String {
self.0
}
}
};
}
impl_string_newtype!(Id);
impl_string_newtype!(UTCDate);
impl_string_newtype!(Date);
impl_string_newtype!(State);
fn validate_id(s: &str) -> Result<(), ValidationError> {
if s.is_empty() {
return Err(ValidationError("Id must not be empty".into()));
}
if s.len() > 255 {
return Err(ValidationError(format!(
"Id exceeds 255 bytes (got {})",
s.len()
)));
}
for ch in s.chars() {
if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
return Err(ValidationError(format!(
"Id contains invalid character {:?} (U+{:04X})",
ch, ch as u32
)));
}
}
Ok(())
}
fn validate_utcdate(s: &str) -> Result<(), ValidationError> {
if s.len() != 20 {
return Err(ValidationError(format!(
"UTCDate must be exactly 20 characters (YYYY-MM-DDTHH:MM:SSZ), got {:?}",
s
)));
}
let b = s.as_bytes();
if b[4] != b'-'
|| b[7] != b'-'
|| b[10] != b'T'
|| b[13] != b':'
|| b[16] != b':'
|| b[19] != b'Z'
{
return Err(ValidationError(format!(
"UTCDate has wrong structure, expected YYYY-MM-DDTHH:MM:SSZ, got {:?}",
s
)));
}
for &pos in &[0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18] {
if !b[pos].is_ascii_digit() {
return Err(ValidationError(format!(
"UTCDate position {} is not a digit in {:?}",
pos, s
)));
}
}
let parse = |start: usize, end: usize| -> i64 {
let mut n: i64 = 0;
for &byte in &b[start..end] {
n = n * 10 + (byte - b'0') as i64;
}
n
};
let year = parse(0, 4);
let month = parse(5, 7) as u32;
let day = parse(8, 10) as u32;
let hour = parse(11, 13) as u32;
let minute = parse(14, 16) as u32;
let second = parse(17, 19) as u32;
if !(1..=12).contains(&month) {
return Err(ValidationError(format!(
"UTCDate month must be 1..=12, got {month} in {s:?}"
)));
}
let max_day = days_in_month(year, month);
if !(1..=max_day).contains(&day) {
return Err(ValidationError(format!(
"UTCDate day must be 1..={max_day} for {year:04}-{month:02}, got {day} in {s:?}"
)));
}
if hour > 23 {
return Err(ValidationError(format!(
"UTCDate hour must be 0..=23, got {hour} in {s:?}"
)));
}
if minute > 59 {
return Err(ValidationError(format!(
"UTCDate minute must be 0..=59, got {minute} in {s:?}"
)));
}
if second > 59 {
return Err(ValidationError(format!(
"UTCDate second must be 0..=59, got {second} in {s:?}"
)));
}
Ok(())
}
fn validate_state(s: &str) -> Result<(), ValidationError> {
if s.is_empty() {
return Err(ValidationError("State must not be empty".into()));
}
Ok(())
}
impl Id {
pub fn new_validated(s: impl Into<String>) -> Result<Self, ValidationError> {
let s = s.into();
validate_id(&s)?;
Ok(Self(s))
}
}
impl UTCDate {
pub fn new_validated(s: impl Into<String>) -> Result<Self, ValidationError> {
let s = s.into();
validate_utcdate(&s)?;
Ok(Self(s))
}
pub fn to_epoch_seconds(&self) -> Result<i64, ValidationError> {
utcdate_to_epoch_seconds(&self.0)
}
}
fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
29
} else {
28
}
}
_ => 0, }
}
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400; let m = m as i64;
let d = d as i64;
let mp = if m > 2 { m - 3 } else { m + 9 }; let doy = (153 * mp + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146097 + doe - 719_468
}
fn utcdate_to_epoch_seconds(s: &str) -> Result<i64, ValidationError> {
validate_utcdate(s)?;
let parse = |start: usize, end: usize| -> i64 {
let mut n: i64 = 0;
for &b in &s.as_bytes()[start..end] {
n = n * 10 + (b - b'0') as i64;
}
n
};
let year = parse(0, 4);
let month = parse(5, 7) as u32;
let day = parse(8, 10) as u32;
let hour = parse(11, 13) as u32;
let minute = parse(14, 16) as u32;
let second = parse(17, 19) as u32;
let days = days_from_civil(year, month, day);
Ok(days * 86_400 + hour as i64 * 3_600 + minute as i64 * 60 + second as i64)
}
impl State {
pub fn new_validated(s: impl Into<String>) -> Result<Self, ValidationError> {
let s = s.into();
validate_state(&s)?;
Ok(Self(s))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_serializes_as_plain_string() {
let id = Id("abc123".to_owned());
let json = serde_json::to_string(&id).expect("serialize Id");
assert_eq!(json, "\"abc123\"");
}
#[test]
fn id_deserializes_from_plain_string() {
let id: Id = serde_json::from_str("\"abc123\"").expect("deserialize Id");
assert_eq!(id.as_ref(), "abc123");
}
#[test]
fn utcdate_serializes_as_plain_string() {
let d = UTCDate("2014-10-30T06:12:00Z".to_owned());
let json = serde_json::to_string(&d).expect("serialize UTCDate");
assert_eq!(json, "\"2014-10-30T06:12:00Z\"");
}
#[test]
fn state_serializes_as_plain_string() {
let s = State("75128aab4b1b".to_owned());
let json = serde_json::to_string(&s).expect("serialize State");
assert_eq!(json, "\"75128aab4b1b\"");
}
#[test]
fn id_from_str() {
let id = Id::from("hello");
assert_eq!(id.as_ref(), "hello");
}
#[test]
fn id_display() {
let id = Id("display-test".to_owned());
assert_eq!(id.to_string(), "display-test");
}
#[test]
fn id_as_ref_str() {
let id = Id("ref-test".to_owned());
assert_eq!(id.as_ref(), "ref-test");
}
#[test]
fn state_round_trip() {
let s = State("75128aab4b1b".to_owned());
let json = serde_json::to_string(&s).expect("serialize");
let s2: State = serde_json::from_str(&json).expect("deserialize");
assert_eq!(s, s2);
}
#[test]
fn date_accepts_non_utc_offset() {
let d = Date("2014-10-30T14:12:00+08:00".to_owned());
let json = serde_json::to_string(&d).expect("serialize Date");
assert_eq!(json, "\"2014-10-30T14:12:00+08:00\"");
let d2: Date = serde_json::from_str(&json).expect("deserialize Date");
assert_eq!(d, d2);
}
#[test]
fn id_new_validated_empty_fails() {
let err = Id::new_validated("").unwrap_err();
assert!(err.0.contains("empty"), "error must mention 'empty': {err}");
}
#[test]
fn id_new_validated_space_fails() {
let err = Id::new_validated("has space").unwrap_err();
assert!(err.0.contains("invalid character"), "{err}");
}
#[test]
fn id_new_validated_dquote_fails() {
let err = Id::new_validated("has\"quote").unwrap_err();
assert!(err.0.contains("invalid character"), "{err}");
}
#[test]
fn id_new_validated_control_char_fails() {
let err = Id::new_validated("has\x01ctrl").unwrap_err();
assert!(err.0.contains("invalid character"), "{err}");
}
#[test]
fn id_new_validated_rejects_non_url_safe_base64_chars() {
let rejected = [
'!', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '.', '/', ':', ';', '<', '=',
'>', '?', '@', '[', '\\', ']', '^', '`', '{', '|', '}', '~',
];
for ch in rejected {
let input = format!("a{ch}z");
let err = Id::new_validated(&input).unwrap_err();
assert!(
err.0.contains("invalid character"),
"char {ch:?} (input {input:?}) must be rejected with \
'invalid character', got: {err}"
);
}
}
#[test]
fn id_new_validated_too_long_fails() {
let long = "a".repeat(256);
assert!(Id::new_validated(long).is_err());
}
#[test]
fn id_new_validated_valid_succeeds() {
let id = Id::new_validated("abc123-_ABC").expect("valid Id must succeed");
assert_eq!(id.as_ref(), "abc123-_ABC");
}
#[test]
fn id_new_validated_max_length_succeeds() {
let id255 = "a".repeat(255);
Id::new_validated(id255).expect("255-byte Id must succeed");
}
#[test]
fn utcdate_new_validated_valid_succeeds() {
let d = UTCDate::new_validated("2014-10-30T06:12:00Z").expect("valid UTCDate must succeed");
assert_eq!(d.as_ref(), "2014-10-30T06:12:00Z");
}
#[test]
fn utcdate_new_validated_no_z_fails() {
assert!(UTCDate::new_validated("2014-10-30T06:12:00+00:00").is_err());
}
#[test]
fn utcdate_new_validated_empty_fails() {
assert!(UTCDate::new_validated("").is_err());
}
#[test]
fn utcdate_new_validated_wrong_length_fails() {
assert!(UTCDate::new_validated("2014-10-30").is_err());
assert!(UTCDate::new_validated("2014-10-30T06:12:00.000Z").is_err());
}
#[test]
fn utcdate_new_validated_non_digit_fails() {
assert!(UTCDate::new_validated("XXXX-10-30T06:12:00Z").is_err());
}
#[test]
fn utcdate_new_validated_rejects_month_out_of_range() {
let err = UTCDate::new_validated("2024-13-15T09:00:00Z").unwrap_err();
assert!(err.0.contains("month must be"), "{err}");
let err = UTCDate::new_validated("9999-99-99T09:00:00Z").unwrap_err();
assert!(err.0.contains("month must be"), "{err}");
}
#[test]
fn utcdate_new_validated_rejects_day_out_of_range() {
let err = UTCDate::new_validated("2024-01-32T09:00:00Z").unwrap_err();
assert!(err.0.contains("day must be"), "{err}");
let err = UTCDate::new_validated("2024-02-30T09:00:00Z").unwrap_err();
assert!(err.0.contains("day must be"), "{err}");
let err = UTCDate::new_validated("2023-02-29T09:00:00Z").unwrap_err();
assert!(err.0.contains("day must be"), "{err}");
let err = UTCDate::new_validated("2024-04-31T09:00:00Z").unwrap_err();
assert!(err.0.contains("day must be"), "{err}");
}
#[test]
fn utcdate_new_validated_accepts_feb_29_leap_year() {
UTCDate::new_validated("2024-02-29T00:00:00Z").expect("leap day must succeed");
}
#[test]
fn utcdate_new_validated_rejects_time_out_of_range() {
let err = UTCDate::new_validated("2024-06-15T24:00:00Z").unwrap_err();
assert!(err.0.contains("hour must be"), "{err}");
let err = UTCDate::new_validated("2024-06-15T09:60:00Z").unwrap_err();
assert!(err.0.contains("minute must be"), "{err}");
let err = UTCDate::new_validated("2024-06-15T09:00:60Z").unwrap_err();
assert!(err.0.contains("second must be"), "{err}");
}
#[test]
fn utcdate_new_validated_rejects_absurd_values() {
let err = UTCDate::new_validated("9999-99-99T99:99:99Z").unwrap_err();
assert!(
err.0.contains("month must be"),
"first rejected component (in left-to-right order) is the \
month; got: {err}"
);
}
#[test]
fn state_new_validated_empty_fails() {
let err = State::new_validated("").unwrap_err();
assert!(err.0.contains("empty"), "{err}");
}
#[test]
fn state_new_validated_valid_succeeds() {
let s = State::new_validated("75128aab4b1b").expect("valid State must succeed");
assert_eq!(s.as_ref(), "75128aab4b1b");
}
#[test]
fn validation_error_implements_error() {
let e = Id::new_validated("").unwrap_err();
let _: &dyn std::error::Error = &e;
assert!(!e.to_string().is_empty(), "error message must not be empty");
assert_eq!(format!("{e}"), e.0, "Display must show the inner message");
}
#[test]
fn utcdate_to_epoch_seconds_unix_epoch() {
let d = UTCDate::new_validated("1970-01-01T00:00:00Z").unwrap();
assert_eq!(d.to_epoch_seconds().unwrap(), 0);
}
#[test]
fn utcdate_to_epoch_seconds_rfc8620_example() {
let d = UTCDate::new_validated("2014-10-30T06:12:00Z").unwrap();
assert_eq!(d.to_epoch_seconds().unwrap(), 1_414_649_520);
}
#[test]
fn utcdate_to_epoch_seconds_y2k() {
let d = UTCDate::new_validated("2000-01-01T00:00:00Z").unwrap();
assert_eq!(d.to_epoch_seconds().unwrap(), 946_684_800);
}
#[test]
fn utcdate_to_epoch_seconds_pre_y2k() {
let d = UTCDate::new_validated("1999-12-31T23:59:59Z").unwrap();
assert_eq!(d.to_epoch_seconds().unwrap(), 946_684_799);
}
#[test]
fn utcdate_to_epoch_seconds_leap_day_2024() {
let d = UTCDate::new_validated("2024-02-29T00:00:00Z").unwrap();
assert_eq!(d.to_epoch_seconds().unwrap(), 1_709_164_800);
}
#[test]
fn utcdate_to_epoch_seconds_leap_day_2024_end() {
let d = UTCDate::new_validated("2024-02-29T23:59:59Z").unwrap();
assert_eq!(d.to_epoch_seconds().unwrap(), 1_709_251_199);
}
#[test]
fn utcdate_to_epoch_seconds_2100_non_leap_century() {
let d = UTCDate::new_validated("2100-03-01T00:00:00Z").unwrap();
assert_eq!(d.to_epoch_seconds().unwrap(), 4_107_542_400);
}
#[test]
fn utcdate_to_epoch_seconds_one_before_epoch() {
let d = UTCDate::new_validated("1969-12-31T23:59:59Z").unwrap();
assert_eq!(d.to_epoch_seconds().unwrap(), -1);
}
#[test]
fn utcdate_to_epoch_seconds_1900() {
let d = UTCDate::new_validated("1900-01-01T00:00:00Z").unwrap();
assert_eq!(d.to_epoch_seconds().unwrap(), -2_208_988_800);
}
#[test]
fn utcdate_to_epoch_seconds_year_one() {
let d = UTCDate::new_validated("0001-01-01T00:00:00Z").unwrap();
assert_eq!(d.to_epoch_seconds().unwrap(), -62_135_596_800);
}
#[test]
fn utcdate_to_epoch_seconds_year_9999() {
let d = UTCDate::new_validated("9999-12-31T23:59:59Z").unwrap();
assert_eq!(d.to_epoch_seconds().unwrap(), 253_402_300_799);
}
#[test]
fn utcdate_to_epoch_seconds_y2038_boundary() {
let d = UTCDate::new_validated("2038-01-19T03:14:07Z").unwrap();
assert_eq!(d.to_epoch_seconds().unwrap(), 2_147_483_647);
}
#[test]
fn utcdate_to_epoch_seconds_post_y2038() {
let d = UTCDate::new_validated("2038-01-19T03:14:08Z").unwrap();
assert_eq!(d.to_epoch_seconds().unwrap(), 2_147_483_648);
}
#[test]
fn utcdate_to_epoch_seconds_rejects_month_13() {
let d = UTCDate::from("2024-13-01T00:00:00Z");
let err = d.to_epoch_seconds().unwrap_err();
assert!(err.0.contains("month"), "error must mention month: {err}");
}
#[test]
fn utcdate_to_epoch_seconds_rejects_day_32() {
let d = UTCDate::from("2024-01-32T00:00:00Z");
let err = d.to_epoch_seconds().unwrap_err();
assert!(err.0.contains("day"), "error must mention day: {err}");
}
#[test]
fn utcdate_to_epoch_seconds_rejects_feb_29_non_leap() {
let d = UTCDate::from("2023-02-29T00:00:00Z");
let err = d.to_epoch_seconds().unwrap_err();
assert!(err.0.contains("day"), "error must mention day: {err}");
}
#[test]
fn utcdate_to_epoch_seconds_rejects_hour_24() {
let d = UTCDate::from("2024-01-01T24:00:00Z");
let err = d.to_epoch_seconds().unwrap_err();
assert!(err.0.contains("hour"), "error must mention hour: {err}");
}
#[test]
fn utcdate_to_epoch_seconds_rejects_minute_60() {
let d = UTCDate::from("2024-01-01T00:60:00Z");
let err = d.to_epoch_seconds().unwrap_err();
assert!(err.0.contains("minute"), "error must mention minute: {err}");
}
#[test]
fn utcdate_to_epoch_seconds_rejects_leap_second() {
let d = UTCDate::from("2016-12-31T23:59:60Z");
let err = d.to_epoch_seconds().unwrap_err();
assert!(err.0.contains("second"), "error must mention second: {err}");
}
#[test]
fn utcdate_to_epoch_seconds_rejects_invalid_structure() {
let d = UTCDate::from("not-a-date");
assert!(d.to_epoch_seconds().is_err());
}
#[test]
fn utcdate_to_epoch_seconds_leap_day_boundary_one_sec() {
let a = UTCDate::new_validated("2024-02-28T23:59:59Z").unwrap();
let b = UTCDate::new_validated("2024-02-29T00:00:00Z").unwrap();
let diff = b.to_epoch_seconds().unwrap() - a.to_epoch_seconds().unwrap();
assert_eq!(
diff, 1,
"Feb 28 23:59:59 → Feb 29 00:00:00 must be 1 second"
);
}
#[test]
fn utcdate_to_epoch_seconds_365_day_year_duration() {
let a = UTCDate::new_validated("2023-01-01T00:00:00Z").unwrap();
let b = UTCDate::new_validated("2024-01-01T00:00:00Z").unwrap();
let diff = b.to_epoch_seconds().unwrap() - a.to_epoch_seconds().unwrap();
assert_eq!(diff, 31_536_000);
}
#[test]
fn utcdate_to_epoch_seconds_366_day_leap_year_duration() {
let a = UTCDate::new_validated("2024-01-01T00:00:00Z").unwrap();
let b = UTCDate::new_validated("2025-01-01T00:00:00Z").unwrap();
let diff = b.to_epoch_seconds().unwrap() - a.to_epoch_seconds().unwrap();
assert_eq!(diff, 31_622_400);
}
}