1use crate::agenda::Occurrence;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeSet;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18pub struct Overlap {
19 pub key: String,
21 pub first: Occurrence,
22 pub second: Occurrence,
23 pub gap: i64,
26 pub needed: i64,
28 pub source: String,
32}
33
34impl Overlap {
35 pub fn at_once(&self) -> bool {
37 self.gap < 0
38 }
39}
40
41pub 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 if second.uid == first.uid && second.start == first.start {
57 continue;
58 }
59 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#[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 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 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 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 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 #[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}