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 field that does not parse as a date is handed to `on_rejected` as it is
45/// met rather than collected: the value is only as short as the file makes it,
46/// and one written entirely of rubbish would otherwise be held twice over —
47/// once in the file, once in a vector — for a caller that reports the first
48/// few and drops the rest.
49pub fn parse_excluded_dates(raw: &str, mut on_rejected: impl FnMut(&str)) -> Vec<String> {
50    let mut dates = Vec::new();
51    // A set of what has been seen, rather than a scan of what has been kept:
52    // the scan is linear per date and so quadratic over the value, which on a
53    // long `EXDATE` is the difference between a pass and a stall.
54    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
68/// The occurrence a `RECURRENCE_ID` value names: a date, optionally followed
69/// by a clock time.
70///
71/// Returns the value normalised (`YYYY-MM-DD` or `YYYY-MM-DD HH:MM`), or
72/// `None` when the date does not parse. A trailing field that is not a time
73/// is dropped and the date kept: the date is what the resolver matches on,
74/// and losing the exception over a stray word would be the worse failure.
75pub 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
87/// The date half of a `RECURRENCE_ID`, which is what occurrences match on.
88pub fn recurrence_id_date(value: &str) -> Option<NaiveDate> {
89    NaiveDate::parse_from_str(value.split_whitespace().next()?, "%Y-%m-%d").ok()
90}
91
92/// Which occurrences of which series are not there, for one run.
93///
94/// Built from the whole task list because a replacement lives in an entry of
95/// its own — possibly in another file of the same scan. An exception
96/// therefore reaches only as far as the scan does, which ADR-0031 states as a
97/// consequence.
98#[derive(Debug, Default, Clone)]
99pub struct OccurrenceExceptions {
100    replaced: HashMap<String, HashSet<NaiveDate>>,
101}
102
103impl OccurrenceExceptions {
104    /// Collect every `(SERIES_ID, RECURRENCE_ID)` pair in the run.
105    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    /// Every occurrence `task` does not have: what it cancelled itself, and
121    /// what other entries of the run replace.
122    ///
123    /// The one place that answers the question, and it answers it once per
124    /// task: the day-by-day walk of a week or a month reads a set instead of
125    /// re-reading properties on every cell.
126    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            // A date nothing can read is dropped here as it was dropped at
133            // the parser: a `Task` can also be built by a library caller,
134            // and one bad string must not take the whole list with it.
135            .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/// The occurrences one entry does not have, kept apart by reason.
154///
155/// Both reasons take the occurrence out of the day it would have fallen on.
156/// They part ways over the arrears: a cancelled occurrence never was, so the
157/// debt is whichever earlier one still stands, while a replaced occurrence did
158/// take place — elsewhere — and its debt travels with the entry that replaced
159/// it (ADR-0031).
160#[derive(Debug, Default, Clone, PartialEq, Eq)]
161pub struct ExcludedOccurrences {
162    cancelled: HashSet<NaiveDate>,
163    replaced: HashSet<NaiveDate>,
164}
165
166impl ExcludedOccurrences {
167    /// Whether the series skips `date`, for either reason.
168    pub fn contains(&self, date: &NaiveDate) -> bool {
169        self.cancelled.contains(date) || self.replaced.contains(date)
170    }
171
172    /// Whether another entry of the run stands in for the occurrence on
173    /// `date`.
174    ///
175    /// Asked where the two reasons differ, which is the arrears bucket. A
176    /// date named by both is treated as replaced: the occurrence is somewhere,
177    /// and an `EXDATE` beside a replacement is redundant rather than
178    /// contradictory.
179    pub fn is_replaced(&self, date: &NaiveDate) -> bool {
180        self.replaced.contains(date)
181    }
182
183    /// Whether this entry misses no occurrence at all — the fast path for the
184    /// overwhelmingly common case of an entry without an exception.
185    pub fn is_empty(&self) -> bool {
186        self.cancelled.is_empty() && self.replaced.is_empty()
187    }
188
189    /// How many occurrences are missing, counting a date named by both
190    /// reasons twice. An upper bound is all the walks over a series need, and
191    /// an exact count would cost a pass over the smaller set.
192    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    /// The dates of an `EXDATE` value, for a test that expects all of them to
207    /// read.
208    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        // A value is only as short as the file makes it, and a linear scan of
272        // what is already collected turns that length into its square: 20 000
273        // dates are 2*10^8 string comparisons, seconds of a test run, and on a
274        // file of the size the scanner accepts, an entry nothing finishes
275        // reading.
276        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        // Reachable through the library, where a `Task` is built by hand and
335        // not by the parser that normalises what it writes.
336        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        // `2026-8-20` is what a person writes and what chrono reads. One
408        // definition of "is this occurrence missing" means one answer,
409        // whichever way the value spelled the day.
410        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}