Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
449 changes: 289 additions & 160 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ edition = "2021"
serde = { version = "1.0.162", features = ["derive"] }
libc = "0.2"
nom = "6.0"
rrule = { version = "0.10", features = ["serde", "exrule"] }
chrono = { version = "0.4.19", features = ["serde"] }
chrono-tz = "0.6.1"
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
regex = { version = "1.5.5", default-features = false, features = ["perf", "std"] }
rstar = { version = "0.11.0", features = ["serde"] }
geo = { version = "0.26.0", features = ["use-serde"] }
Expand Down
2 changes: 1 addition & 1 deletion redical_core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ edition = "2021"
[dependencies]
serde = { workspace = true }
nom = "6.0"
rrule = { version = "0.10", features = ["serde", "exrule"] }
rrule = { version = "0.14", features = ["serde", "exrule"] }
chrono = { workspace = true }
chrono-tz = { workspace = true }
lazy_static = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion redical_core/src/event_instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ impl Ord for EventInstance {
#[derive(Debug)]
pub struct EventInstanceIterator<'a> {
event: &'a Event,
internal_iter: EventOccurrenceIterator<'a>,
internal_iter: EventOccurrenceIterator,
}

impl<'a> EventInstanceIterator<'a> {
Expand Down
19 changes: 11 additions & 8 deletions redical_core/src/event_occurrence_iterator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ impl UpperBoundFilterCondition {
}

#[derive(Debug)]
pub struct EventOccurrenceIterator<'a> {
pub struct EventOccurrenceIterator {
event_occurrence_overrides: BTreeMap<i64, EventOccurrenceOverride>,
rrule_set_iter: Option<rrule::RRuleSetIter<'a>>,
rrule_set_iter: Option<rrule::RRuleSetIter>,
base_duration: i64,
limit: Option<usize>,
count: usize,
Expand All @@ -69,15 +69,15 @@ pub struct EventOccurrenceIterator<'a> {
internal_min_max_bounds: Option<(i64, i64)>,
}

impl<'a> EventOccurrenceIterator<'a> {
impl EventOccurrenceIterator {
pub fn new(
schedule_properties: &'a ScheduleProperties,
event_occurrence_overrides: &'a BTreeMap<i64, EventOccurrenceOverride>,
schedule_properties: &ScheduleProperties,
event_occurrence_overrides: &BTreeMap<i64, EventOccurrenceOverride>,
limit: Option<usize>,
filter_from: Option<LowerBoundFilterCondition>,
filter_until: Option<UpperBoundFilterCondition>,
filtering_indexed_conclusion: Option<IndexedConclusion>,
) -> Result<EventOccurrenceIterator<'a>, String> {
) -> Result<EventOccurrenceIterator, String> {
let rrule_set_iter =
schedule_properties.parsed_rrule_set
.as_ref()
Expand Down Expand Up @@ -287,13 +287,16 @@ impl<'a> EventOccurrenceIterator<'a> {

fn rrule_set_iter_next(&mut self) -> Option<chrono::DateTime<rrule::Tz>> {
match &mut self.rrule_set_iter {
Some(rrule_set_iter) => rrule_set_iter.next(),
Some(rrule_set_iter) => {
Iterator::next(rrule_set_iter)
},

None => None,
}
}
}

impl Iterator for EventOccurrenceIterator<'_> {
impl Iterator for EventOccurrenceIterator {
type Item = (i64, i64, Option<EventOccurrenceOverride>);
fn next(&mut self) -> Option<Self::Item> {
if self.is_ended {
Expand Down
98 changes: 84 additions & 14 deletions redical_ical/src/properties/event/dtend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,21 @@ impl ICalendarDateTimeProperty for DTEndProperty {
}
}

impl DTEndProperty {
/// Resolves `self.date_time` against the TZID param (if present) to handle DST
/// transition gaps and ambiguities per industry convention.
///
/// Must be called during parsing (within `map_res` in `parse_ical`) before validation,
/// so that the stored datetime is always a valid wall-clock time in the target timezone.
pub fn resolve_dst_transitions(&mut self) -> Result<(), String> {
if let Some(tzid) = self.params.tzid.as_ref() {
self.date_time = tzid.resolve_dst_transition(&self.date_time)?;
}

Ok(())
}
}

impl ICalendarEntity for DTEndProperty {
fn parse_ical(input: ParserInput) -> ParserResult<Self> {
context(
Expand All @@ -188,19 +203,23 @@ impl ICalendarEntity for DTEndProperty {
preceded(colon, DateTime::parse_ical),
),
|(params, date_time)| {
let dtstart_property =
let mut dtend_property =
DTEndProperty {
params: params.unwrap_or(DTEndPropertyParams::default()),
date_time,
};

if let Err(error) = ICalendarEntity::validate(&dtstart_property) {
// Resolve DST transition gaps/ambiguities before validation.
dtend_property.resolve_dst_transitions()
.map_err(|error| ParserError::new(error, input))?;

if let Err(error) = ICalendarEntity::validate(&dtend_property) {
return Err(
ParserError::new(error, input)
);
}

Ok(dtstart_property)
Ok(dtend_property)
}
)
)
Expand All @@ -217,8 +236,6 @@ impl ICalendarEntity for DTEndProperty {

if let Some(tzid) = self.params.tzid.as_ref() {
tzid.validate()?;

tzid.validate_with_datetime_value(&self.date_time)?;
};

if let Some(value_type) = self.params.value_type.as_ref() {
Expand Down Expand Up @@ -270,7 +287,7 @@ mod tests {
use chrono::{NaiveDate, NaiveTime, NaiveDateTime};
use chrono_tz::Tz;

use crate::tests::{assert_parser_output, assert_parser_error};
use crate::tests::assert_parser_output;

#[test]
fn parse_ical() {
Expand Down Expand Up @@ -334,18 +351,71 @@ mod tests {
}

#[test]
fn parse_ical_wth_tz_dst_gap_date_time() {
// Assert impossible date/time fails validation.
assert_parser_error!(
fn parse_ical_with_tz_dst_transition() {
// Gap at exact boundary: 02:00 Auckland -> adjusted to 03:00
assert_parser_output!(
DTEndProperty::parse_ical("DTEND;TZID=Pacific/Auckland:20240929T020000".into()),
nom::Err::Failure(
span: ";TZID=Pacific/Auckland:20240929T020000",
message: "Error - detected timezone aware datetime within a DST transition gap (supply this as UTC or fully DST adjusted) at \"DTEND;TZID=Pacific/Auckland:20240929T020000\"",
context: ["DTEND"],
(
"",
DTEndProperty {
params: DTEndPropertyParams {
value_type: None,
tzid: Some(Tzid(Tz::Pacific__Auckland)),
other: HashMap::new(),
},
date_time: DateTime::LocalDateTime(
NaiveDateTime::new(
NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(),
NaiveTime::from_hms_opt(3_u32, 0_u32, 0_u32).unwrap(),
)
),
},
),
);

// Gap with offset: 02:30 Auckland -> adjusted to 03:30
assert_parser_output!(
DTEndProperty::parse_ical("DTEND;TZID=Pacific/Auckland:20240929T023000".into()),
(
"",
DTEndProperty {
params: DTEndPropertyParams {
value_type: None,
tzid: Some(Tzid(Tz::Pacific__Auckland)),
other: HashMap::new(),
},
date_time: DateTime::LocalDateTime(
NaiveDateTime::new(
NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(),
NaiveTime::from_hms_opt(3_u32, 30_u32, 0_u32).unwrap(),
)
),
},
),
);

// Ambiguous fall-back: 01:15 London Oct 25 2026 — accepted as-is
assert_parser_output!(
DTEndProperty::parse_ical("DTEND;TZID=Europe/London:20261025T011500".into()),
(
"",
DTEndProperty {
params: DTEndPropertyParams {
value_type: None,
tzid: Some(Tzid(Tz::Europe__London)),
other: HashMap::new(),
},
date_time: DateTime::LocalDateTime(
NaiveDateTime::new(
NaiveDate::from_ymd_opt(2026_i32, 10_u32, 25_u32).unwrap(),
NaiveTime::from_hms_opt(1_u32, 15_u32, 0_u32).unwrap(),
)
),
},
),
);

// Assert possible date/time does not fail validation.
// Non-transition times still work
assert_parser_output!(
DTEndProperty::parse_ical("DTEND;TZID=Pacific/Auckland:20240929T010000".into()),
(
Expand Down
92 changes: 81 additions & 11 deletions redical_ical/src/properties/event/dtstart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,21 @@ impl ICalendarDateTimeProperty for DTStartProperty {
}
}

impl DTStartProperty {
/// Resolves `self.date_time` against the TZID param (if present) to handle DST
/// transition gaps and ambiguities per industry convention.
///
/// Must be called during parsing (within `map_res` in `parse_ical`) before validation,
/// so that the stored datetime is always a valid wall-clock time in the target timezone.
pub fn resolve_dst_transitions(&mut self) -> Result<(), String> {
if let Some(tzid) = self.params.tzid.as_ref() {
self.date_time = tzid.resolve_dst_transition(&self.date_time)?;
}

Ok(())
}
}

impl ICalendarEntity for DTStartProperty {
fn parse_ical(input: ParserInput) -> ParserResult<Self> {
context(
Expand All @@ -191,12 +206,16 @@ impl ICalendarEntity for DTStartProperty {
preceded(colon, DateTime::parse_ical),
),
|(params, date_time)| {
let dtstart_property =
let mut dtstart_property =
DTStartProperty {
params: params.unwrap_or(DTStartPropertyParams::default()),
date_time,
};

// Resolve DST transition gaps/ambiguities before validation.
dtstart_property.resolve_dst_transitions()
.map_err(|error| ParserError::new(error, input))?;

if let Err(error) = ICalendarEntity::validate(&dtstart_property) {
return Err(
ParserError::new(error, input)
Expand All @@ -220,8 +239,6 @@ impl ICalendarEntity for DTStartProperty {

if let Some(tzid) = self.params.tzid.as_ref() {
tzid.validate()?;

tzid.validate_with_datetime_value(&self.date_time)?;
};

if let Some(value_type) = self.params.value_type.as_ref() {
Expand Down Expand Up @@ -337,18 +354,71 @@ mod tests {
}

#[test]
fn parse_ical_wth_tz_dst_gap_date_time() {
// Assert impossible date/time fails validation.
assert_parser_error!(
fn parse_ical_with_tz_dst_transition() {
// Gap at exact boundary: 02:00 Auckland -> adjusted to 03:00
assert_parser_output!(
DTStartProperty::parse_ical("DTSTART;TZID=Pacific/Auckland:20240929T020000".into()),
nom::Err::Failure(
span: ";TZID=Pacific/Auckland:20240929T020000",
message: "Error - detected timezone aware datetime within a DST transition gap (supply this as UTC or fully DST adjusted) at \"DTSTART;TZID=Pacific/Auckland:20240929T020000\"",
context: ["DTSTART"],
(
"",
DTStartProperty {
params: DTStartPropertyParams {
value_type: None,
tzid: Some(Tzid(Tz::Pacific__Auckland)),
other: HashMap::new(),
},
date_time: DateTime::LocalDateTime(
NaiveDateTime::new(
NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(),
NaiveTime::from_hms_opt(3_u32, 0_u32, 0_u32).unwrap(),
)
),
},
),
);

// Gap with offset: 02:30 Auckland -> adjusted to 03:30
assert_parser_output!(
DTStartProperty::parse_ical("DTSTART;TZID=Pacific/Auckland:20240929T023000".into()),
(
"",
DTStartProperty {
params: DTStartPropertyParams {
value_type: None,
tzid: Some(Tzid(Tz::Pacific__Auckland)),
other: HashMap::new(),
},
date_time: DateTime::LocalDateTime(
NaiveDateTime::new(
NaiveDate::from_ymd_opt(2024_i32, 9_u32, 29_u32).unwrap(),
NaiveTime::from_hms_opt(3_u32, 30_u32, 0_u32).unwrap(),
)
),
},
),
);

// Ambiguous fall-back: 01:15 London Oct 25 2026 — accepted as-is
assert_parser_output!(
DTStartProperty::parse_ical("DTSTART;TZID=Europe/London:20261025T011500".into()),
(
"",
DTStartProperty {
params: DTStartPropertyParams {
value_type: None,
tzid: Some(Tzid(Tz::Europe__London)),
other: HashMap::new(),
},
date_time: DateTime::LocalDateTime(
NaiveDateTime::new(
NaiveDate::from_ymd_opt(2026_i32, 10_u32, 25_u32).unwrap(),
NaiveTime::from_hms_opt(1_u32, 15_u32, 0_u32).unwrap(),
)
),
},
),
);

// Assert possible date/time does not fail validation.
// Non-transition times still work
assert_parser_output!(
DTStartProperty::parse_ical("DTSTART;TZID=Pacific/Auckland:20240929T010000".into()),
(
Expand Down
Loading
Loading