1use std::collections::{HashMap, HashSet};
23
24use chrono::NaiveDate;
25
26use crate::types::Task;
27
28pub const EXDATE_KEY: &str = "EXDATE";
30pub const RECURRENCE_ID_KEY: &str = "RECURRENCE_ID";
32pub const SERIES_ID_KEY: &str = "SERIES_ID";
34pub const ID_KEY: &str = "ID";
36
37pub fn parse_excluded_dates(raw: &str, mut on_rejected: impl FnMut(&str)) -> Vec<String> {
50 let mut dates = Vec::new();
51 let mut seen = HashSet::new();
55 for field in raw.split([',', ' ', '\t']).filter(|f| !f.is_empty()) {
56 match NaiveDate::parse_from_str(field, "%Y-%m-%d") {
57 Ok(date) => {
58 if seen.insert(date) {
59 dates.push(date.format("%Y-%m-%d").to_string());
60 }
61 }
62 Err(_) => on_rejected(field),
63 }
64 }
65 dates
66}
67
68pub fn parse_recurrence_id(raw: &str) -> Option<String> {
76 let mut fields = raw.split_whitespace();
77 let date = NaiveDate::parse_from_str(fields.next()?, "%Y-%m-%d").ok()?;
78 let time = fields
79 .next()
80 .and_then(|t| chrono::NaiveTime::parse_from_str(t, "%H:%M").ok());
81 Some(match time {
82 Some(t) => format!("{} {}", date.format("%Y-%m-%d"), t.format("%H:%M")),
83 None => date.format("%Y-%m-%d").to_string(),
84 })
85}
86
87pub fn recurrence_id_date(value: &str) -> Option<NaiveDate> {
89 NaiveDate::parse_from_str(value.split_whitespace().next()?, "%Y-%m-%d").ok()
90}
91
92#[derive(Debug, Default, Clone)]
99pub struct OccurrenceExceptions {
100 replaced: HashMap<String, HashSet<NaiveDate>>,
101}
102
103impl OccurrenceExceptions {
104 pub fn from_tasks(tasks: &[Task]) -> Self {
106 let mut replaced: HashMap<String, HashSet<NaiveDate>> = HashMap::new();
107 for task in tasks {
108 let (Some(series), Some(recurrence)) =
109 (task.series_id.as_deref(), task.recurrence_id.as_deref())
110 else {
111 continue;
112 };
113 if let Some(date) = recurrence_id_date(recurrence) {
114 replaced.entry(series.to_string()).or_default().insert(date);
115 }
116 }
117 Self { replaced }
118 }
119
120 pub fn dates_for(&self, task: &Task) -> ExcludedOccurrences {
127 let cancelled = task
128 .excluded_dates
129 .as_deref()
130 .unwrap_or_default()
131 .iter()
132 .filter_map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
136 .collect();
137 let replaced = self
138 .task_id(task)
139 .and_then(|id| self.replaced.get(id))
140 .cloned()
141 .unwrap_or_default();
142 ExcludedOccurrences {
143 cancelled,
144 replaced,
145 }
146 }
147
148 fn task_id<'a>(&self, task: &'a Task) -> Option<&'a str> {
149 task.properties.as_ref()?.get(ID_KEY).map(String::as_str)
150 }
151}
152
153#[derive(Debug, Default, Clone, PartialEq, Eq)]
161pub struct ExcludedOccurrences {
162 cancelled: HashSet<NaiveDate>,
163 replaced: HashSet<NaiveDate>,
164}
165
166impl ExcludedOccurrences {
167 pub fn contains(&self, date: &NaiveDate) -> bool {
169 self.cancelled.contains(date) || self.replaced.contains(date)
170 }
171
172 pub fn is_replaced(&self, date: &NaiveDate) -> bool {
180 self.replaced.contains(date)
181 }
182
183 pub fn is_empty(&self) -> bool {
186 self.cancelled.is_empty() && self.replaced.is_empty()
187 }
188
189 pub fn len(&self) -> usize {
193 self.cancelled.len() + self.replaced.len()
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use std::collections::BTreeMap;
201
202 fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
203 NaiveDate::from_ymd_opt(y, m, d).unwrap()
204 }
205
206 fn dates_of(raw: &str) -> Vec<String> {
209 parse_excluded_dates(raw, |field| panic!("unexpected reject: {field:?}"))
210 }
211
212 fn series(id: &str) -> Task {
213 let mut props = BTreeMap::new();
214 props.insert(ID_KEY.to_string(), id.to_string());
215 Task {
216 properties: Some(props),
217 ..Task::default()
218 }
219 }
220
221 fn cancelling(dates: &[&str]) -> Task {
222 Task {
223 excluded_dates: Some(dates.iter().map(|d| (*d).to_string()).collect()),
224 ..Task::default()
225 }
226 }
227
228 fn replacement(series_id: &str, recurrence: &str) -> Task {
229 Task {
230 series_id: Some(series_id.to_string()),
231 recurrence_id: Some(recurrence.to_string()),
232 ..Task::default()
233 }
234 }
235
236 #[test]
237 fn excluded_dates_take_commas_and_spaces_alike() {
238 assert_eq!(
239 dates_of("2026-08-20, 2026-08-27 2026-09-03"),
240 ["2026-08-20", "2026-08-27", "2026-09-03"]
241 );
242 }
243
244 #[test]
245 fn excluded_dates_drop_what_is_not_a_date_and_say_so() {
246 let mut rejected = Vec::new();
247 let dates = parse_excluded_dates("2026-08-20, next thursday", |field| {
248 rejected.push(field.to_string());
249 });
250
251 assert_eq!(dates, ["2026-08-20"]);
252 assert_eq!(
253 rejected,
254 ["next", "thursday"],
255 "each field is reported as it is met"
256 );
257 }
258
259 #[test]
260 fn excluded_dates_keep_one_copy_of_a_repeated_date() {
261 assert_eq!(dates_of("2026-08-20 2026-08-20"), ["2026-08-20"]);
262 }
263
264 #[test]
265 fn excluded_dates_keep_one_copy_however_the_date_was_spelled() {
266 assert_eq!(dates_of("2026-8-20, 2026-08-20"), ["2026-08-20"]);
267 }
268
269 #[test]
270 fn a_long_exdate_costs_one_pass_and_not_one_per_date_already_seen() {
271 const DATES: i64 = 20_000;
277 let first = ymd(2000, 1, 1);
278 let raw = (0..DATES)
279 .map(|i| {
280 (first + chrono::Duration::days(i))
281 .format("%Y-%m-%d")
282 .to_string()
283 })
284 .collect::<Vec<_>>()
285 .join(", ");
286
287 let dates = dates_of(&raw);
288
289 assert_eq!(dates.len(), DATES as usize, "every date is kept, once");
290 assert_eq!(dates[0], "2000-01-01", "in the order it was written");
291 }
292
293 #[test]
294 fn a_recurrence_id_keeps_the_time_when_it_carries_one() {
295 assert_eq!(
296 parse_recurrence_id("2026-08-20 15:00").as_deref(),
297 Some("2026-08-20 15:00")
298 );
299 assert_eq!(
300 parse_recurrence_id("2026-08-20").as_deref(),
301 Some("2026-08-20")
302 );
303 }
304
305 #[test]
306 fn a_recurrence_id_without_a_date_is_no_recurrence_id() {
307 assert_eq!(parse_recurrence_id("thursday 15:00"), None);
308 }
309
310 #[test]
311 fn a_trailing_field_that_is_not_a_time_leaves_the_date_standing() {
312 assert_eq!(
313 parse_recurrence_id("2026-08-20 afternoon").as_deref(),
314 Some("2026-08-20")
315 );
316 }
317
318 #[test]
319 fn an_entry_skips_the_date_it_lists_itself() {
320 let task = cancelling(&["2026-08-20"]);
321 let missing =
322 OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);
323
324 assert!(missing.contains(&ymd(2026, 8, 20)));
325 assert!(!missing.contains(&ymd(2026, 8, 27)));
326 assert!(
327 !missing.is_replaced(&ymd(2026, 8, 20)),
328 "an EXDATE cancels an occurrence, it does not move it"
329 );
330 }
331
332 #[test]
333 fn a_date_in_an_exdate_that_cannot_be_read_is_dropped_and_the_rest_kept() {
334 let task = cancelling(&["last thursday", "2026-08-27"]);
337 let missing =
338 OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);
339
340 assert!(missing.contains(&ymd(2026, 8, 27)));
341 assert_eq!(missing.len(), 1);
342 }
343
344 #[test]
345 fn a_replacement_suppresses_the_occurrence_it_names() {
346 let english = series("series-1");
347 let moved = replacement("series-1", "2026-08-20 15:00");
348 let missing =
349 OccurrenceExceptions::from_tasks(&[english.clone(), moved]).dates_for(&english);
350
351 assert!(missing.contains(&ymd(2026, 8, 20)));
352 assert!(!missing.contains(&ymd(2026, 8, 27)));
353 assert!(
354 missing.is_replaced(&ymd(2026, 8, 20)),
355 "the occurrence moved: its debt is the replacement's"
356 );
357 }
358
359 #[test]
360 fn both_reasons_meet_in_one_answer_and_stay_apart_in_it() {
361 let mut english = series("series-1");
362 english.excluded_dates = Some(vec!["2026-08-13".to_string()]);
363 let moved = replacement("series-1", "2026-08-20 15:00");
364 let missing =
365 OccurrenceExceptions::from_tasks(&[english.clone(), moved]).dates_for(&english);
366
367 assert_eq!(missing.len(), 2);
368 assert!(missing.contains(&ymd(2026, 8, 13)) && missing.contains(&ymd(2026, 8, 20)));
369 assert!(!missing.is_replaced(&ymd(2026, 8, 13)), "the 13th is gone");
370 assert!(missing.is_replaced(&ymd(2026, 8, 20)), "the 20th moved");
371 }
372
373 #[test]
374 fn a_replacement_of_another_series_leaves_this_one_alone() {
375 let english = series("series-1");
376 let moved = replacement("series-2", "2026-08-20");
377 let missing =
378 OccurrenceExceptions::from_tasks(&[english.clone(), moved]).dates_for(&english);
379
380 assert!(missing.is_empty());
381 }
382
383 #[test]
384 fn a_series_without_an_id_cannot_be_replaced() {
385 let anonymous = Task::default();
386 let moved = replacement("series-1", "2026-08-20");
387 let missing =
388 OccurrenceExceptions::from_tasks(&[anonymous.clone(), moved]).dates_for(&anonymous);
389
390 assert!(missing.is_empty());
391 }
392
393 #[test]
394 fn an_entry_whose_only_exception_is_an_exdate_is_not_an_entry_without_any() {
395 let task = cancelling(&["2026-08-20"]);
396 let missing =
397 OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);
398
399 assert!(
400 !missing.is_empty(),
401 "an EXDATE is an exception: an entry holding one is not an entry without any"
402 );
403 }
404
405 #[test]
406 fn one_definition_answers_whatever_the_date_is_written_like() {
407 let task = cancelling(&["2026-8-20"]);
411 let missing =
412 OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);
413
414 assert!(missing.contains(&ymd(2026, 8, 20)));
415 }
416}