Skip to main content

markdown_org_extract/
exceptions.rs

1//! Exceptions to a repeating entry: an occurrence that is gone, and one that
2//! moved.
3//!
4//! A repeating timestamp describes an endless series and has nowhere to say
5//! that one of its occurrences is different. ADR-0031 answers that in the
6//! shape iCalendar settled on, written with the `org-properties` keys of
7//! ADR-0020:
8//!
9//! - `EXDATE` on the series lists occurrences the series does not have;
10//! - a separate entry carrying `SERIES_ID` and `RECURRENCE_ID` replaces the
11//!   one occurrence it names, and needs no `EXDATE` beside it — that is RFC
12//!   5545's split between an occurrence that is gone and one that moved.
13//!
14//! The two reasons are kept apart all the way to the agenda, because they part
15//! ways over a debt: nothing is owed for an occurrence that never was, and
16//! what is owed for one that moved is owed by the entry it moved to.
17//!
18//! Matching is at day granularity, because the agenda draws at most one
19//! occurrence of a series per day; the clock time a `RECURRENCE_ID` may carry
20//! is kept for the reader and for export, and is not matched on.
21
22use std::collections::{HashMap, HashSet};
23
24use chrono::NaiveDate;
25
26use crate::types::Task;
27
28/// Property key listing the occurrences a series does not have.
29pub const EXDATE_KEY: &str = "EXDATE";
30/// Property key naming the occurrence an entry replaces.
31pub const RECURRENCE_ID_KEY: &str = "RECURRENCE_ID";
32/// Property key naming the series an entry replaces an occurrence of.
33pub const SERIES_ID_KEY: &str = "SERIES_ID";
34/// Property key holding a task's own stable identifier (ADR-0020).
35pub const ID_KEY: &str = "ID";
36
37/// The dates listed in an `EXDATE` value, normalised to `YYYY-MM-DD`.
38///
39/// Separators are commas and whitespace, in any mix — a list is written for a
40/// person to read, and both are what people write. The result keeps the order
41/// the value was written in and holds one entry per date, whichever way the
42/// value spelled it.
43///
44/// A time right after a date is that date's time, not another field: RFC 5545
45/// writes an `EXDATE` of a timed series that way, and so does a calendar
46/// export. It is read and left out — occurrences are matched by day here
47/// (ADR-0031) — while a time with no date before it is a field like any
48/// other and is rejected.
49///
50/// A field that does not parse is handed to `on_rejected` as it is met rather
51/// than collected: the value is only as short as the file makes it, and one
52/// written entirely of rubbish would otherwise be held twice over — once in
53/// the file, once in a vector — for a caller that reports the first few and
54/// drops the rest.
55pub fn parse_excluded_dates(raw: &str, mut on_rejected: impl FnMut(&str)) -> Vec<String> {
56    let mut dates = Vec::new();
57    // A set of what has been seen, rather than a scan of what has been kept:
58    // the scan is linear per date and so quadratic over the value, which on a
59    // long `EXDATE` is the difference between a pass and a stall.
60    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
82/// The occurrence a `RECURRENCE_ID` value names: a date, optionally followed
83/// by a clock time.
84///
85/// Returns the value normalised (`YYYY-MM-DD` or `YYYY-MM-DD HH:MM`), or
86/// `None` when the date does not parse. Anything after the date that is not a
87/// time is dropped and the date kept: the date is what the resolver matches
88/// on, and losing the exception over a stray word would be the worse failure.
89/// What was dropped is handed to `on_dropped` rather than passed over in
90/// silence -- it is text the file wrote and this value no longer carries, and
91/// an export built from the field will not carry it either.
92pub 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
111/// The clock time of a `RECURRENCE_ID`: written to the minute, or with the
112/// seconds a calendar export adds. Occurrences are named to the minute here,
113/// so the seconds are read and then left out of the normalised value.
114fn 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
120/// The date half of a `RECURRENCE_ID`, which is what occurrences match on.
121pub fn recurrence_id_date(value: &str) -> Option<NaiveDate> {
122    NaiveDate::parse_from_str(value.split_whitespace().next()?, "%Y-%m-%d").ok()
123}
124
125/// Which occurrences of which series are not there, for one run.
126///
127/// Built from the whole task list because a replacement lives in an entry of
128/// its own — possibly in another file of the same scan. An exception
129/// therefore reaches only as far as the scan does, which ADR-0031 states as a
130/// consequence.
131#[derive(Debug, Default, Clone)]
132pub struct OccurrenceExceptions {
133    replaced: HashMap<String, HashSet<NaiveDate>>,
134    unknown_series: Vec<String>,
135}
136
137impl OccurrenceExceptions {
138    /// Collect every `(SERIES_ID, RECURRENCE_ID)` pair in the run.
139    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        // A `SERIES_ID` nothing answers to suppresses nothing, and the day
152        // ends up holding both the series occurrence and the entry that meant
153        // to stand in for it. Both entries are in this run, so the mismatch is
154        // decidable right here -- unlike the one exception ADR-0031 allows to
155        // pass in silence, where the replacement is in a file the scan never
156        // reached.
157        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        // Sorted so a run reports them the same way twice over.
164        unknown_series.sort();
165        Self {
166            replaced,
167            unknown_series,
168        }
169    }
170
171    /// The `SERIES_ID` values of this run that name no entry in it.
172    ///
173    /// Kept rather than reported here: this is built once per pass over the
174    /// task list, and the caller that draws the agenda is the one place that
175    /// can say it once per run.
176    pub fn unknown_series(&self) -> &[String] {
177        &self.unknown_series
178    }
179
180    /// Every occurrence `task` does not have: what it cancelled itself, and
181    /// what other entries of the run replace.
182    ///
183    /// The one place that answers the question, and it answers it once per
184    /// task: the day-by-day walk of a week or a month reads a set instead of
185    /// re-reading properties on every cell.
186    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            // A date nothing can read is dropped here as it was dropped at
193            // the parser: a `Task` can also be built by a library caller,
194            // and one bad string must not take the whole list with it.
195            .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
208/// A task's own identifier, the one a `SERIES_ID` names (ADR-0020).
209fn task_id(task: &Task) -> Option<&str> {
210    task.properties.as_ref()?.get(ID_KEY).map(String::as_str)
211}
212
213/// The occurrences one entry does not have, kept apart by reason.
214///
215/// Both reasons take the occurrence out of the day it would have fallen on.
216/// They part ways over the arrears: a cancelled occurrence never was, so the
217/// debt is whichever earlier one still stands, while a replaced occurrence did
218/// take place — elsewhere — and its debt travels with the entry that replaced
219/// it (ADR-0031).
220#[derive(Debug, Default, Clone, PartialEq, Eq)]
221pub struct ExcludedOccurrences {
222    cancelled: HashSet<NaiveDate>,
223    replaced: HashSet<NaiveDate>,
224}
225
226impl ExcludedOccurrences {
227    /// Whether the series skips `date`, for either reason.
228    pub fn contains(&self, date: &NaiveDate) -> bool {
229        self.cancelled.contains(date) || self.replaced.contains(date)
230    }
231
232    /// Whether another entry of the run stands in for the occurrence on
233    /// `date`.
234    ///
235    /// Asked where the two reasons differ, which is the arrears bucket. A
236    /// date named by both is treated as replaced: the occurrence is somewhere,
237    /// and an `EXDATE` beside a replacement is redundant rather than
238    /// contradictory.
239    pub fn is_replaced(&self, date: &NaiveDate) -> bool {
240        self.replaced.contains(date)
241    }
242
243    /// Whether this entry misses no occurrence at all — the fast path for the
244    /// overwhelmingly common case of an entry without an exception.
245    pub fn is_empty(&self) -> bool {
246        self.cancelled.is_empty() && self.replaced.is_empty()
247    }
248
249    /// How many occurrences are missing, counting a date named by both
250    /// reasons twice. An upper bound is all the walks over a series need, and
251    /// an exact count would cost a pass over the smaller set.
252    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    /// The dates of an `EXDATE` value, for a test that expects all of them to
267    /// read.
268    fn dates_of(raw: &str) -> Vec<String> {
269        parse_excluded_dates(raw, |field| panic!("unexpected reject: {field:?}"))
270    }
271
272    /// The occurrence a `RECURRENCE_ID` names, for a test that expects the
273    /// whole value to read.
274    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        // The form RFC 5545 uses for a timed series, and the one a calendar
328        // export writes. The day is what an occurrence is matched on, so the
329        // time is read and left out -- and not reported as a field nothing
330        // can read, which is what a correct value would otherwise be called.
331        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        // A value is only as short as the file makes it, and a linear scan of
365        // what is already collected turns that length into its square: 20 000
366        // dates are 2*10^8 string comparisons, seconds of a test run, and on a
367        // file of the size the scanner accepts, an entry nothing finishes
368        // reading.
369        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        // The form a calendar export writes. Occurrences are named to the
418        // minute here, so the seconds go and the time stays.
419        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        // A typo in the identifier leaves both entries standing on the day,
439        // which is exactly what an exception is written to avoid. Both are in
440        // this run, so the mismatch is decidable and is not one of the silences
441        // ADR-0031 allows.
442        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        // Reachable through the library, where a `Task` is built by hand and
481        // not by the parser that normalises what it writes.
482        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        // `2026-8-20` is what a person writes and what chrono reads. One
554        // definition of "is this occurrence missing" means one answer,
555        // whichever way the value spelled the day.
556        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}