1use jiff::civil::{Date, Time};
17use jiff::{Span, Timestamp, Zoned};
18use serde::{Deserialize, Serialize};
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
24pub struct Health {
25 #[serde(rename = "prescription", default)]
26 pub prescriptions: Vec<Prescription>,
27 #[serde(rename = "medicine", default)]
28 pub medicines: Vec<Medicine>,
29 #[serde(default)]
30 pub movement: Movement,
31 #[serde(default)]
32 pub chats: ChatLimit,
33 #[serde(default, skip_serializing_if = "String::is_empty")]
36 pub errands_list: String,
37 #[serde(default)]
42 pub needs: crate::needs::Needs,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
48pub struct Prescription {
49 pub id: String,
50 pub title: String,
52 #[serde(default)]
53 pub prescriber: String,
54 #[serde(default, deserialize_with = "crate::budget::dates::optional", skip_serializing_if = "Option::is_none")]
56 pub until: Option<Date>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub refill_days: Option<u32>,
60 #[serde(default, deserialize_with = "crate::budget::dates::optional", skip_serializing_if = "Option::is_none")]
62 pub last_refill: Option<Date>,
63 #[serde(default, skip_serializing_if = "String::is_empty")]
64 pub note: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct Medicine {
70 pub id: String,
71 pub name: String,
74 #[serde(default, skip_serializing_if = "String::is_empty")]
76 pub dose: String,
77 pub schedule: Schedule,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub prescription: Option<String>,
81 #[serde(default, deserialize_with = "crate::budget::dates::optional", skip_serializing_if = "Option::is_none")]
83 pub until: Option<Date>,
84 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
86 pub paused: bool,
87 #[serde(default, skip_serializing_if = "String::is_empty")]
91 pub generic: String,
92 #[serde(default, skip_serializing_if = "String::is_empty")]
95 pub strength: String,
96 #[serde(default, deserialize_with = "crate::budget::dates::optional", skip_serializing_if = "Option::is_none")]
99 pub since: Option<Date>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(tag = "every", rename_all = "kebab-case")]
105pub enum Schedule {
106 Day {
108 times: Vec<String>,
109 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
115 amounts: BTreeMap<String, String>,
116 },
117 Days {
119 days: u32,
120 time: String,
121 #[serde(deserialize_with = "crate::budget::dates::required")]
122 from: Date,
123 },
124 Hours { hours: u32, from: i64 },
127}
128
129fn time_of(text: &str) -> Option<Time> {
131 let (hour, minute) = text.trim().split_once(':')?;
132 Time::new(hour.trim().parse().ok()?, minute.trim().parse().ok()?, 0, 0).ok()
133}
134
135pub fn hhmm(text: &str) -> Option<String> {
137 time_of(text).map(|t| format!("{:02}:{:02}", t.hour(), t.minute()))
138}
139
140impl Schedule {
141 pub fn doses(&self, start: &Zoned, end: &Zoned) -> Vec<Zoned> {
143 self.timed(start, end).into_iter().map(|(at, _)| at).collect()
144 }
145
146 fn timed(&self, start: &Zoned, end: &Zoned) -> Vec<(Zoned, Option<Time>)> {
150 let zone = start.time_zone().clone();
151 let mut out = Vec::new();
152 match self {
153 Schedule::Day { times, .. } => {
154 let mut times: Vec<Time> = times.iter().filter_map(|t| time_of(t)).collect();
155 times.sort();
156 let mut day = start.date();
157 while day <= end.date() {
158 for time in × {
159 if let Ok(at) = day.to_datetime(*time).to_zoned(zone.clone())
160 && &at >= start
161 && &at < end
162 {
163 out.push((at, Some(*time)));
164 }
165 }
166 let Ok(next) = day.tomorrow() else { break };
167 day = next;
168 }
169 }
170 Schedule::Days { days, time, from } => {
171 let (Some(time), true) = (time_of(time), *days > 0) else { return out };
172 let mut day = *from;
173 if day < start.date()
175 && let Ok(behind) = start.date().since(day)
176 {
177 let turns = i64::from(behind.get_days()) / i64::from(*days);
178 day = day.checked_add(Span::new().days(turns * i64::from(*days))).unwrap_or(day);
179 }
180 while day <= end.date() {
181 if let Ok(at) = day.to_datetime(time).to_zoned(zone.clone())
182 && &at >= start
183 && &at < end
184 {
185 out.push((at, None));
186 }
187 let Ok(next) = day.checked_add(Span::new().days(i64::from(*days))) else { break };
188 day = next;
189 }
190 }
191 Schedule::Hours { hours, from } => {
192 if *hours == 0 {
193 return out;
194 }
195 let step = i64::from(*hours) * 3600;
196 let first = start.timestamp().as_second();
197 let mut at = *from;
198 if at < first {
199 at += (first - at + step - 1) / step * step;
200 }
201 while at < end.timestamp().as_second() {
202 if let Ok(t) = Timestamp::from_second(at) {
203 out.push((t.to_zoned(zone.clone()), None));
204 }
205 at += step;
206 }
207 }
208 }
209 out
210 }
211}
212
213#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
216pub struct Take {
217 pub time: String,
218 #[serde(default)]
219 pub amount: String,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
224pub enum TakeProblem {
225 None,
227 Unreadable(String),
229 Twice(String),
231}
232
233impl Medicine {
234 pub fn short_name(&self) -> String {
237 if self.name.trim().is_empty() { self.generic.trim().to_string() } else { self.name.trim().to_string() }
238 }
239
240 pub fn precise(&self) -> String {
245 let (name, generic, strength) = (self.name.trim(), self.generic.trim(), self.strength.trim());
246 let molecule = [generic, strength].into_iter().filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ");
247 if generic.is_empty() {
248 [name, strength].into_iter().filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ")
249 } else if name.is_empty() || name.to_lowercase() == generic.to_lowercase() {
250 molecule
251 } else {
252 format!("{name} — {molecule}")
253 }
254 }
255
256 pub fn amount_at(&self, time: Time) -> String {
260 match &self.schedule {
261 Schedule::Day { amounts, .. } if !amounts.is_empty() => amounts.iter().find(|(at, _)| time_of(at) == Some(time)).map(|(_, amount)| amount.clone()).unwrap_or_default(),
262 _ => self.dose.clone(),
263 }
264 }
265
266 pub fn takes(&self) -> Vec<Take> {
270 let Schedule::Day { times, .. } = &self.schedule else { return Vec::new() };
271 let mut set: Vec<Time> = times.iter().filter_map(|t| time_of(t)).collect();
272 set.sort();
273 set.dedup();
274 set.into_iter().map(|time| Take { time: format!("{:02}:{:02}", time.hour(), time.minute()), amount: self.amount_at(time) }).collect()
275 }
276
277 pub fn usual(&self) -> String {
281 let takes = self.takes();
282 if !matches!(&self.schedule, Schedule::Day { amounts, .. } if !amounts.is_empty()) || takes.is_empty() {
283 return self.dose.clone();
284 }
285 let count = |amount: &str| takes.iter().filter(|t| t.amount == amount).count();
286 let most = takes.iter().map(|t| count(&t.amount)).max().unwrap_or(0);
287 takes.iter().find(|t| count(&t.amount) == most).map(|t| t.amount.clone()).unwrap_or_default()
288 }
289
290 pub fn takes_line(&self) -> String {
294 self.takes().into_iter().map(|t| if t.amount.is_empty() { t.time } else { format!("{} · {}", t.time, t.amount) }).collect::<Vec<_>>().join(", ")
295 }
296
297 pub fn set_takes(&mut self, usual: &str, takes: &[Take]) -> Result<(), TakeProblem> {
305 let mut set: Vec<(Time, String)> = Vec::new();
306 for take in takes {
307 let time = time_of(&take.time).ok_or_else(|| TakeProblem::Unreadable(take.time.trim().to_string()))?;
308 if set.iter().any(|(t, _)| *t == time) {
309 return Err(TakeProblem::Twice(format!("{:02}:{:02}", time.hour(), time.minute())));
310 }
311 let own = take.amount.trim();
312 set.push((time, if own.is_empty() { usual.trim() } else { own }.to_string()));
313 }
314 if set.is_empty() {
315 return Err(TakeProblem::None);
316 }
317 set.sort();
318 let times: Vec<String> = set.iter().map(|(t, _)| format!("{:02}:{:02}", t.hour(), t.minute())).collect();
319 if set.iter().all(|(_, amount)| *amount == set[0].1) {
320 self.dose = set[0].1.clone();
321 self.schedule = Schedule::Day { times, amounts: BTreeMap::new() };
322 } else {
323 let amounts = times.iter().zip(&set).filter(|(_, (_, amount))| !amount.is_empty()).map(|(time, (_, amount))| (time.clone(), amount.clone())).collect();
324 self.schedule = Schedule::Day { times, amounts };
325 self.dose = self.takes_line();
326 }
327 Ok(())
328 }
329}
330
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub struct Movement {
334 #[serde(default = "yes")]
335 pub enabled: bool,
336 #[serde(default = "forty_five")]
337 pub minutes: u32,
338}
339
340impl Default for Movement {
341 fn default() -> Movement {
342 Movement { enabled: true, minutes: 45 }
343 }
344}
345
346fn yes() -> bool {
347 true
348}
349
350fn forty_five() -> u32 {
351 45
352}
353
354#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
357pub struct ChatLimit {
358 #[serde(default)]
359 pub enabled: bool,
360 #[serde(default)]
361 pub minutes: u32,
362 #[serde(default)]
363 pub locked_minutes: u32,
364}
365
366impl Health {
367 pub fn default_path() -> PathBuf {
368 crate::config::data_dir().join("health.toml")
369 }
370
371 pub fn load(path: &Path) -> Health {
376 match std::fs::read_to_string(path).map(|t| toml::from_str::<Health>(&t)) {
377 Ok(Ok(health)) => health,
378 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Health::default(),
379 _ => {
380 let _ = std::fs::copy(path, path.with_extension("toml.unreadable"));
381 Health::default()
382 }
383 }
384 }
385
386 pub fn save(&self, path: &Path) -> Result<(), String> {
389 crate::filelock::with_lock(path, || {
390 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
391 if let Some(parent) = path.parent() {
392 std::fs::create_dir_all(parent).map_err(fail)?;
393 }
394 let temporary = path.with_extension("toml.new");
395 std::fs::write(&temporary, toml::to_string(self).map_err(|e| e.to_string())?).map_err(fail)?;
396 keep_private(&temporary);
397 std::fs::rename(&temporary, path).map_err(fail)
398 })
399 }
400
401 pub fn change<R>(path: &Path, change: impl FnOnce(&mut Health) -> Result<R, String>) -> Result<R, String> {
405 crate::filelock::with_lock(path, || {
406 let mut health = Health::load(path);
407 let out = change(&mut health)?;
408 health.save(path)?;
409 Ok(out)
410 })
411 }
412
413 pub fn new_id(&self, name: &str) -> String {
415 let taken: Vec<String> = self.prescriptions.iter().map(|p| p.id.clone()).chain(self.medicines.iter().map(|m| m.id.clone())).collect();
416 crate::projects::new_id(name, &taken)
417 }
418
419 pub fn doses(&self, start: &Zoned, end: &Zoned) -> Vec<Dose> {
422 let mut out: Vec<Dose> = self
423 .medicines
424 .iter()
425 .filter(|m| !m.paused)
426 .flat_map(|m| {
427 m.schedule.timed(start, end).into_iter().filter(|(at, _)| m.until.is_none_or(|until| at.date() <= until)).map(|(at, set)| Dose {
428 key: format!("{}@{}", m.id, at.timestamp().as_second()),
429 medicine: m.id.clone(),
430 name: m.short_name(),
431 dose: set.map_or_else(|| m.dose.clone(), |time| m.amount_at(time)),
432 at,
433 })
434 })
435 .collect();
436 out.sort_by(|a, b| a.at.cmp(&b.at).then(a.name.cmp(&b.name)));
437 out
438 }
439
440 pub fn amount_of(&self, key: &str, zone: &jiff::tz::TimeZone) -> Option<String> {
445 let (id, due) = key.rsplit_once('@')?;
446 let due = Timestamp::from_second(due.parse().ok()?).ok()?.to_zoned(zone.clone());
447 let medicine = self.medicines.iter().find(|m| m.id == id)?;
448 let end = due.checked_add(Span::new().seconds(1)).ok()?;
449 match medicine.schedule.timed(&due, &end).into_iter().next() {
450 Some((_, Some(time))) => Some(medicine.amount_at(time)),
451 Some((_, None)) => Some(medicine.dose.clone()),
452 None => Some(match medicine.schedule {
453 Schedule::Day { .. } => medicine.amount_at(Time::new(due.hour(), due.minute(), 0, 0).ok()?),
454 _ => medicine.dose.clone(),
455 }),
456 }
457 }
458
459 pub fn taken_at(&mut self, key: &str, at: i64) -> Option<(i64, i64)> {
465 let (id, due) = key.rsplit_once('@')?;
466 let due: i64 = due.parse().ok()?;
467 let medicine = self.medicines.iter_mut().find(|m| m.id == id)?;
468 let Schedule::Hours { hours, from } = &mut medicine.schedule else { return None };
469 let step = i64::from(*hours) * 3600;
470 if step == 0 || at == due {
471 return None;
472 }
473 let before = *from;
474 *from = at + step;
475 Some((before, *from))
476 }
477
478 pub fn mend_shifts(&mut self, state: &HealthState) -> bool {
490 let mut mended = false;
491 for medicine in &mut self.medicines {
492 let Schedule::Hours { from, .. } = &mut medicine.schedule else { continue };
493 let moves: Vec<([i64; 2], i64)> = state.moved.iter().filter(|(key, _)| key.rsplit_once('@').is_some_and(|(id, _)| id == medicine.id)).map(|(key, moved)| (*moved, state.taken.get(key).copied().unwrap_or(i64::MIN))).collect();
495 for _ in 0..moves.len() {
497 let Some(([_, after], _)) = moves.iter().filter(|([before, after], _)| *before == *from && after != before).max_by_key(|(_, taken)| *taken) else { break };
498 *from = *after;
499 mended = true;
500 }
501 }
502 mended
503 }
504
505 pub fn taken_back(&mut self, key: &str, before: i64, after: i64) -> bool {
508 let Some((id, _)) = key.rsplit_once('@') else { return false };
509 let Some(medicine) = self.medicines.iter_mut().find(|m| m.id == id) else { return false };
510 match &mut medicine.schedule {
511 Schedule::Hours { from, .. } if *from == after => {
512 *from = before;
513 true
514 }
515 _ => false,
516 }
517 }
518
519 pub fn errands(&self) -> Vec<Errand> {
522 let mut out = Vec::new();
523 for p in &self.prescriptions {
524 if let (Some(days), Some(last)) = (p.refill_days, p.last_refill)
525 && let Ok(out_of) = last.checked_add(Span::new().days(i64::from(days)))
526 && p.until.is_none_or(|until| out_of <= until)
527 {
528 let day = out_of.checked_sub(Span::new().days(2)).unwrap_or(out_of);
529 out.push(Errand { key: format!("refill:{}:{}", p.id, out_of), prescription: p.id.clone(), kind: ErrandKind::Refill, day, title: p.title.clone() });
530 }
531 if let Some(until) = p.until {
532 let day = until.checked_sub(Span::new().days(14)).unwrap_or(until);
533 out.push(Errand { key: format!("renew:{}:{until}", p.id), prescription: p.id.clone(), kind: ErrandKind::Renew, day, title: p.title.clone() });
534 }
535 }
536 out.sort_by_key(|e| e.day);
537 out
538 }
539}
540
541#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
547pub struct Peer {
548 pub name: String,
550 #[serde(default)]
553 pub known_until: i64,
554 #[serde(default)]
557 pub closed: bool,
558 #[serde(default)]
561 pub delay: Option<i64>,
562 #[serde(default)]
564 pub broken: Option<i64>,
565 #[serde(default)]
567 pub heard: i64,
568 #[serde(default)]
570 pub id: String,
571 #[serde(default)]
574 pub said: Option<Said>,
575 #[serde(default)]
577 pub complete: bool,
578 #[serde(default)]
580 pub seen: i64,
581 #[serde(default)]
585 pub ahead: i64,
586 #[serde(default)]
588 pub off: bool,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub wake: Option<i64>,
596 #[serde(default, skip_serializing_if = "is_zero_i64")]
601 pub claimed: i64,
602 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
605 pub forgotten: bool,
606 #[serde(default, skip_serializing_if = "is_zero_i64")]
611 pub acknowledged: i64,
612 #[serde(default, skip_serializing_if = "is_zero_i64")]
616 pub latest_opening: i64,
617 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
624 pub set_aside: bool,
625}
626
627fn is_zero_i64(n: &i64) -> bool {
628 *n == 0
629}
630
631#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
634pub struct Said {
635 pub started: i64,
638 pub closed: i64,
640 pub working: bool,
642 pub exported: i64,
645 pub imported: i64,
647 pub doses: bool,
649 pub left: bool,
651 pub phone: bool,
653}
654
655#[derive(Debug, Clone, Default, PartialEq, Eq)]
657pub struct Named {
658 pub id: String,
659 pub name: String,
660 pub phone: bool,
661}
662
663#[derive(Debug, Clone, PartialEq, Eq)]
665pub enum Doubt {
666 Record { since: i64 },
668 Unheard { device: Named, until: i64, closed: bool },
672 Broken { device: Named },
674 Working { device: Named, shared: i64, quiet: bool },
678 Coming { device: Named, shared: i64 },
680 Apart { device: Named },
683 Ahead { device: Named },
686}
687
688pub const NEWS_IN: i64 = 3 * 60;
692
693pub const FRESH: i64 = 5 * 60;
697
698pub const SKEW: i64 = 2 * 60;
702
703pub const ALARM_AFTER: i64 = 10 * 60;
707
708fn opened_for(due: i64, now: i64, phone: bool) -> i64 {
712 if phone && now >= due + ALARM_AFTER { due + ALARM_AFTER } else { due }
713}
714
715pub const QUIET: i64 = 60 * 60;
718
719pub const BROKEN_SPAN: i64 = 12 * 3600;
722
723pub const SILENT_DAYS: i64 = 7;
727
728fn phones_counted(peers: &[Peer], now: i64) -> usize {
730 peers.iter().filter(|p| counts(p, now) && p.said.as_ref().is_some_and(|s| s.phone)).count()
731}
732
733fn named(peer: &Peer, phones: usize) -> Named {
738 Named { id: peer.id.clone(), name: peer.name.clone(), phone: peer.said.as_ref().is_some_and(|s| s.phone) && phones == 1 }
739}
740
741pub fn counts(peer: &Peer, now: i64) -> bool {
745 if peer.off {
746 return false;
747 }
748 let silent = SILENT_DAYS * 86_400;
749 match &peer.said {
750 Some(said) if said.left => false,
751 Some(said) => now - peer.seen <= silent || now - said.exported <= silent,
752 None => now - peer.heard < silent,
753 }
754}
755
756pub fn doubts_now(due: i64, now: i64, record_lost: Option<i64>, peers: &[Peer]) -> Vec<Doubt> {
777 doubts_at(due, due, now, record_lost, peers)
778}
779
780pub fn doubts_at(due: i64, waking: i64, now: i64, record_lost: Option<i64>, peers: &[Peer]) -> Vec<Doubt> {
788 let mut out = Vec::new();
789 if let Some(since) = record_lost.filter(|since| due <= *since && *since - due < 86_400) {
791 out.push(Doubt::Record { since });
792 }
793 let phones = phones_counted(peers, now);
794 for peer in peers.iter().filter(|p| counts(p, now)) {
795 let device = named(peer, phones);
796 if peer.set_aside {
798 out.push(Doubt::Ahead { device });
799 continue;
800 }
801 if peer.broken.is_some_and(|at| at >= due - BROKEN_SPAN) {
806 out.push(Doubt::Broken { device });
807 continue;
808 }
809 let phone = peer.said.as_ref().is_some_and(|s| s.phone);
819 let woke = peer.wake.is_none_or(|wake| now - opened_for(waking, now, phone) >= wake + SKEW);
820 let Some(said) = &peer.said else {
821 let closed = peer.closed && peer.heard <= peer.known_until;
822 let quiet = closed && peer.delay.is_some_and(|d| d <= NEWS_IN) && (peer.known_until >= due + SKEW || woke);
823 let fresh = peer.known_until >= due && now - peer.known_until <= FRESH;
824 if !quiet && !fresh {
825 out.push(Doubt::Unheard { device, until: peer.known_until, closed });
826 }
827 continue;
828 };
829 if !peer.complete {
831 out.push(Doubt::Coming { device, shared: said.exported });
832 continue;
833 }
834 let closed = !said.working && said.closed >= said.started;
835 let margin = SKEW + peer.ahead.max(0);
836 if !said.doses {
837 if !(closed && said.closed + SKEW < due) {
841 out.push(Doubt::Apart { device });
842 } else if !woke {
843 out.push(Doubt::Unheard { device, until: said.closed, closed: true });
844 }
845 continue;
846 }
847 if closed {
848 if said.closed < due + margin && !woke {
854 out.push(Doubt::Unheard { device, until: said.closed, closed: true });
855 }
856 continue;
857 }
858 let after = said.exported >= due + margin;
859 let fresh = now - peer.seen <= FRESH && now - said.exported <= FRESH + margin;
860 if !(after && fresh) {
861 out.push(Doubt::Working { device, shared: said.exported, quiet: now - peer.seen > QUIET });
862 }
863 }
864 out
865}
866
867pub fn doubts(due: i64, now: i64, record_lost: Option<i64>, peers: &[Peer]) -> Vec<Doubt> {
870 doubts_now(due, now, record_lost, peers)
871}
872
873pub fn doubts_unread(due: i64, now: i64, record_lost: Option<i64>, peers: &[Peer], since: i64, heard: &std::collections::BTreeSet<String>) -> Vec<Doubt> {
883 doubts_unread_at(due, due, now, record_lost, peers, since, heard)
884}
885
886pub fn doubts_unread_at(due: i64, waking: i64, now: i64, record_lost: Option<i64>, peers: &[Peer], since: i64, heard: &std::collections::BTreeSet<String>) -> Vec<Doubt> {
888 let mut out = doubts_at(due, waking, now, record_lost, peers);
889 let phones = phones_counted(peers, now);
890 for peer in peers.iter().filter(|p| counts(p, now) && !heard.contains(&p.id)) {
891 let device = named(peer, phones);
892 let names = |doubt: &Doubt| match doubt {
893 Doubt::Unheard { device: d, .. } | Doubt::Broken { device: d } | Doubt::Working { device: d, .. } | Doubt::Coming { device: d, .. } | Doubt::Apart { device: d } | Doubt::Ahead { device: d } => *d == device,
894 Doubt::Record { .. } => false,
895 };
896 if out.iter().any(names) {
897 continue;
898 }
899 let until = match &peer.said {
900 Some(said) if said.working => continue,
901 Some(said) => said.closed,
902 None if peer.closed => peer.known_until,
903 None => continue,
904 };
905 out.push(Doubt::Unheard { device, until: until.clamp(1, since.max(1)), closed: true });
906 }
907 out
908}
909
910pub fn known_from(due: i64, from: i64, until: i64, record_lost: Option<i64>, peers: &[Peer]) -> Option<i64> {
917 known_from_at(due, due, from, until, record_lost, peers)
918}
919
920pub fn known_from_at(due: i64, waking: i64, from: i64, until: i64, record_lost: Option<i64>, peers: &[Peer]) -> Option<i64> {
922 let phone = |p: &Peer| p.said.as_ref().is_some_and(|s| s.phone);
923 let after_alarm = peers.iter().filter(|p| phone(p)).filter_map(|p| p.wake).map(|w| waking + ALARM_AFTER + w + SKEW);
924 let mut moments: Vec<i64> = std::iter::once(from).chain(peers.iter().filter_map(|p| p.wake).map(|w| waking + w + SKEW)).chain(after_alarm).filter(|t| *t >= from && *t < until).collect();
925 moments.sort_unstable();
926 moments.dedup();
927 moments.into_iter().find(|t| doubts_at(due, waking, *t, record_lost, peers).is_empty())
928}
929
930pub fn known_until(due: i64, from: i64, until: i64, record_lost: Option<i64>, peers: &[Peer]) -> i64 {
935 let doubted = |at: i64| !doubts_now(due, at, record_lost, peers).is_empty();
936 if doubted(from) {
937 return 0;
938 }
939 let mut ends: Vec<i64> = Vec::new();
940 for peer in peers {
941 match &peer.said {
942 Some(said) => {
943 let margin = SKEW + peer.ahead.max(0);
944 ends.push(peer.seen + FRESH + 1);
945 ends.push(said.exported + FRESH + margin + 1);
946 if said.phone && peer.wake.is_some() {
948 ends.push(due + ALARM_AFTER);
949 }
950 }
951 None => ends.push(peer.known_until + FRESH + 1),
952 }
953 }
954 ends.retain(|t| *t > from && *t < until);
955 ends.sort_unstable();
956 ends.dedup();
957 ends.into_iter().find(|t| doubted(*t)).unwrap_or(until)
958}
959
960pub(crate) fn keep_private(path: &Path) {
963 #[cfg(unix)]
964 {
965 use std::os::unix::fs::PermissionsExt;
966 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
967 }
968 #[cfg(not(unix))]
969 let _ = path;
970}
971
972pub(crate) fn witnesses(path: &Path) -> Vec<PathBuf> {
981 let name = path.file_name().map(|n| format!(".{}.written", n.to_string_lossy())).unwrap_or_default();
982 let mut all = vec![path.with_file_name(&name)];
983 #[cfg(test)]
984 let apart = APART.with(|apart| apart.borrow().as_ref().map(|dir| dir.join(&name)));
985 #[cfg(not(test))]
986 let apart = (path.parent() == Some(crate::config::state_dir().as_path())).then(|| crate::config::data_dir().join(&name));
987 all.extend(apart.filter(|apart| !all.contains(apart)));
988 all
989}
990
991#[cfg(test)]
992thread_local! {
993 pub(crate) static APART: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
995}
996
997pub fn witnessed(path: &Path) -> bool {
999 witnesses(path).iter().any(|witness| witness.exists())
1000}
1001
1002pub fn write_witnessed(path: &Path, bytes: &[u8]) -> Result<(), String> {
1007 use std::io::Write;
1008 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
1009 let parent = path.parent().unwrap_or(Path::new("."));
1010 std::fs::create_dir_all(parent).map_err(fail)?;
1011 let temporary = path.with_extension("toml.new");
1013 let mut file = std::fs::File::create(&temporary).map_err(fail)?;
1014 file.write_all(bytes).and_then(|()| file.sync_all()).map_err(fail)?;
1015 drop(file);
1016 keep_private(&temporary);
1017 std::fs::rename(&temporary, path).map_err(fail)?;
1018 #[cfg(unix)]
1019 let _ = std::fs::File::open(parent).and_then(|dir| dir.sync_all());
1020 for witness in witnesses(path) {
1021 if !witness.exists() {
1022 if let Some(folder) = witness.parent() {
1023 let _ = std::fs::create_dir_all(folder);
1024 }
1025 std::fs::write(&witness, "").map_err(fail)?;
1026 }
1027 }
1028 Ok(())
1029}
1030
1031#[derive(Debug, Clone, PartialEq)]
1033pub struct Dose {
1034 pub key: String,
1036 pub medicine: String,
1037 pub name: String,
1038 pub dose: String,
1039 pub at: Zoned,
1040}
1041
1042#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1043#[serde(rename_all = "lowercase")]
1044pub enum ErrandKind {
1045 Refill,
1047 Renew,
1049}
1050
1051#[derive(Debug, Clone, PartialEq, Eq)]
1053pub struct Errand {
1054 pub key: String,
1056 pub prescription: String,
1057 pub kind: ErrandKind,
1058 pub day: Date,
1060 pub title: String,
1061}
1062
1063#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1066pub struct HealthState {
1067 #[serde(default)]
1069 pub taken: BTreeMap<String, i64>,
1070 #[serde(default)]
1072 pub reminded: BTreeMap<String, i64>,
1073 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1085 pub unshown: BTreeMap<String, i64>,
1086 #[serde(default)]
1088 pub not_taken: BTreeMap<String, i64>,
1089 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1092 pub moved: BTreeMap<String, [i64; 2]>,
1093 #[serde(default)]
1095 pub errands: BTreeMap<String, String>,
1096 #[serde(default, deserialize_with = "crate::budget::dates::optional")]
1098 pub chat_day: Option<Date>,
1099 #[serde(default, skip_serializing_if = "is_zero_u32")]
1105 pub chat_minutes: u32,
1106 #[serde(default, skip_serializing_if = "Option::is_none")]
1107 pub chats_locked_until: Option<i64>,
1108}
1109
1110fn is_zero_u32(n: &u32) -> bool {
1111 *n == 0
1112}
1113
1114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1116pub enum Unsound {
1117 Unreadable,
1119 Lost,
1121}
1122
1123#[derive(Debug, Clone, PartialEq, Eq)]
1125pub enum Problem {
1126 Unsound(Unsound),
1128 Write(String),
1130}
1131
1132impl HealthState {
1133 pub fn default_path() -> PathBuf {
1134 crate::config::state_dir().join("health-state.toml")
1135 }
1136
1137 pub fn read(path: &Path) -> Result<HealthState, Unsound> {
1144 match std::fs::read_to_string(path) {
1145 Ok(text) if text.trim().is_empty() && witnessed(path) => Err(Unsound::Lost),
1146 Ok(text) => toml::from_str(&text).map_err(|_| Unsound::Unreadable),
1147 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1148 if witnessed(path) {
1149 Err(Unsound::Lost)
1150 } else {
1151 Ok(HealthState::default())
1152 }
1153 }
1154 Err(_) => Err(Unsound::Unreadable),
1155 }
1156 }
1157
1158 pub fn update<T>(path: &Path, now: i64, change: impl FnOnce(&mut HealthState) -> T) -> Result<T, Problem> {
1162 crate::filelock::with_lock(path, || {
1163 let mut state = HealthState::read(path).map_err(Problem::Unsound)?;
1164 let out = change(&mut state);
1165 state.save(path, now).map_err(Problem::Write)?;
1166 Ok(out)
1167 })
1168 }
1169
1170 pub fn set_aside(path: &Path, now: i64) -> Result<Option<PathBuf>, String> {
1174 crate::filelock::with_lock(path, || {
1175 for witness in witnesses(path) {
1176 let _ = std::fs::remove_file(witness);
1177 }
1178 if !path.exists() {
1179 return Ok(None);
1180 }
1181 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
1182 let aside = path.with_file_name(format!("{name}.unreadable-{now}"));
1183 std::fs::rename(path, &aside).map_err(|e| format!("{}: {e}", path.display()))?;
1184 Ok(Some(aside))
1185 })
1186 }
1187
1188 pub fn save(&mut self, path: &Path, now: i64) -> Result<(), String> {
1190 let week = now - 7 * 86_400;
1191 self.taken.retain(|_, at| *at >= week);
1192 self.reminded.retain(|_, at| *at >= week);
1193 self.unshown.retain(|_, at| *at >= week);
1194 self.not_taken.retain(|_, at| *at >= week);
1195 self.moved.retain(|key, _| self.taken.contains_key(key));
1196 write_witnessed(path, toml::to_string(self).map_err(|e| e.to_string())?.as_bytes())
1197 }
1198
1199 pub fn to_remind(&self, health: &Health, now: &Zoned, minutes: i64) -> Vec<Dose> {
1201 let start = now.checked_sub(Span::new().minutes(minutes)).unwrap_or_else(|_| now.clone());
1202 let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1203 health.doses(&start, &end).into_iter().filter(|d| !self.taken.contains_key(&d.key) && !self.reminded.contains_key(&d.key)).collect()
1204 }
1205
1206 pub fn unanswered(&self, health: &Health, now: &Zoned, hours: i64, grace: i64) -> Vec<Dose> {
1212 let start = now.checked_sub(Span::new().hours(hours)).unwrap_or_else(|_| now.clone());
1213 let end = now.checked_sub(Span::new().minutes(grace)).unwrap_or_else(|_| now.clone());
1214 health.doses(&start, &end).into_iter().filter(|d| !self.taken.contains_key(&d.key) && !self.reminded.contains_key(&d.key) && !self.not_taken.contains_key(&d.key)).collect()
1215 }
1216
1217 pub fn reminder_unshown(&self, key: &str) -> bool {
1223 self.unshown.contains_key(key) && !self.reminded.contains_key(key)
1224 }
1225
1226 pub fn due_today(&self, health: &Health, now: &Zoned, hours: i64, grace: i64) -> Vec<Dose> {
1232 let midnight = now.date().to_zoned(now.time_zone().clone()).unwrap_or_else(|_| now.clone());
1233 let back = now.checked_sub(Span::new().hours(hours)).unwrap_or_else(|_| now.clone());
1234 let start = if back > midnight { back } else { midnight };
1235 let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1236 let apart: Vec<String> = self.unanswered(health, now, hours, grace).into_iter().map(|d| d.key).collect();
1237 health.doses(&start, &end).into_iter().filter(|d| !self.taken.contains_key(&d.key) && !self.not_taken.contains_key(&d.key) && !apart.contains(&d.key)).collect()
1238 }
1239
1240 pub fn chat_minute(&mut self, limit: &ChatLimit, now: &Zoned) -> bool {
1243 if self.chat_day != Some(now.date()) {
1244 self.chat_day = Some(now.date());
1245 self.chat_minutes = 0;
1246 }
1247 if self.chats_covered(now) {
1248 return true;
1249 }
1250 self.chat_minutes += 1;
1251 if limit.enabled && limit.minutes > 0 && self.chat_minutes >= limit.minutes {
1252 self.chats_locked_until = Some(now.timestamp().as_second() + i64::from(limit.locked_minutes.max(1)) * 60);
1253 self.chat_minutes = 0;
1254 return true;
1255 }
1256 false
1257 }
1258
1259 pub fn chats_covered(&self, now: &Zoned) -> bool {
1261 self.chats_locked_until.is_some_and(|until| until > now.timestamp().as_second())
1262 }
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267 use super::*;
1268
1269 fn at(text: &str) -> Zoned {
1270 text.parse().unwrap()
1271 }
1272
1273 #[test]
1274 fn doses_by_the_day_the_days_and_the_hours() {
1275 let noon_and_six = Schedule::Day { times: vec!["18:00".into(), "12:00".into()], amounts: BTreeMap::new() };
1276 let doses = noon_and_six.doses(&at("2026-10-03T13:00[Europe/Paris]"), &at("2026-10-05T00:00[Europe/Paris]"));
1277 let shown: Vec<String> = doses.iter().map(|z| z.strftime("%d %H:%M").to_string()).collect();
1278 assert_eq!(shown, vec!["03 18:00", "04 12:00", "04 18:00"]);
1279 let other = Schedule::Days { days: 2, time: "08:00".into(), from: "2026-10-04".parse().unwrap() };
1281 let shown: Vec<String> = other.doses(&at("2026-10-03T09:00[Europe/Paris]"), &at("2026-10-09T00:00[Europe/Paris]")).iter().map(|z| z.strftime("%d").to_string()).collect();
1282 assert_eq!(shown, vec!["04", "06", "08"]);
1283 let six = Schedule::Hours { hours: 6, from: at("2026-10-03T18:30[Europe/Paris]").timestamp().as_second() };
1285 let shown: Vec<String> = six.doses(&at("2026-10-04T00:00[Europe/Paris]"), &at("2026-10-04T13:00[Europe/Paris]")).iter().map(|z| z.strftime("%H:%M").to_string()).collect();
1286 assert_eq!(shown, vec!["00:30", "06:30", "12:30"]);
1287 }
1288
1289 #[test]
1290 fn every_few_hours_the_hours_between_doses_are_kept() {
1291 let eight = || Health {
1292 medicines: vec![Medicine { id: "antibiotic".into(), name: "Antibiotic".into(), dose: String::new(), schedule: Schedule::Hours { hours: 8, from: at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None }],
1293 ..Health::default()
1294 };
1295 let times = |health: &Health| -> Vec<String> { health.doses(&at("2026-10-05T12:00[Europe/Paris]"), &at("2026-10-06T12:00[Europe/Paris]")).iter().map(|d| d.at.strftime("%H:%M").to_string()).collect() };
1296 let key = format!("antibiotic@{}", at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second());
1297 let mut health = eight();
1299 let (before, after) = health.taken_at(&key, at("2026-10-05T09:30[Europe/Paris]").timestamp().as_second()).unwrap();
1300 assert_eq!(times(&health), ["17:30", "01:30", "09:30"]);
1301 assert!(health.taken_back(&key, before, after));
1303 assert_eq!(times(&health), ["16:00", "00:00", "08:00"]);
1304 health.taken_at(&key, at("2026-10-05T07:00[Europe/Paris]").timestamp().as_second()).unwrap();
1306 assert_eq!(times(&health), ["15:00", "23:00", "07:00"]);
1307 let mut health = eight();
1309 let (before, after) = health.taken_at(&key, at("2026-10-05T09:30[Europe/Paris]").timestamp().as_second()).unwrap();
1310 let later = format!("antibiotic@{}", at("2026-10-05T17:30[Europe/Paris]").timestamp().as_second());
1311 health.taken_at(&later, at("2026-10-05T18:00[Europe/Paris]").timestamp().as_second()).unwrap();
1312 assert!(!health.taken_back(&key, before, after));
1313 assert_eq!(times(&health), ["02:00", "10:00"]);
1314 assert_eq!(eight().taken_at(&key, at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second()), None);
1316 let mut daily = Health { medicines: vec![Medicine { schedule: Schedule::Day { times: vec!["08:00".into()], amounts: BTreeMap::new() }, ..eight().medicines[0].clone() }], ..Health::default() };
1318 assert_eq!(daily.taken_at(&key, at("2026-10-05T09:30[Europe/Paris]").timestamp().as_second()), None);
1319 }
1320
1321 #[test]
1322 fn a_record_that_cannot_be_trusted_is_never_taken_for_empty() {
1323 let dir = std::env::temp_dir().join(format!("sioul-health-record-{}", std::process::id()));
1324 let _ = std::fs::remove_dir_all(&dir);
1325 std::fs::create_dir_all(&dir).unwrap();
1326 let path = dir.join("health-state.toml");
1327 assert_eq!(HealthState::read(&path), Ok(HealthState::default()));
1329 HealthState::update(&path, 1_000, |s| s.taken.insert("d@1".into(), 1_000)).unwrap();
1330 assert!(HealthState::read(&path).unwrap().taken.contains_key("d@1"));
1331 std::fs::write(&path, "taken = { \"d@1\" = ").unwrap();
1333 assert_eq!(HealthState::read(&path), Err(Unsound::Unreadable));
1334 assert_eq!(HealthState::update(&path, 1_060, |s| s.taken.clear()), Err(Problem::Unsound(Unsound::Unreadable)));
1335 assert_eq!(std::fs::read_to_string(&path).unwrap(), "taken = { \"d@1\" = ", "left as it was");
1336 std::fs::remove_file(&path).unwrap();
1338 assert_eq!(HealthState::read(&path), Err(Unsound::Lost));
1339 assert_eq!(HealthState::set_aside(&path, 1_120), Ok(None));
1341 assert_eq!(HealthState::read(&path), Ok(HealthState::default()));
1342 std::fs::write(&path, "nonsense = [").unwrap();
1344 let aside = HealthState::set_aside(&path, 1_180).unwrap().unwrap();
1345 assert_eq!(std::fs::read_to_string(&aside).unwrap(), "nonsense = [");
1346 assert_eq!(HealthState::read(&path), Ok(HealthState::default()));
1347 let _ = std::fs::remove_dir_all(&dir);
1348 }
1349
1350 #[test]
1355 fn an_older_sioul_is_known_as_its_claims_say() {
1356 let due = 1_800_000_000;
1357 let peer = |known_until: i64, closed: bool, delay: Option<i64>| Peer { name: "laptop".into(), id: "laptop-id".into(), known_until, closed, delay, broken: None, heard: known_until.max(due - 3_600), ..Peer::default() };
1358 let laptop = Named { id: "laptop-id".into(), name: "laptop".into(), phone: false };
1359 assert!(doubts(due, due + 60, None, &[]).is_empty());
1361 assert!(doubts(due, due + 180, None, &[peer(due + 120, false, Some(30))]).is_empty());
1363 assert_eq!(doubts(due, due + 600, None, &[peer(due - 300, false, Some(30))]), vec![Doubt::Unheard { device: laptop.clone(), until: due - 300, closed: false }]);
1365 assert!(doubts(due, due + 600, None, &[peer(due - 3_600, true, Some(60))]).is_empty());
1367 assert_eq!(doubts(due, due + 600, None, &[peer(due - 3_600, true, Some(1_800))]).len(), 1);
1369 assert_eq!(doubts(due, due + 600, None, &[peer(due - 3_600, true, None)]).len(), 1);
1370 assert_eq!(doubts(due, due + 600, None, &[Peer { name: "phone".into(), heard: due, ..Peer::default() }]).len(), 1);
1372 let broken = Peer { broken: Some(due - 60), ..peer(due + 120, false, Some(30)) };
1374 assert_eq!(doubts(due, due + 180, None, &[broken]), vec![Doubt::Broken { device: laptop.clone() }]);
1375 let gone = Peer { heard: due - 8 * 86_400, ..peer(0, false, None) };
1377 assert!(doubts(due, due + 600, None, &[gone]).is_empty());
1378 assert_eq!(doubts(due, due + 600, Some(due + 300), &[]), vec![Doubt::Record { since: due + 300 }]);
1380 assert!(doubts(due + 900, due + 1_000, Some(due + 300), &[]).is_empty());
1381 let then = peer(due + 60, false, Some(40));
1383 assert!(doubts_now(due, due + 120, None, std::slice::from_ref(&then)).is_empty());
1384 assert_eq!(doubts_now(due, due + 3_600, None, std::slice::from_ref(&then)), vec![Doubt::Unheard { device: laptop.clone(), until: due + 60, closed: false }]);
1385 let back = Peer { heard: due + 3_000, ..peer(due - 600, true, Some(60)) };
1387 assert_eq!(doubts_now(due, due + 3_600, None, &[back]), vec![Doubt::Unheard { device: laptop, until: due - 600, closed: false }]);
1388 }
1389
1390 const DUE: i64 = 1_800_000_000;
1392
1393 fn phone(said: Said, seen: i64) -> Peer {
1396 Peer { name: "phone".into(), id: "phone-id".into(), heard: said.exported, said: Some(Said { phone: true, doses: true, ..said }), complete: true, seen, ..Peer::default() }
1397 }
1398
1399 fn the_phone() -> Named {
1400 Named { id: "phone-id".into(), name: "phone".into(), phone: true }
1401 }
1402
1403 #[test]
1404 fn the_phone_closed_cleanly_before_the_dose_is_known() {
1405 let p = phone(Said { started: DUE - 7_200, closed: DUE - 3_600, working: false, exported: DUE - 3_605, imported: DUE - 3_605, ..Said::default() }, DUE - 3_500);
1407 for now in [DUE, DUE + 3_600, DUE + 12 * 3_600] {
1409 assert!(doubts_now(DUE, now, None, std::slice::from_ref(&p)).is_empty(), "{now}");
1410 }
1411 assert_eq!(known_until(DUE, DUE + 60, DUE + 86_400, None, &[p]), DUE + 86_400, "nothing ends it but the day");
1412 }
1413
1414 #[test]
1421 fn a_device_closed_before_the_dose_is_known_once_its_opening_could_have_come() {
1422 let closed = phone(Said { started: DUE - 7_200, closed: DUE - 3_600, working: false, exported: DUE - 3_605, ..Said::default() }, DUE - 3_500);
1423 let doubt = vec![Doubt::Unheard { device: the_phone(), until: DUE - 3_600, closed: true }];
1424 assert!(doubts_now(DUE, DUE + 10, None, std::slice::from_ref(&closed)).is_empty(), "none measured: as before");
1425 let quick = Peer { wake: Some(40), ..closed.clone() };
1427 assert_eq!(doubts_now(DUE, DUE + 10, None, std::slice::from_ref(&quick)), doubt);
1428 assert_eq!(doubts_now(DUE, DUE + 40 + SKEW - 1, None, std::slice::from_ref(&quick)), doubt);
1429 assert!(doubts_now(DUE, DUE + 40 + SKEW, None, std::slice::from_ref(&quick)).is_empty());
1430 let slow = Peer { wake: Some(240), ..closed.clone() };
1432 assert_eq!(doubts_now(DUE, DUE + 240, None, std::slice::from_ref(&slow)), doubt);
1433 assert!(doubts_now(DUE, DUE + 240 + SKEW, None, std::slice::from_ref(&slow)).is_empty());
1434 assert_eq!(known_from(DUE, DUE - 600, DUE + 86_400, None, std::slice::from_ref(&slow)), Some(DUE + 240 + SKEW), "a dose seen ahead: known from then");
1435 assert_eq!(known_until(DUE, DUE + 240 + SKEW, DUE + 86_400, None, std::slice::from_ref(&slow)), DUE + ALARM_AFTER);
1437 assert_eq!(known_from(DUE, DUE + ALARM_AFTER, DUE + 86_400, None, std::slice::from_ref(&slow)), Some(DUE + ALARM_AFTER + 240 + SKEW));
1438 let after = Peer { wake: Some(240), ..phone(Said { started: DUE - 600, closed: DUE + 600, working: false, exported: DUE + 595, ..Said::default() }, DUE + 620) };
1440 assert!(doubts_now(DUE, DUE + 630, None, &[after]).is_empty());
1441 let older = Peer { name: "laptop".into(), id: "laptop-id".into(), known_until: DUE - 3_600, closed: true, delay: Some(30), heard: DUE - 3_600, wake: Some(120), ..Peer::default() };
1443 assert_eq!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&older)).len(), 1);
1444 assert!(doubts_now(DUE, DUE + 120 + SKEW, None, std::slice::from_ref(&older)).is_empty());
1445 let apart = Peer { said: slow.said.clone().map(|s| Said { doses: false, ..s }), ..slow };
1447 assert_eq!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&apart)), doubt);
1448 assert!(doubts_now(DUE, DUE + 240 + SKEW, None, &[apart]).is_empty());
1449 }
1450
1451 #[test]
1460 fn a_phone_put_away_is_known_from_its_alarm_once_that_rang() {
1461 let put_away = Peer { wake: Some(30), ..phone(Said { started: DUE - 7_200, closed: DUE - 3_600, working: false, exported: DUE - 3_600, imported: DUE - 3_600, ..Said::default() }, DUE - 3_600) };
1462 let doubt = vec![Doubt::Unheard { device: the_phone(), until: DUE - 3_600, closed: true }];
1463 let at = |now: i64| doubts_now(DUE, now, None, std::slice::from_ref(&put_away));
1464 assert_eq!(at(DUE + 60), doubt, "picked up at the dose: its news not come yet");
1465 assert!(at(DUE + 30 + SKEW).is_empty(), "known before its alarm");
1466 assert_eq!(at(DUE + ALARM_AFTER), doubt, "its alarm rang");
1467 assert_eq!(at(DUE + ALARM_AFTER + 60), doubt, "a minute after its alarm: the review's moment");
1468 assert!(at(DUE + ALARM_AFTER + 30 + SKEW).is_empty(), "the news of its alarm's opening had the time to come");
1469 assert_eq!(known_until(DUE, DUE + 200, DUE + 86_400, None, std::slice::from_ref(&put_away)), DUE + ALARM_AFTER);
1470 assert_eq!(known_from(DUE, DUE + ALARM_AFTER, DUE + 86_400, None, std::slice::from_ref(&put_away)), Some(DUE + ALARM_AFTER + 30 + SKEW));
1471 let laptop = Peer { name: "laptop".into(), id: "laptop-id".into(), said: put_away.said.clone().map(|s| Said { phone: false, ..s }), ..put_away.clone() };
1473 assert!(doubts_now(DUE, DUE + ALARM_AFTER + 60, None, &[laptop]).is_empty());
1474 let unmeasured = Peer { wake: None, ..put_away };
1476 assert!(doubts_now(DUE, DUE + ALARM_AFTER + 1, None, &[unmeasured]).is_empty());
1477 }
1478
1479 #[test]
1480 fn the_phone_closed_cleanly_after_the_dose_without_marking_is_known() {
1481 let p = phone(Said { started: DUE - 600, closed: DUE + 1_200, working: false, exported: DUE + 1_195, ..Said::default() }, DUE + 1_260);
1483 assert!(doubts_now(DUE, DUE + 1_300, None, std::slice::from_ref(&p)).is_empty());
1484 let behind = Peer { complete: false, ..p };
1486 assert_eq!(doubts_now(DUE, DUE + 1_300, None, &[behind]), vec![Doubt::Coming { device: the_phone(), shared: DUE + 1_195 }]);
1487 }
1488
1489 #[test]
1495 fn with_no_news_since_the_alarm_a_device_known_closed_is_a_doubt() {
1496 let closed = phone(Said { started: DUE - 7_200, closed: DUE - 3_600, working: false, exported: DUE - 3_605, imported: DUE - 3_605, ..Said::default() }, DUE - 3_500);
1497 assert!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&closed)).is_empty(), "closed before the dose, its news read: known");
1498 let none = std::collections::BTreeSet::new();
1499 let unread = doubts_unread(DUE, DUE + 60, None, std::slice::from_ref(&closed), DUE + 30, &none);
1500 assert_eq!(unread, vec![Doubt::Unheard { device: the_phone(), until: DUE - 3_600, closed: true }], "nothing of it read since the alarm: it may have opened since");
1501 let heard: std::collections::BTreeSet<String> = ["phone-id".to_string()].into();
1502 assert!(doubts_unread(DUE, DUE + 60, None, std::slice::from_ref(&closed), DUE + 30, &heard).is_empty(), "its own entry came since the alarm: as it says");
1503 let working = phone(Said { started: DUE - 600, closed: DUE - 7_200, working: true, exported: DUE + 180, ..Said::default() }, DUE + 200);
1504 assert_eq!(doubts_unread(DUE, DUE + 240, None, std::slice::from_ref(&working), DUE + 230, &none), doubts_now(DUE, DUE + 240, None, std::slice::from_ref(&working)), "in use: as FRESH has it");
1505 let stale = phone(Said { started: DUE - 3_600, working: true, exported: DUE - 900, ..Said::default() }, DUE - 870);
1506 assert_eq!(doubts_unread(DUE, DUE + 60, None, std::slice::from_ref(&stale), DUE + 30, &none).len(), 1, "doubted already: once");
1507 }
1508
1509 #[test]
1510 fn the_phone_in_use_and_exported_after_the_dose_is_known() {
1511 let p = phone(Said { started: DUE - 600, closed: DUE - 7_200, working: true, exported: DUE + 180, ..Said::default() }, DUE + 200);
1513 assert!(doubts_now(DUE, DUE + 240, None, std::slice::from_ref(&p)).is_empty());
1514 assert_eq!(known_until(DUE, DUE + 240, DUE + 86_400, None, std::slice::from_ref(&p)), DUE + 200 + FRESH + 1);
1516 assert_eq!(doubts_now(DUE, DUE + 200 + FRESH + 1, None, &[p]), vec![Doubt::Working { device: the_phone(), shared: DUE + 180, quiet: false }]);
1517 }
1518
1519 #[test]
1520 fn the_phone_in_use_not_exported_since_the_dose_is_uncertain_naming_it() {
1521 let p = phone(Said { started: DUE - 3_600, closed: DUE - 7_200, working: true, exported: DUE - 900, ..Said::default() }, DUE - 870);
1523 assert_eq!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&p)), vec![Doubt::Working { device: the_phone(), shared: DUE - 900, quiet: false }]);
1524 let said = p.said.clone().unwrap();
1525 let early = phone(Said { exported: DUE + 60, ..said.clone() }, DUE + 70);
1527 assert_eq!(doubts_now(DUE, DUE + 80, None, &[early]).len(), 1);
1528 let later = phone(Said { exported: DUE + 180, ..said }, DUE + 190);
1530 assert!(doubts_now(DUE, DUE + 200, None, &[later]).is_empty());
1531 }
1532
1533 #[test]
1534 fn the_phone_that_crashed_is_uncertain_until_it_starts_again_or_is_said_off() {
1535 let crashed = phone(Said { started: DUE - 1_800, closed: DUE - 86_400, working: true, exported: DUE - 900, ..Said::default() }, DUE - 880);
1537 assert_eq!(doubts_now(DUE, DUE + 600, None, std::slice::from_ref(&crashed)), vec![Doubt::Working { device: the_phone(), shared: DUE - 900, quiet: false }]);
1538 assert_eq!(doubts_now(DUE, DUE + 4 * 3_600, None, std::slice::from_ref(&crashed)), vec![Doubt::Working { device: the_phone(), shared: DUE - 900, quiet: true }]);
1540 let off = Peer { off: true, ..crashed };
1542 assert!(doubts_now(DUE, DUE + 4 * 3_600, None, &[off]).is_empty());
1543 let back = phone(Said { started: DUE + 4 * 3_600, closed: DUE - 86_400, working: true, exported: DUE + 4 * 3_600 + 60, ..Said::default() }, DUE + 4 * 3_600 + 70);
1545 assert!(doubts_now(DUE, DUE + 4 * 3_600 + 90, None, &[back]).is_empty());
1546 }
1547
1548 #[test]
1549 fn clocks_a_few_minutes_apart_either_way() {
1550 let early = Peer { ahead: 180, ..phone(Said { started: DUE - 3_600, working: true, exported: DUE + 120, ..Said::default() }, DUE - 60) };
1553 assert_eq!(doubts_now(DUE, DUE, None, &[early]).len(), 1, "its 08:02 is 07:59 here");
1554 let later = Peer { ahead: 180, ..phone(Said { started: DUE - 3_600, working: true, exported: DUE + 360, ..Said::default() }, DUE + 190) };
1556 assert!(doubts_now(DUE, DUE + 200, None, &[later]).is_empty());
1557 let behind = phone(Said { started: DUE - 3_600, working: true, exported: DUE + 60, ..Said::default() }, DUE + 250);
1559 assert_eq!(doubts_now(DUE, DUE + 260, None, &[behind]).len(), 1);
1560 let behind_later = phone(Said { started: DUE - 3_600, working: true, exported: DUE + 300, ..Said::default() }, DUE + 490);
1561 assert!(doubts_now(DUE, DUE + 500, None, &[behind_later]).is_empty());
1562 let closed = Peer { ahead: 600, ..phone(Said { started: DUE + 900, closed: DUE + 1_500, working: false, exported: DUE + 1_490, ..Said::default() }, DUE + 300) };
1564 assert!(doubts_now(DUE, DUE + 400, None, &[closed]).is_empty());
1565 }
1566
1567 #[test]
1568 fn a_device_silent_for_a_week_no_longer_counts() {
1569 let gone = phone(Said { started: DUE - 10 * 86_400, working: true, exported: DUE - 8 * 86_400, ..Said::default() }, DUE - 8 * 86_400 + 30);
1571 assert!(doubts_now(DUE, DUE + 60, None, &[gone.clone()]).is_empty());
1572 let six = phone(Said { started: DUE - 7 * 86_400, working: true, exported: DUE - 6 * 86_400, ..Said::default() }, DUE - 6 * 86_400 + 30);
1574 assert_eq!(doubts_now(DUE, DUE + 60, None, &[six]).len(), 1);
1575 let first = Peer { seen: DUE - 60, ..gone };
1578 assert_eq!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&first)).len(), 1);
1579 assert!(!counts(&first, DUE + 8 * 86_400));
1580 let left = phone(Said { started: DUE - 600, working: false, closed: DUE - 60, exported: DUE - 61, left: true, ..Said::default() }, DUE - 30);
1582 assert!(!counts(&left, DUE));
1583 }
1584
1585 #[test]
1586 fn a_device_that_does_not_share_its_doses_is_known_only_closed_before() {
1587 let tablet = |said: Said| Peer { name: "tablet".into(), id: "tablet-id".into(), said: Some(Said { doses: false, ..said }), complete: true, seen: DUE + 310, ..Peer::default() };
1588 let named = Named { id: "tablet-id".into(), name: "tablet".into(), phone: false };
1589 let working = tablet(Said { started: DUE - 600, working: true, exported: DUE + 300, ..Said::default() });
1591 assert_eq!(doubts_now(DUE, DUE + 320, None, &[working]), vec![Doubt::Apart { device: named }]);
1592 assert!(doubts_now(DUE, DUE + 320, None, &[tablet(Said { started: DUE - 7_200, closed: DUE - 3_600, exported: DUE - 3_605, ..Said::default() })]).is_empty());
1594 assert_eq!(doubts_now(DUE, DUE + 700, None, &[tablet(Said { started: DUE - 600, closed: DUE + 600, exported: DUE + 595, ..Said::default() })]).len(), 1);
1596 }
1597
1598 #[test]
1599 fn each_device_said_apart() {
1600 let desktop = Peer { name: "desk".into(), id: "desk-id".into(), said: Some(Said { started: DUE - 9_000, closed: DUE - 3_600, exported: DUE - 3_601, doses: true, ..Said::default() }), complete: true, seen: DUE - 3_500, ..Peer::default() };
1602 let p = phone(Said { started: DUE - 3_600, working: true, exported: DUE - 900, ..Said::default() }, DUE - 880);
1603 let older = Peer { name: "laptop".into(), id: "laptop-id".into(), heard: DUE - 60, ..Peer::default() };
1604 let doubts = doubts_now(DUE, DUE + 60, None, &[desktop, p, older]);
1605 assert_eq!(doubts.len(), 2, "{doubts:?}");
1606 assert!(matches!(&doubts[0], Doubt::Working { device, .. } if device.id == "phone-id"));
1607 assert!(matches!(&doubts[1], Doubt::Unheard { device, until: 0, .. } if device.id == "laptop-id"));
1608 }
1609
1610 #[test]
1611 fn reminded_once_never_counted() {
1612 let health = Health {
1613 medicines: vec![Medicine { id: "vitamin-d".into(), name: "Vitamin D".into(), dose: "1000 IU".into(), schedule: Schedule::Day { times: vec!["12:00".into()], amounts: BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None }],
1614 ..Health::default()
1615 };
1616 let mut state = HealthState::default();
1617 let now = at("2026-10-03T12:03[Europe/Paris]");
1618 let due = state.to_remind(&health, &now, 10);
1619 assert_eq!(due.len(), 1);
1620 state.reminded.insert(due[0].key.clone(), now.timestamp().as_second());
1621 assert!(state.to_remind(&health, &now, 10).is_empty(), "reminded once");
1622 assert!(state.to_remind(&health, &at("2026-10-03T13:00[Europe/Paris]"), 10).is_empty(), "past its window, no second reminder");
1623 }
1624
1625 #[test]
1631 fn at_waking_what_looks_back_counts_from_the_doses_time() {
1632 let (due, waking) = (1_800_018_000, 1_800_025_200);
1633 let now = waking + 30;
1634 assert_eq!(doubts_at(due, waking, now, Some(due + 3_600), &[]), [Doubt::Record { since: due + 3_600 }]);
1636 assert_eq!(doubts_at(waking, waking, now, Some(due + 3_600), &[]), [], "counted from waking, it would be lost");
1637 let apart = Peer {
1639 name: "desk".into(),
1640 id: "desk-id".into(),
1641 said: Some(Said { started: due - 600, closed: due + 3_600, working: false, exported: due + 3_600, imported: due + 3_600, doses: false, left: false, phone: false }),
1642 complete: true,
1643 seen: due + 3_600,
1644 heard: due + 3_600,
1645 ..Peer::default()
1646 };
1647 assert!(matches!(&doubts_at(due, waking, now, None, std::slice::from_ref(&apart))[..], [Doubt::Apart { .. }]));
1648 let phone = Peer {
1650 name: "phone".into(),
1651 id: "phone-id".into(),
1652 said: Some(Said { started: due - 30_000, closed: due - 23_400, working: false, exported: due - 23_400, imported: due - 23_400, doses: true, left: false, phone: true }),
1653 complete: true,
1654 seen: due - 23_400,
1655 heard: due - 23_400,
1656 wake: Some(30),
1657 ..Peer::default()
1658 };
1659 assert!(!doubts_at(due, waking, now, None, std::slice::from_ref(&phone)).is_empty());
1660 assert!(doubts_at(due, waking, waking + 30 + SKEW, None, std::slice::from_ref(&phone)).is_empty());
1661 assert!(doubts_now(due, now, None, std::slice::from_ref(&phone)).is_empty(), "from the dose's time, it would be known by its alarm long rung");
1662 }
1663
1664 #[test]
1671 fn an_older_sioul_reads_a_reminder_that_may_have_shown_as_one_to_give() {
1672 #[derive(Debug, Default, Serialize, Deserialize)]
1674 struct Older {
1675 #[serde(default)]
1676 taken: BTreeMap<String, i64>,
1677 #[serde(default)]
1678 reminded: BTreeMap<String, i64>,
1679 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1680 unshown: BTreeMap<String, i64>,
1681 #[serde(default)]
1682 not_taken: BTreeMap<String, i64>,
1683 }
1684 let key = "levo@1791360000".to_string();
1685 let mut new = HealthState::default();
1686 new.unshown.insert(key.clone(), 1_791_360_004);
1687 let text = toml::to_string(&new).unwrap();
1688 assert!(text.contains("[unshown]") && !text.contains("maybe"));
1689 let older: Older = toml::from_str(&text).unwrap();
1690 assert!(!older.reminded.contains_key(&key) && older.unshown.contains_key(&key), "to remind and to ask about, never reminded");
1691 let rewritten: HealthState = toml::from_str(&toml::to_string(&older).unwrap()).unwrap();
1692 assert!(rewritten.unshown.contains_key(&key) && rewritten.reminder_unshown(&key), "its rewrite keeps unshown");
1693 #[derive(Debug, Default, Deserialize)]
1695 struct Oldest {
1696 #[serde(default)]
1697 reminded: BTreeMap<String, i64>,
1698 }
1699 let oldest: Oldest = toml::from_str(&text).unwrap();
1700 assert!(!oldest.reminded.contains_key(&key), "0.0.3: to remind, never reminded");
1701 let earlier: HealthState = toml::from_str("[unshown]\n\"levo@1791360000\" = 1791360004\n[maybe]\n\"levo@1791360000\" = 1791360002\n").unwrap();
1703 assert!(earlier.reminder_unshown(&key) && !toml::to_string(&earlier).unwrap().contains("maybe"));
1704 }
1705
1706 #[test]
1711 fn a_reminder_not_shown_is_still_asked_about_and_told_apart() {
1712 let health = Health {
1713 medicines: vec![Medicine { id: "demo-pill".into(), name: "Demo pill".into(), dose: String::new(), schedule: Schedule::Day { times: vec!["08:00".into(), "09:00".into()], amounts: BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None }],
1714 ..Health::default()
1715 };
1716 let now = at("2026-10-03T12:00[Europe/Paris]");
1717 let key = |when: &str| format!("demo-pill@{}", at(when).timestamp().as_second());
1718 let mut state = HealthState::default();
1719 state.unshown.insert(key("2026-10-03T08:00[Europe/Paris]"), at("2026-10-03T08:01[Europe/Paris]").timestamp().as_second());
1721 let asked: Vec<String> = state.unanswered(&health, &now, 12, 30).into_iter().map(|d| d.key).collect();
1722 assert_eq!(asked, [key("2026-10-03T08:00[Europe/Paris]"), key("2026-10-03T09:00[Europe/Paris]")], "both still asked about");
1723 assert!(state.reminder_unshown(&key("2026-10-03T08:00[Europe/Paris]")));
1724 assert!(!state.reminder_unshown(&key("2026-10-03T09:00[Europe/Paris]")));
1725 let mut later = state.clone();
1727 later.reminded.insert(key("2026-10-03T08:00[Europe/Paris]"), at("2026-10-03T08:05[Europe/Paris]").timestamp().as_second());
1728 assert!(!later.reminder_unshown(&key("2026-10-03T08:00[Europe/Paris]")));
1729 assert_eq!(later.unanswered(&health, &now, 12, 30).len(), 1);
1730 let mut sent = HealthState::default();
1732 let nine = key("2026-10-03T09:00[Europe/Paris]");
1733 sent.unshown.insert(nine.clone(), at("2026-10-03T09:00[Europe/Paris]").timestamp().as_second() + 4);
1734 assert!(sent.reminder_unshown(&nine));
1735 assert!(sent.unanswered(&health, &now, 12, 30).iter().any(|d| d.key == nine), "asked about");
1736 let mut answered_late = sent.clone();
1737 answered_late.reminded.insert(nine.clone(), at("2026-10-03T09:01[Europe/Paris]").timestamp().as_second());
1738 assert!(!answered_late.reminder_unshown(&nine));
1739 let dir = std::env::temp_dir().join(format!("sioul-unshown-{}", std::process::id()));
1741 let _ = std::fs::remove_dir_all(&dir);
1742 let path = dir.join("health-state.toml");
1743 state.save(&path, now.timestamp().as_second()).unwrap();
1744 let read = HealthState::read(&path).unwrap();
1745 assert!(read.reminder_unshown(&key("2026-10-03T08:00[Europe/Paris]")));
1746 let mut old = read.clone();
1747 old.save(&path, now.timestamp().as_second() + 8 * 86_400).unwrap();
1748 assert!(HealthState::read(&path).unwrap().unshown.is_empty(), "dropped after a week");
1749 std::fs::write(&path, "[taken]\n[reminded]\n").unwrap();
1750 let mut older = HealthState::read(&path).unwrap();
1751 assert!(older.unshown.is_empty());
1752 older.save(&path, now.timestamp().as_second()).unwrap();
1753 assert!(!std::fs::read_to_string(&path).unwrap().contains("unshown"));
1754 let _ = std::fs::remove_dir_all(&dir);
1755 }
1756
1757 #[test]
1758 fn pharmacy_and_doctor_errands() {
1759 let health = Health {
1760 prescriptions: vec![Prescription {
1761 id: "vitamin-d".into(),
1762 title: "Vitamin D 1000 IU".into(),
1763 until: Some("2026-12-31".parse().unwrap()),
1764 refill_days: Some(28),
1765 last_refill: Some("2026-10-01".parse().unwrap()),
1766 ..Prescription::default()
1767 }],
1768 ..Health::default()
1769 };
1770 let errands: Vec<(ErrandKind, String)> = health.errands().into_iter().map(|e| (e.kind, e.day.to_string())).collect();
1771 assert_eq!(errands, vec![(ErrandKind::Refill, "2026-10-27".to_string()), (ErrandKind::Renew, "2026-12-17".to_string())]);
1772 }
1773
1774 #[test]
1775 fn read_as_written_by_hand_and_by_sioul() {
1776 let by_hand = "[[prescription]]\nid = \"d\"\ntitle = \"Vitamin D\"\nuntil = 2026-12-31\n\n[[medicine]]\nid = \"iron\"\nname = \"Iron\"\n[medicine.schedule]\nevery = \"days\"\ndays = 2\ntime = \"12:00\"\nfrom = 2026-10-03\n";
1777 let health: Health = toml::from_str(by_hand).unwrap();
1778 assert_eq!(health.prescriptions[0].until, Some("2026-12-31".parse().unwrap()));
1779 let again: Health = toml::from_str(&toml::to_string(&health).unwrap()).unwrap();
1780 assert_eq!(again, health);
1781 }
1782
1783 #[test]
1784 fn the_watch_s_old_keys_are_read_and_left_aside() {
1785 let dir = std::env::temp_dir().join(format!("sioul-health-watch-{}", std::process::id()));
1787 let _ = std::fs::remove_dir_all(&dir);
1788 std::fs::create_dir_all(&dir).unwrap();
1789 let path = dir.join("health.toml");
1790 let older = "errands_list = \"local/errands\"\nwatch_folder = \"~/Garmin\"\nwatch_offers = false\n\n[[medicine]]\nid = \"iron\"\nname = \"Iron\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\"]\n";
1791 std::fs::write(&path, older).unwrap();
1792 let health = Health::load(&path);
1793 assert_eq!((health.errands_list.as_str(), health.medicines.len()), ("local/errands", 1), "read as before");
1794 assert!(!dir.join("health.toml.unreadable").exists(), "never taken for a file that does not read");
1795 health.save(&path).unwrap();
1796 let written = std::fs::read_to_string(&path).unwrap();
1797 assert!(!written.contains("watch"), "{written}");
1798 assert_eq!(Health::load(&path), health);
1799 let _ = std::fs::remove_dir_all(&dir);
1800 }
1801
1802 #[test]
1803 fn a_file_that_no_longer_reads_is_kept_aside() {
1804 let dir = std::env::temp_dir().join(format!("sioul-health-{}", std::process::id()));
1805 let _ = std::fs::remove_dir_all(&dir);
1806 std::fs::create_dir_all(&dir).unwrap();
1807 let path = dir.join("health.toml");
1808 assert_eq!(Health::load(&path), Health::default(), "none yet");
1809 let broken = "[[medicine]]\nid = \"iron\"\nname = \"Iron\"\n[medicine.schedule]\nevery = \"fortnight\"\n";
1810 std::fs::write(&path, broken).unwrap();
1811 assert_eq!(Health::load(&path), Health::default());
1812 Health::default().save(&path).unwrap();
1813 assert_eq!(std::fs::read_to_string(dir.join("health.toml.unreadable")).unwrap(), broken, "what was there is kept");
1814 #[cfg(unix)]
1815 {
1816 use std::os::unix::fs::PermissionsExt;
1817 assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
1818 }
1819 let _ = std::fs::remove_dir_all(&dir);
1820 }
1821
1822 #[test]
1827 fn todays_doses_until_marked() {
1828 let health = Health {
1829 medicines: vec![
1830 Medicine { id: "levo".into(), name: "Levothyroxine".into(), dose: "75 µg".into(), schedule: Schedule::Day { times: vec!["07:30".into()], amounts: BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None },
1831 Medicine { id: "iron".into(), name: "Iron".into(), dose: String::new(), schedule: Schedule::Day { times: vec!["12:00".into(), "21:00".into()], amounts: BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None },
1832 Medicine { id: "zinc".into(), name: "Zinc".into(), dose: String::new(), schedule: Schedule::Day { times: vec!["09:00".into(), "23:50".into()], amounts: BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None },
1833 ],
1834 ..Health::default()
1835 };
1836 let key = |id: &str, when: &str| format!("{id}@{}", at(when).timestamp().as_second());
1837 let keys = |doses: Vec<Dose>| doses.into_iter().map(|d| d.key).collect::<Vec<_>>();
1838 let mut state = HealthState::default();
1839 state.reminded.insert(key("levo", "2026-10-06T07:30[Europe/Paris]"), at("2026-10-06T07:31[Europe/Paris]").timestamp().as_second());
1841 let noon = at("2026-10-06T12:00:30[Europe/Paris]");
1842 assert_eq!(keys(state.due_today(&health, &noon, 12, 30)), [key("levo", "2026-10-06T07:30[Europe/Paris]"), key("iron", "2026-10-06T12:00[Europe/Paris]")]);
1843 assert_eq!(keys(state.unanswered(&health, &noon, 12, 30)), [key("zinc", "2026-10-06T09:00[Europe/Paris]")]);
1844 assert_eq!(keys(state.due_today(&health, &at("2026-10-06T11:59[Europe/Paris]"), 12, 30)), [key("levo", "2026-10-06T07:30[Europe/Paris]")]);
1846 let mut waiting = state.clone();
1848 waiting.reminded.clear();
1849 assert_eq!(keys(waiting.due_today(&health, &at("2026-10-06T07:45[Europe/Paris]"), 12, 30)), [key("levo", "2026-10-06T07:30[Europe/Paris]")]);
1850 assert!(waiting.due_today(&health, &at("2026-10-06T08:10[Europe/Paris]"), 12, 30).is_empty());
1852 state.taken.insert(key("iron", "2026-10-06T12:00[Europe/Paris]"), noon.timestamp().as_second());
1854 state.not_taken.insert(key("zinc", "2026-10-06T09:00[Europe/Paris]"), noon.timestamp().as_second());
1855 assert_eq!(keys(state.due_today(&health, &noon, 12, 30)), [key("levo", "2026-10-06T07:30[Europe/Paris]")]);
1856 let evening = at("2026-10-06T21:05[Europe/Paris]");
1858 state.reminded.insert(key("iron", "2026-10-06T21:00[Europe/Paris]"), evening.timestamp().as_second());
1859 assert_eq!(keys(state.due_today(&health, &evening, 12, 30)), [key("iron", "2026-10-06T21:00[Europe/Paris]")]);
1860 let night = at("2026-10-07T00:30[Europe/Paris]");
1862 assert!(state.due_today(&health, &night, 12, 30).is_empty());
1863 assert_eq!(keys(state.unanswered(&health, &night, 12, 30)), [key("zinc", "2026-10-06T23:50[Europe/Paris]")]);
1865 }
1866
1867 #[test]
1868 fn chats_covered_after_their_time() {
1869 let limit = ChatLimit { enabled: true, minutes: 2, locked_minutes: 30 };
1870 let mut state = HealthState::default();
1871 let now = at("2026-10-03T20:00[Europe/Paris]");
1872 assert!(!state.chat_minute(&limit, &now));
1873 assert!(state.chat_minute(&limit, &now), "the second minute reaches the limit");
1874 assert!(state.chats_covered(&at("2026-10-03T20:29[Europe/Paris]")));
1875 assert!(!state.chats_covered(&at("2026-10-03T20:31[Europe/Paris]")), "back after the time chosen");
1876 }
1877
1878 mod older {
1882 use jiff::civil::Date;
1883 use serde::{Deserialize, Serialize};
1884
1885 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1886 pub struct Health {
1887 #[serde(rename = "medicine", default)]
1888 pub medicines: Vec<Medicine>,
1889 }
1890
1891 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1892 pub struct Medicine {
1893 pub id: String,
1894 pub name: String,
1895 #[serde(default, skip_serializing_if = "String::is_empty")]
1896 pub dose: String,
1897 pub schedule: Schedule,
1898 #[serde(default, skip_serializing_if = "Option::is_none")]
1899 pub prescription: Option<String>,
1900 #[serde(default, deserialize_with = "crate::budget::dates::optional", skip_serializing_if = "Option::is_none")]
1901 pub until: Option<Date>,
1902 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1903 pub paused: bool,
1904 }
1905
1906 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1907 #[serde(tag = "every", rename_all = "kebab-case")]
1908 pub enum Schedule {
1909 Day {
1910 times: Vec<String>,
1911 },
1912 Days {
1913 days: u32,
1914 time: String,
1915 #[serde(deserialize_with = "crate::budget::dates::required")]
1916 from: Date,
1917 },
1918 Hours {
1919 hours: u32,
1920 from: i64,
1921 },
1922 }
1923 }
1924
1925 fn take(time: &str, amount: &str) -> Take {
1926 Take { time: time.into(), amount: amount.into() }
1927 }
1928
1929 fn at_set_times(id: &str, name: &str) -> Medicine {
1930 Medicine { id: id.into(), name: name.into(), dose: String::new(), schedule: Schedule::Day { times: Vec::new(), amounts: BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None }
1931 }
1932
1933 fn day_of(health: &Health, day: &str) -> Vec<(String, String, String)> {
1935 let start = at(&format!("{day}T00:00[Europe/Paris]"));
1936 let end = start.checked_add(Span::new().days(1)).unwrap();
1937 health.doses(&start, &end).into_iter().map(|d| (d.at.strftime("%H:%M").to_string(), d.name, d.dose)).collect()
1938 }
1939
1940 fn said(rows: &[(&str, &str, &str)]) -> Vec<(String, String, String)> {
1941 rows.iter().map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string())).collect()
1942 }
1943
1944 #[test]
1945 fn each_take_its_time_and_its_own_amount() {
1946 let mut same = at_set_times("zinc", "Zinc");
1948 same.set_takes("1 tablet", &[take("20:00", ""), take("8:00", "1 tablet")]).unwrap();
1949 assert_eq!(same.dose, "1 tablet");
1950 assert_eq!(same.schedule, Schedule::Day { times: vec!["08:00".into(), "20:00".into()], amounts: BTreeMap::new() });
1951 assert!(!toml::to_string(&Health { medicines: vec![same.clone()], ..Health::default() }).unwrap().contains("amounts"));
1952 let mut iron = at_set_times("iron", "Iron");
1954 iron.set_takes("1 tablet", &[take("08:00", ""), take("20:00", "2 tablets")]).unwrap();
1955 assert_eq!(iron.takes(), [take("08:00", "1 tablet"), take("20:00", "2 tablets")]);
1956 assert_eq!(iron.usual(), "1 tablet");
1957 assert_eq!(iron.takes_line(), "08:00 · 1 tablet, 20:00 · 2 tablets");
1958 assert_eq!(iron.dose, iron.takes_line(), "what an older Sioul says with each dose");
1959 let health = Health { medicines: vec![iron.clone(), same], ..Health::default() };
1960 assert_eq!(day_of(&health, "2026-10-08"), said(&[("08:00", "Iron", "1 tablet"), ("08:00", "Zinc", "1 tablet"), ("20:00", "Iron", "2 tablets"), ("20:00", "Zinc", "1 tablet")]));
1961 let plain = Health { medicines: vec![Medicine { schedule: Schedule::Day { times: vec!["08:00".into(), "20:00".into()], amounts: BTreeMap::new() }, ..iron.clone() }], ..Health::default() };
1963 let keys = |h: &Health| h.doses(&at("2026-10-08T00:00[Europe/Paris]"), &at("2026-10-10T00:00[Europe/Paris]")).into_iter().filter(|d| d.medicine == "iron").map(|d| (d.key, d.at)).collect::<Vec<_>>();
1964 assert_eq!(keys(&health), keys(&plain));
1965 let mut some = at_set_times("d", "Vitamin D");
1967 some.set_takes("", &[take("08:00", ""), take("20:00", "2 drops")]).unwrap();
1968 assert_eq!(some.takes(), [take("08:00", ""), take("20:00", "2 drops")]);
1969 assert_eq!(some.dose, "08:00, 20:00 · 2 drops");
1970 assert_eq!(some.usual(), "", "the earliest on a tie");
1971 assert_eq!(at_set_times("x", "X").set_takes("", &[]), Err(TakeProblem::None));
1973 assert_eq!(at_set_times("x", "X").set_takes("", &[take(" 8h ", "")]), Err(TakeProblem::Unreadable("8h".into())));
1974 assert_eq!(at_set_times("x", "X").set_takes("", &[take("08:00", "1"), take("8:00", "2")]), Err(TakeProblem::Twice("08:00".into())));
1975 iron.set_takes("2 tablets", &[take("08:00", ""), take("20:00", "")]).unwrap();
1977 assert_eq!((iron.dose.as_str(), iron.takes()), ("2 tablets", vec![take("08:00", "2 tablets"), take("20:00", "2 tablets")]));
1978 }
1979
1980 #[test]
1981 fn a_prescription_with_three_medicines_and_several_takes_each() {
1982 let mut levo = at_set_times("levo", "Levothyroxine");
1983 levo.set_takes("75 µg", &[take("07:30", "")]).unwrap();
1984 let mut iron = at_set_times("iron", "Iron");
1985 iron.set_takes("1 tablet", &[take("08:00", ""), take("13:00", ""), take("20:00", "2 tablets")]).unwrap();
1986 let mut magnesium = at_set_times("magnesium", "Magnesium");
1987 magnesium.set_takes("300 mg", &[take("12:30", "150 mg"), take("21:30", "")]).unwrap();
1988 let mut health = Health {
1989 prescriptions: vec![Prescription { id: "dr".into(), title: "Thyroid and iron".into(), refill_days: Some(30), last_refill: Some("2026-10-01".parse().unwrap()), ..Prescription::default() }],
1990 medicines: vec![levo, iron, magnesium],
1991 ..Health::default()
1992 };
1993 for medicine in &mut health.medicines {
1994 medicine.prescription = Some("dr".into());
1995 }
1996 assert_eq!(
1997 day_of(&health, "2026-10-08"),
1998 said(&[("07:30", "Levothyroxine", "75 µg"), ("08:00", "Iron", "1 tablet"), ("12:30", "Magnesium", "150 mg"), ("13:00", "Iron", "1 tablet"), ("20:00", "Iron", "2 tablets"), ("21:30", "Magnesium", "300 mg")])
1999 );
2000 let again: Health = toml::from_str(&toml::to_string(&health).unwrap()).unwrap();
2002 assert_eq!(again, health);
2003 health.medicines.retain(|m| m.id != "iron");
2005 assert_eq!(day_of(&health, "2026-10-08"), said(&[("07:30", "Levothyroxine", "75 µg"), ("12:30", "Magnesium", "150 mg"), ("21:30", "Magnesium", "300 mg")]));
2006 assert_eq!(health.errands().len(), 1, "its pharmacy, as before");
2007 }
2008
2009 #[test]
2010 fn an_older_file_reads_as_before() {
2011 let text = "[[medicine]]\nid = \"iron\"\nname = \"Iron\"\ndose = \"1 tablet\"\n\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\", \"20:00\"]\n";
2013 let health: Health = toml::from_str(text).unwrap();
2014 assert_eq!(health.medicines[0].takes(), [take("08:00", "1 tablet"), take("20:00", "1 tablet")]);
2015 assert_eq!(day_of(&health, "2026-10-08"), said(&[("08:00", "Iron", "1 tablet"), ("20:00", "Iron", "1 tablet")]));
2016 let written = toml::to_string(&health).unwrap();
2018 assert!(!written.contains("amounts"), "{written}");
2019 assert_eq!(toml::from_str::<older::Health>(&written).unwrap(), toml::from_str::<older::Health>(text).unwrap());
2020 }
2021
2022 #[test]
2023 fn an_older_sioul_reads_the_times_and_says_every_amount() {
2024 let mut iron = at_set_times("iron", "Iron");
2025 iron.set_takes("1 tablet", &[take("08:00", ""), take("20:00", "2 tablets")]).unwrap();
2026 let health = Health { medicines: vec![iron], ..Health::default() };
2027 let written = toml::to_string(&health).unwrap();
2028 let old: older::Health = toml::from_str(&written).unwrap();
2030 assert_eq!(old.medicines[0].schedule, older::Schedule::Day { times: vec!["08:00".into(), "20:00".into()] });
2031 assert_eq!(old.medicines[0].dose, "08:00 · 1 tablet, 20:00 · 2 tablets");
2032 let back: Health = toml::from_str(&toml::to_string(&old).unwrap()).unwrap();
2034 assert_eq!(back.medicines[0].schedule, Schedule::Day { times: vec!["08:00".into(), "20:00".into()], amounts: BTreeMap::new() });
2035 let (start, end) = (at("2026-10-08T00:00[Europe/Paris]"), at("2026-10-10T00:00[Europe/Paris]"));
2036 let due = |h: &Health| h.doses(&start, &end).into_iter().map(|d| (d.key, d.at)).collect::<Vec<_>>();
2037 assert_eq!(due(&back), due(&health));
2038 assert!(back.doses(&start, &end).iter().all(|d| d.dose == "08:00 · 1 tablet, 20:00 · 2 tablets"));
2039 }
2040
2041 #[test]
2042 fn a_take_moved_by_the_change_of_hour_keeps_its_amount() {
2043 let mut night = at_set_times("night", "Night drops");
2045 night.set_takes("1 drop", &[take("02:30", "3 drops"), take("08:00", "")]).unwrap();
2046 let health = Health { medicines: vec![night], ..Health::default() };
2047 assert_eq!(day_of(&health, "2026-03-29"), said(&[("03:30", "Night drops", "3 drops"), ("08:00", "Night drops", "1 drop")]));
2048 let zone = jiff::tz::TimeZone::get("Europe/Paris").unwrap();
2049 let doses = health.doses(&at("2026-03-29T00:00[Europe/Paris]"), &at("2026-03-30T00:00[Europe/Paris]"));
2050 assert_eq!(health.amount_of(&doses[0].key, &zone).as_deref(), Some("3 drops"));
2051 assert_eq!(health.amount_of(&doses[1].key, &zone).as_deref(), Some("1 drop"));
2052 assert_eq!(health.amount_of(&format!("night@{}", at("2026-03-30T02:30[Europe/Paris]").timestamp().as_second() + 60), &zone).as_deref(), Some(""));
2054 assert_eq!(health.amount_of("gone@1800000000", &zone), None);
2055 }
2056
2057 #[test]
2061 fn generic_name_and_strength_written_only_when_given() {
2062 let mut levo = at_set_times("levo", "Thyrolan");
2063 levo.set_takes("1 tablet", &[take("07:30", "")]).unwrap();
2064 let plain = toml::to_string(&Health { medicines: vec![levo.clone()], ..Health::default() }).unwrap();
2066 assert!(!plain.contains("generic") && !plain.contains("strength") && !plain.contains("since"), "{plain}");
2067 assert_eq!(levo.precise(), "Thyrolan");
2068 levo.generic = "levothyroxine".into();
2069 levo.strength = "75 µg".into();
2070 levo.since = Some("2026-03-02".parse().unwrap());
2071 let health = Health { medicines: vec![levo.clone()], ..Health::default() };
2072 let written = toml::to_string(&health).unwrap();
2073 let again: Health = toml::from_str(&written).unwrap();
2074 assert_eq!(again, health, "read back as written");
2075 assert_eq!(levo.precise(), "Thyrolan — levothyroxine 75 µg");
2076 assert_eq!(levo.short_name(), "Thyrolan");
2077 assert_eq!(Medicine { name: "Levothyroxine".into(), ..levo.clone() }.precise(), "levothyroxine 75 µg");
2079 assert_eq!(day_of(&health, "2026-10-08"), said(&[("07:30", "Thyrolan", "1 tablet")]));
2081 let old: older::Health = toml::from_str(&written).unwrap();
2083 assert_eq!((old.medicines[0].name.as_str(), old.medicines[0].dose.as_str()), ("Thyrolan", "1 tablet"));
2084 let back: Health = toml::from_str(&toml::to_string(&old).unwrap()).unwrap();
2085 assert_eq!((back.medicines[0].generic.as_str(), back.medicines[0].strength.as_str(), back.medicines[0].since), ("", "", None));
2086 let (start, end) = (at("2026-10-08T00:00[Europe/Paris]"), at("2026-10-10T00:00[Europe/Paris]"));
2087 let due = |h: &Health| h.doses(&start, &end).into_iter().map(|d| (d.key, d.at, d.name, d.dose)).collect::<Vec<_>>();
2088 assert_eq!(due(&back), due(&health));
2089 let by_hand = "[[medicine]]\nid = \"m\"\nname = \"\"\ngeneric = \"metformin\"\nstrength = \"500 mg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\"]\n";
2091 let hand: Health = toml::from_str(by_hand).unwrap();
2092 assert_eq!((hand.medicines[0].short_name().as_str(), hand.medicines[0].precise().as_str()), ("metformin", "metformin 500 mg"));
2093 assert_eq!(day_of(&hand, "2026-10-08")[0].1, "metformin");
2094 }
2095
2096 #[test]
2103 fn a_record_of_no_bytes_or_gone_with_its_folder_is_lost_never_empty() {
2104 let dir = std::env::temp_dir().join(format!("sioul-record-lost-{}", std::process::id()));
2105 let _ = std::fs::remove_dir_all(&dir);
2106 let (state, data) = (dir.join("state"), dir.join("data"));
2107 APART.with(|apart| *apart.borrow_mut() = Some(data.clone()));
2108 let path = state.join("health-state.toml");
2109 assert_eq!(HealthState::read(&path), Ok(HealthState::default()), "never written: nothing lost");
2110 HealthState::update(&path, DUE, |s| {
2111 s.taken.insert("iron@1800000000".into(), DUE);
2112 })
2113 .unwrap();
2114 std::fs::write(&path, "").unwrap();
2115 assert_eq!(HealthState::read(&path), Err(Unsound::Lost), "of no bytes, written before");
2116 std::fs::remove_dir_all(&state).unwrap();
2117 assert_eq!(HealthState::read(&path), Err(Unsound::Lost), "the state folder wiped, the witness apart kept");
2118 HealthState::set_aside(&path, DUE).unwrap();
2119 assert_eq!(HealthState::read(&path), Ok(HealthState::default()), "set aside: a new record starts");
2120 let records = state.join("health-doses.toml");
2121 crate::doses::DoseRecords::update(&records, DUE, |r| r.open("iron@1800000000", "desk-id", DUE)).unwrap();
2122 std::fs::write(&records, "").unwrap();
2123 assert_eq!(crate::doses::DoseRecords::read(&records), Err(Unsound::Lost));
2124 std::fs::remove_dir_all(&state).unwrap();
2125 assert_eq!(crate::doses::DoseRecords::read(&records), Err(Unsound::Lost));
2126 APART.with(|apart| *apart.borrow_mut() = None);
2127 let _ = std::fs::remove_dir_all(&dir);
2128 }
2129
2130 #[test]
2134 fn a_move_lost_to_an_edit_elsewhere_is_mended() {
2135 let start = at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second();
2136 let mut health = Health {
2137 medicines: vec![Medicine { id: "antibiotic".into(), name: "Antibiotic".into(), dose: "1 tablet, with food".into(), schedule: Schedule::Hours { hours: 8, from: start }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None }],
2138 ..Health::default()
2139 };
2140 let mut state = HealthState::default();
2141 let late = start + 90 * 60;
2142 state.moved.insert(format!("antibiotic@{start}"), [start, late + 8 * 3600]);
2143 assert!(health.mend_shifts(&state));
2144 assert!(matches!(health.medicines[0].schedule, Schedule::Hours { from, .. } if from == late + 8 * 3600));
2145 assert_eq!(health.medicines[0].dose, "1 tablet, with food", "its other changes kept");
2146 assert!(!health.mend_shifts(&state), "mended once");
2147 let next = late + 8 * 3600;
2148 state.moved.insert(format!("antibiotic@{next}"), [next, next + 3600 + 8 * 3600]);
2149 health.medicines[0].schedule = Schedule::Hours { hours: 8, from: start };
2150 assert!(health.mend_shifts(&state));
2151 assert!(matches!(health.medicines[0].schedule, Schedule::Hours { from, .. } if from == next + 3600 + 8 * 3600), "both moves");
2152 }
2153
2154 #[test]
2158 fn two_moves_from_one_start_mend_to_the_latest_mark() {
2159 let start = at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second();
2160 let medicine = Medicine { id: "antibiotic".into(), name: "Antibiotic".into(), dose: "1 tablet".into(), schedule: Schedule::Hours { hours: 8, from: start }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None };
2161 let mut state = HealthState::default();
2162 let (first, second) = (format!("antibiotic@{start}"), format!("antibiotic@{}", start + 8 * 3600));
2164 state.moved.insert(first.clone(), [start, start + 90 * 60 + 8 * 3600]);
2165 state.moved.insert(second.clone(), [start, start + 8 * 3600 + 45 * 60 + 8 * 3600]);
2166 state.taken.insert(first, start + 90 * 60);
2167 state.taken.insert(second, start + 8 * 3600 + 45 * 60);
2168 let mut health = Health { medicines: vec![medicine.clone()], ..Health::default() };
2169 assert!(health.mend_shifts(&state));
2170 assert!(matches!(health.medicines[0].schedule, Schedule::Hours { from, .. } if from == start + 8 * 3600 + 45 * 60 + 8 * 3600), "the latest mark's move");
2171 let (a, b) = (state.taken.values().copied().min().unwrap(), state.taken.values().copied().max().unwrap());
2173 for taken in state.taken.values_mut() {
2174 *taken = if *taken == a { b } else { a };
2175 }
2176 let mut health = Health { medicines: vec![medicine], ..Health::default() };
2177 assert!(health.mend_shifts(&state));
2178 assert!(matches!(health.medicines[0].schedule, Schedule::Hours { from, .. } if from == start + 90 * 60 + 8 * 3600), "the latest mark's move, whatever its key");
2179 }
2180}