use alloc::{
string::{String, ToString},
vec::Vec,
};
use crate::{
component::{IcalComponent, IcalComponentKind, IcalComponentName},
ical::Ical,
prop::{IcalPropKind, IcalPropName},
recur::{IcalRecurDateTime, set::IcalRecurSet},
value::IcalValue,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IcalOffset {
One(i32),
Gap {
before: i32,
after: i32,
},
Fold {
earlier: i32,
later: i32,
},
}
impl IcalOffset {
pub fn unambiguous(&self) -> Option<i32> {
match self {
Self::One(offset) => Some(*offset),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IcalObservance {
pub daylight: bool,
pub from: i32,
pub to: i32,
pub onsets: IcalRecurSet,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IcalTimezone {
pub id: String,
pub observances: Vec<IcalObservance>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Transition {
before_local: IcalRecurDateTime,
from: i32,
to: i32,
}
impl Transition {
fn after_local(&self) -> IcalRecurDateTime {
IcalRecurDateTime::from_seconds(
self.before_local.seconds() + i64::from(self.to) - i64::from(self.from),
)
}
}
impl IcalTimezone {
pub fn of_component(component: &IcalComponent<'_>) -> Option<Self> {
if !matches!(
component.name,
IcalComponentName::Kind(IcalComponentKind::VTimezone)
) {
return None;
}
let id = component
.props
.iter()
.find_map(|prop| match (&prop.name, &prop.value) {
(IcalPropName::Kind(IcalPropKind::TzId), IcalValue::Text(text)) => {
Some(text.0.to_string())
}
_ => None,
})?;
let observances = component
.components
.iter()
.filter_map(IcalObservance::of_component)
.collect();
Some(Self { id, observances })
}
pub fn of_calendar(ical: &Ical<'_>, tzid: &str) -> Option<Self> {
ical.components
.iter()
.filter_map(Self::of_component)
.find(|zone| zone.id == tzid)
}
pub fn resolve(&self, local: IcalRecurDateTime) -> IcalOffset {
let mut previous: Option<Transition> = None;
let mut next: Option<Transition> = None;
let mut first: Option<Transition> = None;
for observance in &self.observances {
for onset in observance.onsets.expand() {
let transition = Transition {
before_local: onset.start,
from: observance.from,
to: observance.to,
};
if first.is_none_or(|held| transition.before_local < held.before_local) {
first = Some(transition);
}
if transition.before_local <= local {
if previous.is_none_or(|held| held.before_local < transition.before_local) {
previous = Some(transition);
}
} else {
if next.is_none_or(|held| transition.before_local < held.before_local) {
next = Some(transition);
}
break;
}
}
}
if let Some(transition) = previous
&& transition.to > transition.from
&& local < transition.after_local()
{
return IcalOffset::Gap {
before: transition.from,
after: transition.to,
};
}
if let Some(transition) = next
&& transition.to < transition.from
&& local >= transition.after_local()
{
return IcalOffset::Fold {
earlier: transition.from,
later: transition.to,
};
}
match (previous, first) {
(Some(transition), _) => IcalOffset::One(transition.to),
(None, Some(transition)) => IcalOffset::One(transition.from),
(None, None) => IcalOffset::One(0),
}
}
}
impl IcalObservance {
pub fn of_component(component: &IcalComponent<'_>) -> Option<Self> {
let daylight = match component.name {
IcalComponentName::Kind(IcalComponentKind::Daylight) => true,
IcalComponentName::Kind(IcalComponentKind::Standard) => false,
_ => return None,
};
let mut from = None;
let mut to = None;
for prop in &component.props {
let IcalPropName::Kind(kind) = prop.name else {
continue;
};
let IcalValue::UtcOffset(offset) = &prop.value else {
continue;
};
match kind {
IcalPropKind::TzOffsetFrom => from = parse_offset(&offset.0),
IcalPropKind::TzOffsetTo => to = parse_offset(&offset.0),
_ => {}
}
}
Some(Self {
daylight,
from: from?,
to: to?,
onsets: IcalRecurSet::of_component(component),
})
}
}
fn parse_offset(text: &str) -> Option<i32> {
let (sign, digits) = match text.as_bytes().first()? {
b'+' => (1, &text[1..]),
b'-' => (-1, &text[1..]),
_ => (1, text),
};
if !matches!(digits.len(), 4 | 6) || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let part = |range: core::ops::Range<usize>| digits[range].parse::<i32>().ok();
let hours = part(0..2)?;
let minutes = part(2..4)?;
let seconds = if digits.len() == 6 { part(4..6)? } else { 0 };
Some(sign * (hours * 3600 + minutes * 60 + seconds))
}
#[cfg(test)]
mod tests {
use crate::timezone::parse_offset;
#[test]
fn reads_every_offset_spelling() {
assert_eq!(parse_offset("-0500"), Some(-18_000));
assert_eq!(parse_offset("+0100"), Some(3_600));
assert_eq!(parse_offset("+053045"), Some(19_845));
assert_eq!(parse_offset("0000"), Some(0));
}
#[test]
fn refuses_what_is_not_an_offset() {
assert_eq!(parse_offset(""), None);
assert_eq!(parse_offset("+5"), None);
assert_eq!(parse_offset("+05:00"), None);
assert_eq!(parse_offset("+0h00"), None);
}
}