use std::fmt;
use std::str::FromStr;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::error::ParseError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum IntervalResolution {
QuarterHour,
HalfHour,
Hour,
Day,
Month,
Year,
Custom(u32),
}
impl IntervalResolution {
#[must_use]
pub const fn fixed_seconds(self) -> Option<u32> {
match self {
Self::QuarterHour => Some(900),
Self::HalfHour => Some(1800),
Self::Hour => Some(3600),
Self::Custom(0) => None,
Self::Custom(s) => Some(s),
Self::Day | Self::Month | Self::Year => None,
}
}
#[must_use]
pub const fn nominal_seconds(self) -> u32 {
match self {
Self::Day => 86_400,
Self::Month => 30 * 86_400,
Self::Year => 365 * 86_400,
other => match other.fixed_seconds() {
Some(s) => s,
None => 0,
},
}
}
#[must_use]
pub const fn is_fixed(self) -> bool {
self.fixed_seconds().is_some()
}
#[must_use]
pub const fn is_calendar(self) -> bool {
matches!(self, Self::Day | Self::Month | Self::Year)
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::QuarterHour => "15-Minuten",
Self::HalfHour => "30-Minuten",
Self::Hour => "Stunde",
Self::Day => "Tag",
Self::Month => "Monat",
Self::Year => "Jahr",
Self::Custom(_) => "Benutzerdefiniert",
}
}
#[must_use]
pub fn to_iso8601(self) -> String {
match self {
Self::QuarterHour => "PT15M".to_owned(),
Self::HalfHour => "PT30M".to_owned(),
Self::Hour => "PT1H".to_owned(),
Self::Day => "P1D".to_owned(),
Self::Month => "P1M".to_owned(),
Self::Year => "P1Y".to_owned(),
Self::Custom(s) => format!("PT{s}S"),
}
}
#[must_use]
pub const fn from_seconds(s: u32) -> Option<Self> {
match s {
0 => None,
900 => Some(Self::QuarterHour),
1800 => Some(Self::HalfHour),
3600 => Some(Self::Hour),
_ => Some(Self::Custom(s)),
}
}
#[must_use]
pub const fn is_subhourly(self) -> bool {
matches!(self, Self::QuarterHour | Self::HalfHour)
}
}
impl fmt::Display for IntervalResolution {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_iso8601())
}
}
const ISO8601_FORMAT: &str = "an ISO 8601 duration such as PT15M, PT1H, P1D or PT900S";
impl FromStr for IntervalResolution {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let err = || ParseError::format("IntervalResolution", s, ISO8601_FORMAT);
let upper = s.trim().to_uppercase();
let seconds = match upper.as_str() {
"P1D" => return Ok(Self::Day),
"P1M" => return Ok(Self::Month),
"P1Y" => return Ok(Self::Year),
rest => {
let body = rest.strip_prefix("PT").ok_or_else(err)?;
let (digits, unit) = body.split_at(body.len().checked_sub(1).ok_or_else(err)?);
let n: u32 = digits.parse().map_err(|_| err())?;
match unit {
"S" => n,
"M" => n.checked_mul(60).ok_or_else(err)?,
"H" => n.checked_mul(3600).ok_or_else(err)?,
_ => return Err(err()),
}
}
};
Self::from_seconds(seconds).ok_or_else(err)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quarter_hour_is_900s() {
assert_eq!(IntervalResolution::QuarterHour.fixed_seconds(), Some(900));
}
#[test]
fn calendar_resolutions_have_no_fixed_length() {
for r in [
IntervalResolution::Day,
IntervalResolution::Month,
IntervalResolution::Year,
] {
assert_eq!(r.fixed_seconds(), None, "{r} must not claim a fixed length");
assert!(r.is_calendar() && !r.is_fixed());
}
for r in [
IntervalResolution::QuarterHour,
IntervalResolution::HalfHour,
IntervalResolution::Hour,
IntervalResolution::Custom(7200),
] {
assert!(r.is_fixed() && !r.is_calendar(), "{r} must be fixed");
}
}
#[test]
fn from_seconds_round_trip() {
for r in [
IntervalResolution::QuarterHour,
IntervalResolution::HalfHour,
IntervalResolution::Hour,
IntervalResolution::Custom(7200),
] {
let s = r.fixed_seconds().unwrap();
assert_eq!(IntervalResolution::from_seconds(s), Some(r));
}
}
#[test]
fn a_day_is_not_86400_seconds() {
assert_eq!(
IntervalResolution::from_seconds(86_400),
Some(IntervalResolution::Custom(86_400))
);
}
#[test]
fn zero_returns_none() {
assert!(IntervalResolution::from_seconds(0).is_none());
}
#[test]
fn custom_seconds_preserved() {
let r = IntervalResolution::Custom(7200);
assert_eq!(r.fixed_seconds(), Some(7200));
assert!(!r.is_subhourly());
}
#[test]
fn subhourly_detection() {
assert!(IntervalResolution::QuarterHour.is_subhourly());
assert!(IntervalResolution::HalfHour.is_subhourly());
assert!(!IntervalResolution::Hour.is_subhourly());
assert!(!IntervalResolution::Day.is_subhourly());
}
#[test]
fn iso8601_round_trips_every_variant() {
for r in [
IntervalResolution::QuarterHour,
IntervalResolution::HalfHour,
IntervalResolution::Hour,
IntervalResolution::Day,
IntervalResolution::Month,
IntervalResolution::Year,
IntervalResolution::Custom(300),
IntervalResolution::Custom(86_400),
] {
let s = r.to_string();
assert_eq!(s.parse::<IntervalResolution>(), Ok(r), "round trip {s}");
}
assert_eq!(IntervalResolution::QuarterHour.to_string(), "PT15M");
assert_eq!(IntervalResolution::Day.to_string(), "P1D");
assert_eq!(IntervalResolution::Custom(300).to_string(), "PT300S");
}
#[test]
fn equivalent_spellings_normalise() {
assert_eq!(
"PT900S".parse::<IntervalResolution>(),
Ok(IntervalResolution::QuarterHour)
);
assert_eq!(
"pt15m".parse::<IntervalResolution>(),
Ok(IntervalResolution::QuarterHour)
);
assert_eq!(
"PT60M".parse::<IntervalResolution>(),
Ok(IntervalResolution::Hour)
);
assert_eq!(
"PT2H".parse::<IntervalResolution>(),
Ok(IntervalResolution::Custom(7200))
);
}
#[test]
fn invalid_resolution_strings_are_rejected() {
for s in [
"", "P", "PT", "15M", "PT15X", "PTM", "P1W", "PT-5S", "hourly",
] {
assert!(
s.parse::<IntervalResolution>().is_err(),
"{s:?} must not parse"
);
}
}
#[test]
fn nominal_length_is_only_a_hint() {
assert_eq!(IntervalResolution::Day.nominal_seconds(), 86_400);
assert_eq!(IntervalResolution::Month.nominal_seconds(), 30 * 86_400);
assert_eq!(IntervalResolution::Year.nominal_seconds(), 365 * 86_400);
assert!(IntervalResolution::Month.fixed_seconds().is_none());
}
}