use std::fmt;
use std::str::FromStr;
use crate::error::ParseError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IntervalResolution {
QuarterHour,
HalfHour,
Hour,
Day,
Month,
Year,
Custom(CustomSeconds),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CustomSeconds(u32);
impl CustomSeconds {
#[must_use]
pub const fn new(secs: u32) -> Option<Self> {
match secs {
0 | 900 | 1800 | 3600 => None,
n => Some(Self(n)),
}
}
#[must_use]
pub const fn get(self) -> u32 {
self.0
}
}
impl std::fmt::Display for CustomSeconds {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<CustomSeconds> for u32 {
fn from(c: CustomSeconds) -> u32 {
c.0
}
}
impl TryFrom<u32> for CustomSeconds {
type Error = ParseError;
fn try_from(secs: u32) -> Result<Self, Self::Error> {
Self::new(secs).ok_or_else(|| {
ParseError::format(
"CustomSeconds",
&secs.to_string(),
"a positive second count that is not 900, 1800 or 3600",
)
})
}
}
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(s) => Some(s.get()),
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.get()),
}
}
#[must_use]
pub const fn from_seconds(s: u32) -> Option<Self> {
match s {
900 => Some(Self::QuarterHour),
1800 => Some(Self::HalfHour),
3600 => Some(Self::Hour),
other => match CustomSeconds::new(other) {
Some(c) => Some(Self::Custom(c)),
None => None,
},
}
}
#[must_use]
pub const fn from_observed_seconds(secs: i64) -> Option<Self> {
match secs {
750..=1_050 => Some(Self::QuarterHour),
1_650..=1_950 => Some(Self::HalfHour),
3_300..=3_900 => Some(Self::Hour),
82_800..=90_000 => Some(Self::Day),
other => {
if other <= 0 || other > u32::MAX as i64 {
None
} else {
Self::from_seconds(other as u32)
}
}
}
}
#[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.pad(&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(feature = "serde")]
mod serde_impl {
use super::IntervalResolution;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
impl Serialize for IntervalResolution {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
struct IntervalResolutionVisitor;
impl Visitor<'_> for IntervalResolutionVisitor {
type Value = IntervalResolution;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("an ISO 8601 duration string such as \"PT15M\" or \"P1D\"")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<IntervalResolution, E> {
v.parse().map_err(de::Error::custom)
}
}
impl<'de> Deserialize<'de> for IntervalResolution {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_str(IntervalResolutionVisitor)
}
}
}
#[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::from_seconds(7200).unwrap(),
] {
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::from_seconds(7200).unwrap(),
] {
let s = r.fixed_seconds().unwrap();
assert_eq!(IntervalResolution::from_seconds(s), Some(r));
}
}
#[test]
fn a_day_is_not_86400_seconds() {
let fixed = IntervalResolution::from_seconds(86_400).unwrap();
assert_eq!(fixed.fixed_seconds(), Some(86_400));
assert_ne!(fixed, IntervalResolution::Day);
assert!(fixed.is_fixed() && !fixed.is_calendar());
}
#[test]
fn a_named_length_is_never_a_custom() {
for named in [900u32, 1800, 3600] {
assert_eq!(CustomSeconds::new(named), None, "{named} s has a name");
assert!(CustomSeconds::try_from(named).is_err());
}
assert_eq!(CustomSeconds::new(0), None, "zero is not a resolution");
assert_eq!(
IntervalResolution::from_seconds(900),
Some(IntervalResolution::QuarterHour)
);
assert_eq!(CustomSeconds::new(7200).map(CustomSeconds::get), Some(7200));
assert_eq!(u32::from(CustomSeconds::new(7200).unwrap()), 7200);
assert_eq!(CustomSeconds::new(7200).unwrap().to_string(), "7200");
}
#[test]
fn zero_returns_none() {
assert!(IntervalResolution::from_seconds(0).is_none());
}
#[test]
fn custom_seconds_preserved() {
let r = IntervalResolution::from_seconds(7200).unwrap();
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::from_seconds(300).unwrap(),
IntervalResolution::from_seconds(86_400).unwrap(),
] {
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!(format!("{:>6}", IntervalResolution::Day), " P1D");
assert_eq!(
format!("{:<6}|", IntervalResolution::QuarterHour),
"PT15M |"
);
assert_eq!(
IntervalResolution::from_seconds(300).unwrap().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::from_seconds(7200).unwrap())
);
}
#[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());
}
}