use core::fmt;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct TimezoneOffset(i16);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TimezoneLossError;
impl fmt::Display for TimezoneLossError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("conversion to a civil value would discard an explicit XSD timezone")
}
}
impl core::error::Error for TimezoneLossError {}
#[cfg(feature = "borsh")]
impl borsh::BorshSerialize for TimezoneOffset {
fn serialize<W: borsh::io::Write>(&self, writer: &mut W) -> Result<(), borsh::io::Error> {
borsh::BorshSerialize::serialize(&self.0, writer)
}
}
#[cfg(feature = "borsh")]
impl borsh::BorshDeserialize for TimezoneOffset {
fn deserialize_reader<R: borsh::io::Read>(reader: &mut R) -> Result<Self, borsh::io::Error> {
let minutes = <i16 as borsh::BorshDeserialize>::deserialize_reader(reader)?;
Self::from_minutes(minutes).ok_or_else(|| {
borsh::io::Error::new(
borsh::io::ErrorKind::InvalidData,
"XSD timezone offset is outside -14:00..=+14:00",
)
})
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for TimezoneOffset {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_i16(self.0)
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for TimezoneOffset {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let minutes = i16::deserialize(deserializer)?;
Self::from_minutes(minutes)
.ok_or_else(|| serde::de::Error::custom(TimezoneOffsetError::OutOfRange))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum TimezoneOffsetError {
InvalidLexical,
OutOfRange,
SubMinute,
}
impl fmt::Display for TimezoneOffsetError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::InvalidLexical => "XSD timezone offsets require Z, +hh:mm, or -hh:mm",
Self::OutOfRange => {
"XSD timezone offset is outside -14:00..=+14:00 or has invalid minutes"
},
Self::SubMinute => "XSD timezone offsets must use whole minutes",
})
}
}
impl core::error::Error for TimezoneOffsetError {}
#[cfg(feature = "jiff")]
impl TryFrom<jiff::tz::Offset> for TimezoneOffset {
type Error = TimezoneOffsetError;
fn try_from(offset: jiff::tz::Offset) -> Result<Self, Self::Error> {
let seconds = offset.seconds();
if !(-50_400..=50_400).contains(&seconds) {
return Err(TimezoneOffsetError::OutOfRange);
}
if seconds % 60 != 0 {
return Err(TimezoneOffsetError::SubMinute);
}
Ok(Self((seconds / 60) as i16))
}
}
#[cfg(feature = "jiff")]
impl From<TimezoneOffset> for jiff::tz::Offset {
fn from(offset: TimezoneOffset) -> Self {
Self::from_seconds(i32::from(offset.minutes()) * 60)
.expect("validated XSD offsets fit in Jiff's offset range")
}
}
impl core::str::FromStr for TimezoneOffset {
type Err = TimezoneOffsetError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
if input == "Z" {
return Ok(Self::UTC);
}
let [sign @ (b'+' | b'-'), h1, h2, b':', m1, m2] = input.as_bytes() else {
return Err(TimezoneOffsetError::InvalidLexical);
};
if ![h1, h2, m1, m2].iter().all(|byte| byte.is_ascii_digit()) {
return Err(TimezoneOffsetError::InvalidLexical);
}
let hours = i16::from((h1 - b'0') * 10 + (h2 - b'0'));
let minutes = i16::from((m1 - b'0') * 10 + (m2 - b'0'));
if minutes > 59 {
return Err(TimezoneOffsetError::OutOfRange);
}
let total = hours * 60 + minutes;
Self::from_minutes(if *sign == b'-' { -total } else { total })
.ok_or(TimezoneOffsetError::OutOfRange)
}
}
impl TimezoneOffset {
pub const UTC: Self = Self(0);
pub const fn from_minutes(minutes: i16) -> Option<Self> {
if minutes >= -840 && minutes <= 840 {
Some(Self(minutes))
} else {
None
}
}
pub const fn minutes(self) -> i16 {
self.0
}
}
impl fmt::Display for TimezoneOffset {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.0 == 0 {
return f.write_str("Z");
}
let magnitude = self.0.unsigned_abs();
write!(
f,
"{}{:02}:{:02}",
if self.0 < 0 { '-' } else { '+' },
magnitude / 60,
magnitude % 60
)
}
}