Skip to main content

sioul_sync/
devices.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Each device says how it is, in the sharing folder (docs/database.md,
5//! "Devices"): when it last started, when it last closed cleanly, whether it
6//! is working now, when it last read the others and when it last wrote its
7//! own. The doses rely on it (docs/health.md, "Knowing"): a device that closed
8//! cleanly after its last export holds no answer the others have not read; one
9//! still working may.
10//!
11//! One file per device, `<folder>/devices/<id>.device`, written by that device
12//! only, sealed like the claims (bound to the device it names), its size
13//! changed at each writing (`share::pad_for`: some sync apps tell a change by
14//! its size alone), a few hundred bytes, never read past 64 KiB. The same,
15//! privately, in `$XDG_STATE_HOME/sioul/share/device.toml`, never shared,
16//! changed under a lock and read just before (`change`): two processes of
17//! Sioul (the window, a phone's background service) never write an older
18//! state back over the other's.
19//!
20//! Written into the folder only when the others need it (`change`): at each
21//! export while the device is in use (`working`: the doses need its exports
22//! fresh, `sioul_core::health::FRESH`), whether someone is at it or not, so
23//! that the server, which sees when the file changes, cannot tell when
24//! someone sits at a computer (`active` is sealed with the rest); when
25//! anything else it says changes (its session, how far it wrote, whether it
26//! shares its doses, its build), when the folder's copy is not the one it
27//! wrote, and once an hour (`RESTATED`). A device put away whose exchanges
28//! find nothing new writes nothing there: a phone's background step every few
29//! minutes would otherwise rewrite it, and its sync app send it, each time.
30//!
31//! The identity is the sharing's own (`share::Here::id`, a UUID made once per
32//! machine), never the host name, which can change or be another's too.
33
34use serde::{Deserialize, Serialize};
35use std::path::{Path, PathBuf};
36
37/// A device's entry: what it says of itself, its times on its own clock (Unix seconds).
38#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
39pub struct Entry {
40    pub id: String,
41    /// To say where: a computer's host name, a phone's model.
42    #[serde(default)]
43    pub name: String,
44    /// `PHONE` or `COMPUTER`.
45    #[serde(default)]
46    pub kind: String,
47    /// The Sioul that wrote it: its version ("0.0.3") and the commit it was
48    /// built from (`sioul_core::build::COMMIT`); an older Sioul's entry says
49    /// its version alone, or neither, and reads all the same.
50    #[serde(default)]
51    pub version: String,
52    #[serde(default, skip_serializing_if = "String::is_empty")]
53    pub commit: String,
54    /// The format of what travels that its Sioul reads and writes
55    /// (`share::FORMAT`); 0 from an older Sioul, which reads format 1 only.
56    /// The sharing writes format 2 once every device says 2 (`share::format_holders`).
57    #[serde(default, skip_serializing_if = "is_zero")]
58    pub format: u32,
59    /// How its Sioul keeps the sealed files of notes and papers that no record
60    /// points to any more (`blobs::KEEPING`): 1, two weeks, a content used
61    /// again made fresh past a week, earlier versions by reference 12 days;
62    /// 2, the same, and a file another device took out kept as a copy among
63    /// its earlier versions (`blobs::KEEPING_MOVED`); 0 from an older Sioul
64    /// (90 days, 30, 60). A device keeps its own two weeks only once every
65    /// device that counts says 1 at least (`share::unused_days`).
66    /// An older Sioul reads the entry as before, the field unknown to it
67    /// passed over.
68    #[serde(default, skip_serializing_if = "is_zero")]
69    pub keeping: u32,
70    /// Its last start: Sioul opened; on a phone, Sioul back on the screen, or
71    /// one of its reminders handled while it was not.
72    #[serde(default)]
73    pub started: i64,
74    /// Its last clean close, after its last export; 0 never.
75    #[serde(default)]
76    pub closed: i64,
77    /// Up from its start to the last step of a clean close: a crash leaves it
78    /// up. Required: an entry without it is no entry, never one taken as closed.
79    pub working: bool,
80    /// When it last read the others.
81    #[serde(default)]
82    pub imported: i64,
83    /// When its files were last read for an export: everything captured there
84    /// before is in its records, up to `wrote`.
85    #[serde(default)]
86    pub exported: i64,
87    /// How far its records went then: round and number.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub wrote: Option<(u32, u64)>,
90    /// It shares the health part: the answers it captures travel.
91    #[serde(default)]
92    pub doses: bool,
93    /// A phone: whether Android lets Sioul hold other apps' notifications
94    /// there (notification access), as it last looked; None when unsaid (a
95    /// computer, an older Sioul). A computer's "What reaches you" counts a
96    /// phone's messages unless it says no (an older Sioul's, unsaid, as before).
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub notifications: Option<bool>,
99    /// A computer in use: whether a person is at it, as it last looked (input
100    /// within ten minutes, its session unlocked, or a press in Sioul:
101    /// `power::present_of`). None when unsaid: a phone (in use only while
102    /// Sioul is on its screen), a computer whose system says nothing of it, an
103    /// older Sioul; then Sioul running counts as someone at it, as before.
104    /// While you sleep, a phone's background steps slow down when nobody is
105    /// at any computer (`others_in_use`); the doses never read it (`said`).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub active: Option<bool>,
108    /// It stopped sharing: it counts no more.
109    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
110    pub left: bool,
111    /// A phone's word on each computer it counts now: the opening (`started`)
112    /// of that computer's entry it read, by the computer's id. A computer
113    /// reminds a dose plainly as its first only once every phone's entry says
114    /// its current opening here, sealed with the rest (docs/health.md, "What
115    /// became of a reminder"; review of battery part C, the seventh review's
116    /// C1 and M1). Written by phones only, of computers only; a computer the
117    /// phone stops counting (said off, forgotten, silent a week) goes from it.
118    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
119    pub acked: std::collections::BTreeMap<String, i64>,
120    /// The devices this one replaces, as you said when it joined (a phone
121    /// that will never share again: dead, lost; docs/database.md, "Devices"),
122    /// each with what it had last said of itself then. Every device then
123    /// treats it as forgotten (it no longer holds the doses' reminders back,
124    /// nor the newer form of what travels) until it says anything newer:
125    /// a phone wrongly said replaced comes back by itself. Nothing of it is
126    /// deleted. Sealed with the rest; an older Sioul passes over it.
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub replaces: Vec<Replaced>,
129    /// Its size changed at each writing (`share::pad_for`): read by nobody.
130    #[serde(default, skip_serializing_if = "String::is_empty")]
131    pub pad: String,
132}
133
134/// A device replaced (`Entry::replaces`): its id, and the latest it had said
135/// when you said so, by its own clock: its entry's last start (Unix seconds),
136/// and the latest of its entry's times, its claims' renewals and its records'
137/// clocks read (`heard`, milliseconds). Anything it says later (a new start,
138/// a later time) makes it count again.
139#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
140pub struct Replaced {
141    pub id: String,
142    #[serde(default)]
143    pub started: i64,
144    #[serde(default)]
145    pub heard: i64,
146}
147
148impl Replaced {
149    /// Whether the device it names said nothing newer since: its entry still
150    /// that start (`started`), nothing of it later than `heard`
151    /// (milliseconds, its clock): `latest_ms`, the latest this device read of it.
152    pub fn holds(&self, started: Option<i64>, latest_ms: i64) -> bool {
153        started.is_none_or(|started| started == self.started) && latest_ms <= self.heard
154    }
155}
156
157fn is_zero(n: &u32) -> bool {
158    *n == 0
159}
160
161pub const PHONE: &str = "phone";
162pub const COMPUTER: &str = "computer";
163
164impl Entry {
165    /// A session begins: Sioul opened, brought back, or a receiver at work.
166    pub fn start(&mut self, now: i64) {
167        self.started = now;
168        self.working = true;
169        self.left = false;
170    }
171
172    /// The last step of a clean session, after its last export: closed then
173    /// (never before its own start, whatever the clock did meanwhile).
174    pub fn close(&mut self, now: i64) {
175        self.closed = now.max(self.started);
176        self.working = false;
177    }
178
179    /// An exchange done (`share::Outcome`): the others read `now`, this
180    /// device's files read for its export at `looked` (milliseconds; 0 when it
181    /// did not run), its records up to `wrote`. Never its session.
182    pub fn exported(&mut self, looked_ms: i64, wrote: Option<(u32, u64)>, now: i64) {
183        if looked_ms <= 0 {
184            return;
185        }
186        self.imported = now;
187        self.exported = self.exported.max(looked_ms / 1000);
188        if wrote.is_some() {
189            self.wrote = wrote;
190        }
191    }
192
193    /// The build that wrote it, in words: "0.0.3 (eff8661abcde)"; its version
194    /// alone from an older Sioul; "" when unsaid.
195    pub fn build(&self) -> String {
196        sioul_core::build::described(&self.version, &self.commit)
197    }
198
199    /// What the doses read of it (`sioul_core::health::Said`).
200    pub fn said(&self) -> sioul_core::health::Said {
201        sioul_core::health::Said { started: self.started, closed: self.closed, working: self.working, exported: self.exported, imported: self.imported, doses: self.doses, left: self.left, phone: self.kind == PHONE }
202    }
203}
204
205fn folder_of(folder: &Path) -> PathBuf {
206    folder.join("devices")
207}
208
209fn file_of(folder: &Path, id: &str) -> PathBuf {
210    folder_of(folder).join(format!("{id}.device"))
211}
212
213fn bound(id: &str) -> String {
214    format!("device\u{1f}{id}")
215}
216
217/// This device's entry, sealed into its file in the folder, its size changed
218/// from the one there.
219pub fn publish(folder: &Path, key: &[u8; 32], entry: &Entry) -> Result<(), String> {
220    let path = file_of(folder, &entry.id);
221    let sealed = |pad: usize| serde_json::to_vec(&Entry { pad: ".".repeat(pad), ..entry.clone() }).map(|plain| crate::share::seal(key, &bound(&entry.id), &plain)).map_err(|e| e.to_string());
222    let pad = crate::share::pad_for(&path, |pad| sealed(pad).map_or(0, |s| s.len() as u64));
223    crate::share::write_atomically(&path, sealed(pad)?.as_bytes())
224}
225
226/// Every device's entry that reads, and the ids of the devices whose file
227/// does not (its seal not arrived, damaged, another key's, written by an
228/// older Sioul's sharing that knows no `working`): those are no entry, never
229/// one read as closed. Files of other names (a sync app's conflicted copies,
230/// half-written ones) are not read. Of an entry fetched from the server too
231/// (`remote::overlay`), the later of the two copies, its words as both say
232/// them (`chosen`).
233pub fn all(folder: &Path, key: &[u8; 32]) -> (Vec<Entry>, Vec<String>) {
234    let (mut entries, mut unread) = (Vec::new(), Vec::new());
235    let fetched = crate::remote::overlay(folder);
236    let ids = |dir: &Path| -> Vec<String> {
237        let listing = std::fs::read_dir(folder_of(dir)).into_iter().flatten().filter_map(Result::ok);
238        listing.filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".device")).map(str::to_string)).filter(|id| id.len() == 36 && uuid::Uuid::parse_str(id).is_ok()).collect()
239    };
240    let mut all: std::collections::BTreeSet<String> = ids(folder).into_iter().collect();
241    if let Some(fetched) = &fetched {
242        all.extend(ids(fetched));
243    }
244    let read = |dir: &Path, id: &str| crate::share::read_small(&file_of(dir, id)).and_then(|text| crate::share::open(key, &bound(id), text.trim())).and_then(|plain| serde_json::from_slice::<Entry>(&plain).ok()).filter(|entry| entry.id == id);
245    for id in all {
246        let entry = match (read(folder, &id), fetched.as_deref().and_then(|f| read(f, &id))) {
247            (Some(here), Some(fetched)) => Some(chosen(here, fetched)),
248            (here, fetched) => here.or(fetched),
249        };
250        match entry {
251            Some(entry) => entries.push(entry),
252            None => unread.push(id),
253        }
254    }
255    entries.sort_by(|a, b| a.id.cmp(&b.id));
256    unread.sort();
257    (entries, unread)
258}
259
260/// Of an entry read in two copies, the folder's and the one fetched beside
261/// it: the later by its own times (`written`), the folder's on a tie, with
262/// its words (`Entry::acked`) only on the computers the other copy names too.
263/// A phone that no longer names a computer in either copy has stopped
264/// counting it (said off, forgotten, silent a week), whichever copy is dated
265/// later: a phone's clock set back, or two copies written in one second, no
266/// longer keep its word there from the lagging copy. One naming an older
267/// opening of it is only behind, and takes nothing away. The cost: a phone
268/// that counts a computer again, or is paired anew, gives its word there
269/// once both copies name it (the re-check before 0.0.6, LOW-2).
270pub fn chosen(here: Entry, fetched: Entry) -> Entry {
271    let (mut chosen, other) = if written(&fetched) > written(&here) { (fetched, here) } else { (here, fetched) };
272    chosen.acked.retain(|computer, _| other.acked.contains_key(computer));
273    chosen
274}
275
276/// When an entry was written, as far as its times tell: each writing sets
277/// one of them to its device's clock then (a start, a close, an exchange), and
278/// none goes back.
279pub fn written(entry: &Entry) -> (i64, i64, i64, i64, i64) {
280    (entry.started.max(entry.closed).max(entry.imported), entry.closed, entry.started, entry.imported, entry.exported)
281}
282
283/// Where this device keeps its own entry, never shared.
284pub fn own_path(state: &Path) -> PathBuf {
285    state.join("share").join("device.toml")
286}
287
288/// This device's own entry as kept here; a new one when none reads.
289pub fn own(path: &Path) -> Entry {
290    std::fs::read_to_string(path).ok().and_then(|text| toml::from_str(&text).ok()).unwrap_or_default()
291}
292
293/// Whether this device is in use, as its own entry says (`Entry::working`):
294/// the entry kept beside the sharing's memory (`<state>/share/`, `own_path`).
295pub(crate) fn in_use(memory: &Path) -> bool {
296    own(&memory.with_file_name("device.toml")).working
297}
298
299/// How long a device not in use leaves its entry in the folder as it is, at
300/// most, while only the times of its exchanges change (seconds): restated
301/// then, so that the others still hear of it (Settings ▸ Your folder and
302/// sharing, the texts' "last shared", `sioul_core::health::SILENT_DAYS`). The
303/// doses never wait for it: a device put away is known by its close and how
304/// far it wrote, both written at once.
305pub const RESTATED: i64 = 60 * 60;
306
307/// Whether another device than `own` is in use, for the rhythm of a phone's
308/// background steps (`steps::next_step`) and of its pulls: in use, still
309/// sharing, its export dated lately (`FRESH`, `SKEW`). While you are awake, a
310/// computer counts while its Sioul runs, whoever is at it, as before; while
311/// you sleep (`asleep`), only with someone at it: one that says nobody is
312/// (`Entry::active`) does not count, so that a computer left on at night with
313/// Sioul in its tray no longer makes the phone step every two minutes. One
314/// that says nothing of it (an older Sioul, a system that cannot tell) counts
315/// by Sioul running, as before. The doses' knowing is `sioul_core::health`'s,
316/// never this.
317pub fn others_in_use(entries: &[Entry], own: &str, now: i64, asleep: bool) -> bool {
318    use sioul_core::health::{FRESH, SKEW};
319    entries.iter().any(|e| e.id != own && e.working && !e.left && now - e.exported <= FRESH + SKEW && (!asleep || e.active != Some(false)))
320}
321
322/// What this device last wrote into the folder, kept beside its own entry and
323/// never shared (`published_path`): what it said then, its exchanges' times
324/// left out (`steady`), the file as written (its size and time), where, under
325/// which key (`sealed_with`: the folder sealed again, it is written again at
326/// once), and when.
327#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
328struct Published {
329    said: String,
330    folder: String,
331    #[serde(default)]
332    key: String,
333    size: u64,
334    modified: u64,
335    at: i64,
336}
337
338/// Which key an entry was sealed with, as a mark that says nothing of the key
339/// itself (a hash of it under a name of its own).
340fn sealed_with(key: &[u8; 32]) -> String {
341    use sha2::Digest;
342    let digest = sha2::Sha256::new().chain_update(b"sioul: the key a device entry was sealed with\x1f").chain_update(key).finalize();
343    digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
344}
345
346fn published_path(own: &Path) -> PathBuf {
347    own.with_file_name("device-published.toml")
348}
349
350/// What an entry says but for its exchanges' times, as a hash: what the
351/// others must hear at once when it changes.
352pub(crate) fn steady(entry: &Entry) -> String {
353    let steady = Entry { imported: 0, exported: 0, pad: String::new(), ..entry.clone() };
354    serde_json::to_string(&steady).map(|text| crate::share::hash(&text)).unwrap_or_default()
355}
356
357/// The size and time of the folder's copy of an entry, when there is one.
358fn stamp_of(path: &Path) -> Option<(u64, u64)> {
359    std::fs::metadata(path).ok().map(|meta| (meta.len(), crate::share::modified_ns(&meta)))
360}
361
362/// This device's own entry changed by `change`, under its lock, read just
363/// before: written here, then, sharing on (`vault`: the folder and its key),
364/// into the folder when the others need it (see the module's words).
365/// Returns it as written here.
366pub fn change(path: &Path, vault: Option<(&Path, &[u8; 32])>, change: impl FnOnce(&mut Entry)) -> Result<Entry, String> {
367    change_at(path, vault, jiff::Timestamp::now().as_second(), change)
368}
369
370/// `change`, at `now` (Unix seconds, this device's clock): when the folder's copy was last restated.
371pub(crate) fn change_at(path: &Path, vault: Option<(&Path, &[u8; 32])>, now: i64, change: impl FnOnce(&mut Entry)) -> Result<Entry, String> {
372    sioul_core::filelock::with_lock(path, || {
373        let mut entry = own(path);
374        change(&mut entry);
375        entry.pad.clear();
376        let text = toml::to_string(&entry).map_err(|e| e.to_string())?;
377        crate::share::write_atomically(path, format!("# This device, as it tells your other devices (docs/database.md, \"Devices\").\n{text}").as_bytes())?;
378        if let Some((folder, key)) = vault
379            && !entry.id.is_empty()
380        {
381            let file = file_of(folder, &entry.id);
382            let said = steady(&entry);
383            let there = folder.display().to_string();
384            let last: Option<Published> = std::fs::read_to_string(published_path(path)).ok().and_then(|t| toml::from_str(&t).ok());
385            // As the others last read it: the same words, the folder's copy the one written, within the hour.
386            let mark = sealed_with(key);
387            let current = last.is_some_and(|last| last.said == said && last.folder == there && last.key == mark && stamp_of(&file) == Some((last.size, last.modified)) && (0..RESTATED).contains(&(now - last.at)));
388            // In use, every export goes out, someone at it or not: one cadence, which says nothing of who is there.
389            if entry.working || !current {
390                publish(folder, key, &entry)?;
391                let (size, modified) = stamp_of(&file).unwrap_or_default();
392                let published = Published { said, folder: there, key: mark, size, modified, at: now };
393                // Lost, the entry is only written again at the next change: nothing is said wrongly.
394                let _ = toml::to_string(&published).map_err(|e| e.to_string()).and_then(|text| crate::share::write_atomically(&published_path(path), text.as_bytes()));
395            }
396        }
397        Ok(entry)
398    })
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use crate::share::{Roots, Sharing, Store};
405    use sioul_core::config::Config;
406    use sioul_core::doses::{Answer, Answered, DoseRecords, SKIPPED, TAKEN};
407    use sioul_core::health::{Doubt, HealthState, Peer, doubts_now};
408
409    fn scratch(name: &str) -> PathBuf {
410        let dir = std::env::temp_dir().join(format!("sioul-devices-{name}-{}", std::process::id()));
411        let _ = std::fs::remove_dir_all(&dir);
412        std::fs::create_dir_all(&dir).unwrap();
413        dir
414    }
415
416    const KEY: [u8; 32] = [7u8; 32];
417
418    #[test]
419    fn a_device_says_how_it_is() {
420        let dir = scratch("says");
421        let (folder, state) = (dir.join("shared"), dir.join("state"));
422        let id = uuid::Uuid::new_v4().to_string();
423        let own = own_path(&state);
424        let vault = Some((folder.as_path(), &KEY));
425        // Started: up, said in the folder, sealed (nothing of it readable there).
426        change(&own, vault, |e| {
427            e.id = id.clone();
428            e.kind = PHONE.into();
429            e.doses = true;
430            e.start(1_000);
431        })
432        .unwrap();
433        let raw = std::fs::read_to_string(file_of(&folder, &id)).unwrap();
434        assert!(!raw.contains("working") && !raw.contains(&id), "sealed");
435        let size = raw.len();
436        let (entries, unread) = all(&folder, &KEY);
437        assert!(unread.is_empty());
438        assert_eq!((entries[0].started, entries[0].working, entries[0].said().phone), (1_000, true, true));
439        // An exchange: what it read and wrote, never its session.
440        change(&own, vault, |e| e.exported(1_060_000, Some((1, 4)), 1_061)).unwrap();
441        // Closed after its last export: down.
442        change(&own, vault, |e| e.close(1_120)).unwrap();
443        let (entries, _) = all(&folder, &KEY);
444        assert_eq!((entries[0].exported, entries[0].wrote, entries[0].closed, entries[0].working), (1_060, Some((1, 4)), 1_120, false));
445        assert_ne!(std::fs::metadata(file_of(&folder, &id)).unwrap().len() as usize, size, "its size changes at each writing");
446        // Kept here too, never shared.
447        assert_eq!(super::own(&own).closed, 1_120);
448        // Another key reads nothing: no entry, said apart; never taken as closed.
449        let (entries, unread) = all(&folder, &[1u8; 32]);
450        assert!(entries.is_empty() && unread == [id.clone()]);
451        // A file of another name (a conflicted copy) is not read; one sealed for another device is no entry.
452        std::fs::copy(file_of(&folder, &id), folder_of(&folder).join(format!("{id} (conflicted copy).device"))).unwrap();
453        let other = uuid::Uuid::new_v4().to_string();
454        std::fs::copy(file_of(&folder, &id), file_of(&folder, &other)).unwrap();
455        let (entries, unread) = all(&folder, &KEY);
456        assert_eq!((entries.len(), unread), (1, vec![other]));
457        // An entry without `working` (written by something else): no entry, never closed.
458        let without = serde_json::json!({ "id": id, "started": 1_000, "closed": 2_000 }).to_string();
459        std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), without.as_bytes())).unwrap();
460        assert!(all(&folder, &KEY).0.is_empty());
461        let _ = std::fs::remove_dir_all(&dir);
462    }
463
464    /// Each entry says the build that wrote it (docs/database.md, "Devices");
465    /// one written before the field came, by an older Sioul, reads all the same.
466    #[test]
467    fn an_entry_says_its_build_and_an_older_one_still_reads() {
468        let dir = scratch("build");
469        let (folder, state) = (dir.join("shared"), dir.join("state"));
470        let id = uuid::Uuid::new_v4().to_string();
471        let vault = Some((folder.as_path(), &KEY));
472        change(&own_path(&state), vault, |e| {
473            e.id = id.clone();
474            e.version = sioul_core::build::VERSION.into();
475            e.commit = sioul_core::build::COMMIT.into();
476            e.start(1_000);
477        })
478        .unwrap();
479        let (entries, _) = all(&folder, &KEY);
480        assert_eq!((entries[0].version.as_str(), entries[0].commit.as_str()), (sioul_core::build::VERSION, sioul_core::build::COMMIT));
481        assert_eq!(entries[0].build(), sioul_core::build::DESCRIBED);
482        assert!(!std::fs::read_to_string(file_of(&folder, &id)).unwrap().contains(sioul_core::build::COMMIT), "sealed: the server never learns it");
483        // An older Sioul's entry: its version alone, no commit; one older still, neither.
484        for (plain, said) in [(serde_json::json!({ "id": id, "version": "0.0.2", "started": 1_000, "working": true }), "0.0.2"), (serde_json::json!({ "id": id, "started": 1_000, "working": true }), "")] {
485            std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), plain.to_string().as_bytes())).unwrap();
486            let (entries, unread) = all(&folder, &KEY);
487            assert!(unread.is_empty() && entries[0].working && entries[0].commit.is_empty(), "{entries:?}");
488            assert_eq!(entries[0].build(), said);
489        }
490        let _ = std::fs::remove_dir_all(&dir);
491    }
492
493    /// A phone says whether Android lets Sioul hold other apps' notifications;
494    /// a change that does not look again keeps what it said; an older Sioul
495    /// says nothing, read as unsaid.
496    #[test]
497    fn a_phone_says_whether_it_holds_notifications() {
498        let dir = scratch("notifications");
499        let (folder, state) = (dir.join("shared"), dir.join("state"));
500        let id = uuid::Uuid::new_v4().to_string();
501        let vault = Some((folder.as_path(), &KEY));
502        change(&own_path(&state), vault, |e| {
503            e.id = id.clone();
504            e.kind = PHONE.into();
505            e.notifications = Some(true);
506            e.start(1_000);
507        })
508        .unwrap();
509        change(&own_path(&state), vault, |e| e.close(1_100)).unwrap();
510        assert_eq!(all(&folder, &KEY).0[0].notifications, Some(true), "kept by a change that did not look");
511        change(&own_path(&state), vault, |e| e.notifications = Some(false)).unwrap();
512        assert_eq!(all(&folder, &KEY).0[0].notifications, Some(false));
513        let older = serde_json::json!({ "id": id, "kind": "phone", "started": 1_000, "working": true });
514        std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), older.to_string().as_bytes())).unwrap();
515        assert_eq!(all(&folder, &KEY).0[0].notifications, None, "an older Sioul's: unsaid");
516        let _ = std::fs::remove_dir_all(&dir);
517    }
518
519    /// A device put away (a phone's background step every few minutes) whose
520    /// exchanges find nothing new leaves its entry in the folder as it is:
521    /// what the others need goes there at once (its session, how far it
522    /// wrote, whether it shares its doses), a copy there that is not the one
523    /// it wrote is written again, and an hour on it is restated. In use, every
524    /// export goes there, as the doses' knowing needs (`FRESH`).
525    #[test]
526    fn a_device_put_away_writes_its_entry_only_when_it_says_something_new() {
527        let dir = scratch("put-away");
528        let (folder, state) = (dir.join("shared"), dir.join("state"));
529        let own = own_path(&state);
530        let vault = Some((folder.as_path(), &KEY));
531        let id = uuid::Uuid::new_v4().to_string();
532        let file = file_of(&folder, &id);
533        let bytes = || std::fs::read(&file).unwrap();
534        let exported = |at: i64, wrote: (u32, u64)| change_at(&own, vault, at, |e| e.exported(at * 1000, Some(wrote), at)).unwrap();
535        let t = 1_800_000_000;
536        change_at(&own, vault, t, |e| {
537            e.id = id.clone();
538            e.kind = PHONE.into();
539            e.doses = true;
540            e.start(t);
541        })
542        .unwrap();
543        exported(t, (1, 4));
544        change_at(&own, vault, t + 5, |e| e.close(t + 5)).unwrap();
545        let closed = bytes();
546        // Exchanges that wrote nothing: kept here, nothing written there.
547        for n in 1..=5 {
548            exported(t + 5 + n * 120, (1, 4));
549        }
550        assert_eq!(bytes(), closed, "nothing new: the folder's copy as it was");
551        assert_eq!(super::own(&own).exported, t + 605, "kept here");
552        let seen = &all(&folder, &KEY).0[0];
553        assert_eq!((seen.exported, seen.wrote, seen.working), (t, Some((1, 4)), false));
554        // How far it wrote changed: there at once, with its time.
555        exported(t + 700, (2, 1));
556        let seen = all(&folder, &KEY).0[0].clone();
557        assert_eq!((seen.exported, seen.wrote, seen.working), (t + 700, Some((2, 1)), false));
558        // Within the hour, nothing new: as it is; an hour on: restated.
559        let wrote = bytes();
560        exported(t + 700 + RESTATED - 1, (2, 1));
561        assert_eq!(bytes(), wrote);
562        exported(t + 700 + RESTATED, (2, 1));
563        assert_eq!(all(&folder, &KEY).0[0].exported, t + 700 + RESTATED, "restated after an hour");
564        // An older copy put back by a sync app: written again at the next export.
565        std::fs::write(&file, &closed).unwrap();
566        exported(t + 800 + RESTATED, (2, 1));
567        assert_eq!(all(&folder, &KEY).0[0].wrote, Some((2, 1)), "the copy put back is replaced");
568        // Its doses no longer shared: there at once.
569        change_at(&own, vault, t + 900 + RESTATED, |e| e.doses = false).unwrap();
570        assert!(!all(&folder, &KEY).0[0].doses);
571        // The folder sealed again (another passphrase, the same place): written under the new key at once.
572        let other = [9u8; 32];
573        exported(t + 950 + RESTATED, (2, 1));
574        change_at(&own, Some((folder.as_path(), &other)), t + 960 + RESTATED, |e| e.exported((t + 960 + RESTATED) * 1000, Some((2, 1)), t + 960 + RESTATED)).unwrap();
575        assert_eq!(all(&folder, &other).0.len(), 1, "readable under the new key at once");
576        // In use: every export, each with its time.
577        change_at(&own, vault, t + 1_000 + RESTATED, |e| e.start(t + 1_000 + RESTATED)).unwrap();
578        for n in 1..=3 {
579            let at = t + 1_000 + RESTATED + n * 60;
580            exported(at, (2, 1));
581            let seen = &all(&folder, &KEY).0[0];
582            assert_eq!((seen.exported, seen.working), (at, true), "in use: export {n} there at once");
583        }
584        // Another folder (sharing moved): written there at once.
585        let elsewhere = dir.join("elsewhere");
586        change_at(&own, Some((elsewhere.as_path(), &KEY)), t + 2_000 + RESTATED, |e| e.close(t + 2_000 + RESTATED)).unwrap();
587        exported(t + 2_100 + RESTATED, (2, 1));
588        let moved = dir.join("moved");
589        change_at(&own, Some((moved.as_path(), &KEY)), t + 2_200 + RESTATED, |e| e.exported((t + 2_200 + RESTATED) * 1000, Some((2, 1)), t + 2_200 + RESTATED)).unwrap();
590        assert_eq!(all(&moved, &KEY).0.len(), 1, "the new folder has it at once");
591        let _ = std::fs::remove_dir_all(&dir);
592    }
593
594    /// A computer counts as in use for a phone's rhythm while its Sioul runs
595    /// and its news is fresh; while you sleep, only with someone at it (review
596    /// of battery part C: the sleep column alone). One that says nothing of it
597    /// (an older Sioul, a system that cannot tell) counts by Sioul running, as
598    /// before, asleep too.
599    #[test]
600    fn a_computer_counts_in_use_for_a_phones_rhythm_while_someone_is_at_it() {
601        use sioul_core::health::{FRESH, SKEW};
602        let now = DUE;
603        let desk = |active: Option<bool>| Entry { id: "desk".into(), kind: COMPUTER.into(), working: true, started: now - 3_600, exported: now - 60, active, ..Entry::default() };
604        for asleep in [false, true] {
605            assert!(others_in_use(&[desk(Some(true))], "phone", now, asleep));
606            assert!(others_in_use(&[desk(None)], "phone", now, asleep), "unsaid: Sioul running counts, as before");
607            assert!(!others_in_use(&[Entry { exported: now - FRESH - SKEW - 1, ..desk(Some(true)) }], "phone", now, asleep), "its news stale");
608            assert!(!others_in_use(&[Entry { working: false, ..desk(Some(true)) }], "phone", now, asleep), "closed");
609            assert!(!others_in_use(&[Entry { left: true, ..desk(None) }], "phone", now, asleep), "no longer sharing");
610            assert!(!others_in_use(&[desk(Some(true))], "desk", now, asleep), "itself");
611            assert!(others_in_use(&[desk(Some(false)), Entry { id: "laptop".into(), ..desk(Some(true)) }], "phone", now, asleep), "someone at another computer");
612            assert!(!others_in_use(&[], "phone", now, asleep), "no other device");
613        }
614        // Left on, nobody at it: in use while you are awake, as 0.0.5 had it; not while you sleep.
615        assert!(others_in_use(&[desk(Some(false))], "phone", now, false), "awake: Sioul running counts");
616        assert!(!others_in_use(&[desk(Some(false))], "phone", now, true), "asleep: nobody at it, not in use");
617    }
618
619    /// A computer in use writes its entry at each export, someone at it or
620    /// not (review of battery part C, F5): the server, which sees when the
621    /// file changes, cannot tell when someone sits at it; whether someone is
622    /// travels sealed with the rest, its size padded as every writing's. A
623    /// phone looking at any moment knows a dose due meanwhile once an export
624    /// after it is read, as before, and a computer stopped without closing is
625    /// doubted once `FRESH` has passed (docs/health.md, "Knowing").
626    #[test]
627    fn a_computer_writes_its_entry_at_one_cadence_whoever_is_at_it() {
628        use sioul_core::health::{FRESH, SKEW};
629        let dir = scratch("one-cadence");
630        let (folder, state) = (dir.join("shared"), dir.join("state"));
631        let own = own_path(&state);
632        let vault = Some((folder.as_path(), &KEY));
633        let id = uuid::Uuid::new_v4().to_string();
634        let file = file_of(&folder, &id);
635        let t = DUE - 3_600;
636        change_at(&own, vault, t, |e| {
637            e.id = id.clone();
638            e.kind = COMPUTER.into();
639            e.doses = true;
640            e.active = Some(true);
641            e.start(t);
642        })
643        .unwrap();
644        // The window's export each minute, saying whether someone is at it; whether the folder's copy changed.
645        let export = |at: i64, active: Option<bool>| -> bool {
646            let before = std::fs::read(&file).unwrap();
647            change_at(&own, vault, at, |e| {
648                e.exported(at * 1000, Some((1, 4)), at);
649                e.active = active;
650            })
651            .unwrap();
652            std::fs::read(&file).unwrap() != before
653        };
654        // Someone at it, then nobody, then unsaid: every export goes out, the same.
655        let mut written = Vec::new();
656        let mut watched: Option<((i64, i64, bool, i64, bool), i64)> = None;
657        for at in (t + 60..=t + 90 * 60).step_by(10) {
658            let active = match (at - t) / 60 {
659                0..=10 => Some(true),
660                11..=80 => Some(false),
661                _ => None,
662            };
663            if (at - t) % 60 == 0 && export(at, active) {
664                written.push(at);
665            }
666            // A phone looking every seventy seconds: when it saw the entry change, as `health::know` keeps it.
667            if (at - t) % 70 != 0 {
668                continue;
669            }
670            let entry = all(&folder, &KEY).0.into_iter().find(|e| e.id == id).unwrap();
671            assert!(at - entry.exported < 60, "the folder's copy from the last export: {} s at {at}", at - entry.exported);
672            let state = (entry.started, entry.closed, entry.working, entry.exported, entry.left);
673            if watched.is_none_or(|(last, _)| last != state) {
674                watched = Some((state, at));
675            }
676            let peer = Peer { id: id.clone(), said: Some(entry.said()), complete: true, seen: watched.map_or(at, |(_, seen)| seen), heard: entry.exported, ..Peer::default() };
677            let doubts = doubts_now(DUE, at, None, &[peer]);
678            if entry.exported >= DUE + SKEW {
679                assert!(doubts.is_empty(), "an export after the dose read: known at {at}: {doubts:?}");
680            } else {
681                assert!(matches!(doubts.as_slice(), [Doubt::Working { .. }]), "not shared since the dose: in doubt at {at}");
682            }
683        }
684        assert_eq!(written, (1..=90).map(|n| t + n * 60).collect::<Vec<_>>(), "one writing a minute, whoever is at it");
685        // Stopped without closing (a crash) after its last export: the doubt once FRESH has passed.
686        let entry = all(&folder, &KEY).0.into_iter().find(|e| e.id == id).unwrap();
687        let stopped = entry.exported + FRESH + 1;
688        let peer = Peer { id: id.clone(), said: Some(entry.said()), complete: true, seen: entry.exported, heard: entry.exported, ..Peer::default() };
689        assert!(matches!(doubts_now(DUE, stopped, None, &[peer]).as_slice(), [Doubt::Working { .. }]));
690        let _ = std::fs::remove_dir_all(&dir);
691    }
692
693    /// The entry that says whether someone is at a computer reads on an older
694    /// Sioul as before: `active` passed over (serde's way with a field it does
695    /// not know), its other fields the same, so an older phone keeps its own
696    /// rule (Sioul running counts) and its doses read the same. An older
697    /// computer's entry, which says nothing of it, reads here as in use while
698    /// its Sioul runs, as before. No field changed meaning: no new format.
699    #[test]
700    fn an_older_device_reads_the_entry_that_says_whether_someone_is_at_it() {
701        use sioul_core::health::{FRESH, SKEW};
702        /// The entry as Sioul 0.0.5 reads it: the fields it knows.
703        #[derive(Deserialize)]
704        struct Older {
705            id: String,
706            #[serde(default)]
707            kind: String,
708            #[serde(default)]
709            started: i64,
710            #[serde(default)]
711            closed: i64,
712            working: bool,
713            #[serde(default)]
714            exported: i64,
715            #[serde(default)]
716            wrote: Option<(u32, u64)>,
717            #[serde(default)]
718            doses: bool,
719            #[serde(default)]
720            left: bool,
721        }
722        let dir = scratch("older-reads");
723        let (folder, state) = (dir.join("shared"), dir.join("state"));
724        let id = uuid::Uuid::new_v4().to_string();
725        let vault = Some((folder.as_path(), &KEY));
726        change(&own_path(&state), vault, |e| {
727            e.id = id.clone();
728            e.kind = COMPUTER.into();
729            e.doses = true;
730            e.start(1_000);
731            e.exported(1_060_000, Some((1, 2)), 1_061);
732            e.active = Some(false);
733        })
734        .unwrap();
735        let sealed = std::fs::read_to_string(file_of(&folder, &id)).unwrap();
736        let plain = crate::share::open(&KEY, &bound(&id), sealed.trim()).unwrap();
737        assert!(String::from_utf8_lossy(&plain).contains("\"active\":false"));
738        let older: Older = serde_json::from_slice(&plain).expect("an older Sioul reads it");
739        assert_eq!((older.id.as_str(), older.kind.as_str(), older.started, older.closed, older.working, older.exported, older.wrote, older.doses, older.left), (id.as_str(), COMPUTER, 1_000, 0, true, 1_060, Some((1, 2)), true, false));
740        // Its own rule (0.0.5's `others_in_use`): Sioul running, its export fresh.
741        assert!(older.working && !older.left && 1_100 - older.exported <= FRESH + SKEW, "an older phone steps as before");
742        // What the doses read of it is the same, someone at it or not.
743        let said = all(&folder, &KEY).0[0].said();
744        change(&own_path(&state), vault, |e| e.active = Some(true)).unwrap();
745        assert_eq!(all(&folder, &KEY).0[0].said(), said);
746        // An older computer's entry, saying nothing of it: in use here while its Sioul runs.
747        let plain = serde_json::json!({ "id": id, "kind": "computer", "started": 1_000, "working": true, "exported": 1_060 }).to_string();
748        std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), plain.as_bytes())).unwrap();
749        let (entries, _) = all(&folder, &KEY);
750        assert_eq!(entries[0].active, None);
751        assert!(others_in_use(&entries, "phone", 1_100, false) && others_in_use(&entries, "phone", 1_100, true));
752        let _ = std::fs::remove_dir_all(&dir);
753    }
754
755    #[test]
756    fn two_processes_never_undo_each_others_session() {
757        let dir = scratch("processes");
758        let (folder, state) = (dir.join("shared"), dir.join("state"));
759        let own = own_path(&state);
760        let vault = Some((folder.as_path(), &KEY));
761        let id = uuid::Uuid::new_v4().to_string();
762        change(&own, vault, |e| {
763            e.id = id.clone();
764            e.start(1_000);
765        })
766        .unwrap();
767        // The window's process puts Sioul away (down); a background service then exports: still down.
768        change(&own, vault, |e| e.close(1_100)).unwrap();
769        change(&own, vault, |e| e.exported(1_200_000, Some((1, 9)), 1_201)).unwrap();
770        let (entries, _) = all(&folder, &KEY);
771        assert_eq!((entries[0].working, entries[0].closed, entries[0].exported), (false, 1_100, 1_200));
772        // Brought back (up); the service exports again: still up.
773        change(&own, vault, |e| e.start(1_300)).unwrap();
774        change(&own, vault, |e| e.exported(1_310_000, Some((1, 9)), 1_311)).unwrap();
775        assert!(all(&folder, &KEY).0[0].working);
776        // An exchange that did not run (busy) changes nothing.
777        change(&own, vault, |e| e.exported(0, None, 1_400)).unwrap();
778        assert_eq!(all(&folder, &KEY).0[0].exported, 1_310);
779        let _ = std::fs::remove_dir_all(&dir);
780    }
781
782    // ------------------------------------------------ two devices, one folder
783
784    /// A device of its own, in a scratch folder: its folders, its id, its memory.
785    struct Device {
786        roots: Roots,
787        id: String,
788        memory: PathBuf,
789        kind: &'static str,
790    }
791
792    impl Device {
793        fn new(base: &Path, name: &str, kind: &'static str) -> Device {
794            let root = base.join(name);
795            let roots = Roots { config: root.join("config"), data: root.join("data"), state: root.join("state") };
796            for dir in [&roots.config, &roots.data, &roots.state] {
797                std::fs::create_dir_all(dir).unwrap();
798            }
799            let memory = roots.state.join("share").join("memory.json");
800            Device { roots, id: uuid::Uuid::new_v4().to_string(), memory, kind }
801        }
802
803        fn stores(&self) -> Vec<Store> {
804            crate::share::stores(&Config::default(), &self.roots)
805        }
806
807        /// An exchange as `crate::share::exchange` makes it, then its entry
808        /// says so: what it read and wrote (`exported`).
809        fn exchange(&self, folder: &Path, now: i64) -> crate::share::Outcome {
810            let outcome = crate::share::exchange(&Sharing { folder, computer: &self.id, key: &KEY, memory: &self.memory, files: false, hurry: None }, &self.stores(), now * 1000).unwrap();
811            // The exchange's own clock read the files; a test's clock is `now`.
812            change(&own_path(&self.roots.state), Some((folder, &KEY)), |e| e.exported(now * 1000, outcome.wrote, now)).unwrap();
813            outcome
814        }
815
816        fn session(&self, folder: &Path, f: impl FnOnce(&mut Entry)) {
817            let kind = self.kind;
818            let id = self.id.clone();
819            change(&own_path(&self.roots.state), Some((folder, &KEY)), |e| {
820                e.id = id;
821                e.kind = kind.into();
822                e.doses = true;
823                f(e);
824            })
825            .unwrap();
826        }
827
828        fn records(&self) -> PathBuf {
829            self.roots.state.join(sioul_core::doses::FILE)
830        }
831
832        fn marks(&self) -> PathBuf {
833            self.roots.state.join("health-state.toml")
834        }
835
836        /// The person answers here, as the window or a receiver records it: the
837        /// answer in the records, the mark beside it for an older Sioul.
838        fn answer(&self, key: &str, state: &str, at: i64) {
839            DoseRecords::update(&self.records(), at, |r| r.answer(key, &self.id, Answer { state: state.into(), at, noted: at, name: self.kind.into(), kind: self.kind.into() })).unwrap();
840            HealthState::update(&self.marks(), at, |s| {
841                if state == TAKEN {
842                    s.taken.insert(key.to_string(), at);
843                } else {
844                    s.not_taken.insert(key.to_string(), at);
845                }
846            })
847            .unwrap();
848        }
849
850        /// What this device knows of `other`, as the window's `know` builds it:
851        /// its entry, and whether everything it wrote up to it is read here.
852        fn sees(&self, folder: &Path, other: &Device, now: i64) -> Peer {
853            let entry = all(folder, &KEY).0.into_iter().find(|e| e.id == other.id).expect("its entry");
854            let heard = crate::share::heard(&self.memory, &self.id);
855            Peer { id: other.id.clone(), name: entry.name.clone(), said: Some(entry.said()), complete: entry.wrote.is_none_or(|wrote| heard.complete(&other.id, wrote)), seen: now, heard: entry.exported, ..Peer::default() }
856        }
857
858        fn answered(&self, key: &str) -> Answered {
859            sioul_core::doses::answered(key, &HealthState::read(&self.marks()).unwrap(), &DoseRecords::read(&self.records()).unwrap())
860        }
861    }
862
863    const DUE: i64 = 1_800_000_000;
864    const DOSE: &str = "levo@1800000000";
865
866    /// Two devices sharing one folder, each joined.
867    fn pair(name: &str) -> (PathBuf, PathBuf, Device, Device) {
868        let base = scratch(name);
869        let folder = base.join("shared");
870        std::fs::create_dir_all(&folder).unwrap();
871        let desk = Device::new(&base, "desk", COMPUTER);
872        let phone = Device::new(&base, "phone", PHONE);
873        desk.session(&folder, |e| e.start(DUE - 7_200));
874        phone.session(&folder, |e| e.start(DUE - 7_200));
875        desk.exchange(&folder, DUE - 7_100);
876        phone.exchange(&folder, DUE - 7_090);
877        desk.exchange(&folder, DUE - 7_080);
878        (base, folder, desk, phone)
879    }
880
881    #[test]
882    fn the_phone_marks_taken_in_the_background_and_the_desktop_shows_taken() {
883        let (base, folder, desk, phone) = pair("background");
884        // The phone put away at 07:00: its last export, then down.
885        phone.exchange(&folder, DUE - 3_600);
886        phone.session(&folder, |e| e.close(DUE - 3_595));
887        desk.exchange(&folder, DUE - 3_500);
888        // 08:00, before anything: the phone closed cleanly before the dose, read in full: known not taken.
889        let at_dose = desk.sees(&folder, &phone, DUE + 30);
890        assert!(doubts_now(DUE, DUE + 30, None, &[at_dose]).is_empty());
891        assert_eq!(desk.answered(DOSE), Answered::Open);
892        // 08:05, "Taken" pressed on the phone's notification, Sioul in the background:
893        // the receiver raises its session, records, exports, then lowers it.
894        phone.session(&folder, |e| e.start(DUE + 300));
895        phone.answer(DOSE, TAKEN, DUE + 300);
896        phone.exchange(&folder, DUE + 302);
897        phone.session(&folder, |e| e.close(DUE + 303));
898        // The desktop reads the folder: taken, on the phone, at 08:05; nothing in doubt.
899        desk.exchange(&folder, DUE + 360);
900        match desk.answered(DOSE) {
901            Answered::Taken(given) => assert_eq!((given.device.as_str(), given.at), (phone.id.as_str(), DUE + 300)),
902            other => panic!("{other:?}"),
903        }
904        assert!(doubts_now(DUE, DUE + 360, None, &[desk.sees(&folder, &phone, DUE + 360)]).is_empty());
905        // The old mark came too, for an older Sioul.
906        assert!(HealthState::read(&desk.marks()).unwrap().taken.contains_key(DOSE));
907        let _ = std::fs::remove_dir_all(&base);
908    }
909
910    /// The phone put away, its background step exchanging every few minutes
911    /// and finding nothing new: its entry in the folder stays as it was, and
912    /// the desktop knows a dose due since as surely as before (closed after its
913    /// last export, everything it wrote read), hours later too. "Taken"
914    /// pressed on its reminder in the background goes at once, as before:
915    /// raised, recorded, exported, lowered, each in the folder.
916    #[test]
917    fn the_phone_put_away_and_quiet_is_known_without_rewriting_its_entry() {
918        let (base, folder, desk, phone) = pair("quiet-phone");
919        phone.exchange(&folder, DUE - 3_600);
920        phone.session(&folder, |e| e.close(DUE - 3_595));
921        let entry = std::fs::read(file_of(&folder, &phone.id)).unwrap();
922        for n in 0..12 {
923            phone.exchange(&folder, DUE - 3_500 + n * 300);
924            desk.exchange(&folder, DUE - 3_490 + n * 300);
925        }
926        assert_eq!(std::fs::read(file_of(&folder, &phone.id)).unwrap(), entry, "nothing new: its entry as it was");
927        for at in [DUE + 30, DUE + 3_600, DUE + 6 * 3_600] {
928            assert!(doubts_now(DUE, at, None, &[desk.sees(&folder, &phone, at)]).is_empty(), "known at {at}");
929        }
930        // Raised by a reminder: in the folder at once; the desk doubts until its export is read.
931        phone.session(&folder, |e| e.start(DUE + 300));
932        assert!(matches!(doubts_now(DUE, DUE + 310, None, &[desk.sees(&folder, &phone, DUE + 310)]).as_slice(), [Doubt::Working { .. }]));
933        phone.answer(DOSE, TAKEN, DUE + 300);
934        phone.exchange(&folder, DUE + 302);
935        phone.session(&folder, |e| e.close(DUE + 303));
936        desk.exchange(&folder, DUE + 360);
937        assert!(matches!(desk.answered(DOSE), Answered::Taken(_)));
938        assert!(doubts_now(DUE, DUE + 360, None, &[desk.sees(&folder, &phone, DUE + 360)]).is_empty());
939        let _ = std::fs::remove_dir_all(&base);
940    }
941
942    /// What the desktop knows of the phone with `seen` as `health::know` keeps
943    /// it (when the phone's entry was last seen changing: its session, its
944    /// export), not the moment it looks (review of battery part B, F7). Put
945    /// away and quiet for hours, its entry unchanged: `seen` hours old, the
946    /// dose known all the same (a closed device's knowing never reads it). In
947    /// use, exporting every minute: known after the dose while it goes on;
948    /// stopped without closing (a crash), its entry still: the doubt said once
949    /// `FRESH` has passed.
950    #[test]
951    fn a_dose_is_known_with_seen_as_the_desktop_keeps_it() {
952        let (base, folder, desk, phone) = pair("seen-kept");
953        let mut watched: Option<((i64, i64, bool, i64, bool), i64)> = None;
954        let mut sees = |at: i64| -> Peer {
955            let entry = all(&folder, &KEY).0.into_iter().find(|e| e.id == phone.id).expect("its entry");
956            let state = (entry.started, entry.closed, entry.working, entry.exported, entry.left);
957            if watched.is_none_or(|(last, _)| last != state) {
958                watched = Some((state, at));
959            }
960            Peer { seen: watched.map_or(at, |(_, seen)| seen), ..desk.sees(&folder, &phone, at) }
961        };
962        phone.exchange(&folder, DUE - 3_600);
963        phone.session(&folder, |e| e.close(DUE - 3_595));
964        desk.exchange(&folder, DUE - 3_590);
965        let closed_seen = sees(DUE - 3_590).seen;
966        for n in 0..12 {
967            phone.exchange(&folder, DUE - 3_500 + n * 300);
968            desk.exchange(&folder, DUE - 3_490 + n * 300);
969            assert_eq!(sees(DUE - 3_490 + n * 300).seen, closed_seen, "put away and quiet: its entry not seen changing");
970        }
971        for at in [DUE + 30, DUE + 3_600, DUE + 6 * 3_600] {
972            let peer = sees(at);
973            assert!(at - peer.seen > sioul_core::health::FRESH);
974            assert!(doubts_now(DUE, at, None, &[peer]).is_empty(), "closed, its seen {} s old: known at {at}", at - closed_seen);
975        }
976        // In use from DUE + 400, exporting every minute: seen follows, the dose known after it.
977        let later = DUE + 7 * 3_600;
978        phone.session(&folder, |e| e.start(later));
979        for n in 1..=5 {
980            phone.exchange(&folder, later + n * 60);
981            desk.exchange(&folder, later + n * 60 + 5);
982            assert!(doubts_now(later, later + n * 60 + 10, None, &[sees(later + n * 60 + 10)]).is_empty() || n * 60 < sioul_core::health::SKEW, "in use, exporting: known at minute {n}");
983        }
984        // Stopped without closing (a crash): its entry still, the doubt said once FRESH has passed.
985        let stopped = later + 5 * 60 + 10 + sioul_core::health::FRESH + 60;
986        desk.exchange(&folder, stopped - 5);
987        assert!(matches!(doubts_now(later, stopped, None, &[sees(stopped)]).as_slice(), [Doubt::Working { .. }]), "a phone in use gone quiet: the doubt said");
988        let _ = std::fs::remove_dir_all(&base);
989    }
990
991    #[test]
992    fn the_phone_raised_but_not_exported_is_uncertain_and_its_records_behind_its_entry_too() {
993        let (base, folder, desk, phone) = pair("raised");
994        // The phone's receiver raised its session and recorded, then was stopped before its export.
995        phone.session(&folder, |e| e.start(DUE + 300));
996        phone.answer(DOSE, TAKEN, DUE + 300);
997        desk.exchange(&folder, DUE + 400);
998        assert_eq!(desk.answered(DOSE), Answered::Open, "its answer not exported");
999        let doubts = doubts_now(DUE, DUE + 400, None, &[desk.sees(&folder, &phone, DUE + 400)]);
1000        assert!(matches!(doubts.as_slice(), [Doubt::Working { device, .. }] if device.id == phone.id), "{doubts:?}");
1001        // Its export done, its entry in the folder, but its new records only half arrived here (the
1002        // sync brought its entry first): what it last shared is coming, not known.
1003        let rounds = || -> Vec<PathBuf> { std::fs::read_dir(&folder).unwrap().filter_map(Result::ok).map(|e| e.path()).filter(|p| p.file_name().unwrap().to_string_lossy().starts_with(&phone.id) && p.extension().is_some_and(|x| x == "jsonl")).collect() };
1004        let before: Vec<(PathBuf, usize)> = rounds().into_iter().map(|p| (p.clone(), std::fs::read(&p).unwrap().len())).collect();
1005        phone.exchange(&folder, DUE + 500);
1006        phone.session(&folder, |e| e.close(DUE + 501));
1007        let held: Vec<(PathBuf, Vec<u8>)> = rounds().into_iter().map(|p| (p.clone(), std::fs::read(&p).unwrap())).collect();
1008        for (path, bytes) in &held {
1009            let had = before.iter().find(|(p, _)| p == path).map_or(0, |(_, size)| *size);
1010            std::fs::write(path, &bytes[..had + (bytes.len() - had) / 2]).unwrap();
1011        }
1012        desk.exchange(&folder, DUE + 510);
1013        let behind = desk.sees(&folder, &phone, DUE + 520);
1014        assert!(!behind.complete);
1015        assert!(matches!(doubts_now(DUE, DUE + 520, None, &[behind]).as_slice(), [Doubt::Coming { .. }]));
1016        for (path, bytes) in &held {
1017            std::fs::write(path, bytes).unwrap();
1018        }
1019        desk.exchange(&folder, DUE + 560);
1020        assert!(matches!(desk.answered(DOSE), Answered::Taken(_)));
1021        assert!(doubts_now(DUE, DUE + 560, None, &[desk.sees(&folder, &phone, DUE + 560)]).is_empty());
1022        let _ = std::fs::remove_dir_all(&base);
1023    }
1024
1025    #[test]
1026    fn the_same_dose_answered_on_both_devices_keeps_both() {
1027        let (base, folder, desk, phone) = pair("both");
1028        // Taken on the phone at 08:02 (its sync slow), skipped on the desktop at 08:10.
1029        phone.answer(DOSE, TAKEN, DUE + 120);
1030        desk.answer(DOSE, SKIPPED, DUE + 600);
1031        phone.exchange(&folder, DUE + 700);
1032        desk.exchange(&folder, DUE + 710);
1033        phone.exchange(&folder, DUE + 720);
1034        // Both answers kept on both devices, both said; none picked.
1035        for device in [&desk, &phone] {
1036            match device.answered(DOSE) {
1037                Answered::Differ(given) => {
1038                    let said: Vec<(String, sioul_core::doses::State)> = given.iter().map(|g| (g.device.clone(), g.state.clone())).collect();
1039                    assert_eq!(said, [(phone.id.clone(), sioul_core::doses::State::Taken), (desk.id.clone(), sioul_core::doses::State::Skipped)]);
1040                }
1041                other => panic!("{other:?}"),
1042            }
1043        }
1044        // You choose on the desktop, seeing both: "taken". Settled everywhere, the phone's answer kept.
1045        DoseRecords::update(&desk.records(), DUE + 800, |r| r.choose(DOSE, &desk.id, Answer { state: TAKEN.into(), at: DUE + 120, noted: DUE + 800, name: "desk".into(), kind: COMPUTER.into() })).unwrap();
1046        // The marks beside, for an older Sioul, as the window settles them.
1047        HealthState::update(&desk.marks(), DUE + 800, |s| {
1048            s.not_taken.remove(DOSE);
1049            s.taken.insert(DOSE.into(), DUE + 120);
1050        })
1051        .unwrap();
1052        desk.exchange(&folder, DUE + 810);
1053        phone.exchange(&folder, DUE + 820);
1054        assert!(matches!(phone.answered(DOSE), Answered::Taken(ref g) if g.device == phone.id));
1055        let _ = std::fs::remove_dir_all(&base);
1056    }
1057
1058    #[test]
1059    fn a_choice_or_a_take_back_on_one_device_settles_both() {
1060        let (base, folder, desk, phone) = pair("settle");
1061        // Taken on the phone, skipped on the desktop: both said on both devices.
1062        phone.answer(DOSE, TAKEN, DUE + 120);
1063        desk.answer(DOSE, SKIPPED, DUE + 600);
1064        phone.exchange(&folder, DUE + 700);
1065        desk.exchange(&folder, DUE + 710);
1066        phone.exchange(&folder, DUE + 720);
1067        assert!(matches!(phone.answered(DOSE), Answered::Differ(_)));
1068        // You choose "not taken" on the desktop: the phone's "taken" goes, on purpose, on both devices.
1069        DoseRecords::update(&desk.records(), DUE + 800, |r| r.choose(DOSE, &desk.id, Answer { state: SKIPPED.into(), at: DUE + 800, noted: DUE + 800, name: "desk".into(), kind: COMPUTER.into() })).unwrap();
1070        HealthState::update(&desk.marks(), DUE + 800, |s| {
1071            s.taken.remove(DOSE);
1072            s.not_taken.insert(DOSE.into(), DUE + 800);
1073        })
1074        .unwrap();
1075        desk.exchange(&folder, DUE + 810);
1076        phone.exchange(&folder, DUE + 820);
1077        desk.exchange(&folder, DUE + 830);
1078        for device in [&desk, &phone] {
1079            let answered = device.answered(DOSE);
1080            assert!(matches!(answered, Answered::Skipped(ref g) if g.device == desk.id), "{answered:?}");
1081        }
1082        // Another dose taken on the phone, taken back on the desktop: open again on both, never taken on one.
1083        let iron = "iron@1800000000";
1084        phone.answer(iron, TAKEN, DUE + 900);
1085        phone.exchange(&folder, DUE + 910);
1086        desk.exchange(&folder, DUE + 920);
1087        assert!(matches!(desk.answered(iron), Answered::Taken(_)));
1088        DoseRecords::update(&desk.records(), DUE + 1_000, |r| r.take_back(iron)).unwrap();
1089        HealthState::update(&desk.marks(), DUE + 1_000, |s| {
1090            s.taken.remove(iron);
1091        })
1092        .unwrap();
1093        desk.exchange(&folder, DUE + 1_010);
1094        phone.exchange(&folder, DUE + 1_020);
1095        desk.exchange(&folder, DUE + 1_030);
1096        for device in [&desk, &phone] {
1097            assert_eq!(device.answered(iron), Answered::Open);
1098        }
1099        let _ = std::fs::remove_dir_all(&base);
1100    }
1101
1102    #[test]
1103    fn a_canary_opened_on_two_devices_merges_into_one_record() {
1104        let (base, folder, desk, phone) = pair("canary");
1105        DoseRecords::update(&desk.records(), DUE + 30, |r| r.open(DOSE, &desk.id, DUE + 30)).unwrap();
1106        DoseRecords::update(&phone.records(), DUE + 20, |r| r.open(DOSE, &phone.id, DUE + 20)).unwrap();
1107        desk.exchange(&folder, DUE + 60);
1108        phone.exchange(&folder, DUE + 70);
1109        desk.exchange(&folder, DUE + 80);
1110        for device in [&desk, &phone] {
1111            let records = DoseRecords::read(&device.records()).unwrap();
1112            assert_eq!(records.dose.len(), 1);
1113            assert_eq!(records.dose[DOSE].opened.len(), 2, "both openings kept");
1114            assert_eq!(records.opened_first(DOSE), Some((phone.id.clone(), DUE + 20)));
1115            assert_eq!(device.answered(DOSE), Answered::Open, "not taken yet: the canary");
1116        }
1117        // An answer on one device lifts it on both; nothing taken out by the merge.
1118        phone.answer(DOSE, TAKEN, DUE + 90);
1119        phone.exchange(&folder, DUE + 100);
1120        desk.exchange(&folder, DUE + 110);
1121        assert!(matches!(desk.answered(DOSE), Answered::Taken(_)));
1122        assert_eq!(DoseRecords::read(&desk.records()).unwrap().dose[DOSE].opened.len(), 2);
1123        let _ = std::fs::remove_dir_all(&base);
1124    }
1125
1126    #[test]
1127    fn an_older_sioul_takes_nothing_out_and_its_marks_still_count() {
1128        let base = scratch("older");
1129        let folder = base.join("shared");
1130        std::fs::create_dir_all(&folder).unwrap();
1131        let desk = Device::new(&base, "desk", COMPUTER);
1132        let old = Device::new(&base, "old", PHONE);
1133        // The older Sioul: no records file among its stores, no entry in the registry.
1134        let old_stores = || old.stores().into_iter().filter(|s| s.name != "state/health-doses.toml").collect::<Vec<_>>();
1135        let old_exchange = |now: i64| crate::share::exchange(&Sharing { folder: &folder, computer: &old.id, key: &KEY, memory: &old.memory, files: false, hurry: None }, &old_stores(), now * 1000).unwrap();
1136        desk.session(&folder, |e| e.start(DUE - 3_600));
1137        desk.exchange(&folder, DUE - 3_500);
1138        old_exchange(DUE - 3_400);
1139        // The desktop opens and answers in its records; the older one exchanges again and again.
1140        DoseRecords::update(&desk.records(), DUE + 30, |r| r.open(DOSE, &desk.id, DUE + 30)).unwrap();
1141        desk.answer("iron@1800000000", SKIPPED, DUE + 60);
1142        desk.exchange(&folder, DUE + 90);
1143        for n in 0..3 {
1144            old_exchange(DUE + 100 + n);
1145        }
1146        desk.exchange(&folder, DUE + 200);
1147        let records = DoseRecords::read(&desk.records()).unwrap();
1148        assert!(records.dose.contains_key(DOSE) && records.dose.contains_key("iron@1800000000"), "nothing taken out by the older one");
1149        // Its own marks (health-state.toml) still read as answers on the desktop.
1150        HealthState::update(&old.marks(), DUE + 300, |s| {
1151            s.taken.insert(DOSE.into(), DUE + 300);
1152        })
1153        .unwrap();
1154        old_exchange(DUE + 310);
1155        desk.exchange(&folder, DUE + 320);
1156        assert!(matches!(desk.answered(DOSE), Answered::Taken(ref g) if g.device.is_empty()));
1157        // It wrote no entry: the desktop has none, and judges it by its claims, as before (never closed).
1158        assert!(all(&folder, &KEY).0.iter().all(|e| e.id != old.id));
1159        let _ = std::fs::remove_dir_all(&base);
1160    }
1161
1162    /// The handshake (review of battery part C, the sixth and seventh
1163    /// reviews): a phone's word on a computer's opening travels in its own
1164    /// sealed entry (`Entry::acked`), read by the computer with the rest of
1165    /// it; a word written in the folder otherwise (a file not sealed with the
1166    /// key, another device's name) is no entry at all.
1167    /// Of two copies of a phone's entry (the folder's, the one fetched): the
1168    /// later by its times, the folder's on a tie; a computer either copy no
1169    /// longer names is withdrawn, whichever is later, while a copy naming an
1170    /// older opening of it takes nothing away (the re-check before 0.0.6, LOW-2).
1171    #[test]
1172    fn of_two_copies_the_later_and_a_word_both_name() {
1173        let copy = |exported: i64, word: Option<i64>| Entry { id: "phone".into(), kind: PHONE.into(), working: true, started: 1_000, exported, imported: exported, acked: word.map(|opening| ("desk".to_string(), opening)).into_iter().collect(), ..Entry::default() };
1174        // The phone's clock set back: its newest copy, dated earlier, no longer names the desk; the lagging one does.
1175        let picked = chosen(copy(9_000, Some(500)), copy(7_000, None));
1176        assert_eq!((picked.exported, picked.acked.get("desk")), (9_000, None), "withdrawn, though the copy naming it is dated later");
1177        // Two copies written in one second: the folder's, with the word; the fetched one, without.
1178        assert!(chosen(copy(9_000, Some(500)), copy(9_000, None)).acked.is_empty());
1179        // A lagging copy naming an older opening takes nothing away: the new word stands.
1180        assert_eq!(chosen(copy(7_000, Some(500)), copy(9_000, Some(800))).acked.get("desk"), Some(&800));
1181        assert_eq!(chosen(copy(9_000, Some(800)), copy(7_000, Some(500))).acked.get("desk"), Some(&800));
1182        // The cost: counted again (or paired anew) while the other copy lags without it, its word waits for that copy.
1183        assert!(chosen(copy(7_000, None), copy(9_000, Some(800))).acked.is_empty());
1184        // Both alike: the later as it is.
1185        assert_eq!(chosen(copy(7_000, Some(800)), copy(9_000, Some(800))).acked.get("desk"), Some(&800));
1186    }
1187
1188    #[test]
1189    fn a_phones_word_on_an_opening_travels_sealed_in_its_entry() {
1190        let base = scratch("acknowledged");
1191        let folder = base.join("shared");
1192        std::fs::create_dir_all(&folder).unwrap();
1193        let desk = Device::new(&base, "desk", COMPUTER);
1194        let phone = Device::new(&base, "phone", PHONE);
1195        desk.session(&folder, |e| e.start(DUE - 30));
1196        let desk_id = desk.id.clone();
1197        phone.session(&folder, |e| {
1198            e.start(DUE - 3_600);
1199            e.acked.insert(desk_id.clone(), DUE - 30);
1200        });
1201        let read = |id: &str| all(&folder, &KEY).0.into_iter().find(|e| e.id == id);
1202        assert_eq!(read(&phone.id).and_then(|e| e.acked.get(&desk.id).copied()), Some(DUE - 30), "the phone's word, sealed");
1203        // Forged in the folder without the key: no entry read, so no word.
1204        let forged = Entry { id: phone.id.clone(), kind: PHONE.into(), working: true, acked: [(desk.id.clone(), i64::MAX)].into_iter().collect(), ..Entry::default() };
1205        std::fs::write(file_of(&folder, &phone.id), serde_json::to_vec(&forged).unwrap()).unwrap();
1206        assert!(read(&phone.id).is_none() && all(&folder, &KEY).1.contains(&phone.id), "unsealed: unread");
1207        let _ = std::fs::remove_dir_all(&base);
1208    }
1209
1210    /// What each device did to remind a dose (`health-reminders.toml`, review
1211    /// of battery part C, the fifth review): each device's entry comes whole
1212    /// to the others; an older Sioul, which knows no such file, takes none of
1213    /// it out, whatever it writes in the files it knows.
1214    #[test]
1215    fn what_each_device_did_to_remind_travels_and_an_older_sioul_leaves_it() {
1216        use sioul_core::dose_reminders::{DoseReminders, Tried};
1217        let base = scratch("reminders");
1218        let folder = base.join("shared");
1219        std::fs::create_dir_all(&folder).unwrap();
1220        let desk = Device::new(&base, "desk", COMPUTER);
1221        let phone = Device::new(&base, "phone", PHONE);
1222        let old = Device::new(&base, "old", PHONE);
1223        let old_stores = || old.stores().into_iter().filter(|s| s.name != "state/health-doses.toml" && s.name != "state/health-reminders.toml").collect::<Vec<_>>();
1224        let old_exchange = |now: i64| crate::share::exchange(&Sharing { folder: &folder, computer: &old.id, key: &KEY, memory: &old.memory, files: false, hurry: None }, &old_stores(), now * 1000).unwrap();
1225        let reminders = |device: &Device| device.roots.state.join(sioul_core::dose_reminders::FILE);
1226        desk.session(&folder, |e| e.start(DUE - 3_600));
1227        phone.session(&folder, |e| e.start(DUE - 3_600));
1228        desk.exchange(&folder, DUE - 3_500);
1229        phone.exchange(&folder, DUE - 3_450);
1230        old_exchange(DUE - 3_400);
1231        // The desk's first try did not go out, then it promised quiet past the phone's alarm.
1232        let promised = Tried { unshown: DUE + 5, quiet: DUE + 900, ..Tried::default() };
1233        DoseReminders::update(&reminders(&desk), DUE + 5, &desk.id, |all| *all.entry(DOSE, &desk.id) = promised).unwrap();
1234        desk.exchange(&folder, DUE + 420);
1235        for n in 0..3 {
1236            old_exchange(DUE + 430 + n);
1237        }
1238        HealthState::update(&old.marks(), DUE + 440, |s| {
1239            s.reminded.insert("iron@1800000000".into(), DUE + 440);
1240        })
1241        .unwrap();
1242        old_exchange(DUE + 450);
1243        phone.exchange(&folder, DUE + 600);
1244        assert_eq!(DoseReminders::read(&reminders(&phone)).unwrap().of(DOSE, &desk.id), Some(promised), "whole, as the desk wrote it");
1245        // The phone's alarm shows it: its own entry, read back on the desk.
1246        DoseReminders::update(&reminders(&phone), DUE + 601, &phone.id, |all| all.entry(DOSE, &phone.id).shown = DUE + 601).unwrap();
1247        phone.exchange(&folder, DUE + 610);
1248        desk.exchange(&folder, DUE + 620);
1249        let read = DoseReminders::read(&reminders(&desk)).unwrap();
1250        assert!(read.shown(DOSE) && read.of(DOSE, &desk.id) == Some(promised), "both entries, nothing taken out");
1251        let _ = std::fs::remove_dir_all(&base);
1252    }
1253}