1use crate::health::{Problem, Unsound};
25use serde::{Deserialize, Serialize};
26use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28
29pub const FILE: &str = "health-reminders.toml";
31
32pub const KEPT_DAYS: i64 = 2;
34
35pub const NEVER: i64 = i64::MAX;
37
38pub const STALE_DAYS: i64 = 30;
44
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
47pub struct DoseReminders {
48 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
49 pub dose: BTreeMap<String, BTreeMap<String, Tried>>,
50}
51
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
54pub struct Tried {
55 #[serde(default)]
59 pub maybe: i64,
60 #[serde(default)]
63 pub shown: i64,
64 #[serde(default)]
66 pub unshown: i64,
67 #[serde(default)]
70 pub quiet: i64,
71}
72
73impl DoseReminders {
74 pub fn default_path() -> PathBuf {
75 crate::config::state_dir().join(FILE)
76 }
77
78 pub fn read(path: &Path) -> Result<DoseReminders, Unsound> {
83 match std::fs::read_to_string(path) {
84 Ok(text) if text.trim().is_empty() && crate::health::witnessed(path) => Err(Unsound::Lost),
85 Ok(text) => toml::from_str(&text).map_err(|_| Unsound::Unreadable),
86 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
87 if crate::health::witnessed(path) {
88 Err(Unsound::Lost)
89 } else {
90 Ok(DoseReminders::default())
91 }
92 }
93 Err(_) => Err(Unsound::Unreadable),
94 }
95 }
96
97 pub fn update<T>(path: &Path, now: i64, me: &str, change: impl FnOnce(&mut DoseReminders) -> T) -> Result<T, Problem> {
101 crate::filelock::with_lock(path, || {
102 let mut all = DoseReminders::read(path).map_err(Problem::Unsound)?;
103 let out = change(&mut all);
104 all.save(path, now, me).map_err(Problem::Write)?;
105 Ok(out)
106 })
107 }
108
109 fn save(&mut self, path: &Path, now: i64, me: &str) -> Result<(), String> {
118 let others = self.dose.iter().filter(|(_, devices)| devices.keys().any(|device| device != me)).map(|(key, _)| due_of(key)).max().unwrap_or(0);
120 let (oldest, stale) = (now - KEPT_DAYS * 86_400, now.min(others) - STALE_DAYS * 86_400);
121 for (key, devices) in &mut self.dose {
122 if due_of(key) < stale {
123 devices.clear();
124 } else if due_of(key) < oldest {
125 devices.remove(me);
126 }
127 }
128 self.dose.retain(|_, devices| !devices.is_empty());
129 let text = format!("# What each device did to remind each dose (docs/health.md, \"What became of a reminder\").\n{}", toml::to_string(self).map_err(|e| e.to_string())?);
130 crate::health::write_witnessed(path, text.as_bytes())
131 }
132
133 pub fn forget(&mut self, gone: &[String]) -> bool {
140 let mut changed = false;
141 for devices in self.dose.values_mut() {
142 for id in gone {
143 changed |= devices.remove(id).is_some();
144 }
145 }
146 self.dose.retain(|_, devices| !devices.is_empty());
147 changed
148 }
149
150 pub fn holds_any(&self, devices: &[String]) -> bool {
152 self.dose.values().any(|by| devices.iter().any(|id| by.contains_key(id)))
153 }
154
155 pub fn of(&self, key: &str, device: &str) -> Option<Tried> {
157 self.dose.get(key)?.get(device).copied()
158 }
159
160 pub fn entry(&mut self, key: &str, device: &str) -> &mut Tried {
162 self.dose.entry(key.to_string()).or_default().entry(device.to_string()).or_default()
163 }
164
165 pub fn shown(&self, key: &str) -> bool {
167 self.dose.get(key).is_some_and(|devices| devices.values().any(|t| t.shown > 0))
168 }
169
170 pub fn maybe_elsewhere(&self, key: &str, me: &str) -> Option<i64> {
173 self.dose.get(key)?.iter().filter(|(device, t)| device.as_str() != me && t.maybe > 0 && t.shown == 0).map(|(_, t)| t.maybe).max()
174 }
175
176 pub fn maybe_by(&self, key: &str, me: &str) -> Option<(String, i64)> {
179 self.dose.get(key)?.iter().filter(|(device, t)| device.as_str() != me && t.maybe > 0 && t.shown == 0).max_by_key(|(_, t)| t.maybe).map(|(device, t)| (device.clone(), t.maybe))
180 }
181
182 pub fn maybe_anywhere(&self, key: &str) -> Option<i64> {
185 self.maybe_elsewhere(key, "")
186 }
187}
188
189fn due_of(key: &str) -> i64 {
191 key.rsplit_once('@').and_then(|(_, due)| due.parse().ok()).unwrap_or(0)
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum Elsewhere {
198 Sent { device: crate::health::Named, at: i64 },
200 Unshown { device: crate::health::Named, at: i64 },
202 Unknown { device: crate::health::Named },
204 Closed { device: crate::health::Named, at: i64 },
209 Alarm { device: crate::health::Named },
212 Ahead { device: crate::health::Named },
215 Unread,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum Others {
222 Reminded,
224 First,
227 Doubt(Vec<Elsewhere>),
229}
230
231pub const QUIET_AFTER: i64 = crate::health::ALARM_AFTER + 5 * 60;
236
237#[allow(clippy::too_many_arguments)]
264pub fn others(key: &str, since: i64, at: i64, me: &str, me_phone: bool, entries: Option<&DoseReminders>, peers: &[crate::health::Peer], pulled: bool) -> Others {
265 use crate::health::{Named, SKEW, counts};
266 let Some(entries) = entries else { return Others::Doubt(vec![Elsewhere::Unread]) };
267 if entries.shown(key) {
268 return Others::Reminded;
269 }
270 let counted: Vec<&crate::health::Peer> = peers.iter().filter(|p| p.id != me && counts(p, at) && p.said.as_ref().is_none_or(|s| s.doses)).collect();
271 let phones = counted.iter().filter(|p| p.said.as_ref().is_some_and(|s| s.phone)).count() + usize::from(me_phone);
273 let mut doubts = Vec::new();
274 for peer in counted {
275 let phone = peer.said.as_ref().is_some_and(|s| s.phone);
276 let device = Named { id: peer.id.clone(), name: peer.name.clone(), phone: phone && phones == 1 };
277 let margin = SKEW + peer.ahead.max(0);
278 if peer.set_aside {
280 doubts.push(Elsewhere::Ahead { device });
281 continue;
282 }
283 match entries.of(key, &peer.id) {
284 Some(tried) if tried.maybe > 0 => doubts.push(Elsewhere::Sent { device, at: tried.maybe }),
285 Some(tried) if tried.quiet == NEVER || tried.quiet.saturating_sub(margin) >= at => {}
286 Some(tried) if tried.unshown > 0 => doubts.push(Elsewhere::Unshown { device, at: tried.unshown }),
287 _ if phone || peer.said.is_none() => doubts.push(Elsewhere::Alarm { device }),
289 _ => match peer.said.as_ref().filter(|s| peer.complete && !s.working && s.closed >= s.started) {
290 Some(said) => {
291 let later = peer.claimed > said.closed || entries.dose.values().filter_map(|devices| devices.get(&peer.id)).any(|t| t.maybe.max(t.shown).max(t.unshown) > said.closed);
293 let ours = me_phone && peer.acknowledged > 0 && said.started >= peer.acknowledged;
297 if !(ours && pulled && !later && at <= since + QUIET_AFTER - margin) {
298 doubts.push(Elsewhere::Closed { device, at: said.closed });
299 }
300 }
301 None => doubts.push(Elsewhere::Unknown { device }),
302 },
303 }
304 }
305 if doubts.is_empty() { Others::First } else { Others::Doubt(doubts) }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::health::{Peer, Said};
312
313 const DUE: i64 = 1_800_000_000;
314 const KEY: &str = "levo@1800000000";
315
316 fn computer(id: &str, exported: i64, working: bool) -> Peer {
317 Peer {
318 name: id.into(),
319 id: id.into(),
320 said: Some(Said { started: DUE - 3_600, closed: if working { 0 } else { DUE - 600 }, working, exported, imported: exported, doses: true, left: false, phone: false }),
321 complete: true,
322 seen: exported,
323 heard: exported,
324 ..Peer::default()
325 }
326 }
327
328 fn phone(id: &str) -> Peer {
329 Peer { said: Some(Said { phone: true, ..computer(id, DUE + 300, false).said.unwrap() }), ..computer(id, DUE + 300, false) }
330 }
331
332 fn with(device: &str, tried: Tried) -> DoseReminders {
333 let mut all = DoseReminders::default();
334 *all.entry(KEY, device) = tried;
335 all
336 }
337
338 #[test]
343 fn a_phone_is_first_only_on_the_others_word() {
344 let at = DUE + 600;
345 let desk = computer("desk", DUE + 540, true);
346 assert_eq!(others(KEY, DUE, at, "me", true, Some(&with("desk", Tried { maybe: DUE + 1, shown: DUE + 2, ..Tried::default() })), std::slice::from_ref(&desk), true), Others::Reminded);
348 assert!(matches!(others(KEY, DUE, at, "me", true, Some(&with("desk", Tried { maybe: DUE + 1, ..Tried::default() })), std::slice::from_ref(&desk), true), Others::Doubt(d) if matches!(d[..], [Elsewhere::Sent { at, .. }] if at == DUE + 1)));
350 let quiet = Tried { unshown: DUE + 5, quiet: DUE + QUIET_AFTER, ..Tried::default() };
352 assert_eq!(others(KEY, DUE, at, "me", true, Some(&with("desk", quiet)), std::slice::from_ref(&desk), true), Others::First);
353 assert!(matches!(others(KEY, DUE, DUE + 1_200, "me", true, Some(&with("desk", quiet)), std::slice::from_ref(&desk), true), Others::Doubt(d) if matches!(d[..], [Elsewhere::Unshown { .. }])));
355 assert!(matches!(others(KEY, DUE, at, "me", true, Some(&with("desk", Tried { unshown: DUE + 5, ..Tried::default() })), std::slice::from_ref(&desk), true), Others::Doubt(_)));
357 let ahead = Peer { ahead: 180, ..desk.clone() };
359 assert!(matches!(others(KEY, DUE, DUE + QUIET_AFTER - 200, "me", true, Some(&with("desk", quiet)), std::slice::from_ref(&ahead), true), Others::Doubt(_)));
360 assert!(matches!(others(KEY, DUE, at, "me", true, Some(&DoseReminders::default()), std::slice::from_ref(&desk), true), Others::Doubt(d) if matches!(d[..], [Elsewhere::Unknown { .. }])));
362 let closed = Peer { acknowledged: DUE - 3_600, ..computer("desk", DUE - 600, false) };
364 assert_eq!(others(KEY, DUE, at, "me", true, Some(&DoseReminders::default()), std::slice::from_ref(&closed), true), Others::First);
365 assert!(matches!(others(KEY, DUE, DUE + QUIET_AFTER, "me", true, Some(&DoseReminders::default()), std::slice::from_ref(&closed), true), Others::Doubt(_)));
366 assert!(matches!(others(KEY, DUE, at, "me", true, Some(&DoseReminders::default()), std::slice::from_ref(&closed), false), Others::Doubt(d) if matches!(d[..], [Elsewhere::Closed { at, .. }] if at == DUE - 600)));
368 let claimed = Peer { claimed: DUE - 30, ..closed.clone() };
370 assert!(matches!(others(KEY, DUE, at, "me", true, Some(&DoseReminders::default()), std::slice::from_ref(&claimed), true), Others::Doubt(d) if matches!(d[..], [Elsewhere::Closed { .. }])));
371 let mut later = DoseReminders::default();
373 later.entry("iron@1800000000", "desk").maybe = DUE - 60;
374 assert!(matches!(others(KEY, DUE, at, "me", true, Some(&later), std::slice::from_ref(&closed), true), Others::Doubt(_)));
375 assert!(matches!(others(KEY, DUE, at, "laptop", false, Some(&DoseReminders::default()), std::slice::from_ref(&closed), true), Others::Doubt(d) if matches!(d[..], [Elsewhere::Closed { .. }])));
377 let stale = Peer { acknowledged: DUE - 60, ..closed.clone() };
379 assert!(matches!(others(KEY, DUE, at, "me", true, Some(&DoseReminders::default()), std::slice::from_ref(&stale), true), Others::Doubt(_)));
380 let unacknowledged = Peer { acknowledged: 0, ..closed.clone() };
382 assert!(matches!(others(KEY, DUE, at, "me", true, Some(&DoseReminders::default()), std::slice::from_ref(&unacknowledged), true), Others::Doubt(_)));
383 let older = Peer { said: None, heard: at - 60, ..desk.clone() };
385 assert!(matches!(others(KEY, DUE, at, "me", true, Some(&DoseReminders::default()), std::slice::from_ref(&older), true), Others::Doubt(d) if matches!(d[..], [Elsewhere::Alarm { .. }])));
386 assert_eq!(others(KEY, DUE, DUE + 7_200, "me", true, Some(&with("desk", Tried { unshown: DUE + 5, quiet: NEVER, ..Tried::default() })), std::slice::from_ref(&desk), true), Others::First);
388 let peers = [desk.clone(), phone("tablet")];
390 match others(KEY, DUE, at, "me", true, Some(&with("desk", quiet)), &peers, true) {
391 Others::Doubt(d) => assert!(matches!(&d[..], [Elsewhere::Alarm { device }] if device.name == "tablet" && !device.phone), "{d:?}"),
392 other => panic!("{other:?}"),
393 }
394 assert_eq!(others(KEY, DUE, at, "me", true, None, std::slice::from_ref(&desk), true), Others::Doubt(vec![Elsewhere::Unread]));
396 assert_eq!(others(KEY, DUE, at, "desk", true, Some(&DoseReminders::default()), std::slice::from_ref(&desk), true), Others::First);
398 assert_eq!(others(KEY, DUE, at, "me", true, Some(&DoseReminders::default()), &[Peer { off: true, ..desk }], true), Others::First);
399 }
400
401 #[test]
404 fn each_device_writes_its_own_entry_and_a_lost_file_is_not_empty() {
405 let dir = std::env::temp_dir().join(format!("sioul-dose-reminders-{}", std::process::id()));
406 let _ = std::fs::remove_dir_all(&dir);
407 std::fs::create_dir_all(&dir).unwrap();
408 crate::health::APART.with(|apart| *apart.borrow_mut() = Some(dir.join("apart")));
409 let path = dir.join(FILE);
410 assert_eq!(DoseReminders::read(&path), Ok(DoseReminders::default()), "never written: empty");
411 DoseReminders::update(&path, DUE + 1, "desk", |all| all.entry(KEY, "desk").maybe = DUE + 1).unwrap();
412 DoseReminders::update(&path, DUE + 2, "phone", |all| all.entry(KEY, "phone").shown = DUE + 2).unwrap();
413 let read = DoseReminders::read(&path).unwrap();
414 assert_eq!(read.of(KEY, "desk"), Some(Tried { maybe: DUE + 1, ..Tried::default() }));
415 assert!(read.shown(KEY), "shown on the phone: reminded");
416 assert_eq!(read.maybe_elsewhere(KEY, "phone"), Some(DUE + 1), "the desk's, as the phone reads it");
417 assert_eq!(read.maybe_elsewhere(KEY, "desk"), None, "none but its own, as the desk reads it");
418 let text = std::fs::read_to_string(&path).unwrap();
419 assert!(text.contains("[dose.\"levo@1800000000\".desk]"), "{text}");
420 DoseReminders::update(&path, DUE + 3 * 86_400, "phone", |_| ()).unwrap();
422 let read = DoseReminders::read(&path).unwrap();
423 assert!(read.of(KEY, "phone").is_none() && read.of(KEY, "desk").is_some(), "the desk's entry is the desk's to take out");
424 DoseReminders::update(&path, DUE + 3 * 86_400, "desk", |_| ()).unwrap();
425 assert!(DoseReminders::read(&path).unwrap().dose.is_empty(), "each its own, two days on");
426 let month = 31 * 86_400;
428 DoseReminders::update(&path, DUE + 1, "phone", |all| all.entry(KEY, "phone").shown = DUE + 1).unwrap();
429 DoseReminders::update(&path, DUE + 2, "desk", |all| {
430 all.entry(KEY, "desk").maybe = DUE + 2;
431 all.entry("iron@1797320000", "desk").maybe = 1_797_320_000;
432 })
433 .unwrap();
434 DoseReminders::update(&path, DUE + month, "ahead", |all| all.entry("zinc@1802678400", "ahead").shown = DUE + month).unwrap();
435 let read = DoseReminders::read(&path).unwrap();
436 assert!(read.of(KEY, "phone").is_some() && read.of(KEY, "desk").is_some(), "today's entries of the others kept by a clock a month ahead");
437 assert!(read.of("iron@1797320000", "desk").is_none(), "a month older than the others' newest dose: gone");
438 DoseReminders::update(&path, DUE + 3 * 86_400, "desk", |all| all.dose.clear()).unwrap();
439 DoseReminders::update(&path, DUE + 1, "phone", |all| all.entry(KEY, "old-phone").shown = DUE + 1).unwrap();
441 let mut read = DoseReminders::read(&path).unwrap();
442 assert!(read.holds_any(&["old-phone".to_string()]) && read.forget(&["old-phone".to_string()]) && read.dose.is_empty());
443 std::fs::remove_file(&path).unwrap();
445 assert_eq!(DoseReminders::read(&path), Err(Unsound::Lost));
446 std::fs::write(&path, "[dose.\"x\"").unwrap();
447 assert_eq!(DoseReminders::read(&path), Err(Unsound::Unreadable));
448 assert!(DoseReminders::update(&path, DUE, "desk", |_| ()).is_err(), "nothing written over it");
449 crate::health::APART.with(|apart| *apart.borrow_mut() = None);
450 let _ = std::fs::remove_dir_all(&dir);
451 }
452}