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    /// When its claim on the doses was last renewed while open, as read here
597    /// (its clock; 0 none): a computer whose entry says it closed but whose
598    /// claim came later is open, its entry not here yet (a sync app carrying
599    /// files one by one: the sixth review's R1d).
600    #[serde(default, skip_serializing_if = "is_zero_i64")]
601    pub claimed: i64,
602    /// You forgot it here (it left the list of your devices): it owes this
603    /// device no word on its openings (`off` counts it out of the doses too).
604    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
605    pub forgotten: bool,
606    /// The opening of it (its entry's `started`) this device said it read, in
607    /// its own sealed entry (`devices::Entry::acked`; a phone's word on a
608    /// computer); 0 none. A phone takes that computer's "closed" as its word
609    /// only for this opening or a later one it read (the seventh review's M1).
610    #[serde(default, skip_serializing_if = "is_zero_i64")]
611    pub acknowledged: i64,
612    /// The latest opening of it ever read here (its entry's `started`), never
613    /// lowered: an older copy of its entry put back cannot make a phone's word
614    /// on it go back (the eighth review's N3).
615    #[serde(default, skip_serializing_if = "is_zero_i64")]
616    pub latest_opening: i64,
617    /// Its entry read now is dated more than ten minutes ahead of this
618    /// device's clock (sioul-app's `FUTURE`): set aside while it reads so (a
619    /// copy read later, or arriving late, within ten minutes by then, is
620    /// taken). Nothing it says counts, nor a word it gives; its doubt says
621    /// why (`Doubt::Ahead`), never "an older Sioul" (the final review before
622    /// 0.0.6, F1H and its words; the re-check's LOW-1).
623    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
624    pub set_aside: bool,
625}
626
627fn is_zero_i64(n: &i64) -> bool {
628    *n == 0
629}
630
631/// What a device says of itself in the sharing folder: its entry in the
632/// devices' registry (`sioul_sync::devices`), its times on its own clock (Unix seconds).
633#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
634pub struct Said {
635    /// Its last start: Sioul opened; on a phone, Sioul back on the screen, or
636    /// one of its reminders handled while it was not.
637    pub started: i64,
638    /// Its last clean close, after its last export; 0 never.
639    pub closed: i64,
640    /// From its start until the last step of a clean close: a crash leaves it so.
641    pub working: bool,
642    /// When its files were last read for an export: every answer captured
643    /// there before is in its records (read here when `Peer::complete`).
644    pub exported: i64,
645    /// When it last read the others.
646    pub imported: i64,
647    /// It shares the health part: the answers it captures travel.
648    pub doses: bool,
649    /// It stopped sharing: it counts no more.
650    pub left: bool,
651    /// A phone, said "the phone"; else a computer, said by its name.
652    pub phone: bool,
653}
654
655/// A device a doubt names: its id (for "This device is off"), its name, a phone or not.
656#[derive(Debug, Clone, Default, PartialEq, Eq)]
657pub struct Named {
658    pub id: String,
659    pub name: String,
660    pub phone: bool,
661}
662
663/// Why a dose is not known here: whether it was taken, Sioul cannot tell.
664#[derive(Debug, Clone, PartialEq, Eq)]
665pub enum Doubt {
666    /// This device's record of doses could not be read, or was lost, then.
667    Record { since: i64 },
668    /// An older Sioul, which writes no entry in the registry, may have marked
669    /// it: everything it wrote is known until then (0: never), and it was open
670    /// or closed then.
671    Unheard { device: Named, until: i64, closed: bool },
672    /// A line another device wrote could not be read here.
673    Broken { device: Named },
674    /// In use, and it has not shared since the dose was due, or not lately:
675    /// `shared`, when it last did (its clock); `quiet`, not seen changing for
676    /// over an hour (`QUIET`): it may have stopped without closing.
677    Working { device: Named, shared: i64, quiet: bool },
678    /// What it last shared, then (`shared`), has not all come here yet.
679    Coming { device: Named, shared: i64 },
680    /// It does not share its doses (its Health part switched off), and it was
681    /// not closed before the dose was due.
682    Apart { device: Named },
683    /// Its clock runs more than ten minutes ahead of this device's: what it
684    /// says is set aside here (`Peer::set_aside`), so nothing of it is known.
685    Ahead { device: Named },
686}
687
688/// The longest a closed computer's news may take to come here for it to count
689/// as closed, for an older Sioul known by its claims only: had it opened
690/// again, it would be known by now.
691pub const NEWS_IN: i64 = 3 * 60;
692
693/// How long a device in use stays known after its last export, as of now:
694/// within it, an answer it captured is one you have just given. Beyond it,
695/// what it captured since may still be on its way.
696pub const FRESH: i64 = 5 * 60;
697
698/// How far apart two devices' clocks are taken to be, beyond what is measured
699/// here (`Peer::ahead`): a guess; devices set their clocks from the network,
700/// to within seconds.
701pub const SKEW: i64 = 2 * 60;
702
703/// How long after a dose's time a phone's alarm rings when a computer keeps
704/// the doses (its wait for news, sioul-app's `WAIT_FOR_NEWS`): a phone put
705/// away is opened then by its own alarm (docs/health.md, "On a phone").
706pub const ALARM_AFTER: i64 = 10 * 60;
707
708/// When a phone put away before a dose may last have been opened for it, at
709/// `now`: by its own alarm (`ALARM_AFTER`) once that rang, else at the dose's
710/// time (picked up to mark it); a computer's opening is the dose's time.
711fn opened_for(due: i64, now: i64, phone: bool) -> i64 {
712    if phone && now >= due + ALARM_AFTER { due + ALARM_AFTER } else { due }
713}
714
715/// A device in use not seen changing for this long may have stopped without
716/// closing (a crash): said so. Only the words change.
717pub const QUIET: i64 = 60 * 60;
718
719/// How long before a dose's time a line lost from another device's records
720/// may have held its answer (a dose marked ahead of its time), in seconds.
721pub const BROKEN_SPAN: i64 = 12 * 3600;
722
723/// A device silent this long stops counting for the doses, and Settings ▸ Your
724/// folder and sharing offers to forget it: a guess (a computer away for a long
725/// weekend comes back within it).
726pub const SILENT_DAYS: i64 = 7;
727
728/// How many phones count among `peers` at `now` (`counts`).
729fn phones_counted(peers: &[Peer], now: i64) -> usize {
730    peers.iter().filter(|p| counts(p, now) && p.said.as_ref().is_some_and(|s| s.phone)).count()
731}
732
733/// A device as a doubt names it: "the phone" only when it is the one phone
734/// among `phones` counted; two or more, each by its name. Words only: what
735/// a phone does (its alarm) is read from `Said::phone` itself (review of
736/// battery part C, the fifth review's C1).
737fn named(peer: &Peer, phones: usize) -> Named {
738    Named { id: peer.id.clone(), name: peer.name.clone(), phone: peer.said.as_ref().is_some_and(|s| s.phone) && phones == 1 }
739}
740
741/// Whether `peer` counts for the doses at `now`: not said off by you, still
742/// sharing, and heard within `SILENT_DAYS`, by this device's clock (its entry
743/// seen changing) or its own (its last export).
744pub fn counts(peer: &Peer, now: i64) -> bool {
745    if peer.off {
746        return false;
747    }
748    let silent = SILENT_DAYS * 86_400;
749    match &peer.said {
750        Some(said) if said.left => false,
751        Some(said) => now - peer.seen <= silent || now - said.exported <= silent,
752        None => now - peer.heard < silent,
753    }
754}
755
756/// Why a dose due at `due` (Unix seconds), not answered here, may have been
757/// answered on another device, as of `now`; none when it is known not taken
758/// (docs/health.md, "Knowing"). This device's record must read, and each
759/// other device that counts (`counts`) be one of:
760/// - closed cleanly (`working` down, `closed` after `started`, both on its
761///   own clock), everything it wrote up to its last export read here: closed
762///   after the dose, or before it and not started since. Its last export holds
763///   every answer it captured, and it captures none until it starts again;
764/// - in use, having exported after the dose (its clock, allowing `SKEW` and
765///   how far ahead its clock was seen) and lately (seen changing here within
766///   `FRESH`, and dated within it), everything read: an answer captured since
767///   goes out at once and comes within the sync's time;
768/// - an older Sioul, which writes no registry: as its claims say, heard in
769///   full after the dose and within `FRESH`, or its last word, read in full,
770///   that it closed, its news quick (`NEWS_IN`). Never closed for want of a word.
771///
772/// Otherwise the doubt names it. One rule for reminders, near the dose's time,
773/// and for what stays in view hours after it (the Porch, the Health page, a
774/// phone's home screen). A dose taken twice can harm: what is not known is
775/// said, never guessed.
776pub fn doubts_now(due: i64, now: i64, record_lost: Option<i64>, peers: &[Peer]) -> Vec<Doubt> {
777    doubts_at(due, due, now, record_lost, peers)
778}
779
780/// `doubts_now` for a dose that waited for `waking` (sleep or a pause held
781/// it: its time, else): what looks back counts from the dose's time (a
782/// record lost after it, a device apart closed before it, a line lost
783/// around it, a device closed or exported after it), and only the moment a
784/// device may have been opened for it counts from waking (picked up then,
785/// or its alarm, held until waking, rung after: review of battery part C,
786/// the sixth review's M1).
787pub fn doubts_at(due: i64, waking: i64, now: i64, record_lost: Option<i64>, peers: &[Peer]) -> Vec<Doubt> {
788    let mut out = Vec::new();
789    // Doses due before the record was found broken or gone, in the day before.
790    if let Some(since) = record_lost.filter(|since| due <= *since && *since - due < 86_400) {
791        out.push(Doubt::Record { since });
792    }
793    let phones = phones_counted(peers, now);
794    for peer in peers.iter().filter(|p| counts(p, now)) {
795        let device = named(peer, phones);
796        // Its entry set aside, its clock far ahead of this one's: nothing it says is known.
797        if peer.set_aside {
798            out.push(Doubt::Ahead { device });
799            continue;
800        }
801        // A line lost from around the dose's time may have been its answer:
802        // one written before it, or within `BROKEN_SPAN` after (a dose marked
803        // ahead of its time). The line is dated by its own time, never by when
804        // it was found (a round read again finds an old one again).
805        if peer.broken.is_some_and(|at| at >= due - BROKEN_SPAN) {
806            out.push(Doubt::Broken { device });
807            continue;
808        }
809        // Closed before the dose, it may open again around it to mark it: the
810        // news of its opening comes after a while, as measured on its last
811        // openings (`Peer::wake`), and it is known closed only once that while
812        // went by, and `SKEW` more (a lead of its clock measured here is that
813        // lead less the quickest news seen: its openings timed short by as
814        // much; review of 10 October 2026, R8). None measured yet: as before,
815        // at once. A phone put away is opened by its own alarm too, after the
816        // dose (`ALARM_AFTER`): from then on, counted from its alarm, never
817        // from the dose's time (review of battery part C, H2).
818        let phone = peer.said.as_ref().is_some_and(|s| s.phone);
819        let woke = peer.wake.is_none_or(|wake| now - opened_for(waking, now, phone) >= wake + SKEW);
820        let Some(said) = &peer.said else {
821            let closed = peer.closed && peer.heard <= peer.known_until;
822            let quiet = closed && peer.delay.is_some_and(|d| d <= NEWS_IN) && (peer.known_until >= due + SKEW || woke);
823            let fresh = peer.known_until >= due && now - peer.known_until <= FRESH;
824            if !quiet && !fresh {
825                out.push(Doubt::Unheard { device, until: peer.known_until, closed });
826            }
827            continue;
828        };
829        // Its entry came ahead of its records: what it last shared may hold the answer.
830        if !peer.complete {
831            out.push(Doubt::Coming { device, shared: said.exported });
832            continue;
833        }
834        let closed = !said.working && said.closed >= said.started;
835        let margin = SKEW + peer.ahead.max(0);
836        if !said.doses {
837            // Its answers never travel: known only if it was closed before the
838            // dose was due, its clock maybe `SKEW` behind this one's, and the
839            // news of an opening since had the time to come.
840            if !(closed && said.closed + SKEW < due) {
841                out.push(Doubt::Apart { device });
842            } else if !woke {
843                out.push(Doubt::Unheard { device, until: said.closed, closed: true });
844            }
845            continue;
846        }
847        if closed {
848            // Closed after the dose (its clock, allowing `SKEW` and its lead):
849            // its last export holds every answer it captured until then. Closed
850            // before: it may have opened again since to mark it (a phone picked
851            // up for that, its sync slow to wake: review of 5 October 2026, F21),
852            // known only once the news of an opening had the time to come.
853            if said.closed < due + margin && !woke {
854                out.push(Doubt::Unheard { device, until: said.closed, closed: true });
855            }
856            continue;
857        }
858        let after = said.exported >= due + margin;
859        let fresh = now - peer.seen <= FRESH && now - said.exported <= FRESH + margin;
860        if !(after && fresh) {
861            out.push(Doubt::Working { device, shared: said.exported, quiet: now - peer.seen > QUIET });
862        }
863    }
864    out
865}
866
867/// The same, as a dose's reminder asks it, near its time: one rule, kept by
868/// its name for its callers.
869pub fn doubts(due: i64, now: i64, record_lost: Option<i64>, peers: &[Peer]) -> Vec<Doubt> {
870    doubts_now(due, now, record_lost, peers)
871}
872
873/// `doubts_now`, at a phone's alarm whose own pull did not go through since
874/// it began (`since`, Unix seconds): failed, or too slow, the sync app asked
875/// instead. Of the devices in `heard`, an entry or a health claim saying more
876/// came since: what they say is as fresh as the sync app makes it. Any other known
877/// only by what it said before then, closed (or an older Sioul whose claims
878/// say so), may have opened since and answered: said in doubt, as it was last
879/// heard, never known (docs/health.md, "Knowing"). One in use stays as `FRESH`
880/// has it: known only while its news is minutes old, which the alarm's own
881/// wait already spends.
882pub fn doubts_unread(due: i64, now: i64, record_lost: Option<i64>, peers: &[Peer], since: i64, heard: &std::collections::BTreeSet<String>) -> Vec<Doubt> {
883    doubts_unread_at(due, due, now, record_lost, peers, since, heard)
884}
885
886/// `doubts_unread` for a dose that waited for `waking` (`doubts_at`).
887pub fn doubts_unread_at(due: i64, waking: i64, now: i64, record_lost: Option<i64>, peers: &[Peer], since: i64, heard: &std::collections::BTreeSet<String>) -> Vec<Doubt> {
888    let mut out = doubts_at(due, waking, now, record_lost, peers);
889    let phones = phones_counted(peers, now);
890    for peer in peers.iter().filter(|p| counts(p, now) && !heard.contains(&p.id)) {
891        let device = named(peer, phones);
892        let names = |doubt: &Doubt| match doubt {
893            Doubt::Unheard { device: d, .. } | Doubt::Broken { device: d } | Doubt::Working { device: d, .. } | Doubt::Coming { device: d, .. } | Doubt::Apart { device: d } | Doubt::Ahead { device: d } => *d == device,
894            Doubt::Record { .. } => false,
895        };
896        if out.iter().any(names) {
897            continue;
898        }
899        let until = match &peer.said {
900            Some(said) if said.working => continue,
901            Some(said) => said.closed,
902            None if peer.closed => peer.known_until,
903            None => continue,
904        };
905        out.push(Doubt::Unheard { device, until: until.clamp(1, since.max(1)), closed: true });
906    }
907    out
908}
909
910/// The first moment from `from` on, before `until`, at which a dose due at
911/// `due` is known not taken (`doubts_now`) while nothing new is read: a
912/// device closed before it becomes known so once the news of an opening had
913/// the time to come (`Peer::wake`), a device in use only goes stale. None when
914/// it is not known by `until`. For what is shown ahead of time (a phone's home
915/// screen card): "check" until then.
916pub fn known_from(due: i64, from: i64, until: i64, record_lost: Option<i64>, peers: &[Peer]) -> Option<i64> {
917    known_from_at(due, due, from, until, record_lost, peers)
918}
919
920/// `known_from` for a dose that waited for `waking` (`doubts_at`).
921pub fn known_from_at(due: i64, waking: i64, from: i64, until: i64, record_lost: Option<i64>, peers: &[Peer]) -> Option<i64> {
922    let phone = |p: &Peer| p.said.as_ref().is_some_and(|s| s.phone);
923    let after_alarm = peers.iter().filter(|p| phone(p)).filter_map(|p| p.wake).map(|w| waking + ALARM_AFTER + w + SKEW);
924    let mut moments: Vec<i64> = std::iter::once(from).chain(peers.iter().filter_map(|p| p.wake).map(|w| waking + w + SKEW)).chain(after_alarm).filter(|t| *t >= from && *t < until).collect();
925    moments.sort_unstable();
926    moments.dedup();
927    moments.into_iter().find(|t| doubts_at(due, waking, *t, record_lost, peers).is_empty())
928}
929
930/// Until when a dose due at `due`, known not taken at `from` (`doubts_now`),
931/// stays known while nothing new is read (a device in use goes stale after
932/// `FRESH`): the first moment it is not, else `until`; 0 when it is not known
933/// at `from`. For what is shown ahead of time (a phone's home screen card).
934pub fn known_until(due: i64, from: i64, until: i64, record_lost: Option<i64>, peers: &[Peer]) -> i64 {
935    let doubted = |at: i64| !doubts_now(due, at, record_lost, peers).is_empty();
936    if doubted(from) {
937        return 0;
938    }
939    let mut ends: Vec<i64> = Vec::new();
940    for peer in peers {
941        match &peer.said {
942            Some(said) => {
943                let margin = SKEW + peer.ahead.max(0);
944                ends.push(peer.seen + FRESH + 1);
945                ends.push(said.exported + FRESH + margin + 1);
946                // A phone put away, opened by its alarm: unknown again from then.
947                if said.phone && peer.wake.is_some() {
948                    ends.push(due + ALARM_AFTER);
949                }
950            }
951            None => ends.push(peer.known_until + FRESH + 1),
952        }
953    }
954    ends.retain(|t| *t > from && *t < until);
955    ends.sort_unstable();
956    ends.dedup();
957    ends.into_iter().find(|t| doubted(*t)).unwrap_or(until)
958}
959
960/// What the body's files hold is yours alone: on Unix, readable by you only
961/// (0600), whatever the system's default for new files.
962pub(crate) fn keep_private(path: &Path) {
963    #[cfg(unix)]
964    {
965        use std::os::unix::fs::PermissionsExt;
966        let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
967    }
968    #[cfg(not(unix))]
969    let _ = path;
970}
971
972/// The witnesses of a record of the doses (the marks, `health-state.toml`;
973/// each dose's record, `health-doses.toml`), made the first time it is
974/// written: gone, or of no bytes, with a witness there, it was lost, not
975/// never written. One beside it; one apart, in the data folder with the
976/// medicines, for a record in its usual place, the state folder: a state
977/// folder wiped whole (a reset, a backup put back without it) takes the
978/// record and the witness beside it, never that one (review of 5 October
979/// 2026, F22).
980pub(crate) fn witnesses(path: &Path) -> Vec<PathBuf> {
981    let name = path.file_name().map(|n| format!(".{}.written", n.to_string_lossy())).unwrap_or_default();
982    let mut all = vec![path.with_file_name(&name)];
983    #[cfg(test)]
984    let apart = APART.with(|apart| apart.borrow().as_ref().map(|dir| dir.join(&name)));
985    #[cfg(not(test))]
986    let apart = (path.parent() == Some(crate::config::state_dir().as_path())).then(|| crate::config::data_dir().join(&name));
987    all.extend(apart.filter(|apart| !all.contains(apart)));
988    all
989}
990
991#[cfg(test)]
992thread_local! {
993    /// Where the witness apart is kept, in this test's thread (else none).
994    pub(crate) static APART: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
995}
996
997/// Whether a record of the doses was written here before (`witnesses`).
998pub fn witnessed(path: &Path) -> bool {
999    witnesses(path).iter().any(|witness| witness.exists())
1000}
1001
1002/// A record of the doses written next to its place, its bytes on the disk
1003/// before it takes its name (a phone's file system may keep the name of a
1004/// file whose bytes a crash lost: a record of no bytes), then its folder's
1005/// change made to last; its witnesses made when missing.
1006pub fn write_witnessed(path: &Path, bytes: &[u8]) -> Result<(), String> {
1007    use std::io::Write;
1008    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
1009    let parent = path.parent().unwrap_or(Path::new("."));
1010    std::fs::create_dir_all(parent).map_err(fail)?;
1011    // Next to its place, then moved: a dose marked is never lost to half a file.
1012    let temporary = path.with_extension("toml.new");
1013    let mut file = std::fs::File::create(&temporary).map_err(fail)?;
1014    file.write_all(bytes).and_then(|()| file.sync_all()).map_err(fail)?;
1015    drop(file);
1016    keep_private(&temporary);
1017    std::fs::rename(&temporary, path).map_err(fail)?;
1018    #[cfg(unix)]
1019    let _ = std::fs::File::open(parent).and_then(|dir| dir.sync_all());
1020    for witness in witnesses(path) {
1021        if !witness.exists() {
1022            if let Some(folder) = witness.parent() {
1023                let _ = std::fs::create_dir_all(folder);
1024            }
1025            std::fs::write(&witness, "").map_err(fail)?;
1026        }
1027    }
1028    Ok(())
1029}
1030
1031/// One dose to take.
1032#[derive(Debug, Clone, PartialEq)]
1033pub struct Dose {
1034    /// `<medicine>@<Unix seconds>`: what marks it taken.
1035    pub key: String,
1036    pub medicine: String,
1037    pub name: String,
1038    pub dose: String,
1039    pub at: Zoned,
1040}
1041
1042#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1043#[serde(rename_all = "lowercase")]
1044pub enum ErrandKind {
1045    /// Fetch the medicines at the pharmacy.
1046    Refill,
1047    /// See the doctor for a new prescription.
1048    Renew,
1049}
1050
1051/// Something to do for a prescription, from a day on.
1052#[derive(Debug, Clone, PartialEq, Eq)]
1053pub struct Errand {
1054    /// Unique to its turn: the task made for it is made once.
1055    pub key: String,
1056    pub prescription: String,
1057    pub kind: ErrandKind,
1058    /// From when the task can start.
1059    pub day: Date,
1060    pub title: String,
1061}
1062
1063/// What is marked of the doses and done of the errands, kept apart from the
1064/// health file: `$XDG_STATE_HOME/sioul/health-state.toml`.
1065#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1066pub struct HealthState {
1067    /// Doses marked taken, by key, when (Unix seconds); kept a week.
1068    #[serde(default)]
1069    pub taken: BTreeMap<String, i64>,
1070    /// Doses already reminded, by key; kept a week.
1071    #[serde(default)]
1072    pub reminded: BTreeMap<String, i64>,
1073    /// Doses whose reminder was not shown, or not known shown, while Sioul
1074    /// ran, by key, when it was tried; kept a week. Not a reminder: such a
1075    /// dose is still asked about afterwards, under its own heading, since
1076    /// Sioul ran (`unanswered`), and another device reminds it (a phone's
1077    /// alarm). What each device did, and whether it may have shown, is in
1078    /// each dose's reminders (`dose_reminders`, a file of its own); this table
1079    /// says it to an older Sioul (0.0.4, 0.0.5), which reads it as a reminder
1080    /// not shown, never as one given: its alarm reminds the dose, plainly, as
1081    /// it always did. 0.0.3 knows no such table: the dose is not reminded
1082    /// there either. Written only when there is one: an older Sioul's file
1083    /// stays as it was.
1084    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1085    pub unshown: BTreeMap<String, i64>,
1086    /// Doses answered "not taken" when asked afterwards, by key; kept a week.
1087    #[serde(default)]
1088    pub not_taken: BTreeMap<String, i64>,
1089    /// Doses whose take moved the next ones, by key: where they started
1090    /// before and after (`Health::taken_at`); kept with the mark.
1091    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1092    pub moved: BTreeMap<String, [i64; 2]>,
1093    /// Errands whose task was made, by key.
1094    #[serde(default)]
1095    pub errands: BTreeMap<String, String>,
1096    /// Chats: the day counted, the minutes used that day, and covered until when.
1097    #[serde(default, deserialize_with = "crate::budget::dates::optional")]
1098    pub chat_day: Option<Date>,
1099    /// Never written while 0, as none: a record started again after a repair
1100    /// holds no minutes of its own, so it sends none over what the others'
1101    /// records hold (the day's chat minutes reset on every device after a
1102    /// repair: review of 15 October 2026, RV-P4 and RF-J). Read as before, 0
1103    /// when missing; an older Sioul reads it the same.
1104    #[serde(default, skip_serializing_if = "is_zero_u32")]
1105    pub chat_minutes: u32,
1106    #[serde(default, skip_serializing_if = "Option::is_none")]
1107    pub chats_locked_until: Option<i64>,
1108}
1109
1110fn is_zero_u32(n: &u32) -> bool {
1111    *n == 0
1112}
1113
1114/// Why the doses' record cannot be trusted: what it held is not known.
1115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1116pub enum Unsound {
1117    /// It does not read: half written, broken by hand, or by a disk.
1118    Unreadable,
1119    /// It is gone, though it was written here before (its witness says so).
1120    Lost,
1121}
1122
1123/// A change to the doses' record that could not be made.
1124#[derive(Debug, Clone, PartialEq, Eq)]
1125pub enum Problem {
1126    /// The record cannot be trusted: nothing is written over it.
1127    Unsound(Unsound),
1128    /// It could not be written (a full disk): what it says.
1129    Write(String),
1130}
1131
1132impl HealthState {
1133    pub fn default_path() -> PathBuf {
1134        crate::config::state_dir().join("health-state.toml")
1135    }
1136
1137    /// The doses' record as written, or why it cannot be trusted. Never an
1138    /// empty record in place of one that does not read: a dose taken would
1139    /// look not taken, and written back empty, the sharing would take every
1140    /// mark out on your other devices too. Of no bytes at all, or gone,
1141    /// though written before (`witnessed`): lost (a crash after a write that
1142    /// had not reached the disk, a full disk, a state folder wiped).
1143    pub fn read(path: &Path) -> Result<HealthState, Unsound> {
1144        match std::fs::read_to_string(path) {
1145            Ok(text) if text.trim().is_empty() && witnessed(path) => Err(Unsound::Lost),
1146            Ok(text) => toml::from_str(&text).map_err(|_| Unsound::Unreadable),
1147            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1148                if witnessed(path) {
1149                    Err(Unsound::Lost)
1150                } else {
1151                    Ok(HealthState::default())
1152                }
1153            }
1154            Err(_) => Err(Unsound::Unreadable),
1155        }
1156    }
1157
1158    /// The record changed by `change`, one writer at a time (the window, the
1159    /// sharing: `filelock`): read again, changed, written, under the lock, so
1160    /// that no change made meanwhile is written over.
1161    pub fn update<T>(path: &Path, now: i64, change: impl FnOnce(&mut HealthState) -> T) -> Result<T, Problem> {
1162        crate::filelock::with_lock(path, || {
1163            let mut state = HealthState::read(path).map_err(Problem::Unsound)?;
1164            let out = change(&mut state);
1165            state.save(path, now).map_err(Problem::Write)?;
1166            Ok(out)
1167        })
1168    }
1169
1170    /// A record that cannot be trusted, kept aside as `<name>.unreadable-<time>`
1171    /// for whoever wants to look, with its witness: a new one can start. Only
1172    /// once nothing it held can be taken out elsewhere (`share::rebuild`).
1173    pub fn set_aside(path: &Path, now: i64) -> Result<Option<PathBuf>, String> {
1174        crate::filelock::with_lock(path, || {
1175            for witness in witnesses(path) {
1176                let _ = std::fs::remove_file(witness);
1177            }
1178            if !path.exists() {
1179                return Ok(None);
1180            }
1181            let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
1182            let aside = path.with_file_name(format!("{name}.unreadable-{now}"));
1183            std::fs::rename(path, &aside).map_err(|e| format!("{}: {e}", path.display()))?;
1184            Ok(Some(aside))
1185        })
1186    }
1187
1188    /// Saved, its marks older than a week dropped. Use `update`: it holds the lock.
1189    pub fn save(&mut self, path: &Path, now: i64) -> Result<(), String> {
1190        let week = now - 7 * 86_400;
1191        self.taken.retain(|_, at| *at >= week);
1192        self.reminded.retain(|_, at| *at >= week);
1193        self.unshown.retain(|_, at| *at >= week);
1194        self.not_taken.retain(|_, at| *at >= week);
1195        self.moved.retain(|key, _| self.taken.contains_key(key));
1196        write_witnessed(path, toml::to_string(self).map_err(|e| e.to_string())?.as_bytes())
1197    }
1198
1199    /// The doses to remind now: due in the last `minutes`, not taken, not reminded yet.
1200    pub fn to_remind(&self, health: &Health, now: &Zoned, minutes: i64) -> Vec<Dose> {
1201        let start = now.checked_sub(Span::new().minutes(minutes)).unwrap_or_else(|_| now.clone());
1202        let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1203        health.doses(&start, &end).into_iter().filter(|d| !self.taken.contains_key(&d.key) && !self.reminded.contains_key(&d.key)).collect()
1204    }
1205
1206    /// The doses of the last `hours`, past their time by `grace` minutes,
1207    /// neither marked nor reminded: due while Sioul ran nowhere, or while it
1208    /// ran but could not show their reminder (`unshown`, `reminder_unshown`).
1209    /// Asked about afterwards, as a question on the past; never reminded to
1210    /// take now.
1211    pub fn unanswered(&self, health: &Health, now: &Zoned, hours: i64, grace: i64) -> Vec<Dose> {
1212        let start = now.checked_sub(Span::new().hours(hours)).unwrap_or_else(|_| now.clone());
1213        let end = now.checked_sub(Span::new().minutes(grace)).unwrap_or_else(|_| now.clone());
1214        health.doses(&start, &end).into_iter().filter(|d| !self.taken.contains_key(&d.key) && !self.reminded.contains_key(&d.key) && !self.not_taken.contains_key(&d.key)).collect()
1215    }
1216
1217    /// Whether Sioul ran when `key` fell due but did not show its reminder,
1218    /// or did not know it shown (no notification server, no connection, no
1219    /// answer in time), here or on another device that shares its doses: its
1220    /// question then says so, never "while Sioul was closed" (which of the
1221    /// two: `dose_reminders`).
1222    pub fn reminder_unshown(&self, key: &str) -> bool {
1223        self.unshown.contains_key(key) && !self.reminded.contains_key(key)
1224    }
1225
1226    /// Today's doses from their time on, not marked yet (taken, or said not
1227    /// taken), for the Porch: reminded or not, since a reminder can go unseen;
1228    /// until marked, until the day ends, or `hours` after their time (as far
1229    /// back as a dose is asked about). Those due while Sioul ran nowhere
1230    /// (`unanswered`) are asked about apart, and left out here.
1231    pub fn due_today(&self, health: &Health, now: &Zoned, hours: i64, grace: i64) -> Vec<Dose> {
1232        let midnight = now.date().to_zoned(now.time_zone().clone()).unwrap_or_else(|_| now.clone());
1233        let back = now.checked_sub(Span::new().hours(hours)).unwrap_or_else(|_| now.clone());
1234        let start = if back > midnight { back } else { midnight };
1235        let end = now.checked_add(Span::new().seconds(1)).unwrap_or_else(|_| now.clone());
1236        let apart: Vec<String> = self.unanswered(health, now, hours, grace).into_iter().map(|d| d.key).collect();
1237        health.doses(&start, &end).into_iter().filter(|d| !self.taken.contains_key(&d.key) && !self.not_taken.contains_key(&d.key) && !apart.contains(&d.key)).collect()
1238    }
1239
1240    /// One more minute of chats today; covered once the day's limit is reached.
1241    /// Returns whether chats are covered now.
1242    pub fn chat_minute(&mut self, limit: &ChatLimit, now: &Zoned) -> bool {
1243        if self.chat_day != Some(now.date()) {
1244            self.chat_day = Some(now.date());
1245            self.chat_minutes = 0;
1246        }
1247        if self.chats_covered(now) {
1248            return true;
1249        }
1250        self.chat_minutes += 1;
1251        if limit.enabled && limit.minutes > 0 && self.chat_minutes >= limit.minutes {
1252            self.chats_locked_until = Some(now.timestamp().as_second() + i64::from(limit.locked_minutes.max(1)) * 60);
1253            self.chat_minutes = 0;
1254            return true;
1255        }
1256        false
1257    }
1258
1259    /// Whether chats are covered now.
1260    pub fn chats_covered(&self, now: &Zoned) -> bool {
1261        self.chats_locked_until.is_some_and(|until| until > now.timestamp().as_second())
1262    }
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267    use super::*;
1268
1269    fn at(text: &str) -> Zoned {
1270        text.parse().unwrap()
1271    }
1272
1273    #[test]
1274    fn doses_by_the_day_the_days_and_the_hours() {
1275        let noon_and_six = Schedule::Day { times: vec!["18:00".into(), "12:00".into()], amounts: BTreeMap::new() };
1276        let doses = noon_and_six.doses(&at("2026-10-03T13:00[Europe/Paris]"), &at("2026-10-05T00:00[Europe/Paris]"));
1277        let shown: Vec<String> = doses.iter().map(|z| z.strftime("%d %H:%M").to_string()).collect();
1278        assert_eq!(shown, vec!["03 18:00", "04 12:00", "04 18:00"]);
1279        // Every other day from tomorrow.
1280        let other = Schedule::Days { days: 2, time: "08:00".into(), from: "2026-10-04".parse().unwrap() };
1281        let shown: Vec<String> = other.doses(&at("2026-10-03T09:00[Europe/Paris]"), &at("2026-10-09T00:00[Europe/Paris]")).iter().map(|z| z.strftime("%d").to_string()).collect();
1282        assert_eq!(shown, vec!["04", "06", "08"]);
1283        // Every six hours from 18:30.
1284        let six = Schedule::Hours { hours: 6, from: at("2026-10-03T18:30[Europe/Paris]").timestamp().as_second() };
1285        let shown: Vec<String> = six.doses(&at("2026-10-04T00:00[Europe/Paris]"), &at("2026-10-04T13:00[Europe/Paris]")).iter().map(|z| z.strftime("%H:%M").to_string()).collect();
1286        assert_eq!(shown, vec!["00:30", "06:30", "12:30"]);
1287    }
1288
1289    #[test]
1290    fn every_few_hours_the_hours_between_doses_are_kept() {
1291        let eight = || Health {
1292            medicines: vec![Medicine { id: "antibiotic".into(), name: "Antibiotic".into(), dose: String::new(), schedule: Schedule::Hours { hours: 8, from: at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None }],
1293            ..Health::default()
1294        };
1295        let times = |health: &Health| -> Vec<String> { health.doses(&at("2026-10-05T12:00[Europe/Paris]"), &at("2026-10-06T12:00[Europe/Paris]")).iter().map(|d| d.at.strftime("%H:%M").to_string()).collect() };
1296        let key = format!("antibiotic@{}", at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second());
1297        // Taken an hour and a half late: the next ones too, always.
1298        let mut health = eight();
1299        let (before, after) = health.taken_at(&key, at("2026-10-05T09:30[Europe/Paris]").timestamp().as_second()).unwrap();
1300        assert_eq!(times(&health), ["17:30", "01:30", "09:30"]);
1301        // The mark taken back: where they were.
1302        assert!(health.taken_back(&key, before, after));
1303        assert_eq!(times(&health), ["16:00", "00:00", "08:00"]);
1304        // Early: the next ones earlier, never closer than eight hours after it.
1305        health.taken_at(&key, at("2026-10-05T07:00[Europe/Paris]").timestamp().as_second()).unwrap();
1306        assert_eq!(times(&health), ["15:00", "23:00", "07:00"]);
1307        // Moved again since: a mark taken back later leaves them.
1308        let mut health = eight();
1309        let (before, after) = health.taken_at(&key, at("2026-10-05T09:30[Europe/Paris]").timestamp().as_second()).unwrap();
1310        let later = format!("antibiotic@{}", at("2026-10-05T17:30[Europe/Paris]").timestamp().as_second());
1311        health.taken_at(&later, at("2026-10-05T18:00[Europe/Paris]").timestamp().as_second()).unwrap();
1312        assert!(!health.taken_back(&key, before, after));
1313        assert_eq!(times(&health), ["02:00", "10:00"]);
1314        // On time to the second: nothing to move.
1315        assert_eq!(eight().taken_at(&key, at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second()), None);
1316        // Medicines at set times keep them: being ready for something.
1317        let mut daily = Health { medicines: vec![Medicine { schedule: Schedule::Day { times: vec!["08:00".into()], amounts: BTreeMap::new() }, ..eight().medicines[0].clone() }], ..Health::default() };
1318        assert_eq!(daily.taken_at(&key, at("2026-10-05T09:30[Europe/Paris]").timestamp().as_second()), None);
1319    }
1320
1321    #[test]
1322    fn a_record_that_cannot_be_trusted_is_never_taken_for_empty() {
1323        let dir = std::env::temp_dir().join(format!("sioul-health-record-{}", std::process::id()));
1324        let _ = std::fs::remove_dir_all(&dir);
1325        std::fs::create_dir_all(&dir).unwrap();
1326        let path = dir.join("health-state.toml");
1327        // Never written: an empty record, honestly.
1328        assert_eq!(HealthState::read(&path), Ok(HealthState::default()));
1329        HealthState::update(&path, 1_000, |s| s.taken.insert("d@1".into(), 1_000)).unwrap();
1330        assert!(HealthState::read(&path).unwrap().taken.contains_key("d@1"));
1331        // Broken: not empty, unreadable; and nothing is written over it.
1332        std::fs::write(&path, "taken = { \"d@1\" = ").unwrap();
1333        assert_eq!(HealthState::read(&path), Err(Unsound::Unreadable));
1334        assert_eq!(HealthState::update(&path, 1_060, |s| s.taken.clear()), Err(Problem::Unsound(Unsound::Unreadable)));
1335        assert_eq!(std::fs::read_to_string(&path).unwrap(), "taken = { \"d@1\" = ", "left as it was");
1336        // Gone after being written: lost, not new.
1337        std::fs::remove_file(&path).unwrap();
1338        assert_eq!(HealthState::read(&path), Err(Unsound::Lost));
1339        // Set aside (here, nothing left to keep), a new record can start.
1340        assert_eq!(HealthState::set_aside(&path, 1_120), Ok(None));
1341        assert_eq!(HealthState::read(&path), Ok(HealthState::default()));
1342        // A broken one is kept aside for whoever wants to look.
1343        std::fs::write(&path, "nonsense = [").unwrap();
1344        let aside = HealthState::set_aside(&path, 1_180).unwrap().unwrap();
1345        assert_eq!(std::fs::read_to_string(&aside).unwrap(), "nonsense = [");
1346        assert_eq!(HealthState::read(&path), Ok(HealthState::default()));
1347        let _ = std::fs::remove_dir_all(&dir);
1348    }
1349
1350    /// An older Sioul, which writes no entry in the devices' registry, is known
1351    /// as its claims say, as before: heard in full after the dose and lately,
1352    /// or its last word, read in full, that it closed, its news quick. Never
1353    /// closed for want of a word.
1354    #[test]
1355    fn an_older_sioul_is_known_as_its_claims_say() {
1356        let due = 1_800_000_000;
1357        let peer = |known_until: i64, closed: bool, delay: Option<i64>| Peer { name: "laptop".into(), id: "laptop-id".into(), known_until, closed, delay, broken: None, heard: known_until.max(due - 3_600), ..Peer::default() };
1358        let laptop = Named { id: "laptop-id".into(), name: "laptop".into(), phone: false };
1359        // Alone, with a record that reads: known.
1360        assert!(doubts(due, due + 60, None, &[]).is_empty());
1361        // Heard in full after the dose was due, a minute ago: known.
1362        assert!(doubts(due, due + 180, None, &[peer(due + 120, false, Some(30))]).is_empty());
1363        // Last heard before the dose, open: it may have marked it.
1364        assert_eq!(doubts(due, due + 600, None, &[peer(due - 300, false, Some(30))]), vec![Doubt::Unheard { device: laptop.clone(), until: due - 300, closed: false }]);
1365        // Closed before, and its news comes within minutes: known.
1366        assert!(doubts(due, due + 600, None, &[peer(due - 3_600, true, Some(60))]).is_empty());
1367        // Closed before, its news slow (a phone's sync) or never measured: not known, as before.
1368        assert_eq!(doubts(due, due + 600, None, &[peer(due - 3_600, true, Some(1_800))]).len(), 1);
1369        assert_eq!(doubts(due, due + 600, None, &[peer(due - 3_600, true, None)]).len(), 1);
1370        // Never said how far it wrote: not known.
1371        assert_eq!(doubts(due, due + 600, None, &[Peer { name: "phone".into(), heard: due, ..Peer::default() }]).len(), 1);
1372        // A line of it lost around the dose's time: not known, whatever else.
1373        let broken = Peer { broken: Some(due - 60), ..peer(due + 120, false, Some(30)) };
1374        assert_eq!(doubts(due, due + 180, None, &[broken]), vec![Doubt::Broken { device: laptop.clone() }]);
1375        // Silent for over a week: no longer counted (`SILENT_DAYS`).
1376        let gone = Peer { heard: due - 8 * 86_400, ..peer(0, false, None) };
1377        assert!(doubts(due, due + 600, None, &[gone]).is_empty());
1378        // This device's record was found broken after the dose was due: not known; doses due after are.
1379        assert_eq!(doubts(due, due + 600, Some(due + 300), &[]), vec![Doubt::Record { since: due + 300 }]);
1380        assert!(doubts(due + 900, due + 1_000, Some(due + 300), &[]).is_empty());
1381        // Heard in full just after the dose, not since (its sync slow, or stopped): known then, not an hour later.
1382        let then = peer(due + 60, false, Some(40));
1383        assert!(doubts_now(due, due + 120, None, std::slice::from_ref(&then)).is_empty());
1384        assert_eq!(doubts_now(due, due + 3_600, None, std::slice::from_ref(&then)), vec![Doubt::Unheard { device: laptop.clone(), until: due + 60, closed: false }]);
1385        // Closed, then spoke again, what it wrote not read here yet: it may have marked it.
1386        let back = Peer { heard: due + 3_000, ..peer(due - 600, true, Some(60)) };
1387        assert_eq!(doubts_now(due, due + 3_600, None, &[back]), vec![Doubt::Unheard { device: laptop, until: due - 600, closed: false }]);
1388    }
1389
1390    /// The dose of the scenarios below: 08:00, Unix seconds.
1391    const DUE: i64 = 1_800_000_000;
1392
1393    /// The phone, as this device knows it: its entry in the registry (a phone
1394    /// sharing its doses), seen changing here at `seen`, all it wrote read.
1395    fn phone(said: Said, seen: i64) -> Peer {
1396        Peer { name: "phone".into(), id: "phone-id".into(), heard: said.exported, said: Some(Said { phone: true, doses: true, ..said }), complete: true, seen, ..Peer::default() }
1397    }
1398
1399    fn the_phone() -> Named {
1400        Named { id: "phone-id".into(), name: "phone".into(), phone: true }
1401    }
1402
1403    #[test]
1404    fn the_phone_closed_cleanly_before_the_dose_is_known() {
1405        // Put away at 07:00, after its last export, read here in full; not started since.
1406        let p = phone(Said { started: DUE - 7_200, closed: DUE - 3_600, working: false, exported: DUE - 3_605, imported: DUE - 3_605, ..Said::default() }, DUE - 3_500);
1407        // At the dose, an hour later, half a day later: known not taken, with no wait for news.
1408        for now in [DUE, DUE + 3_600, DUE + 12 * 3_600] {
1409            assert!(doubts_now(DUE, now, None, std::slice::from_ref(&p)).is_empty(), "{now}");
1410        }
1411        assert_eq!(known_until(DUE, DUE + 60, DUE + 86_400, None, &[p]), DUE + 86_400, "nothing ends it but the day");
1412    }
1413
1414    /// F21 of the review of 5 October 2026: a device closed before the dose
1415    /// may be picked up at its time to mark it, its sync slow to wake; how
1416    /// late the news of its opening comes here is measured on its last
1417    /// openings (`Peer::wake`): known closed only once that while went by
1418    /// after the dose, and `SKEW` more (R8 of the review of 10 October 2026);
1419    /// closed after the dose, at once; none measured, as before.
1420    #[test]
1421    fn a_device_closed_before_the_dose_is_known_once_its_opening_could_have_come() {
1422        let closed = phone(Said { started: DUE - 7_200, closed: DUE - 3_600, working: false, exported: DUE - 3_605, ..Said::default() }, DUE - 3_500);
1423        let doubt = vec![Doubt::Unheard { device: the_phone(), until: DUE - 3_600, closed: true }];
1424        assert!(doubts_now(DUE, DUE + 10, None, std::slice::from_ref(&closed)).is_empty(), "none measured: as before");
1425        // Its openings came here within 40 s lately: known 40 s and `SKEW` after the dose, not before.
1426        let quick = Peer { wake: Some(40), ..closed.clone() };
1427        assert_eq!(doubts_now(DUE, DUE + 10, None, std::slice::from_ref(&quick)), doubt);
1428        assert_eq!(doubts_now(DUE, DUE + 40 + SKEW - 1, None, std::slice::from_ref(&quick)), doubt);
1429        assert!(doubts_now(DUE, DUE + 40 + SKEW, None, std::slice::from_ref(&quick)).is_empty());
1430        // A phone whose sync woke four minutes late once: four minutes, and `SKEW`.
1431        let slow = Peer { wake: Some(240), ..closed.clone() };
1432        assert_eq!(doubts_now(DUE, DUE + 240, None, std::slice::from_ref(&slow)), doubt);
1433        assert!(doubts_now(DUE, DUE + 240 + SKEW, None, std::slice::from_ref(&slow)).is_empty());
1434        assert_eq!(known_from(DUE, DUE - 600, DUE + 86_400, None, std::slice::from_ref(&slow)), Some(DUE + 240 + SKEW), "a dose seen ahead: known from then");
1435        // Then its own alarm opens it (`ALARM_AFTER`): unknown again until the news of that opening had the time to come.
1436        assert_eq!(known_until(DUE, DUE + 240 + SKEW, DUE + 86_400, None, std::slice::from_ref(&slow)), DUE + ALARM_AFTER);
1437        assert_eq!(known_from(DUE, DUE + ALARM_AFTER, DUE + 86_400, None, std::slice::from_ref(&slow)), Some(DUE + ALARM_AFTER + 240 + SKEW));
1438        // Closed after the dose, all it wrote read: its last export holds its answer, at once.
1439        let after = Peer { wake: Some(240), ..phone(Said { started: DUE - 600, closed: DUE + 600, working: false, exported: DUE + 595, ..Said::default() }, DUE + 620) };
1440        assert!(doubts_now(DUE, DUE + 630, None, &[after]).is_empty());
1441        // An older Sioul known by its claims, closed before, its steady news quick: the same wait.
1442        let older = Peer { name: "laptop".into(), id: "laptop-id".into(), known_until: DUE - 3_600, closed: true, delay: Some(30), heard: DUE - 3_600, wake: Some(120), ..Peer::default() };
1443        assert_eq!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&older)).len(), 1);
1444        assert!(doubts_now(DUE, DUE + 120 + SKEW, None, std::slice::from_ref(&older)).is_empty());
1445        // Not sharing its doses, closed before: the same wait, then known.
1446        let apart = Peer { said: slow.said.clone().map(|s| Said { doses: false, ..s }), ..slow };
1447        assert_eq!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&apart)), doubt);
1448        assert!(doubts_now(DUE, DUE + 240 + SKEW, None, &[apart]).is_empty());
1449    }
1450
1451    /// A phone put away before the dose is opened by its own alarm when a
1452    /// computer keeps the doses, `ALARM_AFTER` after the dose: it may mark the
1453    /// dose then, or remind it. Known closed from the dose's time until then
1454    /// (once the news of an opening at the dose had the time to come), then
1455    /// known again only once the news of its alarm's opening had the time to
1456    /// come: never from the dose's time (review of battery part C, H2: the
1457    /// computer, a minute after the phone's alarm, took the phone as known
1458    /// and reminded the dose plainly a second time). None measured: as before.
1459    #[test]
1460    fn a_phone_put_away_is_known_from_its_alarm_once_that_rang() {
1461        let put_away = Peer { wake: Some(30), ..phone(Said { started: DUE - 7_200, closed: DUE - 3_600, working: false, exported: DUE - 3_600, imported: DUE - 3_600, ..Said::default() }, DUE - 3_600) };
1462        let doubt = vec![Doubt::Unheard { device: the_phone(), until: DUE - 3_600, closed: true }];
1463        let at = |now: i64| doubts_now(DUE, now, None, std::slice::from_ref(&put_away));
1464        assert_eq!(at(DUE + 60), doubt, "picked up at the dose: its news not come yet");
1465        assert!(at(DUE + 30 + SKEW).is_empty(), "known before its alarm");
1466        assert_eq!(at(DUE + ALARM_AFTER), doubt, "its alarm rang");
1467        assert_eq!(at(DUE + ALARM_AFTER + 60), doubt, "a minute after its alarm: the review's moment");
1468        assert!(at(DUE + ALARM_AFTER + 30 + SKEW).is_empty(), "the news of its alarm's opening had the time to come");
1469        assert_eq!(known_until(DUE, DUE + 200, DUE + 86_400, None, std::slice::from_ref(&put_away)), DUE + ALARM_AFTER);
1470        assert_eq!(known_from(DUE, DUE + ALARM_AFTER, DUE + 86_400, None, std::slice::from_ref(&put_away)), Some(DUE + ALARM_AFTER + 30 + SKEW));
1471        // A computer put away has no alarm: as before.
1472        let laptop = Peer { name: "laptop".into(), id: "laptop-id".into(), said: put_away.said.clone().map(|s| Said { phone: false, ..s }), ..put_away.clone() };
1473        assert!(doubts_now(DUE, DUE + ALARM_AFTER + 60, None, &[laptop]).is_empty());
1474        // None measured yet: at once, its alarm or not.
1475        let unmeasured = Peer { wake: None, ..put_away };
1476        assert!(doubts_now(DUE, DUE + ALARM_AFTER + 1, None, &[unmeasured]).is_empty());
1477    }
1478
1479    #[test]
1480    fn the_phone_closed_cleanly_after_the_dose_without_marking_is_known() {
1481        // In use from 07:50, closed at 08:20 after its last export (08:19:55), nothing marked there.
1482        let p = phone(Said { started: DUE - 600, closed: DUE + 1_200, working: false, exported: DUE + 1_195, ..Said::default() }, DUE + 1_260);
1483        assert!(doubts_now(DUE, DUE + 1_300, None, std::slice::from_ref(&p)).is_empty());
1484        // Its entry here, but its records not all read yet (a sync that brought one file first): it may hold the answer.
1485        let behind = Peer { complete: false, ..p };
1486        assert_eq!(doubts_now(DUE, DUE + 1_300, None, &[behind]), vec![Doubt::Coming { device: the_phone(), shared: DUE + 1_195 }]);
1487    }
1488
1489    /// No news of a device read since a phone's alarm began (its pull failed,
1490    /// its sync app brought nothing of that device): known closed by what it
1491    /// said before, it is said in doubt, as last heard, never known; its own
1492    /// entry come since, it is as it says; one in use and fresh stays as
1493    /// `FRESH` has it; a doubt said already is not said twice.
1494    #[test]
1495    fn with_no_news_since_the_alarm_a_device_known_closed_is_a_doubt() {
1496        let closed = phone(Said { started: DUE - 7_200, closed: DUE - 3_600, working: false, exported: DUE - 3_605, imported: DUE - 3_605, ..Said::default() }, DUE - 3_500);
1497        assert!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&closed)).is_empty(), "closed before the dose, its news read: known");
1498        let none = std::collections::BTreeSet::new();
1499        let unread = doubts_unread(DUE, DUE + 60, None, std::slice::from_ref(&closed), DUE + 30, &none);
1500        assert_eq!(unread, vec![Doubt::Unheard { device: the_phone(), until: DUE - 3_600, closed: true }], "nothing of it read since the alarm: it may have opened since");
1501        let heard: std::collections::BTreeSet<String> = ["phone-id".to_string()].into();
1502        assert!(doubts_unread(DUE, DUE + 60, None, std::slice::from_ref(&closed), DUE + 30, &heard).is_empty(), "its own entry came since the alarm: as it says");
1503        let working = phone(Said { started: DUE - 600, closed: DUE - 7_200, working: true, exported: DUE + 180, ..Said::default() }, DUE + 200);
1504        assert_eq!(doubts_unread(DUE, DUE + 240, None, std::slice::from_ref(&working), DUE + 230, &none), doubts_now(DUE, DUE + 240, None, std::slice::from_ref(&working)), "in use: as FRESH has it");
1505        let stale = phone(Said { started: DUE - 3_600, working: true, exported: DUE - 900, ..Said::default() }, DUE - 870);
1506        assert_eq!(doubts_unread(DUE, DUE + 60, None, std::slice::from_ref(&stale), DUE + 30, &none).len(), 1, "doubted already: once");
1507    }
1508
1509    #[test]
1510    fn the_phone_in_use_and_exported_after_the_dose_is_known() {
1511        // In use since 07:50, sharing each minute: its 08:03 export seen here at 08:03:20, nothing marked.
1512        let p = phone(Said { started: DUE - 600, closed: DUE - 7_200, working: true, exported: DUE + 180, ..Said::default() }, DUE + 200);
1513        assert!(doubts_now(DUE, DUE + 240, None, std::slice::from_ref(&p)).is_empty());
1514        // Known while that export is fresh: five minutes on with nothing newer, no longer.
1515        assert_eq!(known_until(DUE, DUE + 240, DUE + 86_400, None, std::slice::from_ref(&p)), DUE + 200 + FRESH + 1);
1516        assert_eq!(doubts_now(DUE, DUE + 200 + FRESH + 1, None, &[p]), vec![Doubt::Working { device: the_phone(), shared: DUE + 180, quiet: false }]);
1517    }
1518
1519    #[test]
1520    fn the_phone_in_use_not_exported_since_the_dose_is_uncertain_naming_it() {
1521        // In use; its last export at 07:45, seen at 07:45:30; the dose at 08:00.
1522        let p = phone(Said { started: DUE - 3_600, closed: DUE - 7_200, working: true, exported: DUE - 900, ..Said::default() }, DUE - 870);
1523        assert_eq!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&p)), vec![Doubt::Working { device: the_phone(), shared: DUE - 900, quiet: false }]);
1524        let said = p.said.clone().unwrap();
1525        // An export dated 08:01, within the margin for clocks: not after the dose for sure.
1526        let early = phone(Said { exported: DUE + 60, ..said.clone() }, DUE + 70);
1527        assert_eq!(doubts_now(DUE, DUE + 80, None, &[early]).len(), 1);
1528        // Its 08:03 export comes: known; the doubt lifted by itself.
1529        let later = phone(Said { exported: DUE + 180, ..said }, DUE + 190);
1530        assert!(doubts_now(DUE, DUE + 200, None, &[later]).is_empty());
1531    }
1532
1533    #[test]
1534    fn the_phone_that_crashed_is_uncertain_until_it_starts_again_or_is_said_off() {
1535        // In use from 07:30, last export at 07:45, then nothing: working still up, no close after its start.
1536        let crashed = phone(Said { started: DUE - 1_800, closed: DUE - 86_400, working: true, exported: DUE - 900, ..Said::default() }, DUE - 880);
1537        assert_eq!(doubts_now(DUE, DUE + 600, None, std::slice::from_ref(&crashed)), vec![Doubt::Working { device: the_phone(), shared: DUE - 900, quiet: false }]);
1538        // Hours later, still nothing: it may have stopped without closing, said so; never known.
1539        assert_eq!(doubts_now(DUE, DUE + 4 * 3_600, None, std::slice::from_ref(&crashed)), vec![Doubt::Working { device: the_phone(), shared: DUE - 900, quiet: true }]);
1540        // You say it is off: not counted; the dose known not taken, never answered for you.
1541        let off = Peer { off: true, ..crashed };
1542        assert!(doubts_now(DUE, DUE + 4 * 3_600, None, &[off]).is_empty());
1543        // It starts again, in use, exporting: counted again, and known once it exported after the dose.
1544        let back = phone(Said { started: DUE + 4 * 3_600, closed: DUE - 86_400, working: true, exported: DUE + 4 * 3_600 + 60, ..Said::default() }, DUE + 4 * 3_600 + 70);
1545        assert!(doubts_now(DUE, DUE + 4 * 3_600 + 90, None, &[back]).is_empty());
1546    }
1547
1548    #[test]
1549    fn clocks_a_few_minutes_apart_either_way() {
1550        // The phone's clock 3 minutes ahead, as seen here (`ahead`). Its export at 07:59 real
1551        // time is dated 08:02: not after the dose for sure.
1552        let early = Peer { ahead: 180, ..phone(Said { started: DUE - 3_600, working: true, exported: DUE + 120, ..Said::default() }, DUE - 60) };
1553        assert_eq!(doubts_now(DUE, DUE, None, &[early]).len(), 1, "its 08:02 is 07:59 here");
1554        // Its export at 08:03 real time, dated 08:06: past the dose and the margin; known.
1555        let later = Peer { ahead: 180, ..phone(Said { started: DUE - 3_600, working: true, exported: DUE + 360, ..Said::default() }, DUE + 190) };
1556        assert!(doubts_now(DUE, DUE + 200, None, &[later]).is_empty());
1557        // Its clock 3 minutes behind: the export at 08:04 real time is dated 08:01: Sioul waits longer, never less.
1558        let behind = phone(Said { started: DUE - 3_600, working: true, exported: DUE + 60, ..Said::default() }, DUE + 250);
1559        assert_eq!(doubts_now(DUE, DUE + 260, None, &[behind]).len(), 1);
1560        let behind_later = phone(Said { started: DUE - 3_600, working: true, exported: DUE + 300, ..Said::default() }, DUE + 490);
1561        assert!(doubts_now(DUE, DUE + 500, None, &[behind_later]).is_empty());
1562        // Closed, whatever its clock: started and closed are both on its own clock.
1563        let closed = Peer { ahead: 600, ..phone(Said { started: DUE + 900, closed: DUE + 1_500, working: false, exported: DUE + 1_490, ..Said::default() }, DUE + 300) };
1564        assert!(doubts_now(DUE, DUE + 400, None, &[closed]).is_empty());
1565    }
1566
1567    #[test]
1568    fn a_device_silent_for_a_week_no_longer_counts() {
1569        // Stopped without closing eight days ago, by its clock and as seen here: not counted.
1570        let gone = phone(Said { started: DUE - 10 * 86_400, working: true, exported: DUE - 8 * 86_400, ..Said::default() }, DUE - 8 * 86_400 + 30);
1571        assert!(doubts_now(DUE, DUE + 60, None, &[gone.clone()]).is_empty());
1572        // Six days: still counted, its doubt said.
1573        let six = phone(Said { started: DUE - 7 * 86_400, working: true, exported: DUE - 6 * 86_400, ..Said::default() }, DUE - 6 * 86_400 + 30);
1574        assert_eq!(doubts_now(DUE, DUE + 60, None, &[six]).len(), 1);
1575        // First seen here today (this device just joined), silent a month by its own date: counted a
1576        // week from now, never dropped for a date on another clock.
1577        let first = Peer { seen: DUE - 60, ..gone };
1578        assert_eq!(doubts_now(DUE, DUE + 60, None, std::slice::from_ref(&first)).len(), 1);
1579        assert!(!counts(&first, DUE + 8 * 86_400));
1580        // A device that stopped sharing counts no more.
1581        let left = phone(Said { started: DUE - 600, working: false, closed: DUE - 60, exported: DUE - 61, left: true, ..Said::default() }, DUE - 30);
1582        assert!(!counts(&left, DUE));
1583    }
1584
1585    #[test]
1586    fn a_device_that_does_not_share_its_doses_is_known_only_closed_before() {
1587        let tablet = |said: Said| Peer { name: "tablet".into(), id: "tablet-id".into(), said: Some(Said { doses: false, ..said }), complete: true, seen: DUE + 310, ..Peer::default() };
1588        let named = Named { id: "tablet-id".into(), name: "tablet".into(), phone: false };
1589        // Health switched off there: what it marks never comes. In use: never known.
1590        let working = tablet(Said { started: DUE - 600, working: true, exported: DUE + 300, ..Said::default() });
1591        assert_eq!(doubts_now(DUE, DUE + 320, None, &[working]), vec![Doubt::Apart { device: named }]);
1592        // Closed before the dose: it could not have marked it.
1593        assert!(doubts_now(DUE, DUE + 320, None, &[tablet(Said { started: DUE - 7_200, closed: DUE - 3_600, exported: DUE - 3_605, ..Said::default() })]).is_empty());
1594        // Closed after it: it may have.
1595        assert_eq!(doubts_now(DUE, DUE + 700, None, &[tablet(Said { started: DUE - 600, closed: DUE + 600, exported: DUE + 595, ..Said::default() })]).len(), 1);
1596    }
1597
1598    #[test]
1599    fn each_device_said_apart() {
1600        // The desktop closed, the phone in use and behind, an older laptop never heard in full: two doubts, each named.
1601        let desktop = Peer { name: "desk".into(), id: "desk-id".into(), said: Some(Said { started: DUE - 9_000, closed: DUE - 3_600, exported: DUE - 3_601, doses: true, ..Said::default() }), complete: true, seen: DUE - 3_500, ..Peer::default() };
1602        let p = phone(Said { started: DUE - 3_600, working: true, exported: DUE - 900, ..Said::default() }, DUE - 880);
1603        let older = Peer { name: "laptop".into(), id: "laptop-id".into(), heard: DUE - 60, ..Peer::default() };
1604        let doubts = doubts_now(DUE, DUE + 60, None, &[desktop, p, older]);
1605        assert_eq!(doubts.len(), 2, "{doubts:?}");
1606        assert!(matches!(&doubts[0], Doubt::Working { device, .. } if device.id == "phone-id"));
1607        assert!(matches!(&doubts[1], Doubt::Unheard { device, until: 0, .. } if device.id == "laptop-id"));
1608    }
1609
1610    #[test]
1611    fn reminded_once_never_counted() {
1612        let health = Health {
1613            medicines: vec![Medicine { id: "vitamin-d".into(), name: "Vitamin D".into(), dose: "1000 IU".into(), schedule: Schedule::Day { times: vec!["12:00".into()], amounts: BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None }],
1614            ..Health::default()
1615        };
1616        let mut state = HealthState::default();
1617        let now = at("2026-10-03T12:03[Europe/Paris]");
1618        let due = state.to_remind(&health, &now, 10);
1619        assert_eq!(due.len(), 1);
1620        state.reminded.insert(due[0].key.clone(), now.timestamp().as_second());
1621        assert!(state.to_remind(&health, &now, 10).is_empty(), "reminded once");
1622        assert!(state.to_remind(&health, &at("2026-10-03T13:00[Europe/Paris]"), 10).is_empty(), "past its window, no second reminder");
1623    }
1624
1625    /// A dose held by sleep (due 05:00, waking 07:00): what looks back counts
1626    /// from the dose's time, the opening for it from waking (the sixth
1627    /// review's M1, R4): a record lost at 06:00 and a device apart closed at
1628    /// 06:00 leave it not known, as at its time; a phone put away before the
1629    /// sleep is known only once the news of an opening at waking had the time to come.
1630    #[test]
1631    fn at_waking_what_looks_back_counts_from_the_doses_time() {
1632        let (due, waking) = (1_800_018_000, 1_800_025_200);
1633        let now = waking + 30;
1634        // This device's record found broken at 06:00, the dose due before it.
1635        assert_eq!(doubts_at(due, waking, now, Some(due + 3_600), &[]), [Doubt::Record { since: due + 3_600 }]);
1636        assert_eq!(doubts_at(waking, waking, now, Some(due + 3_600), &[]), [], "counted from waking, it would be lost");
1637        // A computer that does not share its doses, open at 05:00, closed at 06:00.
1638        let apart = Peer {
1639            name: "desk".into(),
1640            id: "desk-id".into(),
1641            said: Some(Said { started: due - 600, closed: due + 3_600, working: false, exported: due + 3_600, imported: due + 3_600, doses: false, left: false, phone: false }),
1642            complete: true,
1643            seen: due + 3_600,
1644            heard: due + 3_600,
1645            ..Peer::default()
1646        };
1647        assert!(matches!(&doubts_at(due, waking, now, None, std::slice::from_ref(&apart))[..], [Doubt::Apart { .. }]));
1648        // A phone put away at 22:30, its openings' news within 30 s: known from waking + 30 s + SKEW, not from its alarm at 05:10.
1649        let phone = Peer {
1650            name: "phone".into(),
1651            id: "phone-id".into(),
1652            said: Some(Said { started: due - 30_000, closed: due - 23_400, working: false, exported: due - 23_400, imported: due - 23_400, doses: true, left: false, phone: true }),
1653            complete: true,
1654            seen: due - 23_400,
1655            heard: due - 23_400,
1656            wake: Some(30),
1657            ..Peer::default()
1658        };
1659        assert!(!doubts_at(due, waking, now, None, std::slice::from_ref(&phone)).is_empty());
1660        assert!(doubts_at(due, waking, waking + 30 + SKEW, None, std::slice::from_ref(&phone)).is_empty());
1661        assert!(doubts_now(due, now, None, std::slice::from_ref(&phone)).is_empty(), "from the dose's time, it would be known by its alarm long rung");
1662    }
1663
1664    /// A reminder not known shown is written in `unshown` for an older Sioul
1665    /// (0.0.4, 0.0.5), which reads it as a dose not reminded: its alarm
1666    /// reminds it, its question asks about it, never "reminded"; written back
1667    /// by it, `unshown` stays. 0.0.3, which knows no such table, does not read
1668    /// it reminded either. What each device did travels in a file of its own
1669    /// (`dose_reminders`), which no older Sioul rewrites.
1670    #[test]
1671    fn an_older_sioul_reads_a_reminder_that_may_have_shown_as_one_to_give() {
1672        /// The record's tables as 0.0.5 knows them.
1673        #[derive(Debug, Default, Serialize, Deserialize)]
1674        struct Older {
1675            #[serde(default)]
1676            taken: BTreeMap<String, i64>,
1677            #[serde(default)]
1678            reminded: BTreeMap<String, i64>,
1679            #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1680            unshown: BTreeMap<String, i64>,
1681            #[serde(default)]
1682            not_taken: BTreeMap<String, i64>,
1683        }
1684        let key = "levo@1791360000".to_string();
1685        let mut new = HealthState::default();
1686        new.unshown.insert(key.clone(), 1_791_360_004);
1687        let text = toml::to_string(&new).unwrap();
1688        assert!(text.contains("[unshown]") && !text.contains("maybe"));
1689        let older: Older = toml::from_str(&text).unwrap();
1690        assert!(!older.reminded.contains_key(&key) && older.unshown.contains_key(&key), "to remind and to ask about, never reminded");
1691        let rewritten: HealthState = toml::from_str(&toml::to_string(&older).unwrap()).unwrap();
1692        assert!(rewritten.unshown.contains_key(&key) && rewritten.reminder_unshown(&key), "its rewrite keeps unshown");
1693        /// The record's tables as 0.0.3 knows them.
1694        #[derive(Debug, Default, Deserialize)]
1695        struct Oldest {
1696            #[serde(default)]
1697            reminded: BTreeMap<String, i64>,
1698        }
1699        let oldest: Oldest = toml::from_str(&text).unwrap();
1700        assert!(!oldest.reminded.contains_key(&key), "0.0.3: to remind, never reminded");
1701        // A file of 6c952fc's, with its `maybe` table: read, the table left out.
1702        let earlier: HealthState = toml::from_str("[unshown]\n\"levo@1791360000\" = 1791360004\n[maybe]\n\"levo@1791360000\" = 1791360002\n").unwrap();
1703        assert!(earlier.reminder_unshown(&key) && !toml::to_string(&earlier).unwrap().contains("maybe"));
1704    }
1705
1706    /// A reminder that could not be shown (no notification server) is no
1707    /// reminder: the dose is still asked about afterwards, and told apart from
1708    /// one due while Sioul was closed; the record keeps it a week, and a file
1709    /// without it (an older Sioul's) reads and is written as before.
1710    #[test]
1711    fn a_reminder_not_shown_is_still_asked_about_and_told_apart() {
1712        let health = Health {
1713            medicines: vec![Medicine { id: "demo-pill".into(), name: "Demo pill".into(), dose: String::new(), schedule: Schedule::Day { times: vec!["08:00".into(), "09:00".into()], amounts: BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None }],
1714            ..Health::default()
1715        };
1716        let now = at("2026-10-03T12:00[Europe/Paris]");
1717        let key = |when: &str| format!("demo-pill@{}", at(when).timestamp().as_second());
1718        let mut state = HealthState::default();
1719        // 08:00: Sioul ran, its reminder failed; 09:00: Sioul ran nowhere.
1720        state.unshown.insert(key("2026-10-03T08:00[Europe/Paris]"), at("2026-10-03T08:01[Europe/Paris]").timestamp().as_second());
1721        let asked: Vec<String> = state.unanswered(&health, &now, 12, 30).into_iter().map(|d| d.key).collect();
1722        assert_eq!(asked, [key("2026-10-03T08:00[Europe/Paris]"), key("2026-10-03T09:00[Europe/Paris]")], "both still asked about");
1723        assert!(state.reminder_unshown(&key("2026-10-03T08:00[Europe/Paris]")));
1724        assert!(!state.reminder_unshown(&key("2026-10-03T09:00[Europe/Paris]")));
1725        // Reminded later (another device, or a notification server back): a reminder, not asked.
1726        let mut later = state.clone();
1727        later.reminded.insert(key("2026-10-03T08:00[Europe/Paris]"), at("2026-10-03T08:05[Europe/Paris]").timestamp().as_second());
1728        assert!(!later.reminder_unshown(&key("2026-10-03T08:00[Europe/Paris]")));
1729        assert_eq!(later.unanswered(&health, &now, 12, 30).len(), 1);
1730        // Sent, not known shown (written in `unshown` too): asked about; a late answer makes it a reminder.
1731        let mut sent = HealthState::default();
1732        let nine = key("2026-10-03T09:00[Europe/Paris]");
1733        sent.unshown.insert(nine.clone(), at("2026-10-03T09:00[Europe/Paris]").timestamp().as_second() + 4);
1734        assert!(sent.reminder_unshown(&nine));
1735        assert!(sent.unanswered(&health, &now, 12, 30).iter().any(|d| d.key == nine), "asked about");
1736        let mut answered_late = sent.clone();
1737        answered_late.reminded.insert(nine.clone(), at("2026-10-03T09:01[Europe/Paris]").timestamp().as_second());
1738        assert!(!answered_late.reminder_unshown(&nine));
1739        // Kept as written, a week; an older Sioul's file has no table, and gets none.
1740        let dir = std::env::temp_dir().join(format!("sioul-unshown-{}", std::process::id()));
1741        let _ = std::fs::remove_dir_all(&dir);
1742        let path = dir.join("health-state.toml");
1743        state.save(&path, now.timestamp().as_second()).unwrap();
1744        let read = HealthState::read(&path).unwrap();
1745        assert!(read.reminder_unshown(&key("2026-10-03T08:00[Europe/Paris]")));
1746        let mut old = read.clone();
1747        old.save(&path, now.timestamp().as_second() + 8 * 86_400).unwrap();
1748        assert!(HealthState::read(&path).unwrap().unshown.is_empty(), "dropped after a week");
1749        std::fs::write(&path, "[taken]\n[reminded]\n").unwrap();
1750        let mut older = HealthState::read(&path).unwrap();
1751        assert!(older.unshown.is_empty());
1752        older.save(&path, now.timestamp().as_second()).unwrap();
1753        assert!(!std::fs::read_to_string(&path).unwrap().contains("unshown"));
1754        let _ = std::fs::remove_dir_all(&dir);
1755    }
1756
1757    #[test]
1758    fn pharmacy_and_doctor_errands() {
1759        let health = Health {
1760            prescriptions: vec![Prescription {
1761                id: "vitamin-d".into(),
1762                title: "Vitamin D 1000 IU".into(),
1763                until: Some("2026-12-31".parse().unwrap()),
1764                refill_days: Some(28),
1765                last_refill: Some("2026-10-01".parse().unwrap()),
1766                ..Prescription::default()
1767            }],
1768            ..Health::default()
1769        };
1770        let errands: Vec<(ErrandKind, String)> = health.errands().into_iter().map(|e| (e.kind, e.day.to_string())).collect();
1771        assert_eq!(errands, vec![(ErrandKind::Refill, "2026-10-27".to_string()), (ErrandKind::Renew, "2026-12-17".to_string())]);
1772    }
1773
1774    #[test]
1775    fn read_as_written_by_hand_and_by_sioul() {
1776        let by_hand = "[[prescription]]\nid = \"d\"\ntitle = \"Vitamin D\"\nuntil = 2026-12-31\n\n[[medicine]]\nid = \"iron\"\nname = \"Iron\"\n[medicine.schedule]\nevery = \"days\"\ndays = 2\ntime = \"12:00\"\nfrom = 2026-10-03\n";
1777        let health: Health = toml::from_str(by_hand).unwrap();
1778        assert_eq!(health.prescriptions[0].until, Some("2026-12-31".parse().unwrap()));
1779        let again: Health = toml::from_str(&toml::to_string(&health).unwrap()).unwrap();
1780        assert_eq!(again, health);
1781    }
1782
1783    #[test]
1784    fn the_watch_s_old_keys_are_read_and_left_aside() {
1785        // As Sioul wrote it while it read a Garmin watch's files (until 8 October 2026).
1786        let dir = std::env::temp_dir().join(format!("sioul-health-watch-{}", std::process::id()));
1787        let _ = std::fs::remove_dir_all(&dir);
1788        std::fs::create_dir_all(&dir).unwrap();
1789        let path = dir.join("health.toml");
1790        let older = "errands_list = \"local/errands\"\nwatch_folder = \"~/Garmin\"\nwatch_offers = false\n\n[[medicine]]\nid = \"iron\"\nname = \"Iron\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\"]\n";
1791        std::fs::write(&path, older).unwrap();
1792        let health = Health::load(&path);
1793        assert_eq!((health.errands_list.as_str(), health.medicines.len()), ("local/errands", 1), "read as before");
1794        assert!(!dir.join("health.toml.unreadable").exists(), "never taken for a file that does not read");
1795        health.save(&path).unwrap();
1796        let written = std::fs::read_to_string(&path).unwrap();
1797        assert!(!written.contains("watch"), "{written}");
1798        assert_eq!(Health::load(&path), health);
1799        let _ = std::fs::remove_dir_all(&dir);
1800    }
1801
1802    #[test]
1803    fn a_file_that_no_longer_reads_is_kept_aside() {
1804        let dir = std::env::temp_dir().join(format!("sioul-health-{}", std::process::id()));
1805        let _ = std::fs::remove_dir_all(&dir);
1806        std::fs::create_dir_all(&dir).unwrap();
1807        let path = dir.join("health.toml");
1808        assert_eq!(Health::load(&path), Health::default(), "none yet");
1809        let broken = "[[medicine]]\nid = \"iron\"\nname = \"Iron\"\n[medicine.schedule]\nevery = \"fortnight\"\n";
1810        std::fs::write(&path, broken).unwrap();
1811        assert_eq!(Health::load(&path), Health::default());
1812        Health::default().save(&path).unwrap();
1813        assert_eq!(std::fs::read_to_string(dir.join("health.toml.unreadable")).unwrap(), broken, "what was there is kept");
1814        #[cfg(unix)]
1815        {
1816            use std::os::unix::fs::PermissionsExt;
1817            assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
1818        }
1819        let _ = std::fs::remove_dir_all(&dir);
1820    }
1821
1822    /// The Porch's doses at a desktop: due now, Sioul running (shown, reminded
1823    /// or not); due earlier today, reminded but not answered (shown, until
1824    /// marked, twelve hours after, or the day's end); due while Sioul ran
1825    /// nowhere (asked apart, not here); marked either way (gone).
1826    #[test]
1827    fn todays_doses_until_marked() {
1828        let health = Health {
1829            medicines: vec![
1830                Medicine { id: "levo".into(), name: "Levothyroxine".into(), dose: "75 µg".into(), schedule: Schedule::Day { times: vec!["07:30".into()], amounts: BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None },
1831                Medicine { id: "iron".into(), name: "Iron".into(), dose: String::new(), schedule: Schedule::Day { times: vec!["12:00".into(), "21:00".into()], amounts: BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None },
1832                Medicine { id: "zinc".into(), name: "Zinc".into(), dose: String::new(), schedule: Schedule::Day { times: vec!["09:00".into(), "23:50".into()], amounts: BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None },
1833            ],
1834            ..Health::default()
1835        };
1836        let key = |id: &str, when: &str| format!("{id}@{}", at(when).timestamp().as_second());
1837        let keys = |doses: Vec<Dose>| doses.into_iter().map(|d| d.key).collect::<Vec<_>>();
1838        let mut state = HealthState::default();
1839        // 07:30 reminded while Sioul ran, never answered; 09:00 due while Sioul ran nowhere.
1840        state.reminded.insert(key("levo", "2026-10-06T07:30[Europe/Paris]"), at("2026-10-06T07:31[Europe/Paris]").timestamp().as_second());
1841        let noon = at("2026-10-06T12:00:30[Europe/Paris]");
1842        assert_eq!(keys(state.due_today(&health, &noon, 12, 30)), [key("levo", "2026-10-06T07:30[Europe/Paris]"), key("iron", "2026-10-06T12:00[Europe/Paris]")]);
1843        assert_eq!(keys(state.unanswered(&health, &noon, 12, 30)), [key("zinc", "2026-10-06T09:00[Europe/Paris]")]);
1844        // Before its time, a dose is not due; at its minute, it is.
1845        assert_eq!(keys(state.due_today(&health, &at("2026-10-06T11:59[Europe/Paris]"), 12, 30)), [key("levo", "2026-10-06T07:30[Europe/Paris]")]);
1846        // Within its half hour and not reminded (the reminder waits for news): shown all the same.
1847        let mut waiting = state.clone();
1848        waiting.reminded.clear();
1849        assert_eq!(keys(waiting.due_today(&health, &at("2026-10-06T07:45[Europe/Paris]"), 12, 30)), [key("levo", "2026-10-06T07:30[Europe/Paris]")]);
1850        // Past its half hour without a reminder anywhere: the question on doses due while closed instead.
1851        assert!(waiting.due_today(&health, &at("2026-10-06T08:10[Europe/Paris]"), 12, 30).is_empty());
1852        // Marked taken, or said not taken: gone.
1853        state.taken.insert(key("iron", "2026-10-06T12:00[Europe/Paris]"), noon.timestamp().as_second());
1854        state.not_taken.insert(key("zinc", "2026-10-06T09:00[Europe/Paris]"), noon.timestamp().as_second());
1855        assert_eq!(keys(state.due_today(&health, &noon, 12, 30)), [key("levo", "2026-10-06T07:30[Europe/Paris]")]);
1856        // Twelve hours after its time: no longer shown; the evening's iron instead.
1857        let evening = at("2026-10-06T21:05[Europe/Paris]");
1858        state.reminded.insert(key("iron", "2026-10-06T21:00[Europe/Paris]"), evening.timestamp().as_second());
1859        assert_eq!(keys(state.due_today(&health, &evening, 12, 30)), [key("iron", "2026-10-06T21:00[Europe/Paris]")]);
1860        // The day ends: yesterday's are not today's, even within twelve hours.
1861        let night = at("2026-10-07T00:30[Europe/Paris]");
1862        assert!(state.due_today(&health, &night, 12, 30).is_empty());
1863        // Yesterday's 23:50 zinc, reminded nowhere, past its half hour: the question's.
1864        assert_eq!(keys(state.unanswered(&health, &night, 12, 30)), [key("zinc", "2026-10-06T23:50[Europe/Paris]")]);
1865    }
1866
1867    #[test]
1868    fn chats_covered_after_their_time() {
1869        let limit = ChatLimit { enabled: true, minutes: 2, locked_minutes: 30 };
1870        let mut state = HealthState::default();
1871        let now = at("2026-10-03T20:00[Europe/Paris]");
1872        assert!(!state.chat_minute(&limit, &now));
1873        assert!(state.chat_minute(&limit, &now), "the second minute reaches the limit");
1874        assert!(state.chats_covered(&at("2026-10-03T20:29[Europe/Paris]")));
1875        assert!(!state.chats_covered(&at("2026-10-03T20:31[Europe/Paris]")), "back after the time chosen");
1876    }
1877
1878    /// A medicine as Sioul 0.0.3 reads and writes it, before takes had
1879    /// amounts of their own: the fields it knows, the others dropped at its
1880    /// next save.
1881    mod older {
1882        use jiff::civil::Date;
1883        use serde::{Deserialize, Serialize};
1884
1885        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1886        pub struct Health {
1887            #[serde(rename = "medicine", default)]
1888            pub medicines: Vec<Medicine>,
1889        }
1890
1891        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1892        pub struct Medicine {
1893            pub id: String,
1894            pub name: String,
1895            #[serde(default, skip_serializing_if = "String::is_empty")]
1896            pub dose: String,
1897            pub schedule: Schedule,
1898            #[serde(default, skip_serializing_if = "Option::is_none")]
1899            pub prescription: Option<String>,
1900            #[serde(default, deserialize_with = "crate::budget::dates::optional", skip_serializing_if = "Option::is_none")]
1901            pub until: Option<Date>,
1902            #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1903            pub paused: bool,
1904        }
1905
1906        #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1907        #[serde(tag = "every", rename_all = "kebab-case")]
1908        pub enum Schedule {
1909            Day {
1910                times: Vec<String>,
1911            },
1912            Days {
1913                days: u32,
1914                time: String,
1915                #[serde(deserialize_with = "crate::budget::dates::required")]
1916                from: Date,
1917            },
1918            Hours {
1919                hours: u32,
1920                from: i64,
1921            },
1922        }
1923    }
1924
1925    fn take(time: &str, amount: &str) -> Take {
1926        Take { time: time.into(), amount: amount.into() }
1927    }
1928
1929    fn at_set_times(id: &str, name: &str) -> Medicine {
1930        Medicine { id: id.into(), name: name.into(), dose: String::new(), schedule: Schedule::Day { times: Vec::new(), amounts: BTreeMap::new() }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None }
1931    }
1932
1933    /// The doses of a day: time, name and amount, in order.
1934    fn day_of(health: &Health, day: &str) -> Vec<(String, String, String)> {
1935        let start = at(&format!("{day}T00:00[Europe/Paris]"));
1936        let end = start.checked_add(Span::new().days(1)).unwrap();
1937        health.doses(&start, &end).into_iter().map(|d| (d.at.strftime("%H:%M").to_string(), d.name, d.dose)).collect()
1938    }
1939
1940    fn said(rows: &[(&str, &str, &str)]) -> Vec<(String, String, String)> {
1941        rows.iter().map(|(a, b, c)| (a.to_string(), b.to_string(), c.to_string())).collect()
1942    }
1943
1944    #[test]
1945    fn each_take_its_time_and_its_own_amount() {
1946        // The same amount at every take: the medicine's dose, the file as before.
1947        let mut same = at_set_times("zinc", "Zinc");
1948        same.set_takes("1 tablet", &[take("20:00", ""), take("8:00", "1 tablet")]).unwrap();
1949        assert_eq!(same.dose, "1 tablet");
1950        assert_eq!(same.schedule, Schedule::Day { times: vec!["08:00".into(), "20:00".into()], amounts: BTreeMap::new() });
1951        assert!(!toml::to_string(&Health { medicines: vec![same.clone()], ..Health::default() }).unwrap().contains("amounts"));
1952        // 1 tablet at 08:00, 2 at 20:00: each take its own; the form's dose, the most usual.
1953        let mut iron = at_set_times("iron", "Iron");
1954        iron.set_takes("1 tablet", &[take("08:00", ""), take("20:00", "2 tablets")]).unwrap();
1955        assert_eq!(iron.takes(), [take("08:00", "1 tablet"), take("20:00", "2 tablets")]);
1956        assert_eq!(iron.usual(), "1 tablet");
1957        assert_eq!(iron.takes_line(), "08:00 · 1 tablet, 20:00 · 2 tablets");
1958        assert_eq!(iron.dose, iron.takes_line(), "what an older Sioul says with each dose");
1959        let health = Health { medicines: vec![iron.clone(), same], ..Health::default() };
1960        assert_eq!(day_of(&health, "2026-10-08"), said(&[("08:00", "Iron", "1 tablet"), ("08:00", "Zinc", "1 tablet"), ("20:00", "Iron", "2 tablets"), ("20:00", "Zinc", "1 tablet")]));
1961        // The doses fall due as they would with one amount for all: the same keys, the same times.
1962        let plain = Health { medicines: vec![Medicine { schedule: Schedule::Day { times: vec!["08:00".into(), "20:00".into()], amounts: BTreeMap::new() }, ..iron.clone() }], ..Health::default() };
1963        let keys = |h: &Health| h.doses(&at("2026-10-08T00:00[Europe/Paris]"), &at("2026-10-10T00:00[Europe/Paris]")).into_iter().filter(|d| d.medicine == "iron").map(|d| (d.key, d.at)).collect::<Vec<_>>();
1964        assert_eq!(keys(&health), keys(&plain));
1965        // A take with no amount among others: none said for it, never another's.
1966        let mut some = at_set_times("d", "Vitamin D");
1967        some.set_takes("", &[take("08:00", ""), take("20:00", "2 drops")]).unwrap();
1968        assert_eq!(some.takes(), [take("08:00", ""), take("20:00", "2 drops")]);
1969        assert_eq!(some.dose, "08:00, 20:00 · 2 drops");
1970        assert_eq!(some.usual(), "", "the earliest on a tie");
1971        // What cannot be set: no take, a time that does not read, two takes at one time.
1972        assert_eq!(at_set_times("x", "X").set_takes("", &[]), Err(TakeProblem::None));
1973        assert_eq!(at_set_times("x", "X").set_takes("", &[take(" 8h ", "")]), Err(TakeProblem::Unreadable("8h".into())));
1974        assert_eq!(at_set_times("x", "X").set_takes("", &[take("08:00", "1"), take("8:00", "2")]), Err(TakeProblem::Twice("08:00".into())));
1975        // Set again alike: the amounts go, the dose is the one amount again.
1976        iron.set_takes("2 tablets", &[take("08:00", ""), take("20:00", "")]).unwrap();
1977        assert_eq!((iron.dose.as_str(), iron.takes()), ("2 tablets", vec![take("08:00", "2 tablets"), take("20:00", "2 tablets")]));
1978    }
1979
1980    #[test]
1981    fn a_prescription_with_three_medicines_and_several_takes_each() {
1982        let mut levo = at_set_times("levo", "Levothyroxine");
1983        levo.set_takes("75 µg", &[take("07:30", "")]).unwrap();
1984        let mut iron = at_set_times("iron", "Iron");
1985        iron.set_takes("1 tablet", &[take("08:00", ""), take("13:00", ""), take("20:00", "2 tablets")]).unwrap();
1986        let mut magnesium = at_set_times("magnesium", "Magnesium");
1987        magnesium.set_takes("300 mg", &[take("12:30", "150 mg"), take("21:30", "")]).unwrap();
1988        let mut health = Health {
1989            prescriptions: vec![Prescription { id: "dr".into(), title: "Thyroid and iron".into(), refill_days: Some(30), last_refill: Some("2026-10-01".parse().unwrap()), ..Prescription::default() }],
1990            medicines: vec![levo, iron, magnesium],
1991            ..Health::default()
1992        };
1993        for medicine in &mut health.medicines {
1994            medicine.prescription = Some("dr".into());
1995        }
1996        assert_eq!(
1997            day_of(&health, "2026-10-08"),
1998            said(&[("07:30", "Levothyroxine", "75 µg"), ("08:00", "Iron", "1 tablet"), ("12:30", "Magnesium", "150 mg"), ("13:00", "Iron", "1 tablet"), ("20:00", "Iron", "2 tablets"), ("21:30", "Magnesium", "300 mg")])
1999        );
2000        // Written and read again: the same.
2001        let again: Health = toml::from_str(&toml::to_string(&health).unwrap()).unwrap();
2002        assert_eq!(again, health);
2003        // One taken out: the others fall due as before.
2004        health.medicines.retain(|m| m.id != "iron");
2005        assert_eq!(day_of(&health, "2026-10-08"), said(&[("07:30", "Levothyroxine", "75 µg"), ("12:30", "Magnesium", "150 mg"), ("21:30", "Magnesium", "300 mg")]));
2006        assert_eq!(health.errands().len(), 1, "its pharmacy, as before");
2007    }
2008
2009    #[test]
2010    fn an_older_file_reads_as_before() {
2011        // As Sioul 0.0.3 writes a medicine: no amounts.
2012        let text = "[[medicine]]\nid = \"iron\"\nname = \"Iron\"\ndose = \"1 tablet\"\n\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\", \"20:00\"]\n";
2013        let health: Health = toml::from_str(text).unwrap();
2014        assert_eq!(health.medicines[0].takes(), [take("08:00", "1 tablet"), take("20:00", "1 tablet")]);
2015        assert_eq!(day_of(&health, "2026-10-08"), said(&[("08:00", "Iron", "1 tablet"), ("20:00", "Iron", "1 tablet")]));
2016        // Written again by this Sioul: what 0.0.3 reads, field for field.
2017        let written = toml::to_string(&health).unwrap();
2018        assert!(!written.contains("amounts"), "{written}");
2019        assert_eq!(toml::from_str::<older::Health>(&written).unwrap(), toml::from_str::<older::Health>(text).unwrap());
2020    }
2021
2022    #[test]
2023    fn an_older_sioul_reads_the_times_and_says_every_amount() {
2024        let mut iron = at_set_times("iron", "Iron");
2025        iron.set_takes("1 tablet", &[take("08:00", ""), take("20:00", "2 tablets")]).unwrap();
2026        let health = Health { medicines: vec![iron], ..Health::default() };
2027        let written = toml::to_string(&health).unwrap();
2028        // Sioul 0.0.3 reads the file: the same times, and the whole line as the dose of each.
2029        let old: older::Health = toml::from_str(&written).unwrap();
2030        assert_eq!(old.medicines[0].schedule, older::Schedule::Day { times: vec!["08:00".into(), "20:00".into()] });
2031        assert_eq!(old.medicines[0].dose, "08:00 · 1 tablet, 20:00 · 2 tablets");
2032        // It saves it again, dropping the amounts: the doses fall due as they did, each saying the whole line.
2033        let back: Health = toml::from_str(&toml::to_string(&old).unwrap()).unwrap();
2034        assert_eq!(back.medicines[0].schedule, Schedule::Day { times: vec!["08:00".into(), "20:00".into()], amounts: BTreeMap::new() });
2035        let (start, end) = (at("2026-10-08T00:00[Europe/Paris]"), at("2026-10-10T00:00[Europe/Paris]"));
2036        let due = |h: &Health| h.doses(&start, &end).into_iter().map(|d| (d.key, d.at)).collect::<Vec<_>>();
2037        assert_eq!(due(&back), due(&health));
2038        assert!(back.doses(&start, &end).iter().all(|d| d.dose == "08:00 · 1 tablet, 20:00 · 2 tablets"));
2039    }
2040
2041    #[test]
2042    fn a_take_moved_by_the_change_of_hour_keeps_its_amount() {
2043        // 02:30 does not exist in Paris on 29 March 2026: the dose comes at 03:30, its amount with it.
2044        let mut night = at_set_times("night", "Night drops");
2045        night.set_takes("1 drop", &[take("02:30", "3 drops"), take("08:00", "")]).unwrap();
2046        let health = Health { medicines: vec![night], ..Health::default() };
2047        assert_eq!(day_of(&health, "2026-03-29"), said(&[("03:30", "Night drops", "3 drops"), ("08:00", "Night drops", "1 drop")]));
2048        let zone = jiff::tz::TimeZone::get("Europe/Paris").unwrap();
2049        let doses = health.doses(&at("2026-03-29T00:00[Europe/Paris]"), &at("2026-03-30T00:00[Europe/Paris]"));
2050        assert_eq!(health.amount_of(&doses[0].key, &zone).as_deref(), Some("3 drops"));
2051        assert_eq!(health.amount_of(&doses[1].key, &zone).as_deref(), Some("1 drop"));
2052        // A dose the schedule no longer has: the amount at its time of day; a medicine gone: none.
2053        assert_eq!(health.amount_of(&format!("night@{}", at("2026-03-30T02:30[Europe/Paris]").timestamp().as_second() + 60), &zone).as_deref(), Some(""));
2054        assert_eq!(health.amount_of("gone@1800000000", &zone), None);
2055    }
2056
2057    /// A medicine's generic name, strength and first day: written only when
2058    /// given, read back, said precisely for a professional; reminders keep
2059    /// the short name. Sioul 0.0.3 reads the file, its doses the same.
2060    #[test]
2061    fn generic_name_and_strength_written_only_when_given() {
2062        let mut levo = at_set_times("levo", "Thyrolan");
2063        levo.set_takes("1 tablet", &[take("07:30", "")]).unwrap();
2064        // None given: the file as before.
2065        let plain = toml::to_string(&Health { medicines: vec![levo.clone()], ..Health::default() }).unwrap();
2066        assert!(!plain.contains("generic") && !plain.contains("strength") && !plain.contains("since"), "{plain}");
2067        assert_eq!(levo.precise(), "Thyrolan");
2068        levo.generic = "levothyroxine".into();
2069        levo.strength = "75 µg".into();
2070        levo.since = Some("2026-03-02".parse().unwrap());
2071        let health = Health { medicines: vec![levo.clone()], ..Health::default() };
2072        let written = toml::to_string(&health).unwrap();
2073        let again: Health = toml::from_str(&written).unwrap();
2074        assert_eq!(again, health, "read back as written");
2075        assert_eq!(levo.precise(), "Thyrolan — levothyroxine 75 µg");
2076        assert_eq!(levo.short_name(), "Thyrolan");
2077        // The name is the generic name: said once.
2078        assert_eq!(Medicine { name: "Levothyroxine".into(), ..levo.clone() }.precise(), "levothyroxine 75 µg");
2079        // Reminders, the day's list, the card: the short name, the take's amount.
2080        assert_eq!(day_of(&health, "2026-10-08"), said(&[("07:30", "Thyrolan", "1 tablet")]));
2081        // Sioul 0.0.3 reads it: the same name and times; the new fields dropped at its save, the doses the same.
2082        let old: older::Health = toml::from_str(&written).unwrap();
2083        assert_eq!((old.medicines[0].name.as_str(), old.medicines[0].dose.as_str()), ("Thyrolan", "1 tablet"));
2084        let back: Health = toml::from_str(&toml::to_string(&old).unwrap()).unwrap();
2085        assert_eq!((back.medicines[0].generic.as_str(), back.medicines[0].strength.as_str(), back.medicines[0].since), ("", "", None));
2086        let (start, end) = (at("2026-10-08T00:00[Europe/Paris]"), at("2026-10-10T00:00[Europe/Paris]"));
2087        let due = |h: &Health| h.doses(&start, &end).into_iter().map(|d| (d.key, d.at, d.name, d.dose)).collect::<Vec<_>>();
2088        assert_eq!(due(&back), due(&health));
2089        // Written by hand with a generic name and no name: the generic name stands in.
2090        let by_hand = "[[medicine]]\nid = \"m\"\nname = \"\"\ngeneric = \"metformin\"\nstrength = \"500 mg\"\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\"]\n";
2091        let hand: Health = toml::from_str(by_hand).unwrap();
2092        assert_eq!((hand.medicines[0].short_name().as_str(), hand.medicines[0].precise().as_str()), ("metformin", "metformin 500 mg"));
2093        assert_eq!(day_of(&hand, "2026-10-08")[0].1, "metformin");
2094    }
2095
2096    /// The review of 5 October 2026, F10 and F22: a record of no bytes (a
2097    /// crash after a write that had not reached a phone's disk, a full disk)
2098    /// read as lost, never as a sound empty one, the doses due before then in
2099    /// doubt; the same once the state folder is wiped whole, its witness with
2100    /// it, the one kept apart in the data folder telling. Each dose's records
2101    /// alike.
2102    #[test]
2103    fn a_record_of_no_bytes_or_gone_with_its_folder_is_lost_never_empty() {
2104        let dir = std::env::temp_dir().join(format!("sioul-record-lost-{}", std::process::id()));
2105        let _ = std::fs::remove_dir_all(&dir);
2106        let (state, data) = (dir.join("state"), dir.join("data"));
2107        APART.with(|apart| *apart.borrow_mut() = Some(data.clone()));
2108        let path = state.join("health-state.toml");
2109        assert_eq!(HealthState::read(&path), Ok(HealthState::default()), "never written: nothing lost");
2110        HealthState::update(&path, DUE, |s| {
2111            s.taken.insert("iron@1800000000".into(), DUE);
2112        })
2113        .unwrap();
2114        std::fs::write(&path, "").unwrap();
2115        assert_eq!(HealthState::read(&path), Err(Unsound::Lost), "of no bytes, written before");
2116        std::fs::remove_dir_all(&state).unwrap();
2117        assert_eq!(HealthState::read(&path), Err(Unsound::Lost), "the state folder wiped, the witness apart kept");
2118        HealthState::set_aside(&path, DUE).unwrap();
2119        assert_eq!(HealthState::read(&path), Ok(HealthState::default()), "set aside: a new record starts");
2120        let records = state.join("health-doses.toml");
2121        crate::doses::DoseRecords::update(&records, DUE, |r| r.open("iron@1800000000", "desk-id", DUE)).unwrap();
2122        std::fs::write(&records, "").unwrap();
2123        assert_eq!(crate::doses::DoseRecords::read(&records), Err(Unsound::Lost));
2124        std::fs::remove_dir_all(&state).unwrap();
2125        assert_eq!(crate::doses::DoseRecords::read(&records), Err(Unsound::Lost));
2126        APART.with(|apart| *apart.borrow_mut() = None);
2127        let _ = std::fs::remove_dir_all(&dir);
2128    }
2129
2130    /// F23: a medicine's start, moved by a dose taken late, put back by an
2131    /// edit made elsewhere meanwhile: moved again where the mark said, its
2132    /// edit kept; twice moved, both; nothing to mend, nothing changed.
2133    #[test]
2134    fn a_move_lost_to_an_edit_elsewhere_is_mended() {
2135        let start = at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second();
2136        let mut health = Health {
2137            medicines: vec![Medicine { id: "antibiotic".into(), name: "Antibiotic".into(), dose: "1 tablet, with food".into(), schedule: Schedule::Hours { hours: 8, from: start }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None }],
2138            ..Health::default()
2139        };
2140        let mut state = HealthState::default();
2141        let late = start + 90 * 60;
2142        state.moved.insert(format!("antibiotic@{start}"), [start, late + 8 * 3600]);
2143        assert!(health.mend_shifts(&state));
2144        assert!(matches!(health.medicines[0].schedule, Schedule::Hours { from, .. } if from == late + 8 * 3600));
2145        assert_eq!(health.medicines[0].dose, "1 tablet, with food", "its other changes kept");
2146        assert!(!health.mend_shifts(&state), "mended once");
2147        let next = late + 8 * 3600;
2148        state.moved.insert(format!("antibiotic@{next}"), [next, next + 3600 + 8 * 3600]);
2149        health.medicines[0].schedule = Schedule::Hours { hours: 8, from: start };
2150        assert!(health.mend_shifts(&state));
2151        assert!(matches!(health.medicines[0].schedule, Schedule::Hours { from, .. } if from == next + 3600 + 8 * 3600), "both moves");
2152    }
2153
2154    /// R10 of the review of 10 October 2026: two moves from one start (the
2155    /// second dose marked on a device the first move had not reached): the
2156    /// mend goes where the latest mark moved it, whatever the keys' order.
2157    #[test]
2158    fn two_moves_from_one_start_mend_to_the_latest_mark() {
2159        let start = at("2026-10-05T08:00[Europe/Paris]").timestamp().as_second();
2160        let medicine = Medicine { id: "antibiotic".into(), name: "Antibiotic".into(), dose: "1 tablet".into(), schedule: Schedule::Hours { hours: 8, from: start }, prescription: None, until: None, paused: false, generic: String::new(), strength: String::new(), since: None };
2161        let mut state = HealthState::default();
2162        // 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.
2163        let (first, second) = (format!("antibiotic@{start}"), format!("antibiotic@{}", start + 8 * 3600));
2164        state.moved.insert(first.clone(), [start, start + 90 * 60 + 8 * 3600]);
2165        state.moved.insert(second.clone(), [start, start + 8 * 3600 + 45 * 60 + 8 * 3600]);
2166        state.taken.insert(first, start + 90 * 60);
2167        state.taken.insert(second, start + 8 * 3600 + 45 * 60);
2168        let mut health = Health { medicines: vec![medicine.clone()], ..Health::default() };
2169        assert!(health.mend_shifts(&state));
2170        assert!(matches!(health.medicines[0].schedule, Schedule::Hours { from, .. } if from == start + 8 * 3600 + 45 * 60 + 8 * 3600), "the latest mark's move");
2171        // Marked in the other order: the same.
2172        let (a, b) = (state.taken.values().copied().min().unwrap(), state.taken.values().copied().max().unwrap());
2173        for taken in state.taken.values_mut() {
2174            *taken = if *taken == a { b } else { a };
2175        }
2176        let mut health = Health { medicines: vec![medicine], ..Health::default() };
2177        assert!(health.mend_shifts(&state));
2178        assert!(matches!(health.medicines[0].schedule, Schedule::Hours { from, .. } if from == start + 90 * 60 + 8 * 3600), "the latest mark's move, whatever its key");
2179    }
2180}