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}
597
598#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
601pub struct Said {
602 pub started: i64,
605 pub closed: i64,
607 pub working: bool,
609 pub exported: i64,
612 pub imported: i64,
614 pub doses: bool,
616 pub left: bool,
618 pub phone: bool,
620}
621
622#[derive(Debug, Clone, Default, PartialEq, Eq)]
624pub struct Named {
625 pub id: String,
626 pub name: String,
627 pub phone: bool,
628}
629
630#[derive(Debug, Clone, PartialEq, Eq)]
632pub enum Doubt {
633 Record { since: i64 },
635 Unheard { device: Named, until: i64, closed: bool },
639 Broken { device: Named },
641 Working { device: Named, shared: i64, quiet: bool },
645 Coming { device: Named, shared: i64 },
647 Apart { device: Named },
650}
651
652pub const NEWS_IN: i64 = 3 * 60;
656
657pub const FRESH: i64 = 5 * 60;
661
662pub const SKEW: i64 = 2 * 60;
666
667pub const QUIET: i64 = 60 * 60;
670
671pub const BROKEN_SPAN: i64 = 12 * 3600;
674
675pub const SILENT_DAYS: i64 = 7;
679
680pub fn counts(peer: &Peer, now: i64) -> bool {
684 if peer.off {
685 return false;
686 }
687 let silent = SILENT_DAYS * 86_400;
688 match &peer.said {
689 Some(said) if said.left => false,
690 Some(said) => now - peer.seen <= silent || now - said.exported <= silent,
691 None => now - peer.heard < silent,
692 }
693}
694
695pub fn doubts_now(due: i64, now: i64, record_lost: Option<i64>, peers: &[Peer]) -> Vec<Doubt> {
716 let mut out = Vec::new();
717 if let Some(since) = record_lost.filter(|since| due <= *since && *since - due < 86_400) {
719 out.push(Doubt::Record { since });
720 }
721 for peer in peers.iter().filter(|p| counts(p, now)) {
722 let device = Named { id: peer.id.clone(), name: peer.name.clone(), phone: peer.said.as_ref().is_some_and(|s| s.phone) };
723 if peer.broken.is_some_and(|at| at >= due - BROKEN_SPAN) {
728 out.push(Doubt::Broken { device });
729 continue;
730 }
731 let woke = peer.wake.is_none_or(|wake| now - due >= wake + SKEW);
739 let Some(said) = &peer.said else {
740 let closed = peer.closed && peer.heard <= peer.known_until;
741 let quiet = closed && peer.delay.is_some_and(|d| d <= NEWS_IN) && (peer.known_until >= due + SKEW || woke);
742 let fresh = peer.known_until >= due && now - peer.known_until <= FRESH;
743 if !quiet && !fresh {
744 out.push(Doubt::Unheard { device, until: peer.known_until, closed });
745 }
746 continue;
747 };
748 if !peer.complete {
750 out.push(Doubt::Coming { device, shared: said.exported });
751 continue;
752 }
753 let closed = !said.working && said.closed >= said.started;
754 let margin = SKEW + peer.ahead.max(0);
755 if !said.doses {
756 if !(closed && said.closed + SKEW < due) {
760 out.push(Doubt::Apart { device });
761 } else if !woke {
762 out.push(Doubt::Unheard { device, until: said.closed, closed: true });
763 }
764 continue;
765 }
766 if closed {
767 if said.closed < due + margin && !woke {
773 out.push(Doubt::Unheard { device, until: said.closed, closed: true });
774 }
775 continue;
776 }
777 let after = said.exported >= due + margin;
778 let fresh = now - peer.seen <= FRESH && now - said.exported <= FRESH + margin;
779 if !(after && fresh) {
780 out.push(Doubt::Working { device, shared: said.exported, quiet: now - peer.seen > QUIET });
781 }
782 }
783 out
784}
785
786pub fn doubts(due: i64, now: i64, record_lost: Option<i64>, peers: &[Peer]) -> Vec<Doubt> {
789 doubts_now(due, now, record_lost, peers)
790}
791
792pub fn doubts_unread(due: i64, now: i64, record_lost: Option<i64>, peers: &[Peer], since: i64, heard: &std::collections::BTreeSet<String>) -> Vec<Doubt> {
802 let mut out = doubts_now(due, now, record_lost, peers);
803 for peer in peers.iter().filter(|p| counts(p, now) && !heard.contains(&p.id)) {
804 let device = Named { id: peer.id.clone(), name: peer.name.clone(), phone: peer.said.as_ref().is_some_and(|s| s.phone) };
805 let names = |doubt: &Doubt| match doubt {
806 Doubt::Unheard { device: d, .. } | Doubt::Broken { device: d } | Doubt::Working { device: d, .. } | Doubt::Coming { device: d, .. } | Doubt::Apart { device: d } => *d == device,
807 Doubt::Record { .. } => false,
808 };
809 if out.iter().any(names) {
810 continue;
811 }
812 let until = match &peer.said {
813 Some(said) if said.working => continue,
814 Some(said) => said.closed,
815 None if peer.closed => peer.known_until,
816 None => continue,
817 };
818 out.push(Doubt::Unheard { device, until: until.clamp(1, since.max(1)), closed: true });
819 }
820 out
821}
822
823pub fn known_from(due: i64, from: i64, until: i64, record_lost: Option<i64>, peers: &[Peer]) -> Option<i64> {
830 let mut moments: Vec<i64> = std::iter::once(from).chain(peers.iter().filter_map(|p| p.wake).map(|w| due + w + SKEW)).filter(|t| *t >= from && *t < until).collect();
831 moments.sort_unstable();
832 moments.dedup();
833 moments.into_iter().find(|t| doubts_now(due, *t, record_lost, peers).is_empty())
834}
835
836pub fn known_until(due: i64, from: i64, until: i64, record_lost: Option<i64>, peers: &[Peer]) -> i64 {
841 let doubted = |at: i64| !doubts_now(due, at, record_lost, peers).is_empty();
842 if doubted(from) {
843 return 0;
844 }
845 let mut ends: Vec<i64> = Vec::new();
846 for peer in peers {
847 match &peer.said {
848 Some(said) => {
849 let margin = SKEW + peer.ahead.max(0);
850 ends.push(peer.seen + FRESH + 1);
851 ends.push(said.exported + FRESH + margin + 1);
852 }
853 None => ends.push(peer.known_until + FRESH + 1),
854 }
855 }
856 ends.retain(|t| *t > from && *t < until);
857 ends.sort_unstable();
858 ends.dedup();
859 ends.into_iter().find(|t| doubted(*t)).unwrap_or(until)
860}
861
862pub(crate) fn keep_private(path: &Path) {
865 #[cfg(unix)]
866 {
867 use std::os::unix::fs::PermissionsExt;
868 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
869 }
870 #[cfg(not(unix))]
871 let _ = path;
872}
873
874pub(crate) fn witnesses(path: &Path) -> Vec<PathBuf> {
883 let name = path.file_name().map(|n| format!(".{}.written", n.to_string_lossy())).unwrap_or_default();
884 let mut all = vec![path.with_file_name(&name)];
885 #[cfg(test)]
886 let apart = APART.with(|apart| apart.borrow().as_ref().map(|dir| dir.join(&name)));
887 #[cfg(not(test))]
888 let apart = (path.parent() == Some(crate::config::state_dir().as_path())).then(|| crate::config::data_dir().join(&name));
889 all.extend(apart.filter(|apart| !all.contains(apart)));
890 all
891}
892
893#[cfg(test)]
894thread_local! {
895 pub(crate) static APART: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
897}
898
899pub fn witnessed(path: &Path) -> bool {
901 witnesses(path).iter().any(|witness| witness.exists())
902}
903
904pub fn write_witnessed(path: &Path, bytes: &[u8]) -> Result<(), String> {
909 use std::io::Write;
910 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
911 let parent = path.parent().unwrap_or(Path::new("."));
912 std::fs::create_dir_all(parent).map_err(fail)?;
913 let temporary = path.with_extension("toml.new");
915 let mut file = std::fs::File::create(&temporary).map_err(fail)?;
916 file.write_all(bytes).and_then(|()| file.sync_all()).map_err(fail)?;
917 drop(file);
918 keep_private(&temporary);
919 std::fs::rename(&temporary, path).map_err(fail)?;
920 #[cfg(unix)]
921 let _ = std::fs::File::open(parent).and_then(|dir| dir.sync_all());
922 for witness in witnesses(path) {
923 if !witness.exists() {
924 if let Some(folder) = witness.parent() {
925 let _ = std::fs::create_dir_all(folder);
926 }
927 std::fs::write(&witness, "").map_err(fail)?;
928 }
929 }
930 Ok(())
931}
932
933#[derive(Debug, Clone, PartialEq)]
935pub struct Dose {
936 pub key: String,
938 pub medicine: String,
939 pub name: String,
940 pub dose: String,
941 pub at: Zoned,
942}
943
944#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
945#[serde(rename_all = "lowercase")]
946pub enum ErrandKind {
947 Refill,
949 Renew,
951}
952
953#[derive(Debug, Clone, PartialEq, Eq)]
955pub struct Errand {
956 pub key: String,
958 pub prescription: String,
959 pub kind: ErrandKind,
960 pub day: Date,
962 pub title: String,
963}
964
965#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
968pub struct HealthState {
969 #[serde(default)]
971 pub taken: BTreeMap<String, i64>,
972 #[serde(default)]
974 pub reminded: BTreeMap<String, i64>,
975 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
981 pub unshown: BTreeMap<String, i64>,
982 #[serde(default)]
984 pub not_taken: BTreeMap<String, i64>,
985 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
988 pub moved: BTreeMap<String, [i64; 2]>,
989 #[serde(default)]
991 pub errands: BTreeMap<String, String>,
992 #[serde(default, deserialize_with = "crate::budget::dates::optional")]
994 pub chat_day: Option<Date>,
995 #[serde(default)]
996 pub chat_minutes: u32,
997 #[serde(default, skip_serializing_if = "Option::is_none")]
998 pub chats_locked_until: Option<i64>,
999}
1000
1001#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1003pub enum Unsound {
1004 Unreadable,
1006 Lost,
1008}
1009
1010#[derive(Debug, Clone, PartialEq, Eq)]
1012pub enum Problem {
1013 Unsound(Unsound),
1015 Write(String),
1017}
1018
1019impl HealthState {
1020 pub fn default_path() -> PathBuf {
1021 crate::config::state_dir().join("health-state.toml")
1022 }
1023
1024 pub fn read(path: &Path) -> Result<HealthState, Unsound> {
1031 match std::fs::read_to_string(path) {
1032 Ok(text) if text.trim().is_empty() && witnessed(path) => Err(Unsound::Lost),
1033 Ok(text) => toml::from_str(&text).map_err(|_| Unsound::Unreadable),
1034 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1035 if witnessed(path) {
1036 Err(Unsound::Lost)
1037 } else {
1038 Ok(HealthState::default())
1039 }
1040 }
1041 Err(_) => Err(Unsound::Unreadable),
1042 }
1043 }
1044
1045 pub fn update<T>(path: &Path, now: i64, change: impl FnOnce(&mut HealthState) -> T) -> Result<T, Problem> {
1049 crate::filelock::with_lock(path, || {
1050 let mut state = HealthState::read(path).map_err(Problem::Unsound)?;
1051 let out = change(&mut state);
1052 state.save(path, now).map_err(Problem::Write)?;
1053 Ok(out)
1054 })
1055 }
1056
1057 pub fn set_aside(path: &Path, now: i64) -> Result<Option<PathBuf>, String> {
1061 crate::filelock::with_lock(path, || {
1062 for witness in witnesses(path) {
1063 let _ = std::fs::remove_file(witness);
1064 }
1065 if !path.exists() {
1066 return Ok(None);
1067 }
1068 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
1069 let aside = path.with_file_name(format!("{name}.unreadable-{now}"));
1070 std::fs::rename(path, &aside).map_err(|e| format!("{}: {e}", path.display()))?;
1071 Ok(Some(aside))
1072 })
1073 }
1074
1075 pub fn save(&mut self, path: &Path, now: i64) -> Result<(), String> {
1077 let week = now - 7 * 86_400;
1078 self.taken.retain(|_, at| *at >= week);
1079 self.reminded.retain(|_, at| *at >= week);
1080 self.unshown.retain(|_, at| *at >= week);
1081 self.not_taken.retain(|_, at| *at >= week);
1082 self.moved.retain(|key, _| self.taken.contains_key(key));
1083 write_witnessed(path, toml::to_string(self).map_err(|e| e.to_string())?.as_bytes())
1084 }
1085
1086 pub fn to_remind(&self, health: &Health, now: &Zoned, minutes: i64) -> Vec<Dose> {
1088 let start = now.checked_sub(Span::new().minutes(minutes)).unwrap_or_else(|_| now.clone());
1089 let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1090 health.doses(&start, &end).into_iter().filter(|d| !self.taken.contains_key(&d.key) && !self.reminded.contains_key(&d.key)).collect()
1091 }
1092
1093 pub fn unanswered(&self, health: &Health, now: &Zoned, hours: i64, grace: i64) -> Vec<Dose> {
1099 let start = now.checked_sub(Span::new().hours(hours)).unwrap_or_else(|_| now.clone());
1100 let end = now.checked_sub(Span::new().minutes(grace)).unwrap_or_else(|_| now.clone());
1101 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()
1102 }
1103
1104 pub fn reminder_unshown(&self, key: &str) -> bool {
1108 self.unshown.contains_key(key) && !self.reminded.contains_key(key)
1109 }
1110
1111 pub fn due_today(&self, health: &Health, now: &Zoned, hours: i64, grace: i64) -> Vec<Dose> {
1117 let midnight = now.date().to_zoned(now.time_zone().clone()).unwrap_or_else(|_| now.clone());
1118 let back = now.checked_sub(Span::new().hours(hours)).unwrap_or_else(|_| now.clone());
1119 let start = if back > midnight { back } else { midnight };
1120 let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1121 let apart: Vec<String> = self.unanswered(health, now, hours, grace).into_iter().map(|d| d.key).collect();
1122 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()
1123 }
1124
1125 pub fn chat_minute(&mut self, limit: &ChatLimit, now: &Zoned) -> bool {
1128 if self.chat_day != Some(now.date()) {
1129 self.chat_day = Some(now.date());
1130 self.chat_minutes = 0;
1131 }
1132 if self.chats_covered(now) {
1133 return true;
1134 }
1135 self.chat_minutes += 1;
1136 if limit.enabled && limit.minutes > 0 && self.chat_minutes >= limit.minutes {
1137 self.chats_locked_until = Some(now.timestamp().as_second() + i64::from(limit.locked_minutes.max(1)) * 60);
1138 self.chat_minutes = 0;
1139 return true;
1140 }
1141 false
1142 }
1143
1144 pub fn chats_covered(&self, now: &Zoned) -> bool {
1146 self.chats_locked_until.is_some_and(|until| until > now.timestamp().as_second())
1147 }
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152 use super::*;
1153
1154 fn at(text: &str) -> Zoned {
1155 text.parse().unwrap()
1156 }
1157
1158 #[test]
1159 fn doses_by_the_day_the_days_and_the_hours() {
1160 let noon_and_six = Schedule::Day { times: vec!["18:00".into(), "12:00".into()], amounts: BTreeMap::new() };
1161 let doses = noon_and_six.doses(&at("2026-10-03T13:00[Europe/Paris]"), &at("2026-10-05T00:00[Europe/Paris]"));
1162 let shown: Vec<String> = doses.iter().map(|z| z.strftime("%d %H:%M").to_string()).collect();
1163 assert_eq!(shown, vec!["03 18:00", "04 12:00", "04 18:00"]);
1164 let other = Schedule::Days { days: 2, time: "08:00".into(), from: "2026-10-04".parse().unwrap() };
1166 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();
1167 assert_eq!(shown, vec!["04", "06", "08"]);
1168 let six = Schedule::Hours { hours: 6, from: at("2026-10-03T18:30[Europe/Paris]").timestamp().as_second() };
1170 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();
1171 assert_eq!(shown, vec!["00:30", "06:30", "12:30"]);
1172 }
1173
1174 #[test]
1175 fn every_few_hours_the_hours_between_doses_are_kept() {
1176 let eight = || Health {
1177 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 }],
1178 ..Health::default()
1179 };
1180 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() };
1181 let key = format!("antibiotic@{}", at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second());
1182 let mut health = eight();
1184 let (before, after) = health.taken_at(&key, at("2026-10-05T09:30[Europe/Paris]").timestamp().as_second()).unwrap();
1185 assert_eq!(times(&health), ["17:30", "01:30", "09:30"]);
1186 assert!(health.taken_back(&key, before, after));
1188 assert_eq!(times(&health), ["16:00", "00:00", "08:00"]);
1189 health.taken_at(&key, at("2026-10-05T07:00[Europe/Paris]").timestamp().as_second()).unwrap();
1191 assert_eq!(times(&health), ["15:00", "23:00", "07:00"]);
1192 let mut health = eight();
1194 let (before, after) = health.taken_at(&key, at("2026-10-05T09:30[Europe/Paris]").timestamp().as_second()).unwrap();
1195 let later = format!("antibiotic@{}", at("2026-10-05T17:30[Europe/Paris]").timestamp().as_second());
1196 health.taken_at(&later, at("2026-10-05T18:00[Europe/Paris]").timestamp().as_second()).unwrap();
1197 assert!(!health.taken_back(&key, before, after));
1198 assert_eq!(times(&health), ["02:00", "10:00"]);
1199 assert_eq!(eight().taken_at(&key, at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second()), None);
1201 let mut daily = Health { medicines: vec![Medicine { schedule: Schedule::Day { times: vec!["08:00".into()], amounts: BTreeMap::new() }, ..eight().medicines[0].clone() }], ..Health::default() };
1203 assert_eq!(daily.taken_at(&key, at("2026-10-05T09:30[Europe/Paris]").timestamp().as_second()), None);
1204 }
1205
1206 #[test]
1207 fn a_record_that_cannot_be_trusted_is_never_taken_for_empty() {
1208 let dir = std::env::temp_dir().join(format!("sioul-health-record-{}", std::process::id()));
1209 let _ = std::fs::remove_dir_all(&dir);
1210 std::fs::create_dir_all(&dir).unwrap();
1211 let path = dir.join("health-state.toml");
1212 assert_eq!(HealthState::read(&path), Ok(HealthState::default()));
1214 HealthState::update(&path, 1_000, |s| s.taken.insert("d@1".into(), 1_000)).unwrap();
1215 assert!(HealthState::read(&path).unwrap().taken.contains_key("d@1"));
1216 std::fs::write(&path, "taken = { \"d@1\" = ").unwrap();
1218 assert_eq!(HealthState::read(&path), Err(Unsound::Unreadable));
1219 assert_eq!(HealthState::update(&path, 1_060, |s| s.taken.clear()), Err(Problem::Unsound(Unsound::Unreadable)));
1220 assert_eq!(std::fs::read_to_string(&path).unwrap(), "taken = { \"d@1\" = ", "left as it was");
1221 std::fs::remove_file(&path).unwrap();
1223 assert_eq!(HealthState::read(&path), Err(Unsound::Lost));
1224 assert_eq!(HealthState::set_aside(&path, 1_120), Ok(None));
1226 assert_eq!(HealthState::read(&path), Ok(HealthState::default()));
1227 std::fs::write(&path, "nonsense = [").unwrap();
1229 let aside = HealthState::set_aside(&path, 1_180).unwrap().unwrap();
1230 assert_eq!(std::fs::read_to_string(&aside).unwrap(), "nonsense = [");
1231 assert_eq!(HealthState::read(&path), Ok(HealthState::default()));
1232 let _ = std::fs::remove_dir_all(&dir);
1233 }
1234
1235 #[test]
1240 fn an_older_sioul_is_known_as_its_claims_say() {
1241 let due = 1_800_000_000;
1242 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() };
1243 let laptop = Named { id: "laptop-id".into(), name: "laptop".into(), phone: false };
1244 assert!(doubts(due, due + 60, None, &[]).is_empty());
1246 assert!(doubts(due, due + 180, None, &[peer(due + 120, false, Some(30))]).is_empty());
1248 assert_eq!(doubts(due, due + 600, None, &[peer(due - 300, false, Some(30))]), vec![Doubt::Unheard { device: laptop.clone(), until: due - 300, closed: false }]);
1250 assert!(doubts(due, due + 600, None, &[peer(due - 3_600, true, Some(60))]).is_empty());
1252 assert_eq!(doubts(due, due + 600, None, &[peer(due - 3_600, true, Some(1_800))]).len(), 1);
1254 assert_eq!(doubts(due, due + 600, None, &[peer(due - 3_600, true, None)]).len(), 1);
1255 assert_eq!(doubts(due, due + 600, None, &[Peer { name: "phone".into(), heard: due, ..Peer::default() }]).len(), 1);
1257 let broken = Peer { broken: Some(due - 60), ..peer(due + 120, false, Some(30)) };
1259 assert_eq!(doubts(due, due + 180, None, &[broken]), vec![Doubt::Broken { device: laptop.clone() }]);
1260 let gone = Peer { heard: due - 8 * 86_400, ..peer(0, false, None) };
1262 assert!(doubts(due, due + 600, None, &[gone]).is_empty());
1263 assert_eq!(doubts(due, due + 600, Some(due + 300), &[]), vec![Doubt::Record { since: due + 300 }]);
1265 assert!(doubts(due + 900, due + 1_000, Some(due + 300), &[]).is_empty());
1266 let then = peer(due + 60, false, Some(40));
1268 assert!(doubts_now(due, due + 120, None, std::slice::from_ref(&then)).is_empty());
1269 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 }]);
1270 let back = Peer { heard: due + 3_000, ..peer(due - 600, true, Some(60)) };
1272 assert_eq!(doubts_now(due, due + 3_600, None, &[back]), vec![Doubt::Unheard { device: laptop, until: due - 600, closed: false }]);
1273 }
1274
1275 const DUE: i64 = 1_800_000_000;
1277
1278 fn phone(said: Said, seen: i64) -> Peer {
1281 Peer { name: "phone".into(), id: "phone-id".into(), heard: said.exported, said: Some(Said { phone: true, doses: true, ..said }), complete: true, seen, ..Peer::default() }
1282 }
1283
1284 fn the_phone() -> Named {
1285 Named { id: "phone-id".into(), name: "phone".into(), phone: true }
1286 }
1287
1288 #[test]
1289 fn the_phone_closed_cleanly_before_the_dose_is_known() {
1290 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);
1292 for now in [DUE, DUE + 3_600, DUE + 12 * 3_600] {
1294 assert!(doubts_now(DUE, now, None, std::slice::from_ref(&p)).is_empty(), "{now}");
1295 }
1296 assert_eq!(known_until(DUE, DUE + 60, DUE + 86_400, None, &[p]), DUE + 86_400, "nothing ends it but the day");
1297 }
1298
1299 #[test]
1306 fn a_device_closed_before_the_dose_is_known_once_its_opening_could_have_come() {
1307 let closed = phone(Said { started: DUE - 7_200, closed: DUE - 3_600, working: false, exported: DUE - 3_605, ..Said::default() }, DUE - 3_500);
1308 let doubt = vec![Doubt::Unheard { device: the_phone(), until: DUE - 3_600, closed: true }];
1309 assert!(doubts_now(DUE, DUE + 10, None, std::slice::from_ref(&closed)).is_empty(), "none measured: as before");
1310 let quick = Peer { wake: Some(40), ..closed.clone() };
1312 assert_eq!(doubts_now(DUE, DUE + 10, None, std::slice::from_ref(&quick)), doubt);
1313 assert_eq!(doubts_now(DUE, DUE + 40 + SKEW - 1, None, std::slice::from_ref(&quick)), doubt);
1314 assert!(doubts_now(DUE, DUE + 40 + SKEW, None, std::slice::from_ref(&quick)).is_empty());
1315 let slow = Peer { wake: Some(240), ..closed.clone() };
1317 assert_eq!(doubts_now(DUE, DUE + 240, None, std::slice::from_ref(&slow)), doubt);
1318 assert!(doubts_now(DUE, DUE + 240 + SKEW, None, std::slice::from_ref(&slow)).is_empty());
1319 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");
1320 assert_eq!(known_until(DUE, DUE + 240 + SKEW, DUE + 86_400, None, std::slice::from_ref(&slow)), DUE + 86_400);
1321 let after = Peer { wake: Some(240), ..phone(Said { started: DUE - 600, closed: DUE + 600, working: false, exported: DUE + 595, ..Said::default() }, DUE + 620) };
1323 assert!(doubts_now(DUE, DUE + 630, None, &[after]).is_empty());
1324 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() };
1326 assert_eq!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&older)).len(), 1);
1327 assert!(doubts_now(DUE, DUE + 120 + SKEW, None, std::slice::from_ref(&older)).is_empty());
1328 let apart = Peer { said: slow.said.clone().map(|s| Said { doses: false, ..s }), ..slow };
1330 assert_eq!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&apart)), doubt);
1331 assert!(doubts_now(DUE, DUE + 240 + SKEW, None, &[apart]).is_empty());
1332 }
1333
1334 #[test]
1335 fn the_phone_closed_cleanly_after_the_dose_without_marking_is_known() {
1336 let p = phone(Said { started: DUE - 600, closed: DUE + 1_200, working: false, exported: DUE + 1_195, ..Said::default() }, DUE + 1_260);
1338 assert!(doubts_now(DUE, DUE + 1_300, None, std::slice::from_ref(&p)).is_empty());
1339 let behind = Peer { complete: false, ..p };
1341 assert_eq!(doubts_now(DUE, DUE + 1_300, None, &[behind]), vec![Doubt::Coming { device: the_phone(), shared: DUE + 1_195 }]);
1342 }
1343
1344 #[test]
1350 fn with_no_news_since_the_alarm_a_device_known_closed_is_a_doubt() {
1351 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);
1352 assert!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&closed)).is_empty(), "closed before the dose, its news read: known");
1353 let none = std::collections::BTreeSet::new();
1354 let unread = doubts_unread(DUE, DUE + 60, None, std::slice::from_ref(&closed), DUE + 30, &none);
1355 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");
1356 let heard: std::collections::BTreeSet<String> = ["phone-id".to_string()].into();
1357 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");
1358 let working = phone(Said { started: DUE - 600, closed: DUE - 7_200, working: true, exported: DUE + 180, ..Said::default() }, DUE + 200);
1359 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");
1360 let stale = phone(Said { started: DUE - 3_600, working: true, exported: DUE - 900, ..Said::default() }, DUE - 870);
1361 assert_eq!(doubts_unread(DUE, DUE + 60, None, std::slice::from_ref(&stale), DUE + 30, &none).len(), 1, "doubted already: once");
1362 }
1363
1364 #[test]
1365 fn the_phone_in_use_and_exported_after_the_dose_is_known() {
1366 let p = phone(Said { started: DUE - 600, closed: DUE - 7_200, working: true, exported: DUE + 180, ..Said::default() }, DUE + 200);
1368 assert!(doubts_now(DUE, DUE + 240, None, std::slice::from_ref(&p)).is_empty());
1369 assert_eq!(known_until(DUE, DUE + 240, DUE + 86_400, None, std::slice::from_ref(&p)), DUE + 200 + FRESH + 1);
1371 assert_eq!(doubts_now(DUE, DUE + 200 + FRESH + 1, None, &[p]), vec![Doubt::Working { device: the_phone(), shared: DUE + 180, quiet: false }]);
1372 }
1373
1374 #[test]
1375 fn the_phone_in_use_not_exported_since_the_dose_is_uncertain_naming_it() {
1376 let p = phone(Said { started: DUE - 3_600, closed: DUE - 7_200, working: true, exported: DUE - 900, ..Said::default() }, DUE - 870);
1378 assert_eq!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&p)), vec![Doubt::Working { device: the_phone(), shared: DUE - 900, quiet: false }]);
1379 let said = p.said.clone().unwrap();
1380 let early = phone(Said { exported: DUE + 60, ..said.clone() }, DUE + 70);
1382 assert_eq!(doubts_now(DUE, DUE + 80, None, &[early]).len(), 1);
1383 let later = phone(Said { exported: DUE + 180, ..said }, DUE + 190);
1385 assert!(doubts_now(DUE, DUE + 200, None, &[later]).is_empty());
1386 }
1387
1388 #[test]
1389 fn the_phone_that_crashed_is_uncertain_until_it_starts_again_or_is_said_off() {
1390 let crashed = phone(Said { started: DUE - 1_800, closed: DUE - 86_400, working: true, exported: DUE - 900, ..Said::default() }, DUE - 880);
1392 assert_eq!(doubts_now(DUE, DUE + 600, None, std::slice::from_ref(&crashed)), vec![Doubt::Working { device: the_phone(), shared: DUE - 900, quiet: false }]);
1393 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 }]);
1395 let off = Peer { off: true, ..crashed };
1397 assert!(doubts_now(DUE, DUE + 4 * 3_600, None, &[off]).is_empty());
1398 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);
1400 assert!(doubts_now(DUE, DUE + 4 * 3_600 + 90, None, &[back]).is_empty());
1401 }
1402
1403 #[test]
1404 fn clocks_a_few_minutes_apart_either_way() {
1405 let early = Peer { ahead: 180, ..phone(Said { started: DUE - 3_600, working: true, exported: DUE + 120, ..Said::default() }, DUE - 60) };
1408 assert_eq!(doubts_now(DUE, DUE, None, &[early]).len(), 1, "its 08:02 is 07:59 here");
1409 let later = Peer { ahead: 180, ..phone(Said { started: DUE - 3_600, working: true, exported: DUE + 360, ..Said::default() }, DUE + 190) };
1411 assert!(doubts_now(DUE, DUE + 200, None, &[later]).is_empty());
1412 let behind = phone(Said { started: DUE - 3_600, working: true, exported: DUE + 60, ..Said::default() }, DUE + 250);
1414 assert_eq!(doubts_now(DUE, DUE + 260, None, &[behind]).len(), 1);
1415 let behind_later = phone(Said { started: DUE - 3_600, working: true, exported: DUE + 300, ..Said::default() }, DUE + 490);
1416 assert!(doubts_now(DUE, DUE + 500, None, &[behind_later]).is_empty());
1417 let closed = Peer { ahead: 600, ..phone(Said { started: DUE + 900, closed: DUE + 1_500, working: false, exported: DUE + 1_490, ..Said::default() }, DUE + 300) };
1419 assert!(doubts_now(DUE, DUE + 400, None, &[closed]).is_empty());
1420 }
1421
1422 #[test]
1423 fn a_device_silent_for_a_week_no_longer_counts() {
1424 let gone = phone(Said { started: DUE - 10 * 86_400, working: true, exported: DUE - 8 * 86_400, ..Said::default() }, DUE - 8 * 86_400 + 30);
1426 assert!(doubts_now(DUE, DUE + 60, None, &[gone.clone()]).is_empty());
1427 let six = phone(Said { started: DUE - 7 * 86_400, working: true, exported: DUE - 6 * 86_400, ..Said::default() }, DUE - 6 * 86_400 + 30);
1429 assert_eq!(doubts_now(DUE, DUE + 60, None, &[six]).len(), 1);
1430 let first = Peer { seen: DUE - 60, ..gone };
1433 assert_eq!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&first)).len(), 1);
1434 assert!(!counts(&first, DUE + 8 * 86_400));
1435 let left = phone(Said { started: DUE - 600, working: false, closed: DUE - 60, exported: DUE - 61, left: true, ..Said::default() }, DUE - 30);
1437 assert!(!counts(&left, DUE));
1438 }
1439
1440 #[test]
1441 fn a_device_that_does_not_share_its_doses_is_known_only_closed_before() {
1442 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() };
1443 let named = Named { id: "tablet-id".into(), name: "tablet".into(), phone: false };
1444 let working = tablet(Said { started: DUE - 600, working: true, exported: DUE + 300, ..Said::default() });
1446 assert_eq!(doubts_now(DUE, DUE + 320, None, &[working]), vec![Doubt::Apart { device: named }]);
1447 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());
1449 assert_eq!(doubts_now(DUE, DUE + 700, None, &[tablet(Said { started: DUE - 600, closed: DUE + 600, exported: DUE + 595, ..Said::default() })]).len(), 1);
1451 }
1452
1453 #[test]
1454 fn each_device_said_apart() {
1455 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() };
1457 let p = phone(Said { started: DUE - 3_600, working: true, exported: DUE - 900, ..Said::default() }, DUE - 880);
1458 let older = Peer { name: "laptop".into(), id: "laptop-id".into(), heard: DUE - 60, ..Peer::default() };
1459 let doubts = doubts_now(DUE, DUE + 60, None, &[desktop, p, older]);
1460 assert_eq!(doubts.len(), 2, "{doubts:?}");
1461 assert!(matches!(&doubts[0], Doubt::Working { device, .. } if device.id == "phone-id"));
1462 assert!(matches!(&doubts[1], Doubt::Unheard { device, until: 0, .. } if device.id == "laptop-id"));
1463 }
1464
1465 #[test]
1466 fn reminded_once_never_counted() {
1467 let health = Health {
1468 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 }],
1469 ..Health::default()
1470 };
1471 let mut state = HealthState::default();
1472 let now = at("2026-10-03T12:03[Europe/Paris]");
1473 let due = state.to_remind(&health, &now, 10);
1474 assert_eq!(due.len(), 1);
1475 state.reminded.insert(due[0].key.clone(), now.timestamp().as_second());
1476 assert!(state.to_remind(&health, &now, 10).is_empty(), "reminded once");
1477 assert!(state.to_remind(&health, &at("2026-10-03T13:00[Europe/Paris]"), 10).is_empty(), "past its window, no second reminder");
1478 }
1479
1480 #[test]
1485 fn a_reminder_not_shown_is_still_asked_about_and_told_apart() {
1486 let health = Health {
1487 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 }],
1488 ..Health::default()
1489 };
1490 let now = at("2026-10-03T12:00[Europe/Paris]");
1491 let key = |when: &str| format!("demo-pill@{}", at(when).timestamp().as_second());
1492 let mut state = HealthState::default();
1493 state.unshown.insert(key("2026-10-03T08:00[Europe/Paris]"), at("2026-10-03T08:01[Europe/Paris]").timestamp().as_second());
1495 let asked: Vec<String> = state.unanswered(&health, &now, 12, 30).into_iter().map(|d| d.key).collect();
1496 assert_eq!(asked, [key("2026-10-03T08:00[Europe/Paris]"), key("2026-10-03T09:00[Europe/Paris]")], "both still asked about");
1497 assert!(state.reminder_unshown(&key("2026-10-03T08:00[Europe/Paris]")));
1498 assert!(!state.reminder_unshown(&key("2026-10-03T09:00[Europe/Paris]")));
1499 let mut later = state.clone();
1501 later.reminded.insert(key("2026-10-03T08:00[Europe/Paris]"), at("2026-10-03T08:05[Europe/Paris]").timestamp().as_second());
1502 assert!(!later.reminder_unshown(&key("2026-10-03T08:00[Europe/Paris]")));
1503 assert_eq!(later.unanswered(&health, &now, 12, 30).len(), 1);
1504 let dir = std::env::temp_dir().join(format!("sioul-unshown-{}", std::process::id()));
1506 let _ = std::fs::remove_dir_all(&dir);
1507 let path = dir.join("health-state.toml");
1508 state.save(&path, now.timestamp().as_second()).unwrap();
1509 let read = HealthState::read(&path).unwrap();
1510 assert!(read.reminder_unshown(&key("2026-10-03T08:00[Europe/Paris]")));
1511 let mut old = read.clone();
1512 old.save(&path, now.timestamp().as_second() + 8 * 86_400).unwrap();
1513 assert!(HealthState::read(&path).unwrap().unshown.is_empty(), "dropped after a week");
1514 std::fs::write(&path, "[taken]\n[reminded]\n").unwrap();
1515 let mut older = HealthState::read(&path).unwrap();
1516 assert!(older.unshown.is_empty());
1517 older.save(&path, now.timestamp().as_second()).unwrap();
1518 assert!(!std::fs::read_to_string(&path).unwrap().contains("unshown"));
1519 let _ = std::fs::remove_dir_all(&dir);
1520 }
1521
1522 #[test]
1523 fn pharmacy_and_doctor_errands() {
1524 let health = Health {
1525 prescriptions: vec![Prescription {
1526 id: "vitamin-d".into(),
1527 title: "Vitamin D 1000 IU".into(),
1528 until: Some("2026-12-31".parse().unwrap()),
1529 refill_days: Some(28),
1530 last_refill: Some("2026-10-01".parse().unwrap()),
1531 ..Prescription::default()
1532 }],
1533 ..Health::default()
1534 };
1535 let errands: Vec<(ErrandKind, String)> = health.errands().into_iter().map(|e| (e.kind, e.day.to_string())).collect();
1536 assert_eq!(errands, vec![(ErrandKind::Refill, "2026-10-27".to_string()), (ErrandKind::Renew, "2026-12-17".to_string())]);
1537 }
1538
1539 #[test]
1540 fn read_as_written_by_hand_and_by_sioul() {
1541 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";
1542 let health: Health = toml::from_str(by_hand).unwrap();
1543 assert_eq!(health.prescriptions[0].until, Some("2026-12-31".parse().unwrap()));
1544 let again: Health = toml::from_str(&toml::to_string(&health).unwrap()).unwrap();
1545 assert_eq!(again, health);
1546 }
1547
1548 #[test]
1549 fn the_watch_s_old_keys_are_read_and_left_aside() {
1550 let dir = std::env::temp_dir().join(format!("sioul-health-watch-{}", std::process::id()));
1552 let _ = std::fs::remove_dir_all(&dir);
1553 std::fs::create_dir_all(&dir).unwrap();
1554 let path = dir.join("health.toml");
1555 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";
1556 std::fs::write(&path, older).unwrap();
1557 let health = Health::load(&path);
1558 assert_eq!((health.errands_list.as_str(), health.medicines.len()), ("local/errands", 1), "read as before");
1559 assert!(!dir.join("health.toml.unreadable").exists(), "never taken for a file that does not read");
1560 health.save(&path).unwrap();
1561 let written = std::fs::read_to_string(&path).unwrap();
1562 assert!(!written.contains("watch"), "{written}");
1563 assert_eq!(Health::load(&path), health);
1564 let _ = std::fs::remove_dir_all(&dir);
1565 }
1566
1567 #[test]
1568 fn a_file_that_no_longer_reads_is_kept_aside() {
1569 let dir = std::env::temp_dir().join(format!("sioul-health-{}", std::process::id()));
1570 let _ = std::fs::remove_dir_all(&dir);
1571 std::fs::create_dir_all(&dir).unwrap();
1572 let path = dir.join("health.toml");
1573 assert_eq!(Health::load(&path), Health::default(), "none yet");
1574 let broken = "[[medicine]]\nid = \"iron\"\nname = \"Iron\"\n[medicine.schedule]\nevery = \"fortnight\"\n";
1575 std::fs::write(&path, broken).unwrap();
1576 assert_eq!(Health::load(&path), Health::default());
1577 Health::default().save(&path).unwrap();
1578 assert_eq!(std::fs::read_to_string(dir.join("health.toml.unreadable")).unwrap(), broken, "what was there is kept");
1579 #[cfg(unix)]
1580 {
1581 use std::os::unix::fs::PermissionsExt;
1582 assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
1583 }
1584 let _ = std::fs::remove_dir_all(&dir);
1585 }
1586
1587 #[test]
1592 fn todays_doses_until_marked() {
1593 let health = Health {
1594 medicines: vec![
1595 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 },
1596 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 },
1597 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 },
1598 ],
1599 ..Health::default()
1600 };
1601 let key = |id: &str, when: &str| format!("{id}@{}", at(when).timestamp().as_second());
1602 let keys = |doses: Vec<Dose>| doses.into_iter().map(|d| d.key).collect::<Vec<_>>();
1603 let mut state = HealthState::default();
1604 state.reminded.insert(key("levo", "2026-10-06T07:30[Europe/Paris]"), at("2026-10-06T07:31[Europe/Paris]").timestamp().as_second());
1606 let noon = at("2026-10-06T12:00:30[Europe/Paris]");
1607 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]")]);
1608 assert_eq!(keys(state.unanswered(&health, &noon, 12, 30)), [key("zinc", "2026-10-06T09:00[Europe/Paris]")]);
1609 assert_eq!(keys(state.due_today(&health, &at("2026-10-06T11:59[Europe/Paris]"), 12, 30)), [key("levo", "2026-10-06T07:30[Europe/Paris]")]);
1611 let mut waiting = state.clone();
1613 waiting.reminded.clear();
1614 assert_eq!(keys(waiting.due_today(&health, &at("2026-10-06T07:45[Europe/Paris]"), 12, 30)), [key("levo", "2026-10-06T07:30[Europe/Paris]")]);
1615 assert!(waiting.due_today(&health, &at("2026-10-06T08:10[Europe/Paris]"), 12, 30).is_empty());
1617 state.taken.insert(key("iron", "2026-10-06T12:00[Europe/Paris]"), noon.timestamp().as_second());
1619 state.not_taken.insert(key("zinc", "2026-10-06T09:00[Europe/Paris]"), noon.timestamp().as_second());
1620 assert_eq!(keys(state.due_today(&health, &noon, 12, 30)), [key("levo", "2026-10-06T07:30[Europe/Paris]")]);
1621 let evening = at("2026-10-06T21:05[Europe/Paris]");
1623 state.reminded.insert(key("iron", "2026-10-06T21:00[Europe/Paris]"), evening.timestamp().as_second());
1624 assert_eq!(keys(state.due_today(&health, &evening, 12, 30)), [key("iron", "2026-10-06T21:00[Europe/Paris]")]);
1625 let night = at("2026-10-07T00:30[Europe/Paris]");
1627 assert!(state.due_today(&health, &night, 12, 30).is_empty());
1628 assert_eq!(keys(state.unanswered(&health, &night, 12, 30)), [key("zinc", "2026-10-06T23:50[Europe/Paris]")]);
1630 }
1631
1632 #[test]
1633 fn chats_covered_after_their_time() {
1634 let limit = ChatLimit { enabled: true, minutes: 2, locked_minutes: 30 };
1635 let mut state = HealthState::default();
1636 let now = at("2026-10-03T20:00[Europe/Paris]");
1637 assert!(!state.chat_minute(&limit, &now));
1638 assert!(state.chat_minute(&limit, &now), "the second minute reaches the limit");
1639 assert!(state.chats_covered(&at("2026-10-03T20:29[Europe/Paris]")));
1640 assert!(!state.chats_covered(&at("2026-10-03T20:31[Europe/Paris]")), "back after the time chosen");
1641 }
1642
1643 mod older {
1647 use jiff::civil::Date;
1648 use serde::{Deserialize, Serialize};
1649
1650 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1651 pub struct Health {
1652 #[serde(rename = "medicine", default)]
1653 pub medicines: Vec<Medicine>,
1654 }
1655
1656 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1657 pub struct Medicine {
1658 pub id: String,
1659 pub name: String,
1660 #[serde(default, skip_serializing_if = "String::is_empty")]
1661 pub dose: String,
1662 pub schedule: Schedule,
1663 #[serde(default, skip_serializing_if = "Option::is_none")]
1664 pub prescription: Option<String>,
1665 #[serde(default, deserialize_with = "crate::budget::dates::optional", skip_serializing_if = "Option::is_none")]
1666 pub until: Option<Date>,
1667 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1668 pub paused: bool,
1669 }
1670
1671 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1672 #[serde(tag = "every", rename_all = "kebab-case")]
1673 pub enum Schedule {
1674 Day {
1675 times: Vec<String>,
1676 },
1677 Days {
1678 days: u32,
1679 time: String,
1680 #[serde(deserialize_with = "crate::budget::dates::required")]
1681 from: Date,
1682 },
1683 Hours {
1684 hours: u32,
1685 from: i64,
1686 },
1687 }
1688 }
1689
1690 fn take(time: &str, amount: &str) -> Take {
1691 Take { time: time.into(), amount: amount.into() }
1692 }
1693
1694 fn at_set_times(id: &str, name: &str) -> Medicine {
1695 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 }
1696 }
1697
1698 fn day_of(health: &Health, day: &str) -> Vec<(String, String, String)> {
1700 let start = at(&format!("{day}T00:00[Europe/Paris]"));
1701 let end = start.checked_add(Span::new().days(1)).unwrap();
1702 health.doses(&start, &end).into_iter().map(|d| (d.at.strftime("%H:%M").to_string(), d.name, d.dose)).collect()
1703 }
1704
1705 fn said(rows: &[(&str, &str, &str)]) -> Vec<(String, String, String)> {
1706 rows.iter().map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string())).collect()
1707 }
1708
1709 #[test]
1710 fn each_take_its_time_and_its_own_amount() {
1711 let mut same = at_set_times("zinc", "Zinc");
1713 same.set_takes("1 tablet", &[take("20:00", ""), take("8:00", "1 tablet")]).unwrap();
1714 assert_eq!(same.dose, "1 tablet");
1715 assert_eq!(same.schedule, Schedule::Day { times: vec!["08:00".into(), "20:00".into()], amounts: BTreeMap::new() });
1716 assert!(!toml::to_string(&Health { medicines: vec![same.clone()], ..Health::default() }).unwrap().contains("amounts"));
1717 let mut iron = at_set_times("iron", "Iron");
1719 iron.set_takes("1 tablet", &[take("08:00", ""), take("20:00", "2 tablets")]).unwrap();
1720 assert_eq!(iron.takes(), [take("08:00", "1 tablet"), take("20:00", "2 tablets")]);
1721 assert_eq!(iron.usual(), "1 tablet");
1722 assert_eq!(iron.takes_line(), "08:00 · 1 tablet, 20:00 · 2 tablets");
1723 assert_eq!(iron.dose, iron.takes_line(), "what an older Sioul says with each dose");
1724 let health = Health { medicines: vec![iron.clone(), same], ..Health::default() };
1725 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")]));
1726 let plain = Health { medicines: vec![Medicine { schedule: Schedule::Day { times: vec!["08:00".into(), "20:00".into()], amounts: BTreeMap::new() }, ..iron.clone() }], ..Health::default() };
1728 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<_>>();
1729 assert_eq!(keys(&health), keys(&plain));
1730 let mut some = at_set_times("d", "Vitamin D");
1732 some.set_takes("", &[take("08:00", ""), take("20:00", "2 drops")]).unwrap();
1733 assert_eq!(some.takes(), [take("08:00", ""), take("20:00", "2 drops")]);
1734 assert_eq!(some.dose, "08:00, 20:00 · 2 drops");
1735 assert_eq!(some.usual(), "", "the earliest on a tie");
1736 assert_eq!(at_set_times("x", "X").set_takes("", &[]), Err(TakeProblem::None));
1738 assert_eq!(at_set_times("x", "X").set_takes("", &[take(" 8h ", "")]), Err(TakeProblem::Unreadable("8h".into())));
1739 assert_eq!(at_set_times("x", "X").set_takes("", &[take("08:00", "1"), take("8:00", "2")]), Err(TakeProblem::Twice("08:00".into())));
1740 iron.set_takes("2 tablets", &[take("08:00", ""), take("20:00", "")]).unwrap();
1742 assert_eq!((iron.dose.as_str(), iron.takes()), ("2 tablets", vec![take("08:00", "2 tablets"), take("20:00", "2 tablets")]));
1743 }
1744
1745 #[test]
1746 fn a_prescription_with_three_medicines_and_several_takes_each() {
1747 let mut levo = at_set_times("levo", "Levothyroxine");
1748 levo.set_takes("75 µg", &[take("07:30", "")]).unwrap();
1749 let mut iron = at_set_times("iron", "Iron");
1750 iron.set_takes("1 tablet", &[take("08:00", ""), take("13:00", ""), take("20:00", "2 tablets")]).unwrap();
1751 let mut magnesium = at_set_times("magnesium", "Magnesium");
1752 magnesium.set_takes("300 mg", &[take("12:30", "150 mg"), take("21:30", "")]).unwrap();
1753 let mut health = Health {
1754 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() }],
1755 medicines: vec![levo, iron, magnesium],
1756 ..Health::default()
1757 };
1758 for medicine in &mut health.medicines {
1759 medicine.prescription = Some("dr".into());
1760 }
1761 assert_eq!(
1762 day_of(&health, "2026-10-08"),
1763 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")])
1764 );
1765 let again: Health = toml::from_str(&toml::to_string(&health).unwrap()).unwrap();
1767 assert_eq!(again, health);
1768 health.medicines.retain(|m| m.id != "iron");
1770 assert_eq!(day_of(&health, "2026-10-08"), said(&[("07:30", "Levothyroxine", "75 µg"), ("12:30", "Magnesium", "150 mg"), ("21:30", "Magnesium", "300 mg")]));
1771 assert_eq!(health.errands().len(), 1, "its pharmacy, as before");
1772 }
1773
1774 #[test]
1775 fn an_older_file_reads_as_before() {
1776 let text = "[[medicine]]\nid = \"iron\"\nname = \"Iron\"\ndose = \"1 tablet\"\n\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\", \"20:00\"]\n";
1778 let health: Health = toml::from_str(text).unwrap();
1779 assert_eq!(health.medicines[0].takes(), [take("08:00", "1 tablet"), take("20:00", "1 tablet")]);
1780 assert_eq!(day_of(&health, "2026-10-08"), said(&[("08:00", "Iron", "1 tablet"), ("20:00", "Iron", "1 tablet")]));
1781 let written = toml::to_string(&health).unwrap();
1783 assert!(!written.contains("amounts"), "{written}");
1784 assert_eq!(toml::from_str::<older::Health>(&written).unwrap(), toml::from_str::<older::Health>(text).unwrap());
1785 }
1786
1787 #[test]
1788 fn an_older_sioul_reads_the_times_and_says_every_amount() {
1789 let mut iron = at_set_times("iron", "Iron");
1790 iron.set_takes("1 tablet", &[take("08:00", ""), take("20:00", "2 tablets")]).unwrap();
1791 let health = Health { medicines: vec![iron], ..Health::default() };
1792 let written = toml::to_string(&health).unwrap();
1793 let old: older::Health = toml::from_str(&written).unwrap();
1795 assert_eq!(old.medicines[0].schedule, older::Schedule::Day { times: vec!["08:00".into(), "20:00".into()] });
1796 assert_eq!(old.medicines[0].dose, "08:00 · 1 tablet, 20:00 · 2 tablets");
1797 let back: Health = toml::from_str(&toml::to_string(&old).unwrap()).unwrap();
1799 assert_eq!(back.medicines[0].schedule, Schedule::Day { times: vec!["08:00".into(), "20:00".into()], amounts: BTreeMap::new() });
1800 let (start, end) = (at("2026-10-08T00:00[Europe/Paris]"), at("2026-10-10T00:00[Europe/Paris]"));
1801 let due = |h: &Health| h.doses(&start, &end).into_iter().map(|d| (d.key, d.at)).collect::<Vec<_>>();
1802 assert_eq!(due(&back), due(&health));
1803 assert!(back.doses(&start, &end).iter().all(|d| d.dose == "08:00 · 1 tablet, 20:00 · 2 tablets"));
1804 }
1805
1806 #[test]
1807 fn a_take_moved_by_the_change_of_hour_keeps_its_amount() {
1808 let mut night = at_set_times("night", "Night drops");
1810 night.set_takes("1 drop", &[take("02:30", "3 drops"), take("08:00", "")]).unwrap();
1811 let health = Health { medicines: vec![night], ..Health::default() };
1812 assert_eq!(day_of(&health, "2026-03-29"), said(&[("03:30", "Night drops", "3 drops"), ("08:00", "Night drops", "1 drop")]));
1813 let zone = jiff::tz::TimeZone::get("Europe/Paris").unwrap();
1814 let doses = health.doses(&at("2026-03-29T00:00[Europe/Paris]"), &at("2026-03-30T00:00[Europe/Paris]"));
1815 assert_eq!(health.amount_of(&doses[0].key, &zone).as_deref(), Some("3 drops"));
1816 assert_eq!(health.amount_of(&doses[1].key, &zone).as_deref(), Some("1 drop"));
1817 assert_eq!(health.amount_of(&format!("night@{}", at("2026-03-30T02:30[Europe/Paris]").timestamp().as_second() + 60), &zone).as_deref(), Some(""));
1819 assert_eq!(health.amount_of("gone@1800000000", &zone), None);
1820 }
1821
1822 #[test]
1826 fn generic_name_and_strength_written_only_when_given() {
1827 let mut levo = at_set_times("levo", "Thyrolan");
1828 levo.set_takes("1 tablet", &[take("07:30", "")]).unwrap();
1829 let plain = toml::to_string(&Health { medicines: vec![levo.clone()], ..Health::default() }).unwrap();
1831 assert!(!plain.contains("generic") && !plain.contains("strength") && !plain.contains("since"), "{plain}");
1832 assert_eq!(levo.precise(), "Thyrolan");
1833 levo.generic = "levothyroxine".into();
1834 levo.strength = "75 µg".into();
1835 levo.since = Some("2026-03-02".parse().unwrap());
1836 let health = Health { medicines: vec![levo.clone()], ..Health::default() };
1837 let written = toml::to_string(&health).unwrap();
1838 let again: Health = toml::from_str(&written).unwrap();
1839 assert_eq!(again, health, "read back as written");
1840 assert_eq!(levo.precise(), "Thyrolan — levothyroxine 75 µg");
1841 assert_eq!(levo.short_name(), "Thyrolan");
1842 assert_eq!(Medicine { name: "Levothyroxine".into(), ..levo.clone() }.precise(), "levothyroxine 75 µg");
1844 assert_eq!(day_of(&health, "2026-10-08"), said(&[("07:30", "Thyrolan", "1 tablet")]));
1846 let old: older::Health = toml::from_str(&written).unwrap();
1848 assert_eq!((old.medicines[0].name.as_str(), old.medicines[0].dose.as_str()), ("Thyrolan", "1 tablet"));
1849 let back: Health = toml::from_str(&toml::to_string(&old).unwrap()).unwrap();
1850 assert_eq!((back.medicines[0].generic.as_str(), back.medicines[0].strength.as_str(), back.medicines[0].since), ("", "", None));
1851 let (start, end) = (at("2026-10-08T00:00[Europe/Paris]"), at("2026-10-10T00:00[Europe/Paris]"));
1852 let due = |h: &Health| h.doses(&start, &end).into_iter().map(|d| (d.key, d.at, d.name, d.dose)).collect::<Vec<_>>();
1853 assert_eq!(due(&back), due(&health));
1854 let by_hand = "[[medicine]]\nid = \"m\"\nname = \"\"\ngeneric = \"metformin\"\nstrength = \"500 mg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\"]\n";
1856 let hand: Health = toml::from_str(by_hand).unwrap();
1857 assert_eq!((hand.medicines[0].short_name().as_str(), hand.medicines[0].precise().as_str()), ("metformin", "metformin 500 mg"));
1858 assert_eq!(day_of(&hand, "2026-10-08")[0].1, "metformin");
1859 }
1860
1861 #[test]
1868 fn a_record_of_no_bytes_or_gone_with_its_folder_is_lost_never_empty() {
1869 let dir = std::env::temp_dir().join(format!("sioul-record-lost-{}", std::process::id()));
1870 let _ = std::fs::remove_dir_all(&dir);
1871 let (state, data) = (dir.join("state"), dir.join("data"));
1872 APART.with(|apart| *apart.borrow_mut() = Some(data.clone()));
1873 let path = state.join("health-state.toml");
1874 assert_eq!(HealthState::read(&path), Ok(HealthState::default()), "never written: nothing lost");
1875 HealthState::update(&path, DUE, |s| {
1876 s.taken.insert("iron@1800000000".into(), DUE);
1877 })
1878 .unwrap();
1879 std::fs::write(&path, "").unwrap();
1880 assert_eq!(HealthState::read(&path), Err(Unsound::Lost), "of no bytes, written before");
1881 std::fs::remove_dir_all(&state).unwrap();
1882 assert_eq!(HealthState::read(&path), Err(Unsound::Lost), "the state folder wiped, the witness apart kept");
1883 HealthState::set_aside(&path, DUE).unwrap();
1884 assert_eq!(HealthState::read(&path), Ok(HealthState::default()), "set aside: a new record starts");
1885 let records = state.join("health-doses.toml");
1886 crate::doses::DoseRecords::update(&records, DUE, |r| r.open("iron@1800000000", "desk-id", DUE)).unwrap();
1887 std::fs::write(&records, "").unwrap();
1888 assert_eq!(crate::doses::DoseRecords::read(&records), Err(Unsound::Lost));
1889 std::fs::remove_dir_all(&state).unwrap();
1890 assert_eq!(crate::doses::DoseRecords::read(&records), Err(Unsound::Lost));
1891 APART.with(|apart| *apart.borrow_mut() = None);
1892 let _ = std::fs::remove_dir_all(&dir);
1893 }
1894
1895 #[test]
1899 fn a_move_lost_to_an_edit_elsewhere_is_mended() {
1900 let start = at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second();
1901 let mut health = Health {
1902 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 }],
1903 ..Health::default()
1904 };
1905 let mut state = HealthState::default();
1906 let late = start + 90 * 60;
1907 state.moved.insert(format!("antibiotic@{start}"), [start, late + 8 * 3600]);
1908 assert!(health.mend_shifts(&state));
1909 assert!(matches!(health.medicines[0].schedule, Schedule::Hours { from, .. } if from == late + 8 * 3600));
1910 assert_eq!(health.medicines[0].dose, "1 tablet, with food", "its other changes kept");
1911 assert!(!health.mend_shifts(&state), "mended once");
1912 let next = late + 8 * 3600;
1913 state.moved.insert(format!("antibiotic@{next}"), [next, next + 3600 + 8 * 3600]);
1914 health.medicines[0].schedule = Schedule::Hours { hours: 8, from: start };
1915 assert!(health.mend_shifts(&state));
1916 assert!(matches!(health.medicines[0].schedule, Schedule::Hours { from, .. } if from == next + 3600 + 8 * 3600), "both moves");
1917 }
1918
1919 #[test]
1923 fn two_moves_from_one_start_mend_to_the_latest_mark() {
1924 let start = at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second();
1925 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 };
1926 let mut state = HealthState::default();
1927 let (first, second) = (format!("antibiotic@{start}"), format!("antibiotic@{}", start + 8 * 3600));
1929 state.moved.insert(first.clone(), [start, start + 90 * 60 + 8 * 3600]);
1930 state.moved.insert(second.clone(), [start, start + 8 * 3600 + 45 * 60 + 8 * 3600]);
1931 state.taken.insert(first, start + 90 * 60);
1932 state.taken.insert(second, start + 8 * 3600 + 45 * 60);
1933 let mut health = Health { medicines: vec![medicine.clone()], ..Health::default() };
1934 assert!(health.mend_shifts(&state));
1935 assert!(matches!(health.medicines[0].schedule, Schedule::Hours { from, .. } if from == start + 8 * 3600 + 45 * 60 + 8 * 3600), "the latest mark's move");
1936 let (a, b) = (state.taken.values().copied().min().unwrap(), state.taken.values().copied().max().unwrap());
1938 for taken in state.taken.values_mut() {
1939 *taken = if *taken == a { b } else { a };
1940 }
1941 let mut health = Health { medicines: vec![medicine], ..Health::default() };
1942 assert!(health.mend_shifts(&state));
1943 assert!(matches!(health.medicines[0].schedule, Schedule::Hours { from, .. } if from == start + 90 * 60 + 8 * 3600), "the latest mark's move, whatever its key");
1944 }
1945}