use std::collections::{HashMap, HashSet};
use chrono::NaiveDate;
use crate::types::Task;
pub const EXDATE_KEY: &str = "EXDATE";
pub const RECURRENCE_ID_KEY: &str = "RECURRENCE_ID";
pub const SERIES_ID_KEY: &str = "SERIES_ID";
pub const ID_KEY: &str = "ID";
pub fn parse_excluded_dates(raw: &str, mut on_rejected: impl FnMut(&str)) -> Vec<String> {
let mut dates = Vec::new();
let mut seen = HashSet::new();
for field in raw.split([',', ' ', '\t']).filter(|f| !f.is_empty()) {
match NaiveDate::parse_from_str(field, "%Y-%m-%d") {
Ok(date) => {
if seen.insert(date) {
dates.push(date.format("%Y-%m-%d").to_string());
}
}
Err(_) => on_rejected(field),
}
}
dates
}
pub fn parse_recurrence_id(raw: &str) -> Option<String> {
let mut fields = raw.split_whitespace();
let date = NaiveDate::parse_from_str(fields.next()?, "%Y-%m-%d").ok()?;
let time = fields
.next()
.and_then(|t| chrono::NaiveTime::parse_from_str(t, "%H:%M").ok());
Some(match time {
Some(t) => format!("{} {}", date.format("%Y-%m-%d"), t.format("%H:%M")),
None => date.format("%Y-%m-%d").to_string(),
})
}
pub fn recurrence_id_date(value: &str) -> Option<NaiveDate> {
NaiveDate::parse_from_str(value.split_whitespace().next()?, "%Y-%m-%d").ok()
}
#[derive(Debug, Default, Clone)]
pub struct OccurrenceExceptions {
replaced: HashMap<String, HashSet<NaiveDate>>,
}
impl OccurrenceExceptions {
pub fn from_tasks(tasks: &[Task]) -> Self {
let mut replaced: HashMap<String, HashSet<NaiveDate>> = HashMap::new();
for task in tasks {
let (Some(series), Some(recurrence)) =
(task.series_id.as_deref(), task.recurrence_id.as_deref())
else {
continue;
};
if let Some(date) = recurrence_id_date(recurrence) {
replaced.entry(series.to_string()).or_default().insert(date);
}
}
Self { replaced }
}
pub fn dates_for(&self, task: &Task) -> ExcludedOccurrences {
let cancelled = task
.excluded_dates
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
.collect();
let replaced = self
.task_id(task)
.and_then(|id| self.replaced.get(id))
.cloned()
.unwrap_or_default();
ExcludedOccurrences {
cancelled,
replaced,
}
}
fn task_id<'a>(&self, task: &'a Task) -> Option<&'a str> {
task.properties.as_ref()?.get(ID_KEY).map(String::as_str)
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ExcludedOccurrences {
cancelled: HashSet<NaiveDate>,
replaced: HashSet<NaiveDate>,
}
impl ExcludedOccurrences {
pub fn contains(&self, date: &NaiveDate) -> bool {
self.cancelled.contains(date) || self.replaced.contains(date)
}
pub fn is_replaced(&self, date: &NaiveDate) -> bool {
self.replaced.contains(date)
}
pub fn is_empty(&self) -> bool {
self.cancelled.is_empty() && self.replaced.is_empty()
}
pub fn len(&self) -> usize {
self.cancelled.len() + self.replaced.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn dates_of(raw: &str) -> Vec<String> {
parse_excluded_dates(raw, |field| panic!("unexpected reject: {field:?}"))
}
fn series(id: &str) -> Task {
let mut props = BTreeMap::new();
props.insert(ID_KEY.to_string(), id.to_string());
Task {
properties: Some(props),
..Task::default()
}
}
fn cancelling(dates: &[&str]) -> Task {
Task {
excluded_dates: Some(dates.iter().map(|d| (*d).to_string()).collect()),
..Task::default()
}
}
fn replacement(series_id: &str, recurrence: &str) -> Task {
Task {
series_id: Some(series_id.to_string()),
recurrence_id: Some(recurrence.to_string()),
..Task::default()
}
}
#[test]
fn excluded_dates_take_commas_and_spaces_alike() {
assert_eq!(
dates_of("2026-08-20, 2026-08-27 2026-09-03"),
["2026-08-20", "2026-08-27", "2026-09-03"]
);
}
#[test]
fn excluded_dates_drop_what_is_not_a_date_and_say_so() {
let mut rejected = Vec::new();
let dates = parse_excluded_dates("2026-08-20, next thursday", |field| {
rejected.push(field.to_string());
});
assert_eq!(dates, ["2026-08-20"]);
assert_eq!(
rejected,
["next", "thursday"],
"each field is reported as it is met"
);
}
#[test]
fn excluded_dates_keep_one_copy_of_a_repeated_date() {
assert_eq!(dates_of("2026-08-20 2026-08-20"), ["2026-08-20"]);
}
#[test]
fn excluded_dates_keep_one_copy_however_the_date_was_spelled() {
assert_eq!(dates_of("2026-8-20, 2026-08-20"), ["2026-08-20"]);
}
#[test]
fn a_long_exdate_costs_one_pass_and_not_one_per_date_already_seen() {
const DATES: i64 = 20_000;
let first = ymd(2000, 1, 1);
let raw = (0..DATES)
.map(|i| {
(first + chrono::Duration::days(i))
.format("%Y-%m-%d")
.to_string()
})
.collect::<Vec<_>>()
.join(", ");
let dates = dates_of(&raw);
assert_eq!(dates.len(), DATES as usize, "every date is kept, once");
assert_eq!(dates[0], "2000-01-01", "in the order it was written");
}
#[test]
fn a_recurrence_id_keeps_the_time_when_it_carries_one() {
assert_eq!(
parse_recurrence_id("2026-08-20 15:00").as_deref(),
Some("2026-08-20 15:00")
);
assert_eq!(
parse_recurrence_id("2026-08-20").as_deref(),
Some("2026-08-20")
);
}
#[test]
fn a_recurrence_id_without_a_date_is_no_recurrence_id() {
assert_eq!(parse_recurrence_id("thursday 15:00"), None);
}
#[test]
fn a_trailing_field_that_is_not_a_time_leaves_the_date_standing() {
assert_eq!(
parse_recurrence_id("2026-08-20 afternoon").as_deref(),
Some("2026-08-20")
);
}
#[test]
fn an_entry_skips_the_date_it_lists_itself() {
let task = cancelling(&["2026-08-20"]);
let missing =
OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);
assert!(missing.contains(&ymd(2026, 8, 20)));
assert!(!missing.contains(&ymd(2026, 8, 27)));
assert!(
!missing.is_replaced(&ymd(2026, 8, 20)),
"an EXDATE cancels an occurrence, it does not move it"
);
}
#[test]
fn a_date_in_an_exdate_that_cannot_be_read_is_dropped_and_the_rest_kept() {
let task = cancelling(&["last thursday", "2026-08-27"]);
let missing =
OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);
assert!(missing.contains(&ymd(2026, 8, 27)));
assert_eq!(missing.len(), 1);
}
#[test]
fn a_replacement_suppresses_the_occurrence_it_names() {
let english = series("series-1");
let moved = replacement("series-1", "2026-08-20 15:00");
let missing =
OccurrenceExceptions::from_tasks(&[english.clone(), moved]).dates_for(&english);
assert!(missing.contains(&ymd(2026, 8, 20)));
assert!(!missing.contains(&ymd(2026, 8, 27)));
assert!(
missing.is_replaced(&ymd(2026, 8, 20)),
"the occurrence moved: its debt is the replacement's"
);
}
#[test]
fn both_reasons_meet_in_one_answer_and_stay_apart_in_it() {
let mut english = series("series-1");
english.excluded_dates = Some(vec!["2026-08-13".to_string()]);
let moved = replacement("series-1", "2026-08-20 15:00");
let missing =
OccurrenceExceptions::from_tasks(&[english.clone(), moved]).dates_for(&english);
assert_eq!(missing.len(), 2);
assert!(missing.contains(&ymd(2026, 8, 13)) && missing.contains(&ymd(2026, 8, 20)));
assert!(!missing.is_replaced(&ymd(2026, 8, 13)), "the 13th is gone");
assert!(missing.is_replaced(&ymd(2026, 8, 20)), "the 20th moved");
}
#[test]
fn a_replacement_of_another_series_leaves_this_one_alone() {
let english = series("series-1");
let moved = replacement("series-2", "2026-08-20");
let missing =
OccurrenceExceptions::from_tasks(&[english.clone(), moved]).dates_for(&english);
assert!(missing.is_empty());
}
#[test]
fn a_series_without_an_id_cannot_be_replaced() {
let anonymous = Task::default();
let moved = replacement("series-1", "2026-08-20");
let missing =
OccurrenceExceptions::from_tasks(&[anonymous.clone(), moved]).dates_for(&anonymous);
assert!(missing.is_empty());
}
#[test]
fn an_entry_whose_only_exception_is_an_exdate_is_not_an_entry_without_any() {
let task = cancelling(&["2026-08-20"]);
let missing =
OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);
assert!(
!missing.is_empty(),
"an EXDATE is an exception: an entry holding one is not an entry without any"
);
}
#[test]
fn one_definition_answers_whatever_the_date_is_written_like() {
let task = cancelling(&["2026-8-20"]);
let missing =
OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);
assert!(missing.contains(&ymd(2026, 8, 20)));
}
}