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, the earlier first.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17pub struct Overlap {
18    /// `<uid>@<start>|<uid>@<start>`: what setting it aside keeps.
19    pub key: String,
20    pub first: Occurrence,
21    pub second: Occurrence,
22}
23
24/// The time an event holds: from getting ready to coming back.
25fn held(event: &Occurrence) -> (i64, i64) {
26    (event.start - i64::from(event.margins.before) * 60, event.end + i64::from(event.margins.after) * 60)
27}
28
29/// The pairs of `events` that overlap: timed, not cancelled, lasting some
30/// time (a reminder at 10:00 holds no time), each pair once.
31pub fn overlaps(events: &[Occurrence]) -> Vec<Overlap> {
32    let mut timed: Vec<&Occurrence> = events.iter().filter(|e| !e.cancelled && !e.all_day && e.end > e.start).collect();
33    timed.sort_by(|a, b| (a.start, &a.uid).cmp(&(b.start, &b.uid)));
34    let mut out = Vec::new();
35    for (i, first) in timed.iter().enumerate() {
36        let (from, to) = held(first);
37        for second in &timed[i + 1..] {
38            // The same event seen twice (in two calendars) is no double booking.
39            if second.uid == first.uid && second.start == first.start {
40                continue;
41            }
42            let (other_from, other_to) = held(second);
43            if other_from < to && from < other_to {
44                out.push(Overlap { key: format!("{}@{}|{}@{}", first.uid, first.start, second.uid, second.start), first: (*first).clone(), second: (*second).clone() });
45            }
46        }
47    }
48    out
49}
50
51/// The overlaps set aside for good, on this device
52/// (`$XDG_STATE_HOME/sioul/overlaps-set-aside.toml`).
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54pub struct SetAside {
55    #[serde(default)]
56    pub keys: BTreeSet<String>,
57}
58
59impl SetAside {
60    pub fn default_path() -> PathBuf {
61        crate::config::state_dir().join("overlaps-set-aside.toml")
62    }
63
64    pub fn load(path: &Path) -> SetAside {
65        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
66    }
67
68    pub fn save(&self, path: &Path) -> Result<(), String> {
69        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
70        if let Some(parent) = path.parent() {
71            std::fs::create_dir_all(parent).map_err(fail)?;
72        }
73        std::fs::write(path, toml::to_string(self).map_err(|e| e.to_string())?).map_err(fail)
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use crate::demands::Margins;
81
82    fn event(uid: &str, start: i64, end: i64, margins: Margins) -> Occurrence {
83        Occurrence { uid: uid.into(), key: format!("{uid}.ics"), summary: uid.into(), start, end, margins, ..Occurrence::default() }
84    }
85
86    #[test]
87    fn two_events_at_once() {
88        let none = Margins::default();
89        // 9–10 and 9:30–10:30 overlap; 10:30–11 only touches the second.
90        let events = [event("a", 32_400, 36_000, none), event("b", 34_200, 37_800, none), event("c", 37_800, 39_600, none)];
91        let found = overlaps(&events);
92        assert_eq!(found.iter().map(|o| o.key.as_str()).collect::<Vec<_>>(), ["a@32400|b@34200"]);
93        // With half an hour to get to the third, it overlaps the second too.
94        let events = [event("b", 34_200, 37_800, none), event("c", 37_800, 39_600, Margins { before: 30, after: 0 })];
95        assert_eq!(overlaps(&events).len(), 1);
96        // Cancelled, all day, a moment (a reminder), the same one twice: none.
97        let cancelled = Occurrence { cancelled: true, ..event("d", 32_400, 36_000, none) };
98        let moment = event("e", 34_200, 34_200, none);
99        let twice = [event("a", 32_400, 36_000, none), event("a", 32_400, 36_000, none)];
100        assert!(overlaps(&[event("a", 32_400, 36_000, none), cancelled, moment]).is_empty());
101        assert!(overlaps(&twice).is_empty());
102        // Set aside for good: kept in a file.
103        let dir = std::env::temp_dir().join(format!("sioul-overlaps-{}", std::process::id()));
104        let path = dir.join("set-aside.toml");
105        let mut aside = SetAside::load(&path);
106        aside.keys.insert(found[0].key.clone());
107        aside.save(&path).unwrap();
108        assert!(SetAside::load(&path).keys.contains("a@32400|b@34200"));
109        let _ = std::fs::remove_dir_all(dir);
110    }
111}