use crate::common::property::{Parameter, Property};
use crate::error::{Error, Result};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DateTimeValue {
Date { year: i32, month: u32, day: u32 },
DateTime {
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
},
DateTimeUtc {
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
},
DateTimeTz {
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
tzid: String,
},
}
impl DateTimeValue {
pub fn parse(s: &str) -> Result<Self> {
let normalized = s.replace(['-', ':'], "");
let normalized = normalized.trim();
if normalized.len() == 8 && normalized.chars().all(|c| c.is_ascii_digit()) {
let (year, month, day) = parse_date_digits(normalized)?;
return Ok(DateTimeValue::Date { year, month, day });
}
if normalized.len() >= 15 && normalized.as_bytes()[8] == b'T' {
let (year, month, day) = parse_date_digits(&normalized[..8])?;
let (hour, minute, second) = parse_time_digits(&normalized[9..15])?;
if normalized.ends_with('Z') || normalized.ends_with('z') {
return Ok(DateTimeValue::DateTimeUtc {
year,
month,
day,
hour,
minute,
second,
});
}
return Ok(DateTimeValue::DateTime {
year,
month,
day,
hour,
minute,
second,
});
}
Err(Error::invalid_value(
"DATE-TIME",
format!("cannot parse '{}' as a date or date-time", s),
))
}
pub fn from_property(prop: &Property) -> Result<Self> {
let mut dt = Self::parse(&prop.value)?;
if let Some(tzid) = prop.param_value("TZID")
&& let DateTimeValue::DateTime {
year,
month,
day,
hour,
minute,
second,
} = dt
{
dt = DateTimeValue::DateTimeTz {
year,
month,
day,
hour,
minute,
second,
tzid: tzid.to_string(),
};
}
Ok(dt)
}
pub fn to_property(&self, name: &str) -> Property {
match self {
DateTimeValue::Date { .. } => {
Property::new(name, self.to_string()).with_param(Parameter::new("VALUE", "DATE"))
}
DateTimeValue::DateTimeTz { tzid, .. } => {
let base = match self {
DateTimeValue::DateTimeTz {
year,
month,
day,
hour,
minute,
second,
..
} => format!(
"{:04}{:02}{:02}T{:02}{:02}{:02}",
year, month, day, hour, minute, second
),
_ => unreachable!(),
};
Property::new(name, base).with_param(Parameter::new("TZID", tzid.clone()))
}
_ => Property::new(name, self.to_string()),
}
}
pub fn is_date(&self) -> bool {
matches!(self, DateTimeValue::Date { .. })
}
pub fn is_utc(&self) -> bool {
matches!(self, DateTimeValue::DateTimeUtc { .. })
}
}
impl fmt::Display for DateTimeValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DateTimeValue::Date { year, month, day } => {
write!(f, "{:04}{:02}{:02}", year, month, day)
}
DateTimeValue::DateTime {
year,
month,
day,
hour,
minute,
second,
} => write!(
f,
"{:04}{:02}{:02}T{:02}{:02}{:02}",
year, month, day, hour, minute, second
),
DateTimeValue::DateTimeUtc {
year,
month,
day,
hour,
minute,
second,
} => write!(
f,
"{:04}{:02}{:02}T{:02}{:02}{:02}Z",
year, month, day, hour, minute, second
),
DateTimeValue::DateTimeTz {
year,
month,
day,
hour,
minute,
second,
..
} => write!(
f,
"{:04}{:02}{:02}T{:02}{:02}{:02}",
year, month, day, hour, minute, second
),
}
}
}
#[cfg(feature = "chrono")]
impl DateTimeValue {
pub fn to_naive_date(&self) -> Option<chrono::NaiveDate> {
match self {
DateTimeValue::Date { year, month, day } => {
chrono::NaiveDate::from_ymd_opt(*year, *month, *day)
}
_ => None,
}
}
pub fn to_naive_date_time(&self) -> Option<chrono::NaiveDateTime> {
let (year, month, day, hour, minute, second) = match self {
DateTimeValue::Date { year, month, day } => (*year, *month, *day, 0, 0, 0),
DateTimeValue::DateTime {
year,
month,
day,
hour,
minute,
second,
}
| DateTimeValue::DateTimeUtc {
year,
month,
day,
hour,
minute,
second,
}
| DateTimeValue::DateTimeTz {
year,
month,
day,
hour,
minute,
second,
..
} => (*year, *month, *day, *hour, *minute, *second),
};
chrono::NaiveDate::from_ymd_opt(year, month, day)
.and_then(|d| d.and_hms_opt(hour, minute, second))
}
pub fn to_chrono_utc(&self) -> Option<chrono::DateTime<chrono::Utc>> {
if let DateTimeValue::DateTimeUtc {
year,
month,
day,
hour,
minute,
second,
} = self
{
chrono::NaiveDate::from_ymd_opt(*year, *month, *day)
.and_then(|d| d.and_hms_opt(*hour, *minute, *second))
.map(|ndt| ndt.and_utc())
} else {
None
}
}
pub fn from_naive_date(date: chrono::NaiveDate) -> Self {
use chrono::Datelike;
DateTimeValue::Date {
year: date.year(),
month: date.month(),
day: date.day(),
}
}
pub fn from_naive_date_time(dt: chrono::NaiveDateTime) -> Self {
use chrono::{Datelike, Timelike};
DateTimeValue::DateTime {
year: dt.date().year(),
month: dt.date().month(),
day: dt.date().day(),
hour: dt.time().hour(),
minute: dt.time().minute(),
second: dt.time().second(),
}
}
pub fn from_chrono_utc(dt: chrono::DateTime<chrono::Utc>) -> Self {
use chrono::{Datelike, Timelike};
DateTimeValue::DateTimeUtc {
year: dt.year(),
month: dt.month(),
day: dt.day(),
hour: dt.hour(),
minute: dt.minute(),
second: dt.second(),
}
}
}
fn parse_date_digits(s: &str) -> Result<(i32, u32, u32)> {
if s.len() < 8 {
return Err(Error::invalid_value("DATE", "too short"));
}
let year: i32 = s[..4]
.parse()
.map_err(|_| Error::invalid_value("DATE", "invalid year"))?;
let month: u32 = s[4..6]
.parse()
.map_err(|_| Error::invalid_value("DATE", "invalid month"))?;
let day: u32 = s[6..8]
.parse()
.map_err(|_| Error::invalid_value("DATE", "invalid day"))?;
if !(1..=12).contains(&month) {
return Err(Error::invalid_value("DATE", "month out of range"));
}
if !(1..=31).contains(&day) {
return Err(Error::invalid_value("DATE", "day out of range"));
}
Ok((year, month, day))
}
fn parse_time_digits(s: &str) -> Result<(u32, u32, u32)> {
if s.len() < 6 {
return Err(Error::invalid_value("TIME", "too short"));
}
let hour: u32 = s[..2]
.parse()
.map_err(|_| Error::invalid_value("TIME", "invalid hour"))?;
let minute: u32 = s[2..4]
.parse()
.map_err(|_| Error::invalid_value("TIME", "invalid minute"))?;
let second: u32 = s[4..6]
.parse()
.map_err(|_| Error::invalid_value("TIME", "invalid second"))?;
if hour > 23 {
return Err(Error::invalid_value("TIME", "hour out of range"));
}
if minute > 59 {
return Err(Error::invalid_value("TIME", "minute out of range"));
}
if second > 60 {
return Err(Error::invalid_value("TIME", "second out of range"));
}
Ok((hour, minute, second))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_date() {
let dt = DateTimeValue::parse("20260315").unwrap();
assert_eq!(
dt,
DateTimeValue::Date {
year: 2026,
month: 3,
day: 15
}
);
assert_eq!(dt.to_string(), "20260315");
}
#[test]
fn parse_datetime() {
let dt = DateTimeValue::parse("20260315T090000").unwrap();
assert_eq!(
dt,
DateTimeValue::DateTime {
year: 2026,
month: 3,
day: 15,
hour: 9,
minute: 0,
second: 0
}
);
}
#[test]
fn parse_datetime_utc() {
let dt = DateTimeValue::parse("20260315T090000Z").unwrap();
assert!(dt.is_utc());
assert_eq!(dt.to_string(), "20260315T090000Z");
}
#[test]
fn parse_iso8601() {
let dt = DateTimeValue::parse("2026-03-15T09:00:00").unwrap();
assert_eq!(
dt,
DateTimeValue::DateTime {
year: 2026,
month: 3,
day: 15,
hour: 9,
minute: 0,
second: 0
}
);
}
#[test]
fn datetime_to_property() {
let dt = DateTimeValue::DateTimeTz {
year: 2026,
month: 3,
day: 15,
hour: 9,
minute: 0,
second: 0,
tzid: "America/New_York".to_string(),
};
let prop = dt.to_property("DTSTART");
assert_eq!(prop.name, "DTSTART");
assert_eq!(prop.value, "20260315T090000");
assert_eq!(prop.param_value("TZID"), Some("America/New_York"));
}
#[test]
fn date_to_property() {
let dt = DateTimeValue::Date {
year: 2026,
month: 3,
day: 15,
};
let prop = dt.to_property("DTSTART");
assert_eq!(prop.param_value("VALUE"), Some("DATE"));
}
#[cfg(feature = "chrono")]
#[test]
fn chrono_roundtrip() {
let original = chrono::NaiveDate::from_ymd_opt(2026, 3, 15)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap();
let dt = DateTimeValue::from_naive_date_time(original);
let back = dt.to_naive_date_time().unwrap();
assert_eq!(original, back);
}
#[cfg(feature = "chrono")]
#[test]
fn chrono_utc_roundtrip() {
let original = chrono::NaiveDate::from_ymd_opt(2026, 3, 15)
.unwrap()
.and_hms_opt(9, 0, 0)
.unwrap()
.and_utc();
let dt = DateTimeValue::from_chrono_utc(original);
let back = dt.to_chrono_utc().unwrap();
assert_eq!(original, back);
}
}