use std::fmt;
use std::str::FromStr;
use thiserror::Error;
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TemporalValue {
DateTime(DateTimeValue),
}
impl TemporalValue {
#[expect(
clippy::too_many_arguments,
reason = "the public constructor intentionally mirrors datetime components"
)]
pub fn datetime(
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
nanosecond: u32,
offset_seconds: Option<i32>,
) -> Result<Self, TemporalValueError> {
Ok(Self::DateTime(DateTimeValue::new(
year,
month,
day,
hour,
minute,
second,
nanosecond,
offset_seconds,
)?))
}
pub fn as_datetime(&self) -> Option<&DateTimeValue> {
match self {
Self::DateTime(value) => Some(value),
}
}
pub fn parse_iso8601(input: &str) -> Result<Self, TemporalValueError> {
Ok(Self::DateTime(DateTimeValue::parse_iso8601(input)?))
}
}
impl fmt::Display for TemporalValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DateTime(value) => value.fmt(f),
}
}
}
impl From<DateTimeValue> for TemporalValue {
fn from(value: DateTimeValue) -> Self {
Self::DateTime(value)
}
}
impl FromStr for TemporalValue {
type Err = TemporalValueError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse_iso8601(s)
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DateTimeValue {
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
nanosecond: u32,
offset_seconds: Option<i32>,
}
impl DateTimeValue {
#[expect(
clippy::too_many_arguments,
reason = "the public constructor intentionally mirrors datetime components"
)]
pub fn new(
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
nanosecond: u32,
offset_seconds: Option<i32>,
) -> Result<Self, TemporalValueError> {
let value = Self {
year,
month,
day,
hour,
minute,
second,
nanosecond,
offset_seconds,
};
validate_datetime(&value)?;
Ok(value)
}
pub fn parse_iso8601(input: &str) -> Result<Self, TemporalValueError> {
let (date, time) = input
.split_once('T')
.ok_or(TemporalValueError::InvalidFormat)?;
let (year, month, day) = parse_date(date)?;
let (hour, minute, second, nanosecond, offset_seconds) = parse_time(time)?;
Self::new(
year,
month,
day,
hour,
minute,
second,
nanosecond,
offset_seconds,
)
}
pub fn year(&self) -> i32 {
self.year
}
pub fn month(&self) -> u8 {
self.month
}
pub fn day(&self) -> u8 {
self.day
}
pub fn hour(&self) -> u8 {
self.hour
}
pub fn minute(&self) -> u8 {
self.minute
}
pub fn second(&self) -> u8 {
self.second
}
pub fn nanosecond(&self) -> u32 {
self.nanosecond
}
pub fn offset_seconds(&self) -> Option<i32> {
self.offset_seconds
}
}
impl fmt::Display for DateTimeValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}-{:02}-{:02}T{:02}:{:02}:{:02}",
format_year(self.year),
self.month,
self.day,
self.hour,
self.minute,
self.second
)?;
if self.nanosecond != 0 {
let mut fraction = format!("{:09}", self.nanosecond);
while fraction.ends_with('0') {
fraction.pop();
}
write!(f, ".{fraction}")?;
}
if let Some(offset_seconds) = self.offset_seconds {
if offset_seconds == 0 {
f.write_str("Z")?;
} else {
let sign = if offset_seconds < 0 { '-' } else { '+' };
let absolute = offset_seconds.unsigned_abs();
let hours = absolute / 3_600;
let minutes = (absolute % 3_600) / 60;
let seconds = absolute % 60;
write!(f, "{sign}{hours:02}:{minutes:02}")?;
if seconds != 0 {
write!(f, ":{seconds:02}")?;
}
}
}
Ok(())
}
}
impl FromStr for DateTimeValue {
type Err = TemporalValueError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse_iso8601(s)
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum TemporalValueError {
#[error("invalid month component {0}; expected 1..=12")]
InvalidMonth(u8),
#[error("invalid day component {day} for {year:04}-{month:02}")]
InvalidDay {
year: i32,
month: u8,
day: u8,
},
#[error("invalid hour component {0}; expected 0..=23")]
InvalidHour(u8),
#[error("invalid minute component {0}; expected 0..=59")]
InvalidMinute(u8),
#[error("invalid second component {0}; expected 0..=59")]
InvalidSecond(u8),
#[error("invalid nanosecond component {0}; expected 0..1_000_000_000")]
InvalidNanosecond(u32),
#[error("invalid UTC offset seconds {0}; expected -86399..=86399")]
InvalidOffsetSeconds(i32),
#[error("invalid datetime format; expected ISO-8601 datetime text")]
InvalidFormat,
#[error("temporal value is missing a UTC offset")]
MissingOffset,
#[error("temporal value unexpectedly contains a UTC offset")]
UnexpectedOffset,
#[error("temporal value is out of range for the requested target type")]
OutOfRange,
}
fn validate_datetime(value: &DateTimeValue) -> Result<(), TemporalValueError> {
if !(1..=12).contains(&value.month) {
return Err(TemporalValueError::InvalidMonth(value.month));
}
let max_day = days_in_month(value.year, value.month);
if value.day == 0 || value.day > max_day {
return Err(TemporalValueError::InvalidDay {
year: value.year,
month: value.month,
day: value.day,
});
}
if value.hour > 23 {
return Err(TemporalValueError::InvalidHour(value.hour));
}
if value.minute > 59 {
return Err(TemporalValueError::InvalidMinute(value.minute));
}
if value.second > 59 {
return Err(TemporalValueError::InvalidSecond(value.second));
}
if value.nanosecond >= 1_000_000_000 {
return Err(TemporalValueError::InvalidNanosecond(value.nanosecond));
}
if let Some(offset_seconds) = value.offset_seconds
&& !(-86_399..=86_399).contains(&offset_seconds)
{
return Err(TemporalValueError::InvalidOffsetSeconds(offset_seconds));
}
Ok(())
}
fn parse_date(input: &str) -> Result<(i32, u8, u8), TemporalValueError> {
let bytes = input.as_bytes();
if bytes.is_empty() {
return Err(TemporalValueError::InvalidFormat);
}
let mut index = 0usize;
if matches!(bytes[index], b'+' | b'-') {
index += 1;
}
let digit_start = index;
while index < bytes.len() && bytes[index].is_ascii_digit() {
index += 1;
}
if index.saturating_sub(digit_start) < 4 {
return Err(TemporalValueError::InvalidFormat);
}
if index >= bytes.len() || bytes[index] != b'-' {
return Err(TemporalValueError::InvalidFormat);
}
let year = input[..index]
.parse::<i32>()
.map_err(|_| TemporalValueError::InvalidFormat)?;
index += 1;
let month = parse_u8_exact(bytes, &mut index, 2)?;
expect_byte(bytes, &mut index, b'-')?;
let day = parse_u8_exact(bytes, &mut index, 2)?;
if index != bytes.len() {
return Err(TemporalValueError::InvalidFormat);
}
Ok((year, month, day))
}
fn parse_time(input: &str) -> Result<(u8, u8, u8, u32, Option<i32>), TemporalValueError> {
let bytes = input.as_bytes();
let mut index = 0usize;
let hour = parse_u8_exact(bytes, &mut index, 2)?;
expect_byte(bytes, &mut index, b':')?;
let minute = parse_u8_exact(bytes, &mut index, 2)?;
expect_byte(bytes, &mut index, b':')?;
let second = parse_u8_exact(bytes, &mut index, 2)?;
let mut nanosecond = 0u32;
if bytes.get(index) == Some(&b'.') {
index += 1;
let fraction_start = index;
while index < bytes.len() && bytes[index].is_ascii_digit() {
index += 1;
}
let digits = &input[fraction_start..index];
if digits.is_empty() || digits.len() > 9 {
return Err(TemporalValueError::InvalidFormat);
}
let mut padded = digits.to_owned();
while padded.len() < 9 {
padded.push('0');
}
nanosecond = padded
.parse::<u32>()
.map_err(|_| TemporalValueError::InvalidFormat)?;
}
let offset_seconds = match bytes.get(index) {
None => None,
Some(b'Z') => {
index += 1;
Some(0)
}
Some(b'+') | Some(b'-') => Some(parse_offset(bytes, &mut index)?),
Some(_) => return Err(TemporalValueError::InvalidFormat),
};
if index != bytes.len() {
return Err(TemporalValueError::InvalidFormat);
}
Ok((hour, minute, second, nanosecond, offset_seconds))
}
fn parse_offset(bytes: &[u8], index: &mut usize) -> Result<i32, TemporalValueError> {
let sign = match bytes.get(*index) {
Some(b'+') => 1i32,
Some(b'-') => -1i32,
_ => return Err(TemporalValueError::InvalidFormat),
};
*index += 1;
let hours = i32::from(parse_u8_exact(bytes, index, 2)?);
expect_byte(bytes, index, b':')?;
let minutes = i32::from(parse_u8_exact(bytes, index, 2)?);
let seconds = if bytes.get(*index) == Some(&b':') {
*index += 1;
i32::from(parse_u8_exact(bytes, index, 2)?)
} else {
0
};
Ok(sign * (hours * 3_600 + minutes * 60 + seconds))
}
fn parse_u8_exact(bytes: &[u8], index: &mut usize, width: usize) -> Result<u8, TemporalValueError> {
let end = index.saturating_add(width);
if end > bytes.len() {
return Err(TemporalValueError::InvalidFormat);
}
let slice = &bytes[*index..end];
if !slice.iter().all(u8::is_ascii_digit) {
return Err(TemporalValueError::InvalidFormat);
}
*index = end;
std::str::from_utf8(slice)
.ok()
.and_then(|text| text.parse::<u8>().ok())
.ok_or(TemporalValueError::InvalidFormat)
}
fn expect_byte(bytes: &[u8], index: &mut usize, expected: u8) -> Result<(), TemporalValueError> {
if bytes.get(*index) != Some(&expected) {
return Err(TemporalValueError::InvalidFormat);
}
*index += 1;
Ok(())
}
fn format_year(year: i32) -> String {
if (0..=9_999).contains(&year) {
return format!("{year:04}");
}
let absolute = year.unsigned_abs();
if year < 0 {
let width = absolute.to_string().len().max(4);
format!("-{absolute:0width$}")
} else {
let width = absolute.to_string().len().max(5);
format!("+{absolute:0width$}")
}
}
fn days_in_month(year: i32, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
#[cfg(test)]
mod tests;