Skip to main content

sioul_core/
everywhere.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Do-not-disturb on every device (docs/do-not-disturb.md): why it holds now,
5//! the switch pressed on any device, and the people who may reach you then.
6//!
7//! - **The reasons**, each read from the one place that already says it, and
8//!   shared as it is: the pause and Free time (`quiet.toml`), the night and
9//!   naps (Health), a focus session (`time/running.toml`), and the switch
10//!   (`state/do-not-disturb.toml`, the only new record). Every device reads
11//!   the same files and comes to the same answer. The latest change wins: the
12//!   switch pressed off after a reason began holds that reason off until it
13//!   ends; a reason beginning after the press holds again.
14//! - **The switch**: each device writes its own table only, its last press
15//!   stamped with a hybrid clock (never before a press already known), so a
16//!   press made after seeing another always comes after it, whatever the
17//!   clocks. Two presses that did not know of each other, less than a minute
18//!   apart: "on" wins, the quieter choice; one more press settles it.
19//! - **The list**, "Who may reach you during do-not-disturb"
20//!   (`config/dnd-people.toml`): names, numbers and addresses, kept in the
21//!   sharing itself, since the address books of two devices may not be in step.
22//! - **Both ways**: each device's own do-not-disturb, turned on or off by
23//!   anything there and heard as it happens, presses the switch when it
24//!   disagrees with what holds there (`heard`); the press says where it came
25//!   from (`via`). A device whose switch "on" came from its own system alone
26//!   adds no mode of Sioul's over it (`held_by_system`), so that the end of
27//!   what turned it on stays visible.
28
29use crate::config::{config_dir, state_dir};
30use serde::{Deserialize, Serialize};
31use std::collections::BTreeMap;
32use std::path::{Path, PathBuf};
33
34/// The switch's file, in the state folder; shared with the settings ("state/do-not-disturb.toml").
35pub const SWITCH_FILE: &str = "do-not-disturb.toml";
36/// The list's file, in the configuration folder; shared with the senders ("config/dnd-people.toml").
37pub const PEOPLE_FILE: &str = "dnd-people.toml";
38/// Two presses on two devices closer than this (milliseconds), neither knowing
39/// of the other: "on" wins. Clocks of devices that set their time from the
40/// network differ by less than a second; this covers a clock left a little off.
41pub const MARGIN_MS: i64 = 60_000;
42/// A focus session holds do-not-disturb this long past the time chosen
43/// (seconds): a session forgotten never keeps you unreachable for long.
44pub const FOCUS_GRACE: i64 = 30 * 60;
45/// …and a session without a time chosen, this long (Sioul's open-ended ceiling).
46pub const FOCUS_OPEN: i64 = 180 * 60;
47/// Other devices heard from within this many days count for "on every device".
48pub const LIVE_DAYS: i64 = 7;
49/// A computer whose news is this old (seconds), its table saying it is
50/// silenced, is waiting for its news: its Sioul may have stopped without a
51/// word (a crash), and its system's do-not-disturb (Plasma's) ended with it. A
52/// running computer exchanges each minute and writes its news at least every
53/// fifteen minutes (sioul-sync's `write_seen`). A phone's modes stay while
54/// Sioul is closed: its table holds.
55pub const COMPUTER_QUIET: i64 = 30 * 60;
56
57fn yes() -> bool {
58    true
59}
60
61fn is_false(value: &bool) -> bool {
62    !*value
63}
64
65/// A press heard from the device's own do-not-disturb, as `Device::via` and `Press::via` say it.
66pub const VIA_SYSTEM: &str = "system";
67
68/// What turns do-not-disturb on (`[dnd]` in the configuration, shared with the settings).
69#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
70pub struct DndSettings {
71    /// The switch in the status line.
72    #[serde(default = "yes")]
73    pub button: bool,
74    /// While a focus session counts.
75    #[serde(default)]
76    pub focus: bool,
77    /// During the pause and Free time: the system's do-not-disturb too, beside
78    /// Sioul's own notifications, which the pauses hold either way.
79    #[serde(default = "yes")]
80    pub pauses: bool,
81    /// While you sleep: the night from winding down, and naps.
82    #[serde(default)]
83    pub sleep: bool,
84    /// The people on the list got through (on a phone, as starred contacts);
85    /// otherwise nobody. Always through's do-not-disturb cells now
86    /// (`attention`), read from here once to seed them.
87    #[serde(default = "yes")]
88    pub people: bool,
89    /// Android: this device keeps in step in the background (its own, never shared).
90    #[serde(default = "yes")]
91    pub background: bool,
92}
93
94impl Default for DndSettings {
95    fn default() -> Self {
96        DndSettings { button: true, focus: false, pauses: true, sleep: false, people: true, background: true }
97    }
98}
99
100/// Why do-not-disturb holds, in the order the first one is said.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
102#[serde(rename_all = "kebab-case")]
103pub enum Why {
104    Paused,
105    FreeTime,
106    Sleep,
107    Focus,
108    Manual,
109}
110
111impl Why {
112    pub fn id(self) -> &'static str {
113        match self {
114            Why::Paused => "paused",
115            Why::FreeTime => "free-time",
116            Why::Sleep => "sleep",
117            Why::Focus => "focus",
118            Why::Manual => "manual",
119        }
120    }
121
122    pub fn parse(id: &str) -> Option<Why> {
123        [Why::Paused, Why::FreeTime, Why::Sleep, Why::Focus, Why::Manual].into_iter().find(|w| w.id() == id)
124    }
125
126    /// Silenced by the switch's own mode (sleep, focus, the switch), not a pause's.
127    pub fn global(self) -> bool {
128        matches!(self, Why::Sleep | Why::Focus | Why::Manual)
129    }
130}
131
132// ---------------------------------------------------------------- the switch
133
134/// One device's table in the switch's file, written by that device alone.
135#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
136pub struct Device {
137    /// "phone" or "computer".
138    #[serde(default)]
139    pub kind: String,
140    /// A computer's host name; "" for a phone.
141    #[serde(default)]
142    pub name: String,
143    /// Its last press of the switch (milliseconds, hybrid clock); 0: never pressed there.
144    #[serde(default)]
145    pub pressed: i64,
146    /// What that press asked.
147    #[serde(default)]
148    pub on: bool,
149    /// The end chosen with it (milliseconds); 0: until turned off.
150    #[serde(default)]
151    pub until: i64,
152    /// The latest press of another device known here when pressing (milliseconds).
153    #[serde(default)]
154    pub seen: i64,
155    /// What holds here now, as this device reads the reasons ("" none).
156    #[serde(default)]
157    pub why: String,
158    /// Its system's do-not-disturb is on for it.
159    #[serde(default)]
160    pub silenced: bool,
161    /// Its system's answer, in its words (the do-not-disturb layer's report).
162    #[serde(default)]
163    pub line: String,
164    /// When this table was written (milliseconds).
165    #[serde(default)]
166    pub at: i64,
167    /// Where its last press came from: "" the switch (in the window, the
168    /// tile), `VIA_SYSTEM` the device's own do-not-disturb, heard as it changed.
169    #[serde(default, skip_serializing_if = "String::is_empty")]
170    pub via: String,
171    /// Do-not-disturb holds, but the person turned this device's own
172    /// do-not-disturb off, unheard, and Sioul leaves it off until it next turns
173    /// it on: `line` says so.
174    #[serde(default, skip_serializing_if = "is_false")]
175    pub turned_off: bool,
176    /// Do-not-disturb does not hold, but this device's system is still
177    /// silenced (Sioul could not turn it off, or it came on unheard): `line` says so.
178    #[serde(default, skip_serializing_if = "is_false")]
179    pub system_on: bool,
180    /// Sioul quit on this computer (`closing`): it follows nothing until it
181    /// starts again; `silenced` says what its system still holds without it.
182    #[serde(default, skip_serializing_if = "is_false")]
183    pub closed: bool,
184    /// What a later version writes here, kept as it is: written back unchanged.
185    #[serde(flatten)]
186    pub other: toml::Table,
187}
188
189impl Device {
190    /// Sioul quits on this computer: its table as it then holds. Its system's
191    /// do-not-disturb ended with Sioul (Plasma's) unless it outlives it
192    /// (`still`: GNOME's switch, a Mac's Focus); nothing more is followed here
193    /// until Sioul starts again.
194    pub fn closing(&mut self, still: bool) {
195        self.closed = true;
196        self.silenced = still;
197        self.turned_off = false;
198        self.system_on = false;
199        self.line = String::new();
200    }
201}
202
203/// The switch's file: a table per device.
204#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
205pub struct Switch {
206    #[serde(default)]
207    pub device: BTreeMap<String, Device>,
208    /// What a later version writes here, kept as it is.
209    #[serde(flatten)]
210    pub other: toml::Table,
211}
212
213/// The press that holds, as every device reads the switch.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct Press {
216    /// Milliseconds, the pressing device's hybrid clock.
217    pub at: i64,
218    pub on: bool,
219    /// Milliseconds; 0: until turned off.
220    pub until: i64,
221    /// The device it was pressed on.
222    pub from: String,
223    /// Where it came from on that device: "" the switch, `VIA_SYSTEM` its own do-not-disturb.
224    pub via: String,
225}
226
227impl Switch {
228    pub fn default_path() -> PathBuf {
229        state_dir().join(SWITCH_FILE)
230    }
231
232    /// The file as it is; none there is no press anywhere yet. One that does
233    /// not read is an error: never taken for no device at all.
234    pub fn read(path: &Path) -> Result<Switch, String> {
235        match std::fs::read_to_string(path) {
236            Ok(text) => toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display())),
237            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Switch::default()),
238            Err(e) => Err(format!("{}: {e}", path.display())),
239        }
240    }
241
242    /// For a question (the status line, an apply): what cannot be read is no press.
243    pub fn load(path: &Path) -> Switch {
244        Switch::read(path).unwrap_or_default()
245    }
246
247    /// The latest press anywhere wins; one that did not know of an opposite
248    /// press made on another device less than `MARGIN_MS` before it gives way
249    /// to it if that one was "on".
250    pub fn latest(&self) -> Option<Press> {
251        let presses: Vec<(&String, &Device)> = self.device.iter().filter(|(_, d)| d.pressed > 0).collect();
252        let (from, last) = presses.iter().max_by_key(|(id, d)| (d.pressed, (*id).clone()))?;
253        let mut winner = (*from, *last);
254        if !last.on {
255            // An "on" that this "off" did not know of, close before it: a tie, "on" wins.
256            let tied = presses.iter().filter(|(id, d)| *id != *from && d.on && last.pressed - d.pressed <= MARGIN_MS && last.seen < d.pressed).max_by_key(|(id, d)| (d.pressed, (*id).clone()));
257            if let Some((id, d)) = tied {
258                winner = (*id, *d);
259            }
260        }
261        Some(Press { at: winner.1.pressed, on: winner.1.on, until: winner.1.until, from: winner.0.clone(), via: winner.1.via.clone() })
262    }
263
264    /// The latest press known here, of any device but `except` (milliseconds).
265    pub fn latest_known(&self, except: Option<&str>) -> i64 {
266        self.device.iter().filter(|(id, _)| Some(id.as_str()) != except).map(|(_, d)| d.pressed).max().unwrap_or(0)
267    }
268
269    /// A press here, stamped after every press known (a hybrid clock): `until`
270    /// in milliseconds, 0 for none. Returns its stamp.
271    pub fn press(&mut self, here: &str, on: bool, until: i64, now_ms: i64) -> i64 {
272        self.press_via(here, on, until, now_ms, "")
273    }
274
275    /// A press here, as `press`, saying where it came from (`VIA_SYSTEM`: this
276    /// device's own do-not-disturb, heard as it changed; "": the switch).
277    pub fn press_via(&mut self, here: &str, on: bool, until: i64, now_ms: i64, via: &str) -> i64 {
278        let stamp = now_ms.max(self.latest_known(None) + 1);
279        let seen = self.latest_known(Some(here));
280        let own = self.device.entry(here.to_string()).or_default();
281        own.pressed = stamp;
282        own.on = on;
283        own.until = if on { until.max(0) } else { 0 };
284        own.seen = seen;
285        own.at = stamp;
286        own.via = via.to_string();
287        stamp
288    }
289
290    /// Written whole, beside it then put in place: never half a file.
291    pub fn save(&self, path: &Path) -> Result<(), String> {
292        write_whole(path, &toml::to_string(self).map_err(|e| e.to_string())?)
293    }
294}
295
296/// This device's own table changed under the file's lock, the file read
297/// again first: the other devices' tables, brought by the sharing meanwhile,
298/// stay as they are. `change` says whether it changed anything; nothing is
299/// written otherwise. A file that does not read is left alone, an error.
300pub fn change_own(path: &Path, here: &str, change: impl FnOnce(&mut Switch) -> bool) -> Result<Switch, String> {
301    crate::filelock::with_lock(path, || {
302        let mut switch = Switch::read(path)?;
303        if !here.is_empty() && change(&mut switch) {
304            // Only this device's table may have changed: the others' are never written from here.
305            switch.save(path)?;
306        }
307        Ok(switch)
308    })
309}
310
311/// A file written beside its place under a hidden name, then renamed.
312fn write_whole(path: &Path, text: &str) -> Result<(), String> {
313    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
314    if let Some(parent) = path.parent() {
315        std::fs::create_dir_all(parent).map_err(fail)?;
316    }
317    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
318    let fresh = path.with_file_name(format!(".{name}.{}.new", std::process::id()));
319    std::fs::write(&fresh, text).map_err(fail)?;
320    std::fs::rename(&fresh, path).map_err(|e| {
321        let _ = std::fs::remove_file(&fresh);
322        fail(e)
323    })
324}
325
326// ---------------------------------------------------------------- the reasons
327
328/// What each reason's source says now (Unix seconds).
329#[derive(Debug, Clone, Default, PartialEq, Eq)]
330pub struct Sources {
331    /// The pause, since.
332    pub paused: Option<i64>,
333    /// Free time: since, and when it ends by itself (the night).
334    pub free: Option<(i64, i64)>,
335    /// Asleep, the night from winding down or a nap: since, until waking.
336    pub sleep: Option<(i64, i64)>,
337    /// A focus session counting: its start, and when it stops holding do-not-disturb (`focus`).
338    pub focus: Option<(i64, i64)>,
339}
340
341/// A focus session as do-not-disturb reads it: counting (not paused), from
342/// its start until 30 minutes past the time chosen (three hours without one);
343/// none otherwise.
344pub fn focus(running: &crate::timelog::Running, now: i64) -> Option<(i64, i64)> {
345    if running.paused_at.is_some() || running.start <= 0 {
346        return None;
347    }
348    let lasts = if running.planned > 0 { i64::from(running.planned) * 60 + FOCUS_GRACE } else { FOCUS_OPEN };
349    let end = running.start + running.paused.max(0) + lasts;
350    (now < end).then_some((running.start, end))
351}
352
353/// A reason that holds now.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
355pub struct Held {
356    pub why: Why,
357    /// Unix seconds.
358    pub since: i64,
359    /// When it ends by itself (Unix seconds): the switch's end, waking, Free
360    /// time's end, a focus session's limit; none for the pause and an open switch.
361    pub until: Option<i64>,
362    /// The device the switch was pressed on; "" for the other reasons.
363    pub from: String,
364}
365
366/// Why do-not-disturb holds now: every reason that does, the one said first first.
367#[derive(Debug, Clone, Default, PartialEq, Eq)]
368pub struct Now {
369    pub holds: Vec<Held>,
370}
371
372impl Now {
373    pub fn on(&self) -> bool {
374        !self.holds.is_empty()
375    }
376
377    /// The reason said: the first that holds.
378    pub fn why(&self) -> Option<Why> {
379        self.holds.first().map(|h| h.why)
380    }
381
382    pub fn has(&self, why: Why) -> bool {
383        self.holds.iter().any(|h| h.why == why)
384    }
385
386    /// Whether the switch's own mode holds (sleep, focus, the switch), beside the pauses'.
387    pub fn global(&self) -> bool {
388        self.holds.iter().any(|h| h.why.global())
389    }
390
391    /// The end said: when every reason that holds ends by itself, the last of
392    /// them; a focus session's limit is never said (it is no promise).
393    pub fn until(&self) -> Option<i64> {
394        if self.holds.is_empty() || self.holds.iter().any(|h| h.until.is_none() || h.why == Why::Focus) {
395            return None;
396        }
397        self.holds.iter().filter_map(|h| h.until).max()
398    }
399
400    /// The next time a reason that holds ends by itself (Unix seconds), to look again then.
401    pub fn next_end(&self) -> Option<i64> {
402        self.holds.iter().filter_map(|h| h.until).min()
403    }
404
405    /// The first reason that holds and is not a pause's (sleep, focus, the switch).
406    pub fn global_why(&self) -> Option<Why> {
407        self.holds.iter().map(|h| h.why).find(|w| w.global())
408    }
409
410    /// Whether Sioul's own notifications wait and mail comes only from the
411    /// list: the switch or a focus session (sleep and the pauses keep their own
412    /// rules, under which nothing is told).
413    pub fn gates(&self) -> bool {
414        self.has(Why::Focus) || self.has(Why::Manual)
415    }
416}
417
418/// A change of this device's own do-not-disturb, heard as it happened, against
419/// what holds here (`holds`: do-not-disturb on, for any reason): the press it
420/// is, `Some(on)`, when the two disagree; none when they agree, which is what
421/// Sioul's own changes look like when they come back (docs/do-not-disturb.md,
422/// "Both ways"). A change found later, not heard as it happened, is never a
423/// press: its time is unknown, and another device may have pressed since.
424pub fn heard(holds: bool, system_on: bool) -> Option<bool> {
425    (holds != system_on).then_some(system_on)
426}
427
428/// Whether the switch's mode is held on this device by its own system alone:
429/// the switch's "on", taken from this device's own do-not-disturb (`here`,
430/// `VIA_SYSTEM`), is the only reason for that mode (the pause and Free time
431/// have modes of their own). Sioul then adds no mode of its own there: the
432/// system's cause silences the device already, and a mode of Sioul's would
433/// hide its end (Android shows an application only the effective state).
434pub fn held_by_system(now: &Now, press: Option<&Press>, here: &str) -> bool {
435    let mut global = now.holds.iter().filter(|h| h.why.global());
436    let only_manual = matches!((global.next(), global.next()), (Some(h), None) if h.why == Why::Manual);
437    only_manual && press.is_some_and(|p| p.on && p.from == here && p.via == VIA_SYSTEM)
438}
439
440/// Why do-not-disturb holds at `now` (Unix seconds), from the reasons'
441/// sources, the settings and the switch's latest press. A press "off" made
442/// after a reason began holds that reason off until it ends; the pause first,
443/// then Free time (never with the pause), sleep, focus, the switch.
444pub fn now(settings: &DndSettings, sources: &Sources, press: Option<&Press>, now: i64) -> Now {
445    // Pressed off after it began: held off. A press "on" never holds a reason off.
446    let pressed_off_after = |since: i64| press.is_some_and(|p| !p.on && p.at > since.saturating_mul(1000));
447    let mut holds = Vec::new();
448    let mut add = |why: Why, since: i64, until: Option<i64>| {
449        if !pressed_off_after(since) {
450            holds.push(Held { why, since, until, from: String::new() });
451        }
452    };
453    if settings.pauses {
454        if let Some(since) = sources.paused {
455            add(Why::Paused, since, None);
456        } else if let Some((since, end)) = sources.free.filter(|(since, end)| *since <= now && now < *end) {
457            add(Why::FreeTime, since, Some(end));
458        }
459    }
460    if settings.sleep
461        && let Some((since, until)) = sources.sleep.filter(|(_, until)| now < *until)
462    {
463        add(Why::Sleep, since, Some(until));
464    }
465    if settings.focus
466        && let Some((since, end)) = sources.focus.filter(|(_, end)| now < *end)
467    {
468        add(Why::Focus, since, Some(end));
469    }
470    if let Some(p) = press.filter(|p| p.on) {
471        let until = (p.until > 0).then_some(p.until / 1000);
472        if until.is_none_or(|u| now < u) {
473            holds.push(Held { why: Why::Manual, since: p.at / 1000, until, from: p.from.clone() });
474        }
475    }
476    holds.sort_by_key(|h| h.why);
477    Now { holds }
478}
479
480// ---------------------------------------------------------------- where it holds
481
482/// How another device follows.
483#[derive(Debug, Clone, PartialEq, Eq)]
484pub enum Follows {
485    /// Do-not-disturb holds there and its system is silenced.
486    Silenced,
487    /// It holds there, but its system could not be silenced: its own words.
488    Cannot(String),
489    /// Its news has not come since do-not-disturb began: on its way, at the
490    /// sharing's pace (about a minute with a Nextcloud server).
491    Behind,
492    /// It runs a Sioul that does not know do-not-disturb: no entry in the
493    /// devices' registry, no table: it cannot follow until it is updated.
494    Older,
495    /// Sioul is closed there (a computer, as it quit): it follows nothing
496    /// until it starts again.
497    Closed,
498}
499
500/// Each other device that counts (heard from in the last `LIVE_DAYS` days, by
501/// the sharing: `heard`, its id and when, Unix seconds), and how it follows
502/// while do-not-disturb holds here, since `since` (milliseconds): a table
503/// written before that (by more than `MARGIN_MS`, for clocks that disagree)
504/// says what held there before, not now. `registered` says whether a device
505/// has its entry in the devices' registry (a Sioul that knows do-not-disturb).
506/// A computer as Sioul quit there is closed; a computer said silenced but not
507/// heard from for `COMPUTER_QUIET` is waiting for its news (a crash says nothing).
508pub fn others(switch: &Switch, here: &str, heard: &[(String, i64)], now: i64, since: i64, registered: &dyn Fn(&str) -> bool) -> Vec<(String, Follows)> {
509    let live = |at: i64| now - at <= LIVE_DAYS * 86_400;
510    let mut ids: Vec<String> = heard.iter().filter(|(id, at)| id != here && live(*at)).map(|(id, _)| id.clone()).collect();
511    // A device whose table here is recent counts too, even if the sharing's notes lag.
512    ids.extend(switch.device.iter().filter(|(id, d)| *id != here && d.at > 0 && live(d.at / 1000)).map(|(id, _)| id.clone()));
513    ids.sort();
514    ids.dedup();
515    ids.into_iter()
516        .map(|id| {
517            // Its latest news: the sharing's note of it, or its table, whichever is later (seconds).
518            let news = heard.iter().filter(|(other, _)| *other == id).map(|(_, at)| *at).chain(switch.device.get(&id).map(|d| d.at / 1000)).max().unwrap_or(0);
519            let follows = match switch.device.get(&id) {
520                Some(d) if d.closed => Follows::Closed,
521                Some(d) if d.why.is_empty() || d.at.saturating_add(MARGIN_MS) < since => Follows::Behind,
522                Some(d) if d.silenced && d.kind == "computer" && now - news > COMPUTER_QUIET => Follows::Behind,
523                Some(d) if d.silenced => Follows::Silenced,
524                Some(d) => Follows::Cannot(d.line.clone()),
525                None if !registered(&id) => Follows::Older,
526                None => Follows::Behind,
527            };
528            (id, follows)
529        })
530        .collect()
531}
532
533/// What the status line and the details say of do-not-disturb.
534#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
535pub struct Said {
536    /// One sentence: "Do not disturb, on every device, until 15:00."; "" when off.
537    pub line: String,
538    /// Why, in a few words: "While you focus on a task."
539    pub why: String,
540    /// A line per device: this one first, then the others.
541    pub details: Vec<String>,
542    /// Every device that counts follows, and this one is silenced.
543    pub everywhere: bool,
544    /// This device's system is silenced.
545    pub here: bool,
546}
547
548/// This device, as `said` words it.
549#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
550pub struct Here<'a> {
551    /// Its system's do-not-disturb is on for Sioul's.
552    pub silenced: bool,
553    /// Its system's answer, in its words.
554    pub line: &'a str,
555    /// The person turned its own do-not-disturb off, unheard, and Sioul leaves
556    /// it off until it next turns it on.
557    pub turned_off: bool,
558}
559
560/// The words of do-not-disturb as it holds now: where (`here`, this device
561/// and its system's answer; `others`, as `others` says), until when (`until`,
562/// already worded: "15:00", "tomorrow at 07:00"), why. `name_of` names a
563/// device by its id, inside a sentence ("your phone", a computer's name,
564/// "another device").
565pub fn said(now_: &Now, here: Here<'_>, others: &[(String, Follows)], until: &str, name_of: &dyn Fn(&str) -> String, tr: &crate::i18n::Translator) -> Said {
566    let (here_silenced, here_line) = (here.silenced, here.line);
567    let Some(why) = now_.why() else { return Said::default() };
568    let mut args = crate::i18n::args();
569    args.set("until", until.to_string());
570    let with_until = |key: &str| if until.is_empty() { tr.text(key, None) } else { tr.text(&format!("{key}-until"), Some(&args)) };
571    let following = others.iter().filter(|(_, f)| *f == Follows::Silenced).count();
572    let everywhere = here_silenced && following == others.len();
573    // Every device not silenced yet is one whose news has not come: on its way,
574    // not left out. One that cannot be silenced, or an older Sioul, is said as such.
575    let not_following: Vec<&Follows> = others.iter().map(|(_, f)| f).filter(|f| **f != Follows::Silenced).collect();
576    let waiting = !not_following.is_empty() && not_following.iter().all(|f| **f == Follows::Behind);
577    let line = if here.turned_off {
578        with_until("dnd-turned-off-here")
579    } else if !here_silenced {
580        if following > 0 { with_until("dnd-elsewhere") } else { with_until("dnd-own-only") }
581    } else if others.is_empty() {
582        with_until("dnd-here-only")
583    } else if everywhere {
584        with_until("dnd-everywhere")
585    } else if waiting {
586        with_until("dnd-waiting")
587    } else if following == 0 {
588        with_until("dnd-here-only")
589    } else {
590        with_until("dnd-not-everywhere")
591    };
592    // "Here: silenced.", "On your phone: waiting for its news."
593    let device = |key: &str, place: String, line: Option<&str>| {
594        let mut args = crate::i18n::args();
595        args.set("device", place);
596        if let Some(line) = line {
597            args.set("line", line.to_string());
598        }
599        tr.text(key, Some(&args))
600    };
601    let on = |id: &str| {
602        let mut args = crate::i18n::args();
603        args.set("name", name_of(id));
604        tr.text("dnd-device-on", Some(&args))
605    };
606    let here_name = tr.text("dnd-device-here", None);
607    let mut details = vec![if here_silenced { device("dnd-device-silenced", here_name, None) } else { device("dnd-device-cannot", here_name, Some(here_line)) }];
608    for (id, follows) in others {
609        details.push(match follows {
610            Follows::Silenced => device("dnd-device-silenced", on(id), None),
611            Follows::Cannot(words) if !words.is_empty() => device("dnd-device-cannot", on(id), Some(words)),
612            Follows::Cannot(_) => device("dnd-device-unsilenced", on(id), None),
613            Follows::Behind => device("dnd-device-behind", on(id), None),
614            Follows::Older => device("dnd-device-older", on(id), None),
615            Follows::Closed => device("dnd-device-closed", on(id), None),
616        });
617    }
618    let why_words = match why {
619        Why::Manual => {
620            let from = now_.holds.iter().find(|h| h.why == Why::Manual).map(|h| h.from.clone()).unwrap_or_default();
621            if from.is_empty() {
622                tr.text("dnd-why-manual", None)
623            } else {
624                let mut args = crate::i18n::args();
625                args.set("device", name_of(&from));
626                tr.text("dnd-why-manual-from", Some(&args))
627            }
628        }
629        other => tr.text(&format!("dnd-why-{}", other.id()), None),
630    };
631    Said { line, why: why_words, details, everywhere, here: here_silenced }
632}
633
634// ---------------------------------------------------------------- the list
635
636/// Someone who may reach you during do-not-disturb.
637#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
638pub struct Person {
639    /// Made once, the same on every device: one entry each in the sharing.
640    pub id: String,
641    #[serde(default)]
642    pub name: String,
643    #[serde(default, skip_serializing_if = "Vec::is_empty")]
644    pub phones: Vec<String>,
645    #[serde(default, skip_serializing_if = "Vec::is_empty")]
646    pub emails: Vec<String>,
647    /// The contact card they came from (its UID), to find it again.
648    #[serde(default, skip_serializing_if = "String::is_empty")]
649    pub contact: String,
650    /// What a later version writes here, kept as it is: written back unchanged,
651    /// so that this device never sends another version's person as changed.
652    #[serde(flatten)]
653    pub other: toml::Table,
654}
655
656/// "Who may reach you during do-not-disturb".
657#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
658pub struct People {
659    #[serde(default, rename = "person", skip_serializing_if = "Vec::is_empty")]
660    pub people: Vec<Person>,
661    /// What a later version writes here, kept as it is.
662    #[serde(flatten)]
663    pub other: toml::Table,
664}
665
666/// An address as compared: its address part ("Jane <jane@example.org>"), trimmed, in lower case.
667fn address_key(text: &str) -> String {
668    let text = text.trim();
669    let inner = text.rsplit_once('<').map_or(text, |(_, rest)| rest.trim_end_matches('>'));
670    inner.trim().trim_start_matches("mailto:").to_lowercase()
671}
672
673/// A new id: the time and a hash keyed at random for this process.
674pub fn new_id() -> String {
675    use std::hash::{BuildHasher, Hasher};
676    use std::sync::atomic::{AtomicU64, Ordering};
677    static COUNT: AtomicU64 = AtomicU64::new(0);
678    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
679    let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
680    hasher.write_u128(now.as_nanos());
681    hasher.write_u64(COUNT.fetch_add(1, Ordering::Relaxed));
682    hasher.write_u32(std::process::id());
683    format!("{:x}{:016x}", now.as_secs(), hasher.finish())
684}
685
686impl People {
687    pub fn default_path() -> PathBuf {
688        config_dir().join(PEOPLE_FILE)
689    }
690
691    /// The list as it is; none there is nobody yet. One that does not read is
692    /// an error, never an empty list (an edit would otherwise take everyone out
693    /// on every device).
694    pub fn read(path: &Path) -> Result<People, String> {
695        match std::fs::read_to_string(path) {
696            Ok(text) => toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display())),
697            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(People::default()),
698            Err(e) => Err(format!("{}: {e}", path.display())),
699        }
700    }
701
702    /// For a question (a mail to tell, a number to look up): what cannot be read is nobody.
703    pub fn load(path: &Path) -> People {
704        People::read(path).unwrap_or_default()
705    }
706
707    pub fn save(&self, path: &Path) -> Result<(), String> {
708        write_whole(path, &toml::to_string(self).map_err(|e| e.to_string())?)
709    }
710
711    /// Whether mail from this address may come during do-not-disturb.
712    pub fn admits_address(&self, address: &str) -> bool {
713        let key = address_key(address);
714        !key.is_empty() && key.contains('@') && self.people.iter().any(|p| p.emails.iter().any(|e| address_key(e) == key))
715    }
716
717    /// Whether this number is someone's on the list (numbers compared as
718    /// `phones::key` reads them, `region` for those written without a country).
719    pub fn admits_number(&self, number: &str, region: Option<&crate::phones::Region>) -> bool {
720        let key = crate::phones::key(number, region);
721        crate::phones::is_whole(&key) && self.people.iter().any(|p| p.phones.iter().any(|n| crate::phones::key(n, region) == key))
722    }
723
724    /// A person added, or merged into the one already there with one of their
725    /// addresses, numbers or their card: their id.
726    pub fn add(&mut self, mut person: Person, region: Option<&crate::phones::Region>) -> String {
727        person.emails.retain(|e| !address_key(e).is_empty());
728        person.phones.retain(|n| !n.trim().is_empty());
729        let same = |p: &Person| {
730            (!person.contact.is_empty() && p.contact == person.contact)
731                || person.emails.iter().any(|e| p.emails.iter().any(|x| address_key(x) == address_key(e)))
732                || person.phones.iter().any(|n| {
733                    let key = crate::phones::key(n, region);
734                    crate::phones::is_whole(&key) && p.phones.iter().any(|x| crate::phones::key(x, region) == key)
735                })
736        };
737        if let Some(known) = self.people.iter_mut().find(|p| same(p)) {
738            for email in person.emails {
739                if !known.emails.iter().any(|x| address_key(x) == address_key(&email)) {
740                    known.emails.push(email.trim().to_string());
741                }
742            }
743            for number in person.phones {
744                let key = crate::phones::key(&number, region);
745                if !known.phones.iter().any(|x| crate::phones::key(x, region) == key) {
746                    known.phones.push(number.trim().to_string());
747                }
748            }
749            if known.name.trim().is_empty() {
750                known.name = person.name;
751            }
752            if known.contact.is_empty() {
753                known.contact = person.contact;
754            }
755            return known.id.clone();
756        }
757        if person.id.is_empty() {
758            person.id = new_id();
759        }
760        person.emails = person.emails.iter().map(|e| e.trim().to_string()).collect();
761        person.phones = person.phones.iter().map(|n| n.trim().to_string()).collect();
762        let id = person.id.clone();
763        self.people.push(person);
764        id
765    }
766
767    /// Someone taken off the list; false when they were not on it.
768    pub fn remove(&mut self, id: &str) -> bool {
769        let before = self.people.len();
770        self.people.retain(|p| p.id != id);
771        self.people.len() != before
772    }
773}
774
775/// Someone blocked comes off Always through (docs/attention.md, Q3: blocked
776/// beats Always through, and putting someone on one list takes them off the
777/// other): their address and number taken off the people on the list, the
778/// person of their card (`card`, its UID) taken off whole; a person left with
779/// nothing to reach them by goes. Whether the list changed.
780pub fn take_off(people: &mut People, addresses: &[String], numbers: &[String], card: &str, region: Option<&crate::phones::Region>) -> bool {
781    let keys: Vec<String> = numbers.iter().map(|n| crate::phones::key(n, region)).filter(|k| crate::phones::is_whole(k)).collect();
782    let card = card.trim();
783    let mut gone: Vec<String> = Vec::new();
784    let mut changed = false;
785    for person in &mut people.people {
786        let before = (person.emails.len(), person.phones.len());
787        person.emails.retain(|e| !addresses.iter().any(|a| address_key(a) == address_key(e)));
788        person.phones.retain(|n| !keys.contains(&crate::phones::key(n, region)));
789        let whole = !card.is_empty() && person.contact == card;
790        if whole || before != (person.emails.len(), person.phones.len()) {
791            changed = true;
792            if whole || (person.emails.is_empty() && person.phones.is_empty()) {
793                gone.push(person.id.clone());
794            }
795        }
796    }
797    people.people.retain(|p| !gone.contains(&p.id));
798    changed
799}
800
801/// The list changed under its lock, read again first: what the sharing
802/// brought meanwhile stays. A file that does not read is left alone, an error.
803pub fn change_people(path: &Path, change: impl FnOnce(&mut People)) -> Result<People, String> {
804    crate::filelock::with_lock(path, || {
805        let mut people = People::read(path)?;
806        let before = people.clone();
807        change(&mut people);
808        if people != before {
809            people.save(path)?;
810        }
811        Ok(people)
812    })
813}
814
815/// Someone from a contact card: their name, numbers and addresses.
816pub fn from_contact(contact: &crate::contacts::Contact) -> Person {
817    Person {
818        id: String::new(),
819        name: contact.name.trim().to_string(),
820        phones: contact.phones.iter().map(|p| p.value.trim().to_string()).filter(|v| !v.is_empty()).collect(),
821        emails: contact.emails.iter().map(|e| e.value.trim().to_string()).filter(|v| !v.is_empty()).collect(),
822        contact: contact.uid.clone(),
823        other: toml::Table::new(),
824    }
825}
826
827/// People from the Safe list: each address, with its card's name and numbers
828/// when a card has it; everyone whose card is in a category on it
829/// (`category:Friends`); a card on it (`contact:<UID>`); a number on it
830/// (`tel:+33…`). Patterns (`@example.org`, `*@example.org`, `tel:+3319900*`)
831/// name nobody: counted, left.
832pub fn from_safe(entries: &[String], contacts: &[crate::contacts::Contact]) -> (Vec<Person>, usize) {
833    let (mut found, mut patterns) = (Vec::new(), 0);
834    for entry in entries {
835        let entry = entry.trim();
836        if let Some(category) = crate::porch::category_of(entry) {
837            found.extend(contacts.iter().filter(|c| crate::contacts::in_category(c, category)).map(from_contact));
838        } else if let Some(uid) = crate::porch::card_of(entry) {
839            found.extend(contacts.iter().filter(|c| c.uid.trim() == uid).map(from_contact));
840        } else if let Some(number) = crate::porch::number_of(entry).filter(|n| !n.contains('*')) {
841            found.push(Person { phones: vec![number.to_string()], ..Person::default() });
842        } else if entry.starts_with('@') || !entry.contains('@') || entry.contains('*') {
843            patterns += 1;
844        } else {
845            match crate::contacts::by_address(contacts, entry) {
846                Some(card) => found.push(from_contact(card)),
847                None => found.push(Person { name: String::new(), emails: vec![entry.to_string()], ..Person::default() }),
848            }
849        }
850    }
851    (found, patterns)
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857    use crate::i18n::Translator;
858
859    fn device(pressed: i64, on: bool, seen: i64) -> Device {
860        Device { pressed, on, seen, ..Device::default() }
861    }
862
863    /// This device as `said` reads it: silenced or not, with its system's words.
864    fn at(silenced: bool, line: &str) -> Here<'_> {
865        Here { silenced, line, turned_off: false }
866    }
867
868    fn switch(devices: &[(&str, Device)]) -> Switch {
869        Switch { device: devices.iter().map(|(id, d)| (id.to_string(), d.clone())).collect(), ..Switch::default() }
870    }
871
872    #[test]
873    fn the_latest_press_wins_whatever_device_made_it() {
874        // The desktop turned it on, the phone off later having seen it: off.
875        let s = switch(&[("desk", device(1_000_000, true, 0)), ("phone", device(1_200_000, false, 1_000_000))]);
876        let latest = s.latest().unwrap();
877        assert!(!latest.on && latest.from == "phone");
878        // Then the desktop on again: on.
879        let s = switch(&[("desk", device(1_300_000, true, 1_200_000)), ("phone", device(1_200_000, false, 1_000_000))]);
880        assert!(s.latest().unwrap().on);
881        // Nobody pressed: nothing.
882        assert_eq!(Switch::default().latest(), None);
883    }
884
885    #[test]
886    fn a_press_after_seeing_another_comes_after_it_whatever_the_clocks() {
887        let mut s = switch(&[("phone", device(5_000_000, true, 0))]);
888        // This computer's clock is two minutes behind the phone's: its press
889        // still comes after the one it saw.
890        let stamp = s.press("desk", false, 0, 5_000_000 - 120_000);
891        assert_eq!(stamp, 5_000_001);
892        assert_eq!(s.device["desk"].seen, 5_000_000);
893        let latest = s.latest().unwrap();
894        assert!(!latest.on && latest.from == "desk");
895    }
896
897    #[test]
898    fn two_presses_that_did_not_see_each_other_within_a_minute_turn_it_on() {
899        // On at the desktop, off on the phone 40 s later without having seen it: on.
900        let s = switch(&[("desk", device(10_000_000, true, 0)), ("phone", device(10_040_000, false, 0))]);
901        let latest = s.latest().unwrap();
902        assert!(latest.on && latest.from == "desk");
903        // Two minutes apart: the later one.
904        let s = switch(&[("desk", device(10_000_000, true, 0)), ("phone", device(10_120_000, false, 0))]);
905        assert!(!s.latest().unwrap().on);
906        // Within the minute but seen: the later one.
907        let s = switch(&[("desk", device(10_000_000, true, 0)), ("phone", device(10_040_000, false, 10_000_000))]);
908        assert!(!s.latest().unwrap().on);
909        // Off first, on after, within the minute: on, as the later.
910        let s = switch(&[("desk", device(10_000_000, false, 0)), ("phone", device(10_040_000, true, 0))]);
911        assert!(s.latest().unwrap().on);
912    }
913
914    #[test]
915    fn until_ends_it_by_each_devices_clock() {
916        let settings = DndSettings::default();
917        let press = Press { at: 1_000_000, on: true, until: 1_600_000, from: "desk".into(), via: String::new() };
918        let held = now(&settings, &Sources::default(), Some(&press), 1_500);
919        assert_eq!(held.why(), Some(Why::Manual));
920        assert_eq!(held.until(), Some(1_600));
921        assert!(!now(&settings, &Sources::default(), Some(&press), 1_600).on());
922    }
923
924    #[test]
925    fn each_trigger_is_a_setting_and_the_pause_comes_first() {
926        let sources = Sources { paused: Some(100), free: Some((50, 900)), sleep: Some((80, 2000)), focus: Some((90, 1000)) };
927        let none = DndSettings { pauses: false, sleep: false, focus: false, ..DndSettings::default() };
928        assert!(!now(&none, &sources, None, 500).on());
929        let all = DndSettings { pauses: true, sleep: true, focus: true, ..DndSettings::default() };
930        let held = now(&all, &sources, None, 500);
931        // Free time never with the pause.
932        assert_eq!(held.holds.iter().map(|h| h.why).collect::<Vec<_>>(), vec![Why::Paused, Why::Sleep, Why::Focus]);
933        assert_eq!(held.why(), Some(Why::Paused));
934        assert!(held.global() && held.global_why() == Some(Why::Sleep));
935        // The pause holds without an end: none said.
936        assert_eq!(held.until(), None);
937        // Free time alone, with its end.
938        let free = Sources { free: Some((50, 900)), ..Sources::default() };
939        let held = now(&all, &free, None, 500);
940        assert_eq!((held.why(), held.until()), (Some(Why::FreeTime), Some(900)));
941        assert!(!held.global());
942        assert!(!now(&all, &free, None, 900).on(), "Free time over at its end");
943    }
944
945    #[test]
946    fn pressed_off_after_a_reason_began_holds_it_off_until_it_ends() {
947        let settings = DndSettings { focus: true, sleep: true, ..DndSettings::default() };
948        let focus = Sources { focus: Some((1_000, 5_000)), ..Sources::default() };
949        let off_after = Press { at: 2_000_000, on: false, until: 0, from: "phone".into(), via: String::new() };
950        assert!(!now(&settings, &focus, Some(&off_after), 3_000).on(), "the person turned it off during the session");
951        // A session begun after the press holds again.
952        let later = Sources { focus: Some((2_500, 6_000)), ..Sources::default() };
953        assert!(now(&settings, &later, Some(&off_after), 3_000).has(Why::Focus));
954        // A press "on" never holds a reason off; an "off" before it began neither.
955        let on = Press { on: true, ..off_after.clone() };
956        let held = now(&settings, &focus, Some(&on), 3_000);
957        assert!(held.has(Why::Focus) && held.has(Why::Manual));
958        let off_before = Press { at: 500_000, ..off_after };
959        assert!(now(&settings, &focus, Some(&off_before), 3_000).has(Why::Focus));
960    }
961
962    #[test]
963    fn mail_and_sioul_s_own_notifications_are_gated_by_the_switch_and_focus_only() {
964        let settings = DndSettings { focus: true, sleep: true, ..DndSettings::default() };
965        let press = Press { at: 1_000_000, on: true, until: 0, from: "desk".into(), via: String::new() };
966        assert!(now(&settings, &Sources::default(), Some(&press), 2_000).gates());
967        assert!(now(&settings, &Sources { focus: Some((1_000, 9_000)), ..Sources::default() }, None, 2_000).gates());
968        // Sleep and the pauses keep their own rules.
969        assert!(!now(&settings, &Sources { sleep: Some((1_000, 9_000)), ..Sources::default() }, None, 2_000).gates());
970        assert!(!now(&settings, &Sources { paused: Some(1_000), ..Sources::default() }, None, 2_000).gates());
971        assert!(!now(&settings, &Sources::default(), None, 2_000).gates());
972    }
973
974    #[test]
975    fn a_forgotten_focus_session_lets_you_be_reached_again() {
976        let mut running = crate::timelog::Running { task: "t".into(), start: 1_000, planned: 25, paused_at: None, paused: 0 };
977        assert_eq!(focus(&running, 1_100), Some((1_000, 1_000 + 25 * 60 + FOCUS_GRACE)));
978        assert_eq!(focus(&running, 1_000 + 25 * 60 + FOCUS_GRACE), None);
979        // Its own pauses push the limit on; paused, it does not hold.
980        running.paused = 600;
981        assert_eq!(focus(&running, 1_100).map(|(_, end)| end), Some(1_000 + 600 + 25 * 60 + FOCUS_GRACE));
982        running.paused_at = Some(1_200);
983        assert_eq!(focus(&running, 1_300), None);
984        // Without a time chosen: three hours.
985        let open = crate::timelog::Running { task: "t".into(), start: 1_000, ..crate::timelog::Running::default() };
986        assert_eq!(focus(&open, 1_100).map(|(_, end)| end), Some(1_000 + FOCUS_OPEN));
987    }
988
989    #[test]
990    fn the_status_says_where_it_holds() {
991        let tr = Translator::new("en");
992        let settings = DndSettings::default();
993        let press = Press { at: 1_000_000, on: true, until: 0, from: "phone".into(), via: String::new() };
994        let held = now(&settings, &Sources::default(), Some(&press), 2_000);
995        let name = |id: &str| if id == "phone" { "your phone".to_string() } else { id.to_string() };
996        let all = [("phone".to_string(), Follows::Silenced)];
997        let s = said(&held, at(true, ""), &all, "15:00", &name, &tr);
998        assert_eq!(s.line, "Do not disturb, on every device, until 15:00.");
999        assert!(s.everywhere && s.here);
1000        assert_eq!(s.why, "Turned on from your phone.");
1001        assert_eq!(s.details, vec!["Here: silenced.", "On your phone: silenced."]);
1002        let s = said(&held, at(true, ""), &[], "", &name, &tr);
1003        assert_eq!(s.line, "Do not disturb, here only.");
1004        // A device whose news has not come yet: on its way, never "here only".
1005        let behind = [("phone".to_string(), Follows::Behind), ("desk".to_string(), Follows::Silenced)];
1006        let s = said(&held, at(true, ""), &behind, "", &name, &tr);
1007        assert_eq!(s.line, "Do not disturb here; your other devices' news is on its way.");
1008        assert_eq!(s.details[1], "On your phone: waiting for its news.");
1009        let s = said(&held, at(true, ""), &[("phone".to_string(), Follows::Behind)], "15:00", &name, &tr);
1010        assert_eq!(s.line, "Do not disturb here until 15:00; your other devices' news is on its way.");
1011        // An older Sioul cannot follow: said as such, never waited for.
1012        let s = said(&held, at(true, ""), &[("phone".to_string(), Follows::Older)], "", &name, &tr);
1013        assert_eq!(s.line, "Do not disturb, here only.");
1014        assert_eq!(s.details[1], "On your phone: an older Sioul, which cannot follow until it is updated.");
1015        let cannot = [("phone".to_string(), Follows::Cannot("Your phone is not silenced: Sioul does not have Android's “Do Not Disturb access”.".into()))];
1016        let s = said(&held, at(true, ""), &cannot, "", &name, &tr);
1017        assert_eq!(s.line, "Do not disturb, here only.");
1018        assert_eq!(s.details[1], "On your phone: Your phone is not silenced: Sioul does not have Android's “Do Not Disturb access”.");
1019        // This device cannot be silenced (Windows): said with its own words.
1020        let s = said(&held, at(false, "This computer's notifications cannot be silenced by Sioul."), &all, "", &name, &tr);
1021        assert_eq!(s.line, "Do not disturb, on your other devices; this one keeps only Sioul's own notifications back.");
1022        assert!(!s.everywhere);
1023        // Off: nothing said.
1024        assert_eq!(said(&Now::default(), at(true, ""), &all, "", &name, &tr), Said::default());
1025        // In French, with its spaces.
1026        let fr = Translator::new("fr");
1027        let s = said(&held, at(true, ""), &all, "15:00", &name, &fr);
1028        assert_eq!(s.line, "Ne pas déranger, sur tous vos appareils, jusqu’à 15:00.");
1029        assert_eq!(s.details[0], "Ici\u{202f}: en silence.");
1030        assert_eq!(s.details[1], "Sur your phone\u{202f}: en silence.");
1031    }
1032
1033    #[test]
1034    fn others_count_when_heard_lately_and_follow_by_their_own_table() {
1035        let now_s = 1_000_000;
1036        let mut s = Switch::default();
1037        s.device.insert("phone".into(), Device { why: "manual".into(), silenced: true, at: now_s * 1000, ..Device::default() });
1038        s.device.insert("old".into(), Device { why: "manual".into(), silenced: true, at: (now_s - 30 * 86_400) * 1000, ..Device::default() });
1039        s.device.insert("laptop".into(), Device { why: "manual".into(), silenced: false, line: "Windows".into(), at: now_s * 1000, ..Device::default() });
1040        let heard = vec![("phone".to_string(), now_s - 60), ("tablet".to_string(), now_s - 3600), ("gone".to_string(), now_s - 10 * 86_400), ("desk".to_string(), now_s)];
1041        let registered = |_: &str| true;
1042        let followed = others(&s, "desk", &heard, now_s, (now_s - 60) * 1000, &registered);
1043        assert_eq!(followed, vec![("laptop".to_string(), Follows::Cannot("Windows".into())), ("phone".to_string(), Follows::Silenced), ("tablet".to_string(), Follows::Behind)]);
1044        // Do-not-disturb begun later than their tables were written, a minute's margin aside: their news is on its way.
1045        let later = others(&s, "desk", &heard, now_s, (now_s + 120) * 1000, &registered);
1046        assert!(later.iter().all(|(_, f)| *f == Follows::Behind), "{later:?}");
1047        let close = others(&s, "desk", &heard, now_s, (now_s + 30) * 1000, &registered);
1048        assert!(close.iter().any(|(_, f)| *f == Follows::Silenced), "{close:?}");
1049        // A device with no table and no entry in the devices' registry runs an older Sioul: it cannot follow.
1050        let older = others(&s, "desk", &heard, now_s, (now_s - 60) * 1000, &|id: &str| id != "tablet");
1051        assert!(older.contains(&("tablet".to_string(), Follows::Older)), "{older:?}");
1052    }
1053
1054    #[test]
1055    fn only_this_devices_table_is_written_and_a_broken_file_is_left_alone() {
1056        let dir = std::env::temp_dir().join(format!("sioul-everywhere-{}-{}", std::process::id(), line!()));
1057        let _ = std::fs::remove_dir_all(&dir);
1058        let path = dir.join(SWITCH_FILE);
1059        // Another device's table, as the sharing brought it.
1060        std::fs::create_dir_all(&dir).unwrap();
1061        std::fs::write(&path, "[device.\"phone-1\"]\nkind = \"phone\"\npressed = 7\non = true\n").unwrap();
1062        let s = change_own(&path, "desk-1", |s| {
1063            s.press("desk-1", false, 0, 1_000);
1064            true
1065        })
1066        .unwrap();
1067        assert_eq!(s.device.len(), 2);
1068        let again = Switch::read(&path).unwrap();
1069        assert_eq!(again.device["phone-1"].pressed, 7, "the other's table untouched");
1070        assert!(!again.device["desk-1"].on && again.device["desk-1"].seen == 7);
1071        // Broken: never written over.
1072        std::fs::write(&path, "[device.\"phone-1\"\nbroken").unwrap();
1073        assert!(change_own(&path, "desk-1", |_| true).is_err());
1074        assert!(std::fs::read_to_string(&path).unwrap().contains("broken"));
1075        let _ = std::fs::remove_dir_all(&dir);
1076    }
1077
1078    #[test]
1079    fn the_list_admits_its_people_by_address_and_number() {
1080        let fr = crate::phones::region_named("FR");
1081        let mut people = People::default();
1082        let alice = people.add(Person { name: "Alice".into(), phones: vec!["04 65 71 23 45".into()], emails: vec!["Alice@Example.org".into()], ..Person::default() }, fr);
1083        assert!(people.admits_address("alice@example.org"));
1084        assert!(people.admits_address("Alice Martin <ALICE@example.org>"));
1085        assert!(!people.admits_address("bob@example.org"));
1086        assert!(!people.admits_address(""));
1087        assert!(people.admits_number("+33 4 65 71 23 45", fr));
1088        assert!(people.admits_number("0465712345", fr));
1089        assert!(!people.admits_number("0465712346", fr));
1090        // A short code is nobody's.
1091        let mut short = People::default();
1092        short.add(Person { phones: vec!["3631".into()], ..Person::default() }, fr);
1093        assert!(!short.admits_number("3631", fr));
1094        // Added again with a new address: one person, merged.
1095        let again = people.add(Person { name: "A.".into(), phones: vec!["+33465712345".into()], emails: vec!["alice@work.example".into()], ..Person::default() }, fr);
1096        assert_eq!(again, alice);
1097        assert_eq!(people.people.len(), 1);
1098        assert_eq!(people.people[0].emails, vec!["Alice@Example.org", "alice@work.example"]);
1099        assert_eq!(people.people[0].name, "Alice", "the name kept");
1100        assert!(people.remove(&alice) && people.people.is_empty());
1101    }
1102
1103    #[test]
1104    fn someone_blocked_comes_off_the_list() {
1105        let fr = crate::phones::region_named("FR");
1106        let mut people = People::default();
1107        let alice = people.add(Person { name: "Alice".into(), phones: vec!["04 65 71 23 45".into()], emails: vec!["alice@example.org".into(), "alice@work.example".into()], ..Person::default() }, fr);
1108        people.add(Person { name: "Bob".into(), emails: vec!["bob@example.org".into()], contact: "uid-bob".into(), ..Person::default() }, fr);
1109        // One address blocked: that address off, the person kept by the rest.
1110        assert!(take_off(&mut people, &["Alice <ALICE@example.org>".into()], &[], "", fr));
1111        assert_eq!(people.people.iter().find(|p| p.id == alice).map(|p| p.emails.clone()), Some(vec!["alice@work.example".to_string()]));
1112        // Nothing of theirs: nothing changes.
1113        assert!(!take_off(&mut people, &["carol@example.org".into()], &["+33 1 99 00 00 01".into()], "", fr));
1114        // Their card blocked: the person off whole; the last ways to reach someone gone: the person too.
1115        assert!(take_off(&mut people, &[], &[], "uid-bob", fr));
1116        assert!(take_off(&mut people, &["alice@work.example".into()], &["+33465712345".into()], "", fr));
1117        assert!(people.people.is_empty());
1118    }
1119
1120    #[test]
1121    fn the_list_travels_one_person_at_a_time_and_is_never_read_empty() {
1122        let dir = std::env::temp_dir().join(format!("sioul-everywhere-{}-{}", std::process::id(), line!()));
1123        let _ = std::fs::remove_dir_all(&dir);
1124        let path = dir.join(PEOPLE_FILE);
1125        change_people(&path, |p| {
1126            p.add(Person { name: "Alice".into(), emails: vec!["alice@example.org".into()], ..Person::default() }, None);
1127        })
1128        .unwrap();
1129        let text = std::fs::read_to_string(&path).unwrap();
1130        assert!(text.contains("[[person]]") && text.contains("alice@example.org"), "{text}");
1131        // Another device's person, brought by the sharing between two edits here: kept.
1132        std::fs::write(&path, format!("{text}\n[[person]]\nid = \"b\"\nname = \"Bob\"\nphones = [\"+33536490002\"]\n")).unwrap();
1133        let people = change_people(&path, |p| {
1134            p.add(Person { name: "Carol".into(), emails: vec!["carol@example.org".into()], ..Person::default() }, None);
1135        })
1136        .unwrap();
1137        assert_eq!(people.people.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Alice", "Bob", "Carol"]);
1138        // Unreadable: an error, never an empty list written over it.
1139        std::fs::write(&path, "[[person]\nbroken").unwrap();
1140        assert!(People::read(&path).is_err());
1141        assert!(change_people(&path, |p| p.people.clear()).is_err());
1142        assert!(std::fs::read_to_string(&path).unwrap().contains("broken"));
1143        assert!(People::load(&path).people.is_empty(), "a question gets nobody");
1144        let _ = std::fs::remove_dir_all(&dir);
1145    }
1146
1147    #[test]
1148    fn the_safe_list_brings_its_people_with_their_cards() {
1149        let card = |name: &str, email: &str, phone: &str, category: &str| crate::contacts::Contact {
1150            uid: format!("uid-{name}"),
1151            name: name.into(),
1152            emails: vec![crate::contacts::Labeled { label: String::new(), value: email.into() }],
1153            phones: vec![crate::contacts::Labeled { label: String::new(), value: phone.into() }],
1154            categories: if category.is_empty() { Vec::new() } else { vec![category.into()] },
1155            ..crate::contacts::Contact::default()
1156        };
1157        let contacts = vec![card("Alice", "alice@example.org", "+33 4 65 71 23 45", ""), card("Bob", "bob@example.org", "+33 1 99 00 00 01", "Famille"), card("Eve", "eve@example.org", "", "")];
1158        let safe = ["alice@example.org", "category:famille", "@example.com", "*@example.net", "dan@example.net", "contact:uid-Eve", "tel:+33 3 53 01 00 07", "tel:+33899*"].map(String::from).to_vec();
1159        let (people, patterns) = from_safe(&safe, &contacts);
1160        assert_eq!(patterns, 3, "a domain, a pattern, a prefix");
1161        assert_eq!(people.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Alice", "Bob", "", "Eve", ""]);
1162        assert_eq!(people[0].phones, vec!["+33 4 65 71 23 45"]);
1163        assert_eq!(people[0].contact, "uid-Alice");
1164        assert_eq!(people[2].emails, vec!["dan@example.net"]);
1165        assert_eq!((people[3].contact.as_str(), people[4].phones.clone()), ("uid-Eve", vec!["+33 3 53 01 00 07".to_string()]));
1166    }
1167
1168    #[test]
1169    fn settings_have_their_defaults_and_old_files_read() {
1170        let config: crate::config::Config = toml::from_str("").unwrap();
1171        assert_eq!(config.dnd, DndSettings::default());
1172        assert!(config.dnd.button && config.dnd.pauses && config.dnd.people && !config.dnd.focus && !config.dnd.sleep);
1173        let config: crate::config::Config = toml::from_str("[dnd]\nfocus = true\n").unwrap();
1174        assert!(config.dnd.focus && config.dnd.pauses);
1175        // A switch's file with fields this version does not know still reads, and keeps them.
1176        let s: Switch = toml::from_str("[device.a]\npressed = 5\non = true\nfuture = \"x\"\n").unwrap();
1177        assert!(s.latest().unwrap().on);
1178        assert!(toml::to_string(&s).unwrap().contains("future = \"x\""), "a later version's field written back");
1179        let p: People = toml::from_str("[[person]]\nid = \"a\"\nname = \"A\"\nring = \"loud\"\n").unwrap();
1180        assert!(toml::to_string(&p).unwrap().contains("ring = \"loud\""));
1181    }
1182
1183    #[test]
1184    fn every_word_has_both_languages() {
1185        for language in ["en", "fr"] {
1186            let tr = Translator::new(language);
1187            for key in [
1188                "dnd-everywhere", "dnd-everywhere-until", "dnd-here-only", "dnd-here-only-until", "dnd-waiting", "dnd-waiting-until", "dnd-not-everywhere", "dnd-not-everywhere-until", "dnd-elsewhere", "dnd-elsewhere-until", "dnd-own-only", "dnd-own-only-until",
1189                "dnd-device-silenced", "dnd-device-cannot", "dnd-device-unsilenced", "dnd-device-behind", "dnd-device-older", "dnd-device-here", "dnd-device-on", "dnd-device-phone", "dnd-device-other",
1190                "dnd-why-manual", "dnd-why-manual-from", "dnd-why-focus", "dnd-why-sleep", "dnd-why-paused", "dnd-why-free-time",
1191                "dnd-turned-off-here", "dnd-turned-off-here-until", "dnd-device-closed",
1192            ] {
1193                let mut args = crate::i18n::args();
1194                args.set("until", "15:00");
1195                args.set("device", "X");
1196                args.set("line", "Y.");
1197                args.set("name", "Z");
1198                let words = tr.text(key, Some(&args));
1199                assert!(!words.is_empty() && words != key && !words.contains('{'), "{language}: {key}: {words}");
1200            }
1201        }
1202    }
1203
1204    #[test]
1205    fn a_change_of_the_system_presses_only_when_it_disagrees() {
1206        // The person's tile, a schedule, another app: on while nothing holds, a press "on".
1207        assert_eq!(heard(false, true), Some(true));
1208        // Off while do-not-disturb holds: a press "off".
1209        assert_eq!(heard(true, false), Some(false));
1210        // Sioul's own changes coming back agree with what holds: nothing.
1211        assert_eq!(heard(true, true), None);
1212        assert_eq!(heard(false, false), None);
1213    }
1214
1215    #[test]
1216    fn a_press_heard_from_the_system_says_so_and_holds_like_any() {
1217        let mut s = switch(&[("desk", device(1_000_000, true, 0))]);
1218        let stamp = s.press_via("phone", false, 0, 900_000, VIA_SYSTEM);
1219        // Stamped after every press known here, whatever the phone's clock.
1220        assert_eq!(stamp, 1_000_001);
1221        let latest = s.latest().unwrap();
1222        assert!(!latest.on && latest.from == "phone" && latest.via == VIA_SYSTEM);
1223        // The switch's own press says nothing of the kind, and clears the word.
1224        s.press("phone", true, 0, 2_000_000);
1225        assert_eq!(s.latest().unwrap().via, "");
1226        assert!(!toml::to_string(&s).unwrap().contains("via"), "an empty word is not written");
1227        // Written and read back; a file without the word reads as the switch's.
1228        s.press_via("desk", true, 0, 3_000_000, VIA_SYSTEM);
1229        let text = toml::to_string(&s).unwrap();
1230        assert!(text.contains("via = \"system\""), "{text}");
1231        let again: Switch = toml::from_str(&text).unwrap();
1232        assert_eq!(again.latest().unwrap().via, VIA_SYSTEM);
1233        let old: Switch = toml::from_str("[device.a]\npressed = 5\non = true\n").unwrap();
1234        assert_eq!(old.latest().unwrap().via, "");
1235        // A press "off" heard from the system holds off a reason begun before it, as any press.
1236        let settings = DndSettings { focus: true, ..DndSettings::default() };
1237        let focus = Sources { focus: Some((1_000, 9_000)), ..Sources::default() };
1238        let off = Press { at: 2_000_000, on: false, until: 0, from: "phone".into(), via: VIA_SYSTEM.into() };
1239        assert!(!now(&settings, &focus, Some(&off), 3_000).on());
1240    }
1241
1242    #[test]
1243    fn the_switch_held_by_the_system_alone_adds_no_mode() {
1244        let settings = DndSettings { sleep: true, focus: true, ..DndSettings::default() };
1245        let heard_here = Press { at: 1_000_000, on: true, until: 0, from: "phone".into(), via: VIA_SYSTEM.into() };
1246        let held = now(&settings, &Sources::default(), Some(&heard_here), 2_000);
1247        // The phone's own do-not-disturb turned the switch on: the phone adds no mode over it.
1248        assert!(held_by_system(&held, Some(&heard_here), "phone"));
1249        // The other devices silence themselves with their modes.
1250        assert!(!held_by_system(&held, Some(&heard_here), "desk"));
1251        // Pressed with the switch: Sioul's own mode everywhere.
1252        let pressed = Press { via: String::new(), ..heard_here.clone() };
1253        assert!(!held_by_system(&held, Some(&pressed), "phone"));
1254        // Another reason holds too (the night): Sioul's mode, so that it outlasts the system's cause.
1255        let night = now(&settings, &Sources { sleep: Some((1_500, 9_000)), ..Sources::default() }, Some(&heard_here), 2_000);
1256        assert!(!held_by_system(&night, Some(&heard_here), "phone"));
1257        // A pause has its own mode: the switch's alone is held by the system.
1258        let paused = now(&DndSettings::default(), &Sources { paused: Some(1_500), ..Sources::default() }, Some(&heard_here), 2_000);
1259        assert!(held_by_system(&paused, Some(&heard_here), "phone"));
1260        // Nothing holds: nothing to hold.
1261        assert!(!held_by_system(&Now::default(), Some(&heard_here), "phone"));
1262    }
1263
1264    #[test]
1265    fn a_nightly_schedule_turns_it_on_then_off_on_every_device() {
1266        let settings = DndSettings::default();
1267        let mut s = Switch::default();
1268        // 22:00: the phone's schedule turns its do-not-disturb on; nothing held it: a press "on".
1269        let night = 22 * 3_600;
1270        let before = now(&settings, &Sources::default(), s.latest().as_ref(), night);
1271        assert_eq!(heard(before.on(), true), Some(true));
1272        s.press_via("phone", true, 0, night * 1000, VIA_SYSTEM);
1273        // The computer reads the same file and holds it too, with its own mode.
1274        let held = now(&settings, &Sources::default(), s.latest().as_ref(), night + 60);
1275        assert!(held.on() && held.has(Why::Manual));
1276        assert!(!held_by_system(&held, s.latest().as_ref(), "desk"));
1277        assert!(held_by_system(&held, s.latest().as_ref(), "phone"));
1278        // 07:00: the schedule ends, the phone's system turns off while it held: a press "off".
1279        let morning = 31 * 3_600;
1280        let still = now(&settings, &Sources::default(), s.latest().as_ref(), morning);
1281        assert_eq!(heard(still.on(), false), Some(false));
1282        s.press_via("phone", false, 0, morning * 1000, VIA_SYSTEM);
1283        assert!(!now(&settings, &Sources::default(), s.latest().as_ref(), morning + 60).on(), "off on every device");
1284        // The next night, again.
1285        let next = night + 86_400;
1286        assert_eq!(heard(now(&settings, &Sources::default(), s.latest().as_ref(), next).on(), true), Some(true));
1287    }
1288
1289    #[test]
1290    fn a_press_made_off_line_meets_the_latest() {
1291        // The phone, off-line, hears its system turn on at 10:00 and presses; it has
1292        // not seen the computer's "off" made at 10:05. Back on line: the later press wins.
1293        let s = switch(&[
1294            ("phone", Device { pressed: 36_000_000, on: true, seen: 0, via: VIA_SYSTEM.into(), ..Device::default() }),
1295            ("desk", device(36_300_000, false, 0)),
1296        ]);
1297        assert!(!s.latest().unwrap().on, "the computer's later off");
1298        // Within a minute of each other, neither seeing the other: on wins, as for any press.
1299        let s = switch(&[
1300            ("phone", Device { pressed: 36_000_000, on: true, seen: 0, via: VIA_SYSTEM.into(), ..Device::default() }),
1301            ("desk", device(36_030_000, false, 0)),
1302        ]);
1303        let latest = s.latest().unwrap();
1304        assert!(latest.on && latest.from == "phone" && latest.via == VIA_SYSTEM);
1305    }
1306
1307    #[test]
1308    fn a_computer_closed_or_quiet_is_said_so() {
1309        let tr = Translator::new("en");
1310        let now_s = 1_000_000;
1311        let since = (now_s - 3_600) * 1000;
1312        let silenced = |at_s: i64| Device { kind: "computer".into(), name: "laptop".into(), why: "manual".into(), silenced: true, at: at_s * 1000, ..Device::default() };
1313        // Sioul quit on the laptop: its table as it then holds, read by another device.
1314        let mut quit = silenced(now_s - 600);
1315        quit.closing(false);
1316        assert!(quit.closed && !quit.silenced && quit.line.is_empty());
1317        let s = Switch { device: [("laptop".to_string(), quit.clone())].into(), ..Switch::default() };
1318        let text = toml::to_string(&s).unwrap();
1319        assert!(text.contains("closed = true"), "{text}");
1320        let read: Switch = toml::from_str(&text).unwrap();
1321        let heard = vec![("laptop".to_string(), now_s - 600)];
1322        let followed = others(&read, "phone", &heard, now_s, since, &|_| true);
1323        assert_eq!(followed, vec![("laptop".to_string(), Follows::Closed)]);
1324        let press = Press { at: since + 1, on: true, until: 0, from: "phone".into(), via: String::new() };
1325        let held = now(&DndSettings::default(), &Sources::default(), Some(&press), now_s);
1326        let name = |id: &str| id.to_string();
1327        let said_ = said(&held, at(true, ""), &followed, "", &name, &tr);
1328        assert_eq!(said_.details[1], "On laptop: Sioul is closed.");
1329        assert_eq!(said_.line, "Do not disturb, here only.");
1330        assert_eq!(said(&held, at(true, ""), &followed, "", &name, &Translator::new("fr")).details[1], "Sur laptop\u{202f}: Sioul est fermé.");
1331        // Closed before do-not-disturb began: still closed, never waited for.
1332        let mut early = silenced(now_s - 10 * 3_600);
1333        early.closing(false);
1334        let s = Switch { device: [("laptop".to_string(), early)].into(), ..Switch::default() };
1335        assert_eq!(others(&s, "phone", &heard, now_s, since, &|_| true)[0].1, Follows::Closed);
1336        // A crash says nothing: a computer said silenced, unheard for half an hour, is waiting for its news.
1337        let crashed = Switch { device: [("laptop".to_string(), silenced(now_s - 2_000))].into(), ..Switch::default() };
1338        let quiet = vec![("laptop".to_string(), now_s - 2_000)];
1339        assert_eq!(others(&crashed, "phone", &quiet, now_s, since, &|_| true)[0].1, Follows::Behind);
1340        // Heard ten minutes ago (a computer writes its news every fifteen at most): silenced.
1341        let lately = vec![("laptop".to_string(), now_s - 600)];
1342        assert_eq!(others(&crashed, "phone", &lately, now_s, since, &|_| true)[0].1, Follows::Silenced);
1343        // A phone's modes stay while Sioul is closed there: its table holds, however quiet.
1344        let phone = Device { kind: "phone".into(), ..silenced(now_s - 2_000) };
1345        let s = Switch { device: [("phone".to_string(), phone)].into(), ..Switch::default() };
1346        let quiet_phone = vec![("phone".to_string(), now_s - 2_000)];
1347        assert_eq!(others(&s, "laptop", &quiet_phone, now_s, since, &|_| true)[0].1, Follows::Silenced);
1348    }
1349
1350    #[test]
1351    fn a_device_turned_off_by_hand_says_so() {
1352        let tr = Translator::new("en");
1353        let press = Press { at: 1_000_000, on: true, until: 0, from: "desk".into(), via: String::new() };
1354        let held = now(&DndSettings::default(), &Sources::default(), Some(&press), 2_000);
1355        let name = |id: &str| id.to_string();
1356        let here = Here { silenced: false, line: "You turned do-not-disturb off on this phone.", turned_off: true };
1357        let s = said(&held, here, &[("desk".to_string(), Follows::Silenced)], "", &name, &tr);
1358        assert_eq!(s.line, "Do not disturb on your other devices; you turned it off here.");
1359        assert_eq!(s.details[0], "Here: You turned do-not-disturb off on this phone.");
1360        assert!(!s.here && !s.everywhere);
1361        let s = said(&held, here, &[], "15:00", &name, &Translator::new("fr"));
1362        assert_eq!(s.line, "Ne pas déranger jusqu\u{2019}à 15:00 sur vos autres appareils\u{202f}; vous l\u{2019}avez désactivé ici.");
1363    }
1364}