Skip to main content

sioul_core/
doses.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Each dose that fell due, as a record your devices share (docs/health.md,
5//! "Doses as records"): `$XDG_STATE_HOME/sioul/health-doses.toml`, in the
6//! sharing's health part.
7//!
8//! A dose falls due: a device that sees it opens its record, not taken yet
9//! (the canary), and says so (`opened`: each device that did, and when). The
10//! first device that captures your answer turns it into taken (when you took
11//! it, earlier if you say so) or skipped, noting itself and when. Each device
12//! writes only its own opening and its own answer, so the sharing, where the
13//! later word wins entry by entry, never has two devices fighting over one
14//! entry: answers merge here, when read (`answered`), by the earliest captured;
15//! two that differ are both kept, both said, and you choose. A merge takes
16//! nothing out. Records go nine days after their dose (`KEPT_DAYS`).
17//!
18//! A file of its own, not a table of `health-state.toml`: an older Sioul saves
19//! that one through what it knows and would drop the table, which the sharing
20//! would then take out on every device. An older Sioul keeps this file's
21//! changes waiting, unknown to it, and writes nothing in it. The marks of
22//! `health-state.toml` (`taken`, `not_taken`) are still written beside the
23//! answers, for it, and read here as answers of a device not named.
24
25use crate::health::{HealthState, Problem, Unsound};
26use serde::{Deserialize, Serialize};
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29
30/// How long a record is kept after its dose was due: the Health page's week
31/// back (shown, read only), and two days.
32pub const KEPT_DAYS: i64 = 9;
33
34/// The file's name, in Sioul's state folder.
35pub const FILE: &str = "health-doses.toml";
36
37/// The doses that fell due, by key (`<medicine>@<Unix seconds>`).
38#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
39pub struct DoseRecords {
40    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
41    pub dose: BTreeMap<String, Record>,
42}
43
44/// One dose that fell due.
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
46pub struct Record {
47    #[serde(default, skip_serializing_if = "String::is_empty")]
48    pub medicine: String,
49    /// When it was due (Unix seconds).
50    #[serde(default)]
51    pub due: i64,
52    /// The canary: each device that saw it fall due, by its id, and when (its clock).
53    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
54    pub opened: BTreeMap<String, i64>,
55    /// Each device's answer, by its id.
56    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
57    pub answer: BTreeMap<String, Answer>,
58}
59
60/// One device's answer, shared whole (`whole = ["dose.*.answer.*"]` in the sharing).
61#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
62pub struct Answer {
63    /// "taken" or "skipped"; another word (a later Sioul's) is an answer not understood here.
64    pub state: String,
65    /// Taken: when, as said (earlier than `noted` when asked). Skipped: when it was said.
66    #[serde(default)]
67    pub at: i64,
68    /// When the answer was captured, on that device's clock.
69    #[serde(default)]
70    pub noted: i64,
71    /// That device's name and kind then ("phone", "computer"), to say where.
72    #[serde(default, skip_serializing_if = "String::is_empty")]
73    pub name: String,
74    #[serde(default, skip_serializing_if = "String::is_empty")]
75    pub kind: String,
76}
77
78pub const TAKEN: &str = "taken";
79pub const SKIPPED: &str = "skipped";
80
81/// What an answer says.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum State {
84    Taken,
85    Skipped,
86    /// A word this Sioul does not know (a later version's): an answer all the same.
87    Other(String),
88}
89
90impl State {
91    fn of(word: &str) -> State {
92        match word {
93            TAKEN => State::Taken,
94            SKIPPED => State::Skipped,
95            other => State::Other(other.to_string()),
96        }
97    }
98}
99
100/// An answer as merged: what, by which device, when.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Given {
103    pub state: State,
104    /// The device that gave it; "" for a mark of `health-state.toml` (an older
105    /// Sioul's, or this device's before records were kept).
106    pub device: String,
107    pub name: String,
108    pub kind: String,
109    /// Taken: when, as said. Skipped: when it was said.
110    pub at: i64,
111    /// When it was captured; 0 when not known (a mark of `health-state.toml`).
112    pub noted: i64,
113}
114
115impl Given {
116    /// When it was captured, as near as known: a mark of `health-state.toml`
117    /// says only when it was taken, which is never after it was captured.
118    fn captured(&self) -> i64 {
119        if self.noted > 0 { self.noted } else { self.at }
120    }
121}
122
123/// A dose's answers, merged.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum Answered {
126    /// No answer anywhere this device has read: not taken yet, as far as the
127    /// records go (whether another device may hold one is `health::doubts_now`'s).
128    Open,
129    /// Taken, everywhere it was answered: the earliest answer captured.
130    Taken(Given),
131    /// Said not taken, everywhere it was answered: the earliest.
132    Skipped(Given),
133    /// Answers that differ, each kept, the earliest captured first: you choose.
134    Differ(Vec<Given>),
135}
136
137/// "levo@1800000000" → ("levo", 1800000000).
138fn parts(key: &str) -> (String, i64) {
139    match key.rsplit_once('@') {
140        Some((medicine, due)) => (medicine.to_string(), due.parse().unwrap_or(0)),
141        None => (key.to_string(), 0),
142    }
143}
144
145/// A dose's answers, from the records and the marks of `health-state.toml`,
146/// merged: the same answer everywhere is that answer, the earliest captured;
147/// answers that differ are all kept. A mark of `health-state.toml` that says
148/// what an answer already says is that answer, written beside it for an older Sioul.
149pub fn answered(key: &str, state: &HealthState, records: &DoseRecords) -> Answered {
150    let mut given: Vec<Given> = records
151        .dose
152        .get(key)
153        .map(|record| record.answer.iter().map(|(device, a)| Given { state: State::of(&a.state), device: device.clone(), name: a.name.clone(), kind: a.kind.clone(), at: a.at, noted: a.noted }).collect())
154        .unwrap_or_default();
155    let has = |given: &[Given], wanted: &State| given.iter().any(|g| g.state == *wanted);
156    if let Some(at) = state.taken.get(key).copied().filter(|_| !has(&given, &State::Taken)) {
157        given.push(Given { state: State::Taken, device: String::new(), name: String::new(), kind: String::new(), at, noted: 0 });
158    }
159    if let Some(at) = state.not_taken.get(key).copied().filter(|_| !has(&given, &State::Skipped)) {
160        given.push(Given { state: State::Skipped, device: String::new(), name: String::new(), kind: String::new(), at, noted: at });
161    }
162    given.sort_by(|a, b| a.captured().cmp(&b.captured()).then_with(|| a.device.cmp(&b.device)));
163    let Some(first) = given.first().cloned() else { return Answered::Open };
164    if given.iter().any(|g| g.state != first.state) {
165        return Answered::Differ(given);
166    }
167    match first.state {
168        State::Taken => Answered::Taken(first),
169        State::Skipped => Answered::Skipped(first),
170        State::Other(_) => Answered::Differ(given),
171    }
172}
173
174impl DoseRecords {
175    pub fn default_path() -> PathBuf {
176        crate::config::state_dir().join(FILE)
177    }
178
179    /// The records as written, or why they cannot be trusted. Never empty in
180    /// place of a file that does not read: an answer given would look not
181    /// given, and written back empty, the sharing would take it out everywhere.
182    /// Of no bytes, or gone, though written before (`health::witnessed`): lost.
183    pub fn read(path: &Path) -> Result<DoseRecords, Unsound> {
184        match std::fs::read_to_string(path) {
185            Ok(text) if text.trim().is_empty() && crate::health::witnessed(path) => Err(Unsound::Lost),
186            Ok(text) => toml::from_str(&text).map_err(|_| Unsound::Unreadable),
187            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
188                if crate::health::witnessed(path) {
189                    Err(Unsound::Lost)
190                } else {
191                    Ok(DoseRecords::default())
192                }
193            }
194            Err(_) => Err(Unsound::Unreadable),
195        }
196    }
197
198    /// The records changed by `change`, one writer at a time (the window, the
199    /// sharing: `filelock`), read again just before, so that nothing written
200    /// meanwhile is written over.
201    pub fn update<T>(path: &Path, now: i64, change: impl FnOnce(&mut DoseRecords) -> T) -> Result<T, Problem> {
202        crate::filelock::with_lock(path, || {
203            let mut records = DoseRecords::read(path).map_err(Problem::Unsound)?;
204            let out = change(&mut records);
205            records.save(path, now).map_err(Problem::Write)?;
206            Ok(out)
207        })
208    }
209
210    /// Records that cannot be trusted, kept aside as `<name>.unreadable-<time>`
211    /// with their witness gone: new ones can start. Only once nothing they
212    /// held can be taken out elsewhere (`share::rebuild`).
213    pub fn set_aside(path: &Path, now: i64) -> Result<Option<PathBuf>, String> {
214        crate::filelock::with_lock(path, || {
215            for witness in crate::health::witnesses(path) {
216                let _ = std::fs::remove_file(witness);
217            }
218            if !path.exists() {
219                return Ok(None);
220            }
221            let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
222            let aside = path.with_file_name(format!("{name}.unreadable-{now}"));
223            std::fs::rename(path, &aside).map_err(|e| format!("{}: {e}", path.display()))?;
224            Ok(Some(aside))
225        })
226    }
227
228    /// Saved, records past `KEPT_DAYS` dropped. Use `update`: it holds the lock.
229    fn save(&mut self, path: &Path, now: i64) -> Result<(), String> {
230        let oldest = now - KEPT_DAYS * 86_400;
231        self.dose.retain(|key, record| (if record.due > 0 { record.due } else { parts(key).1 }) >= oldest);
232        let text = format!("# Each dose that fell due, and the answers your devices captured (docs/health.md).\n{}", toml::to_string(self).map_err(|e| e.to_string())?);
233        crate::health::write_witnessed(path, text.as_bytes())
234    }
235
236    fn record(&mut self, key: &str) -> &mut Record {
237        self.dose.entry(key.to_string()).or_insert_with(|| {
238            let (medicine, due) = parts(key);
239            Record { medicine, due, ..Record::default() }
240        })
241    }
242
243    /// The canary: `device` saw the dose fall due at `now`. Opened already
244    /// here, by any device, it is left as it is. Returns whether it changed.
245    pub fn open(&mut self, key: &str, device: &str, now: i64) -> bool {
246        if self.dose.get(key).is_some_and(|r| !r.opened.is_empty() || !r.answer.is_empty()) {
247            return false;
248        }
249        self.record(key).opened.insert(device.to_string(), now);
250        true
251    }
252
253    /// The device that opened the record first, and when; none when no device did.
254    pub fn opened_first(&self, key: &str) -> Option<(String, i64)> {
255        self.dose.get(key)?.opened.iter().min_by_key(|(device, at)| (**at, (*device).clone())).map(|(device, at)| (device.clone(), *at))
256    }
257
258    /// `device`'s answer, given now: its own, replacing what it said before.
259    /// Another device's answer stays, the same or not: answers that differ are
260    /// both kept, and said (`answered`).
261    pub fn answer(&mut self, key: &str, device: &str, answer: Answer) {
262        self.record(key).answer.insert(device.to_string(), answer);
263    }
264
265    /// You chose, seeing the answers that differ: `device`'s answer, and every
266    /// other answer that says otherwise taken out, on purpose.
267    pub fn choose(&mut self, key: &str, device: &str, answer: Answer) {
268        let state = answer.state.clone();
269        let record = self.record(key);
270        record.answer.retain(|other, given| other == device || given.state == state);
271        record.answer.insert(device.to_string(), answer);
272    }
273
274    /// The mark taken back ("one click takes it back"): every answer saying it
275    /// was taken goes, on purpose; the dose is open again.
276    pub fn take_back(&mut self, key: &str) {
277        if let Some(record) = self.dose.get_mut(key) {
278            record.answer.retain(|_, given| given.state != TAKEN);
279        }
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    fn scratch(name: &str) -> PathBuf {
288        let dir = std::env::temp_dir().join(format!("sioul-doses-{name}-{}", std::process::id()));
289        let _ = std::fs::remove_dir_all(&dir);
290        std::fs::create_dir_all(&dir).unwrap();
291        dir
292    }
293
294    fn said(state: &str, at: i64, noted: i64, name: &str) -> Answer {
295        Answer { state: state.into(), at, noted, name: name.into(), kind: if name == "phone" { "phone".into() } else { "computer".into() } }
296    }
297
298    const KEY: &str = "levo@1800000000";
299
300    #[test]
301    fn records_that_cannot_be_trusted_are_never_taken_for_empty() {
302        let dir = scratch("trust");
303        let path = dir.join("health-doses.toml");
304        assert_eq!(DoseRecords::read(&path), Ok(DoseRecords::default()), "never written: empty, honestly");
305        DoseRecords::update(&path, 1_800_000_100, |r| r.answer(KEY, "phone-id", said(TAKEN, 1_800_000_060, 1_800_000_090, "phone"))).unwrap();
306        assert!(DoseRecords::read(&path).unwrap().dose[KEY].answer.contains_key("phone-id"));
307        // Broken: unreadable, and nothing written over it.
308        std::fs::write(&path, "[dose.\"levo@1800000000\"\nstate =").unwrap();
309        assert_eq!(DoseRecords::read(&path), Err(Unsound::Unreadable));
310        assert_eq!(DoseRecords::update(&path, 1_800_000_200, |r| r.dose.clear()), Err(Problem::Unsound(Unsound::Unreadable)));
311        assert_eq!(std::fs::read_to_string(&path).unwrap(), "[dose.\"levo@1800000000\"\nstate =", "left as it was");
312        // Gone after being written: lost, not new.
313        std::fs::remove_file(&path).unwrap();
314        assert_eq!(DoseRecords::read(&path), Err(Unsound::Lost));
315        assert_eq!(DoseRecords::set_aside(&path, 1_800_000_300), Ok(None));
316        assert_eq!(DoseRecords::read(&path), Ok(DoseRecords::default()), "set aside: new ones can start");
317        let _ = std::fs::remove_dir_all(&dir);
318    }
319
320    #[test]
321    fn answers_merge_by_the_earliest_and_answers_that_differ_both_stay() {
322        let empty = HealthState::default();
323        let mut records = DoseRecords::default();
324        assert_eq!(answered(KEY, &empty, &records), Answered::Open, "nothing anywhere: the canary");
325        records.open(KEY, "desk-id", 1_800_000_010);
326        assert_eq!(answered(KEY, &empty, &records), Answered::Open, "opened, not answered");
327        // Taken on the phone at 08:02, then on the desktop later: one answer, the earliest captured.
328        records.answer(KEY, "desk-id", said(TAKEN, 1_800_000_600, 1_800_000_600, "desk"));
329        records.answer(KEY, "phone-id", said(TAKEN, 1_800_000_120, 1_800_000_125, "phone"));
330        match answered(KEY, &empty, &records) {
331            Answered::Taken(given) => assert_eq!((given.device.as_str(), given.at), ("phone-id", 1_800_000_120)),
332            other => panic!("{other:?}"),
333        }
334        // Skipped on the desktop instead: both kept, both said, the earliest first; never one picked.
335        records.answer(KEY, "desk-id", said(SKIPPED, 1_800_000_600, 1_800_000_600, "desk"));
336        match answered(KEY, &empty, &records) {
337            Answered::Differ(given) => {
338                assert_eq!(given.iter().map(|g| (g.device.as_str(), g.state.clone())).collect::<Vec<_>>(), [("phone-id", State::Taken), ("desk-id", State::Skipped)]);
339            }
340            other => panic!("{other:?}"),
341        }
342        // You choose "taken": the desktop's answer is yours now, the other kept.
343        records.choose(KEY, "desk-id", said(TAKEN, 1_800_000_700, 1_800_000_700, "desk"));
344        assert!(matches!(answered(KEY, &empty, &records), Answered::Taken(ref g) if g.device == "phone-id"));
345        // You choose "not taken": the answers saying otherwise go, on purpose.
346        records.choose(KEY, "desk-id", said(SKIPPED, 1_800_000_800, 1_800_000_800, "desk"));
347        assert!(matches!(answered(KEY, &empty, &records), Answered::Skipped(ref g) if g.device == "desk-id"));
348        assert_eq!(records.dose[KEY].answer.len(), 1);
349        // A word this Sioul does not know (a later version's): an answer that differs, said, never dropped.
350        records.answer(KEY, "tablet-id", said("half", 1_800_000_900, 1_800_000_900, "tablet"));
351        assert!(matches!(answered(KEY, &empty, &records), Answered::Differ(ref g) if g.len() == 2));
352        // Taken back: every "taken" goes; what said otherwise stays.
353        let mut back = DoseRecords::default();
354        back.answer(KEY, "phone-id", said(TAKEN, 1_800_000_120, 1_800_000_125, "phone"));
355        back.take_back(KEY);
356        assert_eq!(answered(KEY, &empty, &back), Answered::Open);
357    }
358
359    #[test]
360    fn the_marks_of_older_sioul_are_answers_too() {
361        let records = DoseRecords::default();
362        // An older phone marked it taken in health-state.toml only: taken, by a device not named.
363        let mut state = HealthState::default();
364        state.taken.insert(KEY.into(), 1_800_000_120);
365        assert!(matches!(answered(KEY, &state, &records), Answered::Taken(ref g) if g.device.is_empty() && g.at == 1_800_000_120));
366        // The same mark beside a record's answer (a new device writes both): one answer, the record's.
367        let mut both = DoseRecords::default();
368        both.answer(KEY, "phone-id", said(TAKEN, 1_800_000_120, 1_800_000_125, "phone"));
369        assert!(matches!(answered(KEY, &state, &both), Answered::Taken(ref g) if g.device == "phone-id"));
370        // An older device said it was not taken; the desktop's record says taken: both said.
371        let mut not = HealthState::default();
372        not.not_taken.insert(KEY.into(), 1_800_000_300);
373        assert!(matches!(answered(KEY, &not, &both), Answered::Differ(ref g) if g.len() == 2));
374        // Both marks in health-state.toml (the later word did not win between two maps): both said.
375        state.not_taken.insert(KEY.into(), 1_800_000_300);
376        assert!(matches!(answered(KEY, &state, &records), Answered::Differ(_)));
377    }
378
379    #[test]
380    fn a_canary_opened_on_two_devices_is_one_record() {
381        let mut desk = DoseRecords::default();
382        assert!(desk.open(KEY, "desk-id", 1_800_000_030));
383        assert!(!desk.open(KEY, "desk-id", 1_800_000_090), "opened once");
384        // The phone's opening arrives (the sharing writes its entry beside the desktop's): one record, both kept.
385        desk.dose.get_mut(KEY).unwrap().opened.insert("phone-id".into(), 1_800_000_020);
386        assert_eq!(desk.dose.len(), 1);
387        assert_eq!(desk.opened_first(KEY), Some(("phone-id".into(), 1_800_000_020)));
388        assert_eq!(desk.dose[KEY].medicine, "levo");
389        assert_eq!(desk.dose[KEY].due, 1_800_000_000);
390    }
391
392    #[test]
393    fn records_go_nine_days_after_their_dose() {
394        let dir = scratch("prune");
395        let path = dir.join("health-doses.toml");
396        let due = 1_800_000_000;
397        DoseRecords::update(&path, due + 60, |r| {
398            r.open(KEY, "desk-id", due + 30);
399            r.open("iron@1800600000", "desk-id", 1_800_600_030);
400        })
401        .unwrap();
402        // Eight days on: both kept (the Health page shows a week back).
403        DoseRecords::update(&path, due + 8 * 86_400, |_| ()).unwrap();
404        assert_eq!(DoseRecords::read(&path).unwrap().dose.len(), 2);
405        // Ten days on: the first goes, the later one stays.
406        DoseRecords::update(&path, due + 10 * 86_400, |_| ()).unwrap();
407        assert_eq!(DoseRecords::read(&path).unwrap().dose.keys().collect::<Vec<_>>(), ["iron@1800600000"]);
408        // As the sharing reads it: one table per answer, kept whole.
409        DoseRecords::update(&path, due + 10 * 86_400, |r| r.answer("iron@1800600000", "phone-id", said(TAKEN, 1_800_600_100, 1_800_600_110, "phone"))).unwrap();
410        let text = std::fs::read_to_string(&path).unwrap();
411        assert!(text.contains("[dose.\"iron@1800600000\".answer.phone-id]"), "{text}");
412        let _ = std::fs::remove_dir_all(&dir);
413    }
414}