Skip to main content

sioul_core/
health.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Health and well-being: prescriptions, when to fetch their medicines and
5//! when to renew them; medicines and when to take them; a pause to move during
6//! long focus; a daily limit on chats. Sent nowhere, except sealed to your
7//! other devices when you share with them (docs/database.md).
8//!
9//! Nothing here counts what was missed. A dose not marked taken is simply not
10//! marked; the next one comes as planned. Two kinds of medicines: those taken
11//! at set times of the day keep their times (being ready for something);
12//! those taken every few hours keep the hours between two doses, which the
13//! body (the liver, the kidneys) needs to clear one before the next: each dose
14//! taken, early or late, sets the next one that many hours after it.
15
16use jiff::civil::{Date, Time};
17use jiff::{Span, Timestamp, Zoned};
18use serde::{Deserialize, Serialize};
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22/// Everything the health page keeps: `$XDG_DATA_HOME/sioul/health.toml`.
23#[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    /// Where the pharmacy and the renewals go as tasks: a list on your server
34    /// ("account/id"), so your phone has them; "" for your usual list.
35    #[serde(default, skip_serializing_if = "String::is_empty")]
36    pub errands_list: String,
37    // `watch_folder` and `watch_offers`, which Sioul wrote until 8 October 2026
38    // (a Garmin watch's files, taken out: docs/health.md), are read and left
39    // aside, as any key it does not know.
40    /// Meals, naps and the night: times kept free, set first (`needs`).
41    #[serde(default)]
42    pub needs: crate::needs::Needs,
43}
44
45/// A prescription: what it is for, who wrote it, until when it is valid,
46/// and how often the pharmacy gives its medicines.
47#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
48pub struct Prescription {
49    pub id: String,
50    /// "Vitamin D 1000 IU".
51    pub title: String,
52    #[serde(default)]
53    pub prescriber: String,
54    /// Its last valid day: renewed before it.
55    #[serde(default, deserialize_with = "crate::budget::dates::optional", skip_serializing_if = "Option::is_none")]
56    pub until: Option<Date>,
57    /// The pharmacy gives this many days at a time (28, 30, 90).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub refill_days: Option<u32>,
60    /// The last time it was fetched at the pharmacy.
61    #[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/// A medicine to take, and when.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct Medicine {
70    pub id: String,
71    /// Its brand name, or your own word for it: what reminders say. Never
72    /// empty once saved (its generic name stands in), as Sioul 0.0.3 reads it.
73    pub name: String,
74    /// "1000 IU", "1 tablet".
75    #[serde(default, skip_serializing_if = "String::is_empty")]
76    pub dose: String,
77    pub schedule: Schedule,
78    /// The prescription it comes with, by id.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub prescription: Option<String>,
81    /// The last day it is taken; none for as long as it goes.
82    #[serde(default, deserialize_with = "crate::budget::dates::optional", skip_serializing_if = "Option::is_none")]
83    pub until: Option<Date>,
84    /// Stopped for now: no reminder.
85    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
86    pub paused: bool,
87    /// The generic name of its molecule, as typed (the INN, "levothyroxine"):
88    /// what a doctor or a pharmacist reads, whatever the brand. Written only
89    /// when given; Sioul 0.0.3 drops it at its next save.
90    #[serde(default, skip_serializing_if = "String::is_empty")]
91    pub generic: String,
92    /// Its strength ("75 µg", "500 mg per tablet"), apart from a take's
93    /// amount ("1 tablet"). Written only when given, as `generic`.
94    #[serde(default, skip_serializing_if = "String::is_empty")]
95    pub strength: String,
96    /// The day it was first taken, when known: how long it has been taken.
97    /// Written only when given, as `generic`.
98    #[serde(default, deserialize_with = "crate::budget::dates::optional", skip_serializing_if = "Option::is_none")]
99    pub since: Option<Date>,
100}
101
102/// When a medicine is taken.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(tag = "every", rename_all = "kebab-case")]
105pub enum Schedule {
106    /// At these times each day: "12:00", "18:00", each a take.
107    Day {
108        times: Vec<String>,
109        /// Each take's own amount, by its time ("20:00" = "2 tablets"), only
110        /// when the takes' amounts differ: then it holds every take's, and the
111        /// medicine's `dose` holds them all in one line for an older Sioul,
112        /// which reads the times alone (`Medicine::set_takes`). Empty: every
113        /// take is the medicine's `dose`, and the file reads as before.
114        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
115        amounts: BTreeMap<String, String>,
116    },
117    /// Every `days` days at `time`, counted from `from` ("every other day from tomorrow").
118    Days {
119        days: u32,
120        time: String,
121        #[serde(deserialize_with = "crate::budget::dates::required")]
122        from: Date,
123    },
124    /// Every `hours` hours from `from`, Unix seconds ("every 6 hours from now"):
125    /// each dose taken sets the next one that many hours after it (`Health::taken_at`).
126    Hours { hours: u32, from: i64 },
127}
128
129/// "18:00" → 18:00.
130fn 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
135/// "8:00" → "08:00": a take's time as `amounts` keys it; none when it does not read.
136pub fn hhmm(text: &str) -> Option<String> {
137    time_of(text).map(|t| format!("{:02}:{:02}", t.hour(), t.minute()))
138}
139
140impl Schedule {
141    /// The doses from `start` (included) to `end` (excluded), in order.
142    pub fn doses(&self, start: &Zoned, end: &Zoned) -> Vec<Zoned> {
143        self.timed(start, end).into_iter().map(|(at, _)| at).collect()
144    }
145
146    /// The same, each with the time of day it is set for, at set times each
147    /// day (its take: a change of hour can move the dose itself, 02:30 to
148    /// 03:30); none for the others.
149    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 &times {
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                // The first turn on or after the start.
174                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/// A take of a medicine at set times each day: its time ("08:00") and its
214/// amount ("1 tablet"; "" when none is said).
215#[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/// Why takes could not be set.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub enum TakeProblem {
225    /// No take at all: a medicine at set times would never fall due.
226    None,
227    /// A time that does not read, as written.
228    Unreadable(String),
229    /// Two takes at the same time: one dose, whose amount would be lost.
230    Twice(String),
231}
232
233impl Medicine {
234    /// What reminders, the day's list and a phone's card call it: its name,
235    /// else (a file written by hand) its generic name.
236    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    /// What it is, precisely, for whoever prescribes or hands it out: its
241    /// name, then its generic name and strength ("Thyrolan — levothyroxine
242    /// 75 µg"); the generic name alone when it is the name ("levothyroxine
243    /// 75 µg"); the name and strength without a generic name.
244    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    /// The amount said with a dose set for `time` of the day: the take's own
257    /// when the takes differ (none when that take has none), else the
258    /// medicine's dose.
259    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    /// Its takes, at set times each day, in time order, each with its amount
267    /// (`amount_at`); a time that does not read is left out, as it never falls
268    /// due. None for the other kinds.
269    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    /// The amount most of its takes have, the earliest first on a tie: what
278    /// its form shows as its dose, each take saying its own only when it
279    /// differs. Its dose when the takes do not differ.
280    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    /// Its takes in a line, in time order: "08:00 · 1 tablet, 20:00 · 2
291    /// tablets" ("12:00" alone for a take with no amount). What an older
292    /// Sioul says with each dose when the takes differ (`set_takes`).
293    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    /// Takes it at set times each day: `takes`, each with its own amount, or
298    /// `usual` when it says none. The same amount for all: that is its dose,
299    /// and `amounts` stays empty (the file as an older Sioul writes it).
300    /// Amounts that differ: `amounts` holds every take's, and its dose is the
301    /// whole line (`takes_line`), so that an older Sioul, which reads the
302    /// times alone, says every take's amount with each dose rather than one
303    /// that is wrong for some. The times are what they say, whatever the amounts.
304    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/// The pause to move: while a focus session runs, after this many minutes.
332#[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/// Chats, a limit a day: after `minutes` of use they are covered, silent
355/// and muted, for `locked_minutes`; then they come back.
356#[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    /// The health file; empty when there is none. One that no longer reads
372    /// (edited by hand, written by a newer Sioul on another computer) is
373    /// copied aside first, as `health.toml.unreadable`: saving over it then
374    /// loses no medicine.
375    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    /// Written next to its place, then moved: never half a file. Yours alone.
387    /// Under the file's lock, which the sharing takes too.
388    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    /// Read, changed by `change` and written back, all under the file's lock,
402    /// which the sharing takes too: a change is set over the file as it is
403    /// then, never over a copy read earlier. Returns what `change` returns.
404    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    /// A new id among the others, from a name.
414    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    /// Every dose from `start` to `end`, of the medicines taken then, each
420    /// with its take's amount (`Medicine::amount_at`).
421    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    /// The amount of the dose `key` (`<medicine>@<Unix seconds>`), as its
441    /// reminder said it: its take's, found at its time; else (a dose the
442    /// schedule no longer has, moved or changed since) the amount at its time
443    /// of day on this clock. None for a medicine no longer here.
444    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    /// A dose taken at `at` (Unix seconds): for a medicine taken every few
460    /// hours, the next doses come that many hours after it, early or late, so
461    /// that the hours between two doses are kept. Medicines at set times keep
462    /// theirs. Returns where the doses started before and after they moved,
463    /// to put them back if the mark is taken back; none when nothing moved.
464    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    /// The moves of medicines taken every few hours, mended: a dose taken
479    /// late moves the next ones (`taken_at`), inside the medicine, which
480    /// travels whole between devices; changed meanwhile on another device (its
481    /// note, its dose's text), the medicine there, its start as it was, can win
482    /// everywhere, and the next dose would come early, the hours between doses
483    /// broken (review of 5 October 2026, F23). Each move is kept with its mark
484    /// (`HealthState::moved`), which travels apart: a medicine whose start is
485    /// one a mark moved it from goes again where that mark moved it, its
486    /// other changes kept. Two moves from one start (the second marked on a
487    /// device the first had not reached yet): the latest mark's, by when it
488    /// was taken (review of 10 October 2026, R10). Returns whether one moved.
489    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            // Each move with when its mark was taken (none known: the earliest).
494            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            // Moved several times since: each from where the one before left it.
496            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    /// A mark taken back: the doses it moved go back where they were, unless
506    /// they moved again since. Returns whether they did.
507    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    /// The pharmacy and the doctor, as errands: when a prescription's
520    /// medicines run out (two days before) and when it ends (two weeks before).
521    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/// Another device sharing with this one, as known here, for the doses
542/// (docs/health.md, "Knowing"): what it last said of itself in the sharing
543/// folder (its entry in the devices' registry, `said`), and, for an older
544/// Sioul that writes none, what its claims said (until when everything it
545/// wrote is read here, whether it said it closed, how late its news comes).
546#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
547pub struct Peer {
548    /// Its name, to say where: the host's; a phone's is said "the phone".
549    pub name: String,
550    /// From its claims (an older Sioul): everything it wrote until then (Unix
551    /// seconds) is read here; 0: never sure.
552    #[serde(default)]
553    pub known_until: i64,
554    /// From its claims: at `known_until`, it said it closed (quit, or put away
555    /// on a phone): it marks nothing until it says otherwise.
556    #[serde(default)]
557    pub closed: bool,
558    /// From its claims: the longest its news took to come here lately, in
559    /// seconds; none measured yet.
560    #[serde(default)]
561    pub delay: Option<i64>,
562    /// When a line it wrote was found unreadable here: what it said is lost.
563    #[serde(default)]
564    pub broken: Option<i64>,
565    /// When it was last heard at all.
566    #[serde(default)]
567    pub heard: i64,
568    /// Its id in the sharing; "" for a device whose claim does not read here.
569    #[serde(default)]
570    pub id: String,
571    /// What it last said of itself in the folder, as read here; none from an
572    /// older Sioul, which says nothing of the kind, or before it is read.
573    #[serde(default)]
574    pub said: Option<Said>,
575    /// Everything it wrote up to its last export (`Said::exported`) is read here.
576    #[serde(default)]
577    pub complete: bool,
578    /// When this device last saw its entry change: this device's clock (Unix seconds).
579    #[serde(default)]
580    pub seen: i64,
581    /// How far its clock is ahead of this device's, at least, in seconds:
582    /// measured here (`Said::exported` against when it was seen), 0 when not
583    /// seen ahead. A clock behind only makes Sioul wait longer.
584    #[serde(default)]
585    pub ahead: i64,
586    /// You said it is off: not counted until it shows life again.
587    #[serde(default)]
588    pub off: bool,
589    /// How late the news of its opening comes here: the longest a start of
590    /// its was seen after it began, of its last starts seen while this device
591    /// looked (seconds, this device's clock, its clock's lead taken off);
592    /// none measured yet. A device closed before a dose may open around it to
593    /// mark it: known closed only once that while went by.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub wake: Option<i64>,
596}
597
598/// What a device says of itself in the sharing folder: its entry in the
599/// devices' registry (`sioul_sync::devices`), its times on its own clock (Unix seconds).
600#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
601pub struct Said {
602    /// Its last start: Sioul opened; on a phone, Sioul back on the screen, or
603    /// one of its reminders handled while it was not.
604    pub started: i64,
605    /// Its last clean close, after its last export; 0 never.
606    pub closed: i64,
607    /// From its start until the last step of a clean close: a crash leaves it so.
608    pub working: bool,
609    /// When its files were last read for an export: every answer captured
610    /// there before is in its records (read here when `Peer::complete`).
611    pub exported: i64,
612    /// When it last read the others.
613    pub imported: i64,
614    /// It shares the health part: the answers it captures travel.
615    pub doses: bool,
616    /// It stopped sharing: it counts no more.
617    pub left: bool,
618    /// A phone, said "the phone"; else a computer, said by its name.
619    pub phone: bool,
620}
621
622/// A device a doubt names: its id (for "This device is off"), its name, a phone or not.
623#[derive(Debug, Clone, Default, PartialEq, Eq)]
624pub struct Named {
625    pub id: String,
626    pub name: String,
627    pub phone: bool,
628}
629
630/// Why a dose is not known here: whether it was taken, Sioul cannot tell.
631#[derive(Debug, Clone, PartialEq, Eq)]
632pub enum Doubt {
633    /// This device's record of doses could not be read, or was lost, then.
634    Record { since: i64 },
635    /// An older Sioul, which writes no entry in the registry, may have marked
636    /// it: everything it wrote is known until then (0: never), and it was open
637    /// or closed then.
638    Unheard { device: Named, until: i64, closed: bool },
639    /// A line another device wrote could not be read here.
640    Broken { device: Named },
641    /// In use, and it has not shared since the dose was due, or not lately:
642    /// `shared`, when it last did (its clock); `quiet`, not seen changing for
643    /// over an hour (`QUIET`): it may have stopped without closing.
644    Working { device: Named, shared: i64, quiet: bool },
645    /// What it last shared, then (`shared`), has not all come here yet.
646    Coming { device: Named, shared: i64 },
647    /// It does not share its doses (its Health part switched off), and it was
648    /// not closed before the dose was due.
649    Apart { device: Named },
650}
651
652/// The longest a closed computer's news may take to come here for it to count
653/// as closed, for an older Sioul known by its claims only: had it opened
654/// again, it would be known by now.
655pub const NEWS_IN: i64 = 3 * 60;
656
657/// How long a device in use stays known after its last export, as of now:
658/// within it, an answer it captured is one you have just given. Beyond it,
659/// what it captured since may still be on its way.
660pub const FRESH: i64 = 5 * 60;
661
662/// How far apart two devices' clocks are taken to be, beyond what is measured
663/// here (`Peer::ahead`): a guess; devices set their clocks from the network,
664/// to within seconds.
665pub const SKEW: i64 = 2 * 60;
666
667/// A device in use not seen changing for this long may have stopped without
668/// closing (a crash): said so. Only the words change.
669pub const QUIET: i64 = 60 * 60;
670
671/// How long before a dose's time a line lost from another device's records
672/// may have held its answer (a dose marked ahead of its time), in seconds.
673pub const BROKEN_SPAN: i64 = 12 * 3600;
674
675/// A device silent this long stops counting for the doses, and Settings ▸ Your
676/// folder and sharing offers to forget it: a guess (a computer away for a long
677/// weekend comes back within it).
678pub const SILENT_DAYS: i64 = 7;
679
680/// Whether `peer` counts for the doses at `now`: not said off by you, still
681/// sharing, and heard within `SILENT_DAYS`, by this device's clock (its entry
682/// seen changing) or its own (its last export).
683pub 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
695/// Why a dose due at `due` (Unix seconds), not answered here, may have been
696/// answered on another device, as of `now`; none when it is known not taken
697/// (docs/health.md, "Knowing"). This device's record must read, and each
698/// other device that counts (`counts`) be one of:
699/// - closed cleanly (`working` down, `closed` after `started`, both on its
700///   own clock), everything it wrote up to its last export read here: closed
701///   after the dose, or before it and not started since. Its last export holds
702///   every answer it captured, and it captures none until it starts again;
703/// - in use, having exported after the dose (its clock, allowing `SKEW` and
704///   how far ahead its clock was seen) and lately (seen changing here within
705///   `FRESH`, and dated within it), everything read: an answer captured since
706///   goes out at once and comes within the sync's time;
707/// - an older Sioul, which writes no registry: as its claims say, heard in
708///   full after the dose and within `FRESH`, or its last word, read in full,
709///   that it closed, its news quick (`NEWS_IN`). Never closed for want of a word.
710///
711/// Otherwise the doubt names it. One rule for reminders, near the dose's time,
712/// and for what stays in view hours after it (the Porch, the Health page, a
713/// phone's home screen). A dose taken twice can harm: what is not known is
714/// said, never guessed.
715pub fn doubts_now(due: i64, now: i64, record_lost: Option<i64>, peers: &[Peer]) -> Vec<Doubt> {
716    let mut out = Vec::new();
717    // Doses due before the record was found broken or gone, in the day before.
718    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        // A line lost from around the dose's time may have been its answer:
724        // one written before it, or within `BROKEN_SPAN` after (a dose marked
725        // ahead of its time). The line is dated by its own time, never by when
726        // it was found (a round read again finds an old one again).
727        if peer.broken.is_some_and(|at| at >= due - BROKEN_SPAN) {
728            out.push(Doubt::Broken { device });
729            continue;
730        }
731        // Closed before the dose, it may open again around it to mark it: the
732        // news of its opening comes after a while, as measured on its last
733        // openings (`Peer::wake`), and it is known closed only once that while
734        // went by, and `SKEW` more (a lead of its clock measured here is that
735        // lead less the quickest news seen: its openings timed short by as
736        // much; review of 10 October 2026, R8). None measured yet: as before,
737        // at once.
738        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        // Its entry came ahead of its records: what it last shared may hold the answer.
749        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            // Its answers never travel: known only if it was closed before the
757            // dose was due, its clock maybe `SKEW` behind this one's, and the
758            // news of an opening since had the time to come.
759            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            // Closed after the dose (its clock, allowing `SKEW` and its lead):
768            // its last export holds every answer it captured until then. Closed
769            // before: it may have opened again since to mark it (a phone picked
770            // up for that, its sync slow to wake: review of 5 October 2026, F21),
771            // known only once the news of an opening had the time to come.
772            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
786/// The same, as a dose's reminder asks it, near its time: one rule, kept by
787/// its name for its callers.
788pub fn doubts(due: i64, now: i64, record_lost: Option<i64>, peers: &[Peer]) -> Vec<Doubt> {
789    doubts_now(due, now, record_lost, peers)
790}
791
792/// `doubts_now`, at a phone's alarm whose own pull did not go through since
793/// it began (`since`, Unix seconds): failed, or too slow, the sync app asked
794/// instead. Of the devices in `heard`, an entry or a health claim saying more
795/// came since: what they say is as fresh as the sync app makes it. Any other known
796/// only by what it said before then, closed (or an older Sioul whose claims
797/// say so), may have opened since and answered: said in doubt, as it was last
798/// heard, never known (docs/health.md, "Knowing"). One in use stays as `FRESH`
799/// has it: known only while its news is minutes old, which the alarm's own
800/// wait already spends.
801pub 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
823/// The first moment from `from` on, before `until`, at which a dose due at
824/// `due` is known not taken (`doubts_now`) while nothing new is read: a
825/// device closed before it becomes known so once the news of an opening had
826/// the time to come (`Peer::wake`), a device in use only goes stale. None when
827/// it is not known by `until`. For what is shown ahead of time (a phone's home
828/// screen card): "check" until then.
829pub 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
836/// Until when a dose due at `due`, known not taken at `from` (`doubts_now`),
837/// stays known while nothing new is read (a device in use goes stale after
838/// `FRESH`): the first moment it is not, else `until`; 0 when it is not known
839/// at `from`. For what is shown ahead of time (a phone's home screen card).
840pub 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
862/// What the body's files hold is yours alone: on Unix, readable by you only
863/// (0600), whatever the system's default for new files.
864pub(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
874/// The witnesses of a record of the doses (the marks, `health-state.toml`;
875/// each dose's record, `health-doses.toml`), made the first time it is
876/// written: gone, or of no bytes, with a witness there, it was lost, not
877/// never written. One beside it; one apart, in the data folder with the
878/// medicines, for a record in its usual place, the state folder: a state
879/// folder wiped whole (a reset, a backup put back without it) takes the
880/// record and the witness beside it, never that one (review of 5 October
881/// 2026, F22).
882pub(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    /// Where the witness apart is kept, in this test's thread (else none).
896    pub(crate) static APART: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
897}
898
899/// Whether a record of the doses was written here before (`witnesses`).
900pub fn witnessed(path: &Path) -> bool {
901    witnesses(path).iter().any(|witness| witness.exists())
902}
903
904/// A record of the doses written next to its place, its bytes on the disk
905/// before it takes its name (a phone's file system may keep the name of a
906/// file whose bytes a crash lost: a record of no bytes), then its folder's
907/// change made to last; its witnesses made when missing.
908pub 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    // Next to its place, then moved: a dose marked is never lost to half a file.
914    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/// One dose to take.
934#[derive(Debug, Clone, PartialEq)]
935pub struct Dose {
936    /// `<medicine>@<Unix seconds>`: what marks it taken.
937    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    /// Fetch the medicines at the pharmacy.
948    Refill,
949    /// See the doctor for a new prescription.
950    Renew,
951}
952
953/// Something to do for a prescription, from a day on.
954#[derive(Debug, Clone, PartialEq, Eq)]
955pub struct Errand {
956    /// Unique to its turn: the task made for it is made once.
957    pub key: String,
958    pub prescription: String,
959    pub kind: ErrandKind,
960    /// From when the task can start.
961    pub day: Date,
962    pub title: String,
963}
964
965/// What is marked of the doses and done of the errands, kept apart from the
966/// health file: `$XDG_STATE_HOME/sioul/health-state.toml`.
967#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
968pub struct HealthState {
969    /// Doses marked taken, by key, when (Unix seconds); kept a week.
970    #[serde(default)]
971    pub taken: BTreeMap<String, i64>,
972    /// Doses already reminded, by key; kept a week.
973    #[serde(default)]
974    pub reminded: BTreeMap<String, i64>,
975    /// Doses whose reminder could not be shown while Sioul ran (a desktop
976    /// without a notification server), by key, when it was tried; kept a
977    /// week. Not a reminder: such a dose is still asked about afterwards,
978    /// under its own heading, since Sioul ran (`unanswered`). Written only
979    /// when there is one: an older Sioul's file stays as it was.
980    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
981    pub unshown: BTreeMap<String, i64>,
982    /// Doses answered "not taken" when asked afterwards, by key; kept a week.
983    #[serde(default)]
984    pub not_taken: BTreeMap<String, i64>,
985    /// Doses whose take moved the next ones, by key: where they started
986    /// before and after (`Health::taken_at`); kept with the mark.
987    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
988    pub moved: BTreeMap<String, [i64; 2]>,
989    /// Errands whose task was made, by key.
990    #[serde(default)]
991    pub errands: BTreeMap<String, String>,
992    /// Chats: the day counted, the minutes used that day, and covered until when.
993    #[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/// Why the doses' record cannot be trusted: what it held is not known.
1002#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1003pub enum Unsound {
1004    /// It does not read: half written, broken by hand, or by a disk.
1005    Unreadable,
1006    /// It is gone, though it was written here before (its witness says so).
1007    Lost,
1008}
1009
1010/// A change to the doses' record that could not be made.
1011#[derive(Debug, Clone, PartialEq, Eq)]
1012pub enum Problem {
1013    /// The record cannot be trusted: nothing is written over it.
1014    Unsound(Unsound),
1015    /// It could not be written (a full disk): what it says.
1016    Write(String),
1017}
1018
1019impl HealthState {
1020    pub fn default_path() -> PathBuf {
1021        crate::config::state_dir().join("health-state.toml")
1022    }
1023
1024    /// The doses' record as written, or why it cannot be trusted. Never an
1025    /// empty record in place of one that does not read: a dose taken would
1026    /// look not taken, and written back empty, the sharing would take every
1027    /// mark out on your other devices too. Of no bytes at all, or gone,
1028    /// though written before (`witnessed`): lost (a crash after a write that
1029    /// had not reached the disk, a full disk, a state folder wiped).
1030    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    /// The record changed by `change`, one writer at a time (the window, the
1046    /// sharing: `filelock`): read again, changed, written, under the lock, so
1047    /// that no change made meanwhile is written over.
1048    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    /// A record that cannot be trusted, kept aside as `<name>.unreadable-<time>`
1058    /// for whoever wants to look, with its witness: a new one can start. Only
1059    /// once nothing it held can be taken out elsewhere (`share::rebuild`).
1060    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    /// Saved, its marks older than a week dropped. Use `update`: it holds the lock.
1076    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    /// The doses to remind now: due in the last `minutes`, not taken, not reminded yet.
1087    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    /// The doses of the last `hours`, past their time by `grace` minutes,
1094    /// neither marked nor reminded: due while Sioul ran nowhere, or while it
1095    /// ran but could not show their reminder (`unshown`, `reminder_unshown`).
1096    /// Asked about afterwards, as a question on the past; never reminded to
1097    /// take now.
1098    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    /// Whether Sioul ran when `key` fell due but could not show its reminder
1105    /// (no notification server), here or on another device that shares its
1106    /// doses: its question then says so, never "while Sioul was closed".
1107    pub fn reminder_unshown(&self, key: &str) -> bool {
1108        self.unshown.contains_key(key) && !self.reminded.contains_key(key)
1109    }
1110
1111    /// Today's doses from their time on, not marked yet (taken, or said not
1112    /// taken), for the Porch: reminded or not, since a reminder can go unseen;
1113    /// until marked, until the day ends, or `hours` after their time (as far
1114    /// back as a dose is asked about). Those due while Sioul ran nowhere
1115    /// (`unanswered`) are asked about apart, and left out here.
1116    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    /// One more minute of chats today; covered once the day's limit is reached.
1126    /// Returns whether chats are covered now.
1127    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    /// Whether chats are covered now.
1145    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        // Every other day from tomorrow.
1165        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        // Every six hours from 18:30.
1169        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        // Taken an hour and a half late: the next ones too, always.
1183        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        // The mark taken back: where they were.
1187        assert!(health.taken_back(&key, before, after));
1188        assert_eq!(times(&health), ["16:00", "00:00", "08:00"]);
1189        // Early: the next ones earlier, never closer than eight hours after it.
1190        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        // Moved again since: a mark taken back later leaves them.
1193        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        // On time to the second: nothing to move.
1200        assert_eq!(eight().taken_at(&key, at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second()), None);
1201        // Medicines at set times keep them: being ready for something.
1202        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        // Never written: an empty record, honestly.
1213        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        // Broken: not empty, unreadable; and nothing is written over it.
1217        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        // Gone after being written: lost, not new.
1222        std::fs::remove_file(&path).unwrap();
1223        assert_eq!(HealthState::read(&path), Err(Unsound::Lost));
1224        // Set aside (here, nothing left to keep), a new record can start.
1225        assert_eq!(HealthState::set_aside(&path, 1_120), Ok(None));
1226        assert_eq!(HealthState::read(&path), Ok(HealthState::default()));
1227        // A broken one is kept aside for whoever wants to look.
1228        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    /// An older Sioul, which writes no entry in the devices' registry, is known
1236    /// as its claims say, as before: heard in full after the dose and lately,
1237    /// or its last word, read in full, that it closed, its news quick. Never
1238    /// closed for want of a word.
1239    #[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        // Alone, with a record that reads: known.
1245        assert!(doubts(due, due + 60, None, &[]).is_empty());
1246        // Heard in full after the dose was due, a minute ago: known.
1247        assert!(doubts(due, due + 180, None, &[peer(due + 120, false, Some(30))]).is_empty());
1248        // Last heard before the dose, open: it may have marked it.
1249        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        // Closed before, and its news comes within minutes: known.
1251        assert!(doubts(due, due + 600, None, &[peer(due - 3_600, true, Some(60))]).is_empty());
1252        // Closed before, its news slow (a phone's sync) or never measured: not known, as before.
1253        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        // Never said how far it wrote: not known.
1256        assert_eq!(doubts(due, due + 600, None, &[Peer { name: "phone".into(), heard: due, ..Peer::default() }]).len(), 1);
1257        // A line of it lost around the dose's time: not known, whatever else.
1258        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        // Silent for over a week: no longer counted (`SILENT_DAYS`).
1261        let gone = Peer { heard: due - 8 * 86_400, ..peer(0, false, None) };
1262        assert!(doubts(due, due + 600, None, &[gone]).is_empty());
1263        // This device's record was found broken after the dose was due: not known; doses due after are.
1264        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        // Heard in full just after the dose, not since (its sync slow, or stopped): known then, not an hour later.
1267        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        // Closed, then spoke again, what it wrote not read here yet: it may have marked it.
1271        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    /// The dose of the scenarios below: 08:00, Unix seconds.
1276    const DUE: i64 = 1_800_000_000;
1277
1278    /// The phone, as this device knows it: its entry in the registry (a phone
1279    /// sharing its doses), seen changing here at `seen`, all it wrote read.
1280    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        // Put away at 07:00, after its last export, read here in full; not started since.
1291        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        // At the dose, an hour later, half a day later: known not taken, with no wait for news.
1293        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    /// F21 of the review of 5 October 2026: a device closed before the dose
1300    /// may be picked up at its time to mark it, its sync slow to wake; how
1301    /// late the news of its opening comes here is measured on its last
1302    /// openings (`Peer::wake`): known closed only once that while went by
1303    /// after the dose, and `SKEW` more (R8 of the review of 10 October 2026);
1304    /// closed after the dose, at once; none measured, as before.
1305    #[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        // Its openings came here within 40 s lately: known 40 s and `SKEW` after the dose, not before.
1311        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        // A phone whose sync woke four minutes late once: four minutes, and `SKEW`.
1316        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        // Closed after the dose, all it wrote read: its last export holds its answer, at once.
1322        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        // An older Sioul known by its claims, closed before, its steady news quick: the same wait.
1325        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        // Not sharing its doses, closed before: the same wait, then known.
1329        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        // In use from 07:50, closed at 08:20 after its last export (08:19:55), nothing marked there.
1337        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        // Its entry here, but its records not all read yet (a sync that brought one file first): it may hold the answer.
1340        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    /// No news of a device read since a phone's alarm began (its pull failed,
1345    /// its sync app brought nothing of that device): known closed by what it
1346    /// said before, it is said in doubt, as last heard, never known; its own
1347    /// entry come since, it is as it says; one in use and fresh stays as
1348    /// `FRESH` has it; a doubt said already is not said twice.
1349    #[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        // In use since 07:50, sharing each minute: its 08:03 export seen here at 08:03:20, nothing marked.
1367        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        // Known while that export is fresh: five minutes on with nothing newer, no longer.
1370        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        // In use; its last export at 07:45, seen at 07:45:30; the dose at 08:00.
1377        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        // An export dated 08:01, within the margin for clocks: not after the dose for sure.
1381        let early = phone(Said { exported: DUE + 60, ..said.clone() }, DUE + 70);
1382        assert_eq!(doubts_now(DUE, DUE + 80, None, &[early]).len(), 1);
1383        // Its 08:03 export comes: known; the doubt lifted by itself.
1384        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        // In use from 07:30, last export at 07:45, then nothing: working still up, no close after its start.
1391        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        // Hours later, still nothing: it may have stopped without closing, said so; never known.
1394        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        // You say it is off: not counted; the dose known not taken, never answered for you.
1396        let off = Peer { off: true, ..crashed };
1397        assert!(doubts_now(DUE, DUE + 4 * 3_600, None, &[off]).is_empty());
1398        // It starts again, in use, exporting: counted again, and known once it exported after the dose.
1399        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        // The phone's clock 3 minutes ahead, as seen here (`ahead`). Its export at 07:59 real
1406        // time is dated 08:02: not after the dose for sure.
1407        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        // Its export at 08:03 real time, dated 08:06: past the dose and the margin; known.
1410        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        // Its clock 3 minutes behind: the export at 08:04 real time is dated 08:01: Sioul waits longer, never less.
1413        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        // Closed, whatever its clock: started and closed are both on its own clock.
1418        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        // Stopped without closing eight days ago, by its clock and as seen here: not counted.
1425        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        // Six days: still counted, its doubt said.
1428        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        // First seen here today (this device just joined), silent a month by its own date: counted a
1431        // week from now, never dropped for a date on another clock.
1432        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        // A device that stopped sharing counts no more.
1436        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        // Health switched off there: what it marks never comes. In use: never known.
1445        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        // Closed before the dose: it could not have marked it.
1448        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        // Closed after it: it may have.
1450        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        // The desktop closed, the phone in use and behind, an older laptop never heard in full: two doubts, each named.
1456        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    /// A reminder that could not be shown (no notification server) is no
1481    /// reminder: the dose is still asked about afterwards, and told apart from
1482    /// one due while Sioul was closed; the record keeps it a week, and a file
1483    /// without it (an older Sioul's) reads and is written as before.
1484    #[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        // 08:00: Sioul ran, its reminder failed; 09:00: Sioul ran nowhere.
1494        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        // Reminded later (another device, or a notification server back): a reminder, not asked.
1500        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        // Kept as written, a week; an older Sioul's file has no table, and gets none.
1505        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        // As Sioul wrote it while it read a Garmin watch's files (until 8 October 2026).
1551        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    /// The Porch's doses at a desktop: due now, Sioul running (shown, reminded
1588    /// or not); due earlier today, reminded but not answered (shown, until
1589    /// marked, twelve hours after, or the day's end); due while Sioul ran
1590    /// nowhere (asked apart, not here); marked either way (gone).
1591    #[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        // 07:30 reminded while Sioul ran, never answered; 09:00 due while Sioul ran nowhere.
1605        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        // Before its time, a dose is not due; at its minute, it is.
1610        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        // Within its half hour and not reminded (the reminder waits for news): shown all the same.
1612        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        // Past its half hour without a reminder anywhere: the question on doses due while closed instead.
1616        assert!(waiting.due_today(&health, &at("2026-10-06T08:10[Europe/Paris]"), 12, 30).is_empty());
1617        // Marked taken, or said not taken: gone.
1618        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        // Twelve hours after its time: no longer shown; the evening's iron instead.
1622        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        // The day ends: yesterday's are not today's, even within twelve hours.
1626        let night = at("2026-10-07T00:30[Europe/Paris]");
1627        assert!(state.due_today(&health, &night, 12, 30).is_empty());
1628        // Yesterday's 23:50 zinc, reminded nowhere, past its half hour: the question's.
1629        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    /// A medicine as Sioul 0.0.3 reads and writes it, before takes had
1644    /// amounts of their own: the fields it knows, the others dropped at its
1645    /// next save.
1646    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    /// The doses of a day: time, name and amount, in order.
1699    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        // The same amount at every take: the medicine's dose, the file as before.
1712        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        // 1 tablet at 08:00, 2 at 20:00: each take its own; the form's dose, the most usual.
1718        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        // The doses fall due as they would with one amount for all: the same keys, the same times.
1727        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        // A take with no amount among others: none said for it, never another's.
1731        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        // What cannot be set: no take, a time that does not read, two takes at one time.
1737        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        // Set again alike: the amounts go, the dose is the one amount again.
1741        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        // Written and read again: the same.
1766        let again: Health = toml::from_str(&toml::to_string(&health).unwrap()).unwrap();
1767        assert_eq!(again, health);
1768        // One taken out: the others fall due as before.
1769        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        // As Sioul 0.0.3 writes a medicine: no amounts.
1777        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        // Written again by this Sioul: what 0.0.3 reads, field for field.
1782        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        // Sioul 0.0.3 reads the file: the same times, and the whole line as the dose of each.
1794        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        // It saves it again, dropping the amounts: the doses fall due as they did, each saying the whole line.
1798        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        // 02:30 does not exist in Paris on 29 March 2026: the dose comes at 03:30, its amount with it.
1809        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        // A dose the schedule no longer has: the amount at its time of day; a medicine gone: none.
1818        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    /// A medicine's generic name, strength and first day: written only when
1823    /// given, read back, said precisely for a professional; reminders keep
1824    /// the short name. Sioul 0.0.3 reads the file, its doses the same.
1825    #[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        // None given: the file as before.
1830        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        // The name is the generic name: said once.
1843        assert_eq!(Medicine { name: "Levothyroxine".into(), ..levo.clone() }.precise(), "levothyroxine 75 µg");
1844        // Reminders, the day's list, the card: the short name, the take's amount.
1845        assert_eq!(day_of(&health, "2026-10-08"), said(&[("07:30", "Thyrolan", "1 tablet")]));
1846        // Sioul 0.0.3 reads it: the same name and times; the new fields dropped at its save, the doses the same.
1847        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        // Written by hand with a generic name and no name: the generic name stands in.
1855        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    /// The review of 5 October 2026, F10 and F22: a record of no bytes (a
1862    /// crash after a write that had not reached a phone's disk, a full disk)
1863    /// read as lost, never as a sound empty one, the doses due before then in
1864    /// doubt; the same once the state folder is wiped whole, its witness with
1865    /// it, the one kept apart in the data folder telling. Each dose's records
1866    /// alike.
1867    #[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    /// F23: a medicine's start, moved by a dose taken late, put back by an
1896    /// edit made elsewhere meanwhile: moved again where the mark said, its
1897    /// edit kept; twice moved, both; nothing to mend, nothing changed.
1898    #[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    /// R10 of the review of 10 October 2026: two moves from one start (the
1920    /// second dose marked on a device the first move had not reached): the
1921    /// mend goes where the latest mark moved it, whatever the keys' order.
1922    #[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        // The dose of 08:00 taken at 09:30 on the phone; the one of 16:00 taken at 16:45 on the desk, which still had 08:00 as its start.
1928        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        // Marked in the other order: the same.
1937        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}