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> {
56 let mut dates = Vec::new();
57 let mut seen = HashSet::new();
61 let mut after_date = false;
62 for field in raw.split([',', ' ', '\t']).filter(|f| !f.is_empty()) {
63 match NaiveDate::parse_from_str(field, "%Y-%m-%d") {
64 Ok(date) => {
65 after_date = true;
66 if seen.insert(date) {
67 dates.push(date.format("%Y-%m-%d").to_string());
68 }
69 }
70 Err(_) => {
71 let is_time_of_that_date = after_date && parse_clock(field).is_some();
72 after_date = false;
73 if !is_time_of_that_date {
74 on_rejected(field);
75 }
76 }
77 }
78 }
79 dates
80}
81
82pub fn parse_recurrence_id(raw: &str, mut on_dropped: impl FnMut(&str)) -> Option<String> {
93 let mut fields = raw.split_whitespace();
94 let date = NaiveDate::parse_from_str(fields.next()?, "%Y-%m-%d").ok()?;
95 let rest: Vec<&str> = fields.collect();
96 let time = rest.first().copied().and_then(parse_clock);
97 let dropped = if time.is_some() {
98 &rest[1..]
99 } else {
100 &rest[..]
101 };
102 if !dropped.is_empty() {
103 on_dropped(&dropped.join(" "));
104 }
105 Some(match time {
106 Some(t) => format!("{} {}", date.format("%Y-%m-%d"), t.format("%H:%M")),
107 None => date.format("%Y-%m-%d").to_string(),
108 })
109}
110
111fn parse_clock(field: &str) -> Option<chrono::NaiveTime> {
115 chrono::NaiveTime::parse_from_str(field, "%H:%M")
116 .or_else(|_| chrono::NaiveTime::parse_from_str(field, "%H:%M:%S"))
117 .ok()
118}
119
120pub fn recurrence_id_date(value: &str) -> Option<NaiveDate> {
122 NaiveDate::parse_from_str(value.split_whitespace().next()?, "%Y-%m-%d").ok()
123}
124
125#[derive(Debug, Default, Clone)]
132pub struct OccurrenceExceptions {
133 replaced: HashMap<String, HashSet<NaiveDate>>,
134 unknown_series: Vec<String>,
135}
136
137impl OccurrenceExceptions {
138 pub fn from_tasks(tasks: &[Task]) -> Self {
140 let mut replaced: HashMap<String, HashSet<NaiveDate>> = HashMap::new();
141 for task in tasks {
142 let (Some(series), Some(recurrence)) =
143 (task.series_id.as_deref(), task.recurrence_id.as_deref())
144 else {
145 continue;
146 };
147 if let Some(date) = recurrence_id_date(recurrence) {
148 replaced.entry(series.to_string()).or_default().insert(date);
149 }
150 }
151 let known: HashSet<&str> = tasks.iter().filter_map(task_id).collect();
158 let mut unknown_series: Vec<String> = replaced
159 .keys()
160 .filter(|id| !known.contains(id.as_str()))
161 .cloned()
162 .collect();
163 unknown_series.sort();
165 Self {
166 replaced,
167 unknown_series,
168 }
169 }
170
171 pub fn unknown_series(&self) -> &[String] {
177 &self.unknown_series
178 }
179
180 pub fn dates_for(&self, task: &Task) -> ExcludedOccurrences {
187 let cancelled = task
188 .excluded_dates
189 .as_deref()
190 .unwrap_or_default()
191 .iter()
192 .filter_map(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
196 .collect();
197 let replaced = task_id(task)
198 .and_then(|id| self.replaced.get(id))
199 .cloned()
200 .unwrap_or_default();
201 ExcludedOccurrences {
202 cancelled,
203 replaced,
204 }
205 }
206}
207
208fn task_id(task: &Task) -> Option<&str> {
210 task.properties.as_ref()?.get(ID_KEY).map(String::as_str)
211}
212
213#[derive(Debug, Default, Clone, PartialEq, Eq)]
221pub struct ExcludedOccurrences {
222 cancelled: HashSet<NaiveDate>,
223 replaced: HashSet<NaiveDate>,
224}
225
226impl ExcludedOccurrences {
227 pub fn contains(&self, date: &NaiveDate) -> bool {
229 self.cancelled.contains(date) || self.replaced.contains(date)
230 }
231
232 pub fn is_replaced(&self, date: &NaiveDate) -> bool {
240 self.replaced.contains(date)
241 }
242
243 pub fn is_empty(&self) -> bool {
246 self.cancelled.is_empty() && self.replaced.is_empty()
247 }
248
249 pub fn len(&self) -> usize {
253 self.cancelled.len() + self.replaced.len()
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use std::collections::BTreeMap;
261
262 fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
263 NaiveDate::from_ymd_opt(y, m, d).unwrap()
264 }
265
266 fn dates_of(raw: &str) -> Vec<String> {
269 parse_excluded_dates(raw, |field| panic!("unexpected reject: {field:?}"))
270 }
271
272 fn occurrence_of(raw: &str) -> Option<String> {
275 parse_recurrence_id(raw, |dropped| panic!("unexpected drop: {dropped:?}"))
276 }
277
278 fn series(id: &str) -> Task {
279 let mut props = BTreeMap::new();
280 props.insert(ID_KEY.to_string(), id.to_string());
281 Task {
282 properties: Some(props),
283 ..Task::default()
284 }
285 }
286
287 fn cancelling(dates: &[&str]) -> Task {
288 Task {
289 excluded_dates: Some(dates.iter().map(|d| (*d).to_string()).collect()),
290 ..Task::default()
291 }
292 }
293
294 fn replacement(series_id: &str, recurrence: &str) -> Task {
295 Task {
296 series_id: Some(series_id.to_string()),
297 recurrence_id: Some(recurrence.to_string()),
298 ..Task::default()
299 }
300 }
301
302 #[test]
303 fn excluded_dates_take_commas_and_spaces_alike() {
304 assert_eq!(
305 dates_of("2026-08-20, 2026-08-27 2026-09-03"),
306 ["2026-08-20", "2026-08-27", "2026-09-03"]
307 );
308 }
309
310 #[test]
311 fn excluded_dates_drop_what_is_not_a_date_and_say_so() {
312 let mut rejected = Vec::new();
313 let dates = parse_excluded_dates("2026-08-20, next thursday", |field| {
314 rejected.push(field.to_string());
315 });
316
317 assert_eq!(dates, ["2026-08-20"]);
318 assert_eq!(
319 rejected,
320 ["next", "thursday"],
321 "each field is reported as it is met"
322 );
323 }
324
325 #[test]
326 fn a_time_after_a_date_belongs_to_that_date() {
327 assert_eq!(
332 dates_of("2026-08-20 15:00, 2026-08-27 15:00:00"),
333 ["2026-08-20", "2026-08-27"]
334 );
335 }
336
337 #[test]
338 fn a_time_with_no_date_before_it_is_a_field_like_any_other() {
339 let mut rejected = Vec::new();
340 let dates = parse_excluded_dates("15:00, 2026-08-20 15:00 16:00", |field| {
341 rejected.push(field.to_string());
342 });
343
344 assert_eq!(dates, ["2026-08-20"]);
345 assert_eq!(
346 rejected,
347 ["15:00", "16:00"],
348 "one time belongs to the date before it; a second one belongs to nothing"
349 );
350 }
351
352 #[test]
353 fn excluded_dates_keep_one_copy_of_a_repeated_date() {
354 assert_eq!(dates_of("2026-08-20 2026-08-20"), ["2026-08-20"]);
355 }
356
357 #[test]
358 fn excluded_dates_keep_one_copy_however_the_date_was_spelled() {
359 assert_eq!(dates_of("2026-8-20, 2026-08-20"), ["2026-08-20"]);
360 }
361
362 #[test]
363 fn a_long_exdate_costs_one_pass_and_not_one_per_date_already_seen() {
364 const DATES: i64 = 20_000;
370 let first = ymd(2000, 1, 1);
371 let raw = (0..DATES)
372 .map(|i| {
373 (first + chrono::Duration::days(i))
374 .format("%Y-%m-%d")
375 .to_string()
376 })
377 .collect::<Vec<_>>()
378 .join(", ");
379
380 let dates = dates_of(&raw);
381
382 assert_eq!(dates.len(), DATES as usize, "every date is kept, once");
383 assert_eq!(dates[0], "2000-01-01", "in the order it was written");
384 }
385
386 #[test]
387 fn a_recurrence_id_keeps_the_time_when_it_carries_one() {
388 assert_eq!(
389 occurrence_of("2026-08-20 15:00").as_deref(),
390 Some("2026-08-20 15:00")
391 );
392 assert_eq!(occurrence_of("2026-08-20").as_deref(), Some("2026-08-20"));
393 }
394
395 #[test]
396 fn a_recurrence_id_without_a_date_is_no_recurrence_id() {
397 assert_eq!(parse_recurrence_id("thursday 15:00", |_| {}), None);
398 }
399
400 #[test]
401 fn a_trailing_field_that_is_not_a_time_leaves_the_date_standing_and_is_told() {
402 let mut dropped = Vec::new();
403 let occurrence = parse_recurrence_id("2026-08-20 afternoon", |text| {
404 dropped.push(text.to_string());
405 });
406
407 assert_eq!(occurrence.as_deref(), Some("2026-08-20"));
408 assert_eq!(
409 dropped,
410 ["afternoon"],
411 "the text the value no longer carries is named"
412 );
413 }
414
415 #[test]
416 fn a_recurrence_id_written_with_seconds_keeps_the_time_it_names() {
417 assert_eq!(
420 occurrence_of("2026-08-20 15:00:00").as_deref(),
421 Some("2026-08-20 15:00")
422 );
423 }
424
425 #[test]
426 fn whatever_follows_the_time_is_dropped_and_named() {
427 let mut dropped = Vec::new();
428 let occurrence = parse_recurrence_id("2026-08-20 15:00 sharp", |text| {
429 dropped.push(text.to_string());
430 });
431
432 assert_eq!(occurrence.as_deref(), Some("2026-08-20 15:00"));
433 assert_eq!(dropped, ["sharp"]);
434 }
435
436 #[test]
437 fn a_replacement_that_names_no_series_of_the_run_is_reported() {
438 let english = series("series-1");
443 let moved = replacement("seires-1", "2026-08-20");
444
445 let exceptions = OccurrenceExceptions::from_tasks(&[english.clone(), moved]);
446
447 assert_eq!(exceptions.unknown_series(), ["seires-1".to_string()]);
448 assert!(
449 exceptions.dates_for(&english).is_empty(),
450 "and nothing is suppressed, which is what the report is about"
451 );
452 }
453
454 #[test]
455 fn a_replacement_naming_a_series_of_the_run_is_not_reported() {
456 let english = series("series-1");
457 let moved = replacement("series-1", "2026-08-20");
458
459 let exceptions = OccurrenceExceptions::from_tasks(&[english, moved]);
460
461 assert!(exceptions.unknown_series().is_empty());
462 }
463
464 #[test]
465 fn an_entry_skips_the_date_it_lists_itself() {
466 let task = cancelling(&["2026-08-20"]);
467 let missing =
468 OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);
469
470 assert!(missing.contains(&ymd(2026, 8, 20)));
471 assert!(!missing.contains(&ymd(2026, 8, 27)));
472 assert!(
473 !missing.is_replaced(&ymd(2026, 8, 20)),
474 "an EXDATE cancels an occurrence, it does not move it"
475 );
476 }
477
478 #[test]
479 fn a_date_in_an_exdate_that_cannot_be_read_is_dropped_and_the_rest_kept() {
480 let task = cancelling(&["last thursday", "2026-08-27"]);
483 let missing =
484 OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);
485
486 assert!(missing.contains(&ymd(2026, 8, 27)));
487 assert_eq!(missing.len(), 1);
488 }
489
490 #[test]
491 fn a_replacement_suppresses_the_occurrence_it_names() {
492 let english = series("series-1");
493 let moved = replacement("series-1", "2026-08-20 15:00");
494 let missing =
495 OccurrenceExceptions::from_tasks(&[english.clone(), moved]).dates_for(&english);
496
497 assert!(missing.contains(&ymd(2026, 8, 20)));
498 assert!(!missing.contains(&ymd(2026, 8, 27)));
499 assert!(
500 missing.is_replaced(&ymd(2026, 8, 20)),
501 "the occurrence moved: its debt is the replacement's"
502 );
503 }
504
505 #[test]
506 fn both_reasons_meet_in_one_answer_and_stay_apart_in_it() {
507 let mut english = series("series-1");
508 english.excluded_dates = Some(vec!["2026-08-13".to_string()]);
509 let moved = replacement("series-1", "2026-08-20 15:00");
510 let missing =
511 OccurrenceExceptions::from_tasks(&[english.clone(), moved]).dates_for(&english);
512
513 assert_eq!(missing.len(), 2);
514 assert!(missing.contains(&ymd(2026, 8, 13)) && missing.contains(&ymd(2026, 8, 20)));
515 assert!(!missing.is_replaced(&ymd(2026, 8, 13)), "the 13th is gone");
516 assert!(missing.is_replaced(&ymd(2026, 8, 20)), "the 20th moved");
517 }
518
519 #[test]
520 fn a_replacement_of_another_series_leaves_this_one_alone() {
521 let english = series("series-1");
522 let moved = replacement("series-2", "2026-08-20");
523 let missing =
524 OccurrenceExceptions::from_tasks(&[english.clone(), moved]).dates_for(&english);
525
526 assert!(missing.is_empty());
527 }
528
529 #[test]
530 fn a_series_without_an_id_cannot_be_replaced() {
531 let anonymous = Task::default();
532 let moved = replacement("series-1", "2026-08-20");
533 let missing =
534 OccurrenceExceptions::from_tasks(&[anonymous.clone(), moved]).dates_for(&anonymous);
535
536 assert!(missing.is_empty());
537 }
538
539 #[test]
540 fn an_entry_whose_only_exception_is_an_exdate_is_not_an_entry_without_any() {
541 let task = cancelling(&["2026-08-20"]);
542 let missing =
543 OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);
544
545 assert!(
546 !missing.is_empty(),
547 "an EXDATE is an exception: an entry holding one is not an entry without any"
548 );
549 }
550
551 #[test]
552 fn one_definition_answers_whatever_the_date_is_written_like() {
553 let task = cancelling(&["2026-8-20"]);
557 let missing =
558 OccurrenceExceptions::from_tasks(std::slice::from_ref(&task)).dates_for(&task);
559
560 assert!(missing.contains(&ymd(2026, 8, 20)));
561 }
562}