Skip to main content

sioul_core/
overlaps.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Two events at once (docs/client.md, "Calendars"): found among
5//! the coming days' events, the time kept before and after each counted
6//! (`demands::Margins`): going from one to the other takes time too. Said
7//! on the Porch for today and above the agenda, until set aside for good;
8//! an event moved is a new question.
9
10use crate::agenda::Occurrence;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeSet;
13use std::path::{Path, PathBuf};
14
15/// Two events that overlap, or follow each other closer than a time you
16/// set between them, the earlier first.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18pub struct Overlap {
19    /// `<uid>@<start>|<uid>@<start>`: what setting it aside keeps.
20    pub key: String,
21    pub first: Occurrence,
22    pub second: Occurrence,
23    /// Minutes from the first's end to the second's start; negative when
24    /// they overlap.
25    pub gap: i64,
26    /// Minutes you keep between them (0 when they overlap: none is needed to say it).
27    pub needed: i64,
28    /// Where that time is set: "after" (the first event's own After),
29    /// "before" (the second's own Before), "setting" (Settings ▸ Agenda,
30    /// Time kept between two events); "" when they overlap.
31    pub source: String,
32}
33
34impl Overlap {
35    /// Their times overlap: the only case called an overlap.
36    pub fn at_once(&self) -> bool {
37        self.gap < 0
38    }
39}
40
41/// The pairs of `events` that clash: timed, not cancelled, lasting some time
42/// (a reminder at 10:00 holds no time), each pair once. Two events overlap
43/// only when their times do (22 October 2026). Closer than a time you set
44/// yourself, they are said too close, never overlapping, that time named
45/// with where you set it: the first's own After, the second's own Before, or
46/// `between` (Settings ▸ Agenda, Time kept between two events; 0 unless you
47/// set it), the longest of the three, never added. No other time is ever
48/// put between two events: none from places, the plan or a default.
49pub fn overlaps(events: &[Occurrence], between: u32) -> Vec<Overlap> {
50    let mut timed: Vec<&Occurrence> = events.iter().filter(|e| !e.cancelled && !e.all_day && e.end > e.start).collect();
51    timed.sort_by(|a, b| (a.start, &a.uid).cmp(&(b.start, &b.uid)));
52    let mut out = Vec::new();
53    for (i, first) in timed.iter().enumerate() {
54        for second in &timed[i + 1..] {
55            // The same event seen twice (in two calendars) is no double booking.
56            if second.uid == first.uid && second.start == first.start {
57                continue;
58            }
59            // Sorted by start: the second starts at or after the first.
60            let key = format!("{}@{}|{}@{}", first.uid, first.start, second.uid, second.start);
61            let gap = (second.start - first.end).div_euclid(60);
62            if second.start < first.end {
63                out.push(Overlap { key, first: (*first).clone(), second: (*second).clone(), gap, needed: 0, source: String::new() });
64                continue;
65            }
66            let kept = [(first.margins.after, "after"), (second.margins.before, "before"), (between, "setting")];
67            let Some((needed, source)) = kept.into_iter().filter(|(m, _)| *m > 0).max_by_key(|(m, _)| *m) else { continue };
68            if second.start - first.end < i64::from(needed) * 60 {
69                out.push(Overlap { key, first: (*first).clone(), second: (*second).clone(), gap, needed: i64::from(needed), source: source.to_string() });
70            }
71        }
72    }
73    out
74}
75
76/// The overlaps set aside for good, on this device
77/// (`$XDG_STATE_HOME/sioul/overlaps-set-aside.toml`).
78#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
79pub struct SetAside {
80    #[serde(default)]
81    pub keys: BTreeSet<String>,
82}
83
84impl SetAside {
85    pub fn default_path() -> PathBuf {
86        crate::config::state_dir().join("overlaps-set-aside.toml")
87    }
88
89    pub fn load(path: &Path) -> SetAside {
90        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
91    }
92
93    pub fn save(&self, path: &Path) -> Result<(), String> {
94        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
95        if let Some(parent) = path.parent() {
96            std::fs::create_dir_all(parent).map_err(fail)?;
97        }
98        std::fs::write(path, toml::to_string(self).map_err(|e| e.to_string())?).map_err(fail)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::demands::Margins;
106
107    fn event(uid: &str, start: i64, end: i64, margins: Margins) -> Occurrence {
108        Occurrence { uid: uid.into(), key: format!("{uid}.ics"), summary: uid.into(), start, end, margins, ..Occurrence::default() }
109    }
110
111    #[test]
112    fn two_events_at_once() {
113        let none = Margins::default();
114        // 9–10 and 9:30–10:30 overlap; 10:30–11 only touches the second.
115        let events = [event("a", 32_400, 36_000, none), event("b", 34_200, 37_800, none), event("c", 37_800, 39_600, none)];
116        let found = overlaps(&events, 0);
117        assert_eq!(found.iter().map(|o| o.key.as_str()).collect::<Vec<_>>(), ["a@32400|b@34200"]);
118        // With half an hour to get to the third, it overlaps the second too.
119        let events = [event("b", 34_200, 37_800, none), event("c", 37_800, 39_600, Margins { before: 30, after: 0 })];
120        assert_eq!(overlaps(&events, 0).len(), 1);
121        // Cancelled, all day, a moment (a reminder), the same one twice: none.
122        let cancelled = Occurrence { cancelled: true, ..event("d", 32_400, 36_000, none) };
123        let moment = event("e", 34_200, 34_200, none);
124        let twice = [event("a", 32_400, 36_000, none), event("a", 32_400, 36_000, none)];
125        assert!(overlaps(&[event("a", 32_400, 36_000, none), cancelled, moment], 0).is_empty());
126        assert!(overlaps(&twice, 0).is_empty());
127        // Set aside for good: kept in a file.
128        let dir = std::env::temp_dir().join(format!("sioul-overlaps-{}", std::process::id()));
129        let path = dir.join("set-aside.toml");
130        let mut aside = SetAside::load(&path);
131        aside.keys.insert(found[0].key.clone());
132        aside.save(&path).unwrap();
133        assert!(SetAside::load(&path).keys.contains("a@32400|b@34200"));
134        let _ = std::fs::remove_dir_all(dir);
135    }
136
137    /// The owner's two events of 22 October 2026, 13:00–13:40 and
138    /// 14:00–15:00 the same day, read from their files in every form a
139    /// calendar writes (floating times, UTC, Europe/Paris with and without
140    /// its VTIMEZONE, a DURATION), on a summer day, the day the clocks go
141    /// back and a winter day: twenty minutes apart, they never overlap. Kept
142    /// after the first and before the second, a quarter of an hour each is
143    /// one way, not two, and fits; half an hour does not, and is said.
144    #[test]
145    fn twenty_minutes_apart_never_overlap_whatever_their_form() {
146        use crate::vdir::{self, Kind};
147        let paris = jiff::tz::TimeZone::get("Europe/Paris").unwrap();
148        let calendar = vdir::create(Kind::Calendars, vdir::LOCAL, "Overlap forms", None, &["VEVENT"]).unwrap();
149        let vtimezone = "BEGIN:VTIMEZONE\r\nTZID:Europe/Paris\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n";
150        for (date, offset) in [("20261023", 2u32), ("20261025", 1), ("20261221", 1)] {
151            let forms = |h: u32, m: u32, eh: u32, em: u32| -> Vec<(&str, String)> {
152                let local = |h: u32, m: u32| format!("{date}T{h:02}{m:02}00");
153                let utc = |h: u32, m: u32| format!("{date}T{:02}{m:02}00Z", h - offset);
154                let minutes = (eh * 60 + em) - (h * 60 + m);
155                vec![
156                    ("floating", format!("DTSTART:{}\r\nDTEND:{}\r\n", local(h, m), local(eh, em))),
157                    ("utc", format!("DTSTART:{}\r\nDTEND:{}\r\n", utc(h, m), utc(eh, em))),
158                    ("paris", format!("DTSTART;TZID=Europe/Paris:{}\r\nDTEND;TZID=Europe/Paris:{}\r\n", local(h, m), local(eh, em))),
159                    ("paris-vtimezone", format!("DTSTART;TZID=Europe/Paris:{}\r\nDTEND;TZID=Europe/Paris:{}\r\n", local(h, m), local(eh, em))),
160                    ("paris-duration", format!("DTSTART;TZID=Europe/Paris:{}\r\nDURATION:PT{minutes}M\r\n", local(h, m))),
161                    ("floating-duration", format!("DTSTART:{}\r\nDURATION:PT{minutes}M\r\n", local(h, m))),
162                ]
163            };
164            for (first_form, first) in forms(13, 0, 13, 40) {
165                for (second_form, second) in forms(14, 0, 15, 0) {
166                    let read = |uid: &str, form: &str, body: &str, kept: &str| {
167                        let zone_lines = if form == "paris-vtimezone" { vtimezone } else { "" };
168                        let path = calendar.dir.join(format!("{uid}.ics"));
169                        std::fs::write(&path, format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{zone_lines}BEGIN:VEVENT\r\nUID:{uid}\r\n{body}{kept}SUMMARY:{uid}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n")).unwrap();
170                        crate::agenda::file_occurrences(&path, &calendar, 1_770_000_000, 1_800_000_000, &paris)
171                    };
172                    let case = format!("{date} {first_form} × {second_form}");
173                    let both = |a: &str, b: &str| [read("a", first_form, &first, a), read("b", second_form, &second, b)].concat();
174                    let plain = both("", "");
175                    assert_eq!(plain.len(), 2, "{case}");
176                    assert_eq!((plain[0].end - plain[0].start, plain[1].end - plain[1].start, plain[1].start - plain[0].end), (2400, 3600, 1200), "{case}");
177                    assert!(overlaps(&plain, 0).is_empty(), "{case}: twenty minutes apart, nothing set");
178                    assert!(overlaps(&both("X-SIOUL-AFTER:15\r\n", "X-SIOUL-BEFORE:15\r\n"), 0).is_empty(), "{case}: a quarter of an hour each, never added, fits");
179                    let said = |o: &[Overlap]| o.iter().map(|o| (o.at_once(), o.gap, o.needed, o.source.clone())).collect::<Vec<_>>();
180                    assert_eq!(said(&overlaps(&both("X-SIOUL-AFTER:30\r\n", "X-SIOUL-BEFORE:10\r\n"), 0)), [(false, 20, 30, "after".to_string())], "{case}: too close, the first's After named");
181                    assert_eq!(said(&overlaps(&both("", "X-SIOUL-BEFORE:45\r\n"), 0)), [(false, 20, 45, "before".to_string())], "{case}: the second's Before named");
182                    assert_eq!(said(&overlaps(&plain, 60)), [(false, 20, 60, "setting".to_string())], "{case}: the time set in Settings ▸ Agenda named");
183                    assert!(overlaps(&plain, 20).is_empty(), "{case}: twenty minutes set, twenty minutes there");
184                }
185            }
186        }
187        let _ = std::fs::remove_dir_all(&calendar.dir);
188    }
189}