Skip to main content

sioul_core/
appnotes.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Other apps' notifications on a phone (docs/android.md, "Notifications from
5//! other apps"; research: docs/research/android-chats.md, 7 and 8). Android
6//! hands Sioul's listener every notification the person lets it see;
7//! Sioul decides, notification by notification, whether it comes now or
8//! waits, and Android holds it (it is snoozed: Android brings it back at its
9//! time, whole, with its own tap and actions; Sioul never cancels one).
10//!
11//! Two kinds, decided per notification, not per app:
12//! - **between people**: SMS, chats, mail, a missed call. Who wrote, as far
13//!   as the app says it (a number, an address, a contact of the phone's
14//!   address book, else a name), then what reaches you when (the matrix of
15//!   `attention`, its Messages rows; a mail app's, its Mail rows; a missed
16//!   call, its Calls rows). A group is judged as the conversation, on its
17//!   own row, never by a member's name; "always through" goes by
18//!   conversation, which the app identifies, never by a name, which anyone
19//!   can take (research 9.3); the blocked never, whatever a conversation says.
20//! - **from an automaton**: everything else (shops, news, social networks,
21//!   a browser's sites). Gathered: it comes at the next of the times set for
22//!   that, `[reminders] gathered` (three a day unless set otherwise, as the
23//!   sites' notifications on a computer), never while you sleep, pause or
24//!   take free time.
25//!
26//! When each kind may come is the matrix of what reaches you's to say
27//! (`attention`: its people's rows, the rows of automatons and of the apps
28//! you set to come at once); the times above are its usual values.
29//!
30//! Never held: calls, alarms, what runs (music, a call in progress, a
31//! download), the reminders you set, Sioul's own; codes and sign-ins or
32//! payments to approve (`codes::detect` on the notification's words, the
33//! approval phrases below, and Android 15's redaction, which says the
34//! phone's own assistant judged it a code).
35//!
36//! What is kept, on the phone alone and never shared: the person's choices
37//! (`Choices`, `app-notes.toml` in the configuration), and what Sioul held,
38//! by a hash of Android's key, with the apps' and conversations' names it
39//! saw in the last weeks, so that the person can choose for them (`Ledger`,
40//! `app-notes-seen.toml` in the state). Never what a notification says.
41
42use crate::areas::{Area, in_view};
43use crate::attention::{self, Attention, Event, Level, Source};
44use crate::i18n::Translator;
45use crate::quiet::Mode;
46use crate::reach::{Channel, Who};
47use jiff::Zoned;
48use serde::{Deserialize, Serialize};
49use std::collections::BTreeMap;
50use std::path::{Path, PathBuf};
51
52/// Sioul's own package: its notifications are never touched.
53pub const OWN: &str = "com.aurelienpierre.sioul";
54/// A gathered time still counts this long after it (seconds): what arrives
55/// in the very minute of a gathering comes with it.
56pub const GRACE: i64 = 60;
57/// How far ahead a moment is looked for (days).
58pub const HORIZON_DAYS: i64 = 8;
59/// Held with no moment found in the coming days (a pause, which lasts until
60/// you come back): asked again this late (seconds). Sioul brings it back
61/// sooner when what moves its time changes (the pause ends: `review`).
62pub const RECHECK: i64 = 6 * 3600;
63/// Do-not-disturb's switch or a focus session without a known end: asked again this soon.
64pub const GATE_RECHECK: i64 = 30 * 60;
65/// Held for good (a blocked sender, a conversation set to never): looked at again weekly.
66pub const FOR_GOOD: i64 = 7 * 86_400;
67/// Held again on its return for less than this (seconds)…
68pub const AGAIN_SHORT: i64 = 5 * 60;
69/// …this many times in a row, it is let through rather than held again and
70/// again: the times worked out disagree with the time it is. A long hold (a
71/// pause, the night) is no such loop and never counts.
72pub const AGAIN_MAX: u32 = 3;
73/// Apps and sites seen are forgotten this long after (seconds), unless chosen for.
74const SEEN_KEPT: i64 = 30 * 86_400;
75/// Conversations seen, the same.
76const TALK_KEPT: i64 = 7 * 86_400;
77/// At most so many conversations kept seen.
78const TALKS_MAX: usize = 60;
79/// What came back is said on the Porch this long after.
80const BACK_SAID: i64 = 2 * 3600;
81
82// ---------------------------------------------------------------- what Java hands
83
84/// A person as an app names them in its notification (`android.app.Person`),
85/// with what the phone's address book says of them when Java could ask it.
86#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
87#[serde(default)]
88pub struct Person {
89    pub name: String,
90    /// The app's own key for them.
91    pub key: String,
92    /// "tel:+33…", "mailto:…", a contact's lookup address, or nothing.
93    pub uri: String,
94    /// The phone's address book: the numbers and addresses of the contact
95    /// its lookup address names.
96    pub numbers: Vec<String>,
97    pub emails: Vec<String>,
98    /// The phone's address book again: those of the one contact with this
99    /// name, when the app gave a name alone. Counted only for an app that
100    /// names people as the address book does (`Sender::Named::book`).
101    pub by_name: Vec<String>,
102    pub emails_by_name: Vec<String>,
103    pub bot: bool,
104}
105
106/// One message of a conversation (`Notification.MessagingStyle`); no sender: the person themselves.
107#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
108#[serde(default)]
109pub struct Message {
110    pub sender: Option<Person>,
111    pub text: String,
112}
113
114/// A notification as the listener hands it (AppNotes.java): what Android
115/// says of it, its words included. Read in memory to decide, never kept.
116#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
117#[serde(default)]
118pub struct Posted {
119    /// Android's key: the same notification updated keeps it.
120    pub key: String,
121    pub package: String,
122    /// The app's name as the phone shows it.
123    pub app: String,
124    pub channel: String,
125    pub channel_name: String,
126    /// It sounded or vibrated as it came (`Ranking.getLastAudiblyAlertedMillis`).
127    pub alerted: bool,
128    /// Its importance (`Ranking.getImportance`): under 3, its channel makes
129    /// no sound (made silent); unknown, under 0.
130    #[serde(default = "unknown_importance")]
131    pub importance: i32,
132    /// Android's own do-not-disturb held it (no sound, no banner).
133    pub intercepted: bool,
134    /// `Notification.category`: "msg", "email", "call", "promo"…
135    pub category: String,
136    pub tag: String,
137    pub group: String,
138    /// A group's summary: Android takes it with its last child.
139    pub summary: bool,
140    /// Ongoing, a foreground service's, or not clearable: never touched.
141    pub ongoing: bool,
142    /// A full-screen intent: a call ringing, an alarm.
143    pub full_screen: bool,
144    /// "android.app.Notification$MessagingStyle", "…$BigTextStyle"…
145    pub template: String,
146    pub title: String,
147    pub text: String,
148    pub sub: String,
149    pub big: String,
150    pub lines: Vec<String>,
151    /// The conversation's title (a group's name, `<server> #<channel>`).
152    pub conversation: String,
153    pub group_conversation: bool,
154    /// Android counts it as a conversation (`Ranking.isConversation`).
155    pub is_conversation: bool,
156    /// The conversation's shortcut.
157    pub shortcut: String,
158    pub messages: Vec<Message>,
159    /// The person themselves, as the conversation names them (`EXTRA_MESSAGING_PERSON`).
160    pub user: Option<Person>,
161    /// `EXTRA_PEOPLE_LIST`, and the older `EXTRA_PEOPLE` as people with a URI only.
162    pub people: Vec<Person>,
163    /// The phone's own assistant judged it sensitive (a code), and Android
164    /// gave Sioul a redacted copy (Android 15).
165    pub redacted: bool,
166    /// The phone's default SMS app.
167    pub sms_app: bool,
168}
169
170fn unknown_importance() -> i32 {
171    -1
172}
173
174// ---------------------------------------------------------------- what it is
175
176/// Who wrote, as the notification identifies them.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum Sender {
179    /// Numbers and addresses: from the app (tel:, mailto:) or from the phone's
180    /// address book. `contact`: the phone's address book has them, so they
181    /// are someone you know (neutral) even when Sioul's address books do not.
182    Known { numbers: Vec<String>, emails: Vec<String>, contact: bool },
183    /// A name only. `book`: the app names people as the phone's address book
184    /// does (SMS apps, WhatsApp): a name that says who; else a name anyone
185    /// can take, which may only hold more (`named`).
186    Named { name: String, book: bool },
187    /// Nobody named.
188    Unknown,
189}
190
191/// A conversation, as the app identifies it.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct Conversation {
194    /// Hashed, the app's own id within (its shortcut, else its title): `talk_key`.
195    pub key: String,
196    /// As the app titles it: a group's name, `<server> #<channel>`, a person's name.
197    pub title: String,
198    pub group: bool,
199    /// Its key came from the one who wrote last (no shortcut, no conversation
200    /// title): it names a person, not a conversation; two groups where the
201    /// same person wrote last share it (`replies::check` refuses a group's
202    /// reply so keyed).
203    pub by_writer: bool,
204}
205
206/// Between people.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct Talk {
209    pub via: Channel,
210    pub sender: Sender,
211    pub conversation: Option<Conversation>,
212    /// The address it came to (a mail app's sub-text), for that address's area.
213    pub to: String,
214    /// An SMS: a sender named only and found in no address book is a
215    /// business's sender id ("AMELI"), an automaton.
216    pub sms: bool,
217}
218
219/// What a notification is, for whether it may come now.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum Kind {
222    /// Never held: calls, alarms, what runs, the reminders you set, Sioul's own.
223    Untouched,
224    /// A code, a sign-in or a payment to approve: at once, whatever the time.
225    Code,
226    /// The app or the site set to come at once.
227    AtOnce,
228    People(Talk),
229    /// From an automaton: the app, or a site in a browser (its host).
230    Automaton { site: String },
231}
232
233/// Categories never held: what runs or shows a state, what the person set.
234const UNTOUCHED: &[&str] = &[
235    "call", "alarm", "navigation", "transport", "stopwatch", "location_sharing", "sys", "service", "progress", "status", "err", "car_emergency",
236    "car_warning", "car_information", "workout", "reminder", "event",
237];
238
239/// Apps that name people as the phone's address book does: their names say who.
240const BOOK_APPS: &[&str] = &["com.whatsapp", "com.whatsapp.w4b"];
241
242/// SMS apps, besides the phone's default one (`Posted::sms_app`).
243const SMS_APPS: &[&str] = &[
244    "com.google.android.apps.messaging", "com.android.messaging", "com.android.mms", "com.samsung.android.messaging", "org.fossify.messages",
245    "com.simplemobiletools.smsmessenger", "com.moez.QKSMS", "dev.octoshrimpy.quik", "foundation.e.message",
246];
247
248/// Mail apps: their notifications are mail, judged on the Mail row.
249const MAIL_APPS: &[&str] = &[
250    "ch.protonmail.android", "com.google.android.gm", "com.fsck.k9", "net.thunderbird.android", "net.thunderbird.android.beta", "eu.faircode.email",
251    "com.microsoft.office.outlook", "foundation.e.mail", "de.tutao.tutanota", "org.kman.AquaMail", "com.samsung.android.email.provider",
252    "com.yahoo.mobile.client.android.mail", "me.proton.android.mail",
253];
254
255/// Firefox's browsers post every site's notification on this one channel, the site in the sub-text.
256const FIREFOX_SITES: &str = "mozac.feature.webnotifications.generic.channel";
257
258/// Whether a notification hands over a code or asks for an approval now:
259/// a code as mail gives one (`codes::detect`); words asking for an approval
260/// (a sign-in, a payment, an identity to confirm); "is this you?" beside what
261/// it is about (a friend's "is this you on the photo?" is none); a channel
262/// named for codes or sign-ins (Proton Mail's "login" channel). Read folded
263/// (case and accents aside), as whole words (`words::Approvals`).
264pub fn is_code(p: &Posted, words: &crate::words::Words) -> bool {
265    if p.redacted {
266        return true;
267    }
268    let mut body = vec![p.text.as_str(), p.big.as_str(), p.sub.as_str()];
269    body.extend(p.lines.iter().map(String::as_str));
270    body.extend(p.messages.iter().map(|m| m.text.as_str()));
271    let body = body.into_iter().filter(|t| !t.trim().is_empty()).collect::<Vec<_>>().join("\n");
272    if crate::codes::detect(&words.codes, &p.title, &body).is_some() {
273        return true;
274    }
275    let folded: Vec<char> = crate::text::fold(&format!("{}\n{body}", p.title)).into_iter().map(|c| if c == '\n' { ' ' } else { c }).collect();
276    let said = |phrases: &[String]| phrases.iter().any(|phrase| crate::text::find_word(&folded, phrase, 0).is_some());
277    let approvals = &words.approvals;
278    if said(&approvals.phrases) || (said(&approvals.asks) && said(&approvals.asked_about)) {
279        return true;
280    }
281    let channel: Vec<char> = crate::text::fold(&format!("{} {}", p.channel.replace(['_', '.'], " "), p.channel_name));
282    approvals.channels.iter().any(|word| crate::text::find_word(&channel, word, 0).is_some())
283}
284
285/// A browser's site the notification is from, as its host: Chromium's
286/// browsers name it in the tag ("p#https://site#…", "n#…"), the channel
287/// ("web:https://site;…") and the group ("Web:site"); Firefox's in the
288/// sub-text, on their one channel for sites (research 8.1).
289pub fn site_of(p: &Posted) -> Option<String> {
290    let tagged = p.tag.strip_prefix("p#").or_else(|| p.tag.strip_prefix("n#")).and_then(|rest| rest.split('#').next());
291    let channel = p.channel.strip_prefix("web:").and_then(|rest| rest.split(';').next());
292    let grouped = p.group.strip_prefix("Web:");
293    let firefox = (p.channel == FIREFOX_SITES).then_some(p.sub.as_str());
294    [tagged, channel, grouped, firefox].into_iter().flatten().map(host_of).find(|h| !h.is_empty())
295}
296
297/// `https://Mail.Example.org:443/inbox` → `mail.example.org`; `""` when it is no host.
298fn host_of(origin: &str) -> String {
299    let rest = origin.trim().split_once("://").map_or(origin.trim(), |(_, rest)| rest);
300    let host = rest.split(['/', '?', '#']).next().unwrap_or("");
301    let host = host.rsplit_once('@').map_or(host, |(_, h)| h);
302    let host = host.split(':').next().unwrap_or("").trim_end_matches('.').to_lowercase();
303    let fine = host.contains('.') && host.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-');
304    if fine { host } else { String::new() }
305}
306
307/// A phone number as written in a notification: digits, at least seven (a
308/// short code has six at most), with spaces, dots, dashes, brackets and a
309/// leading "+" allowed ("+262 6 39 98 01 01").
310pub fn number_in(text: &str) -> Option<String> {
311    let text = text.trim().trim_start_matches('\u{202a}').trim_end_matches('\u{202c}').trim();
312    let fine = !text.is_empty() && text.chars().enumerate().all(|(i, c)| c.is_ascii_digit() || " .-()/\u{a0}\u{202f}".contains(c) || (c == '+' && i == 0));
313    let digits = text.chars().filter(char::is_ascii_digit).count();
314    (fine && digits >= 7).then(|| text.to_string())
315}
316
317/// A short code an SMS comes from ("38015", "36179"): a business's, an automaton.
318fn short_code(text: &str) -> bool {
319    let text = text.trim();
320    !text.is_empty() && text.len() <= 6 && text.chars().all(|c| c.is_ascii_digit())
321}
322
323/// An e-mail address as an app writes a sender: "alice@example.org", "Alice <alice@example.org>".
324fn address_in(text: &str) -> Option<String> {
325    let text = text.trim();
326    let inner = text.rsplit_once('<').and_then(|(_, rest)| rest.split_once('>')).map_or(text, |(a, _)| a).trim();
327    let (local, domain) = inner.split_once('@')?;
328    let fine = !local.is_empty() && domain.contains('.') && !inner.contains(char::is_whitespace);
329    fine.then(|| inner.to_lowercase())
330}
331
332/// What a person is, as the app identifies them: their numbers and
333/// addresses (the app's URI, the phone's address book), else their name.
334fn sender_of(person: &Person, book: bool) -> Sender {
335    let mut numbers = person.numbers.clone();
336    let mut emails = person.emails.clone();
337    if book && person.uri.trim().is_empty() {
338        numbers.extend(person.by_name.iter().cloned());
339        emails.extend(person.emails_by_name.iter().cloned());
340    }
341    let contact = !numbers.is_empty() || !emails.is_empty();
342    let uri = person.uri.trim();
343    if let Some(number) = uri.strip_prefix("tel:") {
344        numbers.insert(0, percent_decoded(number));
345    } else if let Some(address) = uri.strip_prefix("mailto:") {
346        emails.insert(0, percent_decoded(address).to_lowercase());
347    }
348    numbers.retain(|n| !n.trim().is_empty());
349    emails.retain(|e| !e.trim().is_empty());
350    if !numbers.is_empty() || !emails.is_empty() {
351        return Sender::Known { numbers, emails, contact };
352    }
353    // An app's name for someone may be their number or their address itself.
354    if let Some(number) = number_in(&person.name) {
355        return Sender::Known { numbers: vec![number], emails: Vec::new(), contact: false };
356    }
357    if let Some(address) = address_in(&person.name) {
358        return Sender::Known { numbers: Vec::new(), emails: vec![address], contact: false };
359    }
360    let name = one_line(&person.name);
361    if name.is_empty() { Sender::Unknown } else { Sender::Named { name, book } }
362}
363
364/// "%2B262639980101" → "+262639980101": what a URI keeps encoded.
365fn percent_decoded(text: &str) -> String {
366    let bytes = text.as_bytes();
367    let mut out = Vec::with_capacity(bytes.len());
368    let mut i = 0;
369    while i < bytes.len() {
370        let hex = (bytes[i] == b'%').then(|| bytes.get(i + 1..i + 3)).flatten().and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
371        match hex {
372            Some(byte) => {
373                out.push(byte);
374                i += 3;
375            }
376            None => {
377                out.push(bytes[i]);
378                i += 1;
379            }
380        }
381    }
382    String::from_utf8_lossy(&out).to_string()
383}
384
385/// A name on one line: an app's words never break Sioul's.
386fn one_line(text: &str) -> String {
387    text.split_whitespace().collect::<Vec<_>>().join(" ")
388}
389
390fn fnv(text: &str) -> u64 {
391    text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |hash, b| (hash ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3))
392}
393
394/// A notification's key as the ledger keeps it: hashed, Android's own key
395/// naming the app and its tags (a site, a chat's id).
396pub fn key_hash(key: &str) -> String {
397    format!("{:016x}", fnv(key))
398}
399
400/// A conversation's key: the app and its own id for it, hashed.
401pub fn talk_key(package: &str, id: &str) -> String {
402    format!("{:016x}", fnv(&format!("{package}\u{1f}{id}")))
403}
404
405/// The conversation a notification belongs to: its shortcut, else its
406/// title (a group), else, for a conversation between two, the one who
407/// writes; for an SMS app's plain notification, its title (the address
408/// book's name, or the number).
409fn conversation_of(p: &Posted, sender: Option<&Person>, sms: bool) -> Option<Conversation> {
410    // A group as the app says it, or as its messages show it: two people or more write in it, the person aside.
411    let mut writers: Vec<String> = p.messages.iter().filter_map(|m| m.sender.as_ref()).filter(|s| !is_user(p, s)).map(|s| if s.key.trim().is_empty() { one_line(&s.name) } else { s.key.trim().to_string() }).filter(|w| !w.is_empty()).collect();
412    writers.sort();
413    writers.dedup();
414    let group = p.group_conversation || writers.len() >= 2;
415    let title = [p.conversation.as_str(), sender.map_or("", |s| s.name.as_str()), p.title.as_str()].into_iter().map(one_line).find(|t| !t.is_empty()).unwrap_or_default();
416    let from = sender.map_or("", |s| if s.key.trim().is_empty() { s.name.trim() } else { s.key.trim() });
417    let plain = if sms { p.title.trim() } else { "" };
418    let id = [p.shortcut.trim(), p.conversation.trim(), from, plain].into_iter().find(|i| !i.is_empty())?;
419    let by_writer = p.shortcut.trim().is_empty() && p.conversation.trim().is_empty() && !from.is_empty();
420    Some(Conversation { key: talk_key(&p.package, id), title, group, by_writer })
421}
422
423/// Whether a message's sender is the person themselves, as the conversation
424/// names them (some apps name them rather than leave the sender out).
425fn is_user(p: &Posted, sender: &Person) -> bool {
426    p.user.as_ref().is_some_and(|user| {
427        let (key, name) = (user.key.trim(), one_line(&user.name));
428        (!key.is_empty() && key == sender.key.trim()) || (!name.is_empty() && name == one_line(&sender.name))
429    })
430}
431
432/// What a notification is (`Kind`), as its app and Android say it, and as
433/// the person chose for its app and site (`Choices`).
434pub fn classify(p: &Posted, choices: &Choices, words: &crate::words::Words) -> Kind {
435    if p.package == OWN || p.ongoing || p.full_screen || p.summary || UNTOUCHED.contains(&p.category.as_str()) {
436        return Kind::Untouched;
437    }
438    if is_code(p, words) {
439        return Kind::Code;
440    }
441    let app = choices.app(&p.package);
442    if app.kind == AppKind::AtOnce {
443        return Kind::AtOnce;
444    }
445    let site = site_of(p);
446    if let Some(site) = &site
447        && choices.site.get(site).is_some_and(|s| s.at_once)
448    {
449        return Kind::AtOnce;
450    }
451    let automaton = Kind::Automaton { site: site.clone().unwrap_or_default() };
452    if app.kind == AppKind::Automaton || (app.kind == AppKind::Usual && site.is_some()) {
453        return automaton;
454    }
455    let mail = MAIL_APPS.contains(&p.package.as_str()) || p.category == "email";
456    let sms = p.sms_app || SMS_APPS.contains(&p.package.as_str());
457    let via = if matches!(p.category.as_str(), "missed_call" | "voicemail") {
458        Channel::Calls
459    } else if mail {
460        Channel::Mail
461    } else {
462        Channel::Messages
463    };
464    let styled = p.template.ends_with("$MessagingStyle") || !p.messages.is_empty() || p.is_conversation || !p.shortcut.trim().is_empty();
465    let identified = p.people.iter().any(|person| person.uri.starts_with("tel:") || person.uri.starts_with("mailto:"));
466    let between = app.kind == AppKind::People || via != Channel::Messages || styled || identified || sms || p.category == "msg";
467    let advert = matches!(p.category.as_str(), "promo" | "social" | "recommendation");
468    if !between || (advert && app.kind != AppKind::People) {
469        return automaton;
470    }
471    // SMS apps and the phone's own (a missed call) name people as the address book does.
472    let book = sms || via == Channel::Calls || BOOK_APPS.contains(&p.package.as_str());
473    // Who wrote: the last message's sender, else the people named, else the title.
474    let last = p.messages.iter().rev().find_map(|m| m.sender.as_ref().filter(|s| !is_user(p, s)));
475    let person = last.or_else(|| p.people.first());
476    // A machine that says it is one (a bank's assistant, a bot in a chat).
477    if person.is_some_and(|s| s.bot) && app.kind != AppKind::People {
478        return automaton;
479    }
480    let sender = match person {
481        Some(person) => sender_of(person, book),
482        None => {
483            let title = one_line(&p.title);
484            if sms && short_code(&title) {
485                return automaton;
486            }
487            sender_of(&Person { name: title, ..Person::default() }, book)
488        }
489    };
490    if sms && let Sender::Named { name, .. } = &sender
491        && short_code(name)
492    {
493        return automaton;
494    }
495    let to = if via == Channel::Mail { address_in(&p.sub).unwrap_or_default() } else { String::new() };
496    let conversation = if via == Channel::Messages { conversation_of(p, person, sms) } else { None };
497    Kind::People(Talk { via, sender, conversation, to, sms })
498}
499
500/// Who wrote, from what the address books and lists say of a name
501/// (`found`: none when no card has it): an app that names people as the
502/// phone's address book does is taken at its word; a name anyone can take
503/// may only hold more (restricted and blocked kept), never let through more
504/// than someone unknown.
505pub fn named(found: Option<Who>, book: bool) -> Who {
506    match found {
507        Some(who) if book => who,
508        Some(who @ (Who::Restricted | Who::Blocked)) => who,
509        _ => Who::Stranger,
510    }
511}
512
513// ---------------------------------------------------------------- the person's choices
514
515/// What an app is, as the person says.
516#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
517#[serde(rename_all = "kebab-case")]
518pub enum AppKind {
519    /// Notification by notification: between people, or from an automaton.
520    #[default]
521    Usual,
522    /// Messages between people, all of them.
523    People,
524    /// An automaton's: gathered, all of them.
525    Automaton,
526    /// At once, as Sioul's own notifications come.
527    AtOnce,
528}
529
530impl AppKind {
531    pub const ALL: [AppKind; 4] = [AppKind::Usual, AppKind::People, AppKind::Automaton, AppKind::AtOnce];
532
533    pub fn id(self) -> &'static str {
534        match self {
535            AppKind::Usual => "usual",
536            AppKind::People => "people",
537            AppKind::Automaton => "automaton",
538            AppKind::AtOnce => "at-once",
539        }
540    }
541
542    pub fn read(id: &str) -> Option<AppKind> {
543        AppKind::ALL.into_iter().find(|k| k.id() == id)
544    }
545}
546
547/// When a conversation comes, as the person says.
548#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
549#[serde(rename_all = "kebab-case")]
550pub enum Through {
551    /// As who writes says: a group, as people you do not know.
552    #[default]
553    Usual,
554    /// At once, whatever the time: sleep, pauses and do-not-disturb included.
555    Always,
556    /// With the automatons' notifications, at the gathered times.
557    Gathered,
558    /// Held for good.
559    Never,
560}
561
562impl Through {
563    pub const ALL: [Through; 4] = [Through::Usual, Through::Always, Through::Gathered, Through::Never];
564
565    pub fn id(self) -> &'static str {
566        match self {
567            Through::Usual => "usual",
568            Through::Always => "always",
569            Through::Gathered => "gathered",
570            Through::Never => "never",
571        }
572    }
573
574    pub fn read(id: &str) -> Option<Through> {
575        Through::ALL.into_iter().find(|t| t.id() == id)
576    }
577}
578
579#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
580pub struct AppChoice {
581    #[serde(default)]
582    pub kind: AppKind,
583    /// What the app is for: its automaton's notifications then come only in
584    /// that area's times, and its messages from anyone but your safe people.
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub area: Option<Area>,
587    /// Its name, as last seen.
588    #[serde(default, skip_serializing_if = "String::is_empty")]
589    pub label: String,
590}
591
592#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
593pub struct TalkChoice {
594    #[serde(default)]
595    pub through: Through,
596    /// Its title and its app's name, to show it.
597    #[serde(default, skip_serializing_if = "String::is_empty")]
598    pub title: String,
599    #[serde(default, skip_serializing_if = "String::is_empty")]
600    pub app: String,
601}
602
603#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
604pub struct SiteChoice {
605    #[serde(default)]
606    pub at_once: bool,
607}
608
609/// The person's choices, this phone's own (`$XDG_CONFIG_HOME/sioul/app-notes.toml`):
610/// never shared, like what the listener sees.
611#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
612pub struct Choices {
613    /// Holding at all; off, everything comes as the apps send it.
614    #[serde(default = "yes")]
615    pub hold: bool,
616    /// By the app's package.
617    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
618    pub app: BTreeMap<String, AppChoice>,
619    /// By the conversation's key (`talk_key`).
620    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
621    pub conversation: BTreeMap<String, TalkChoice>,
622    /// By the site's host.
623    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
624    pub site: BTreeMap<String, SiteChoice>,
625}
626
627fn yes() -> bool {
628    true
629}
630
631impl Default for Choices {
632    fn default() -> Choices {
633        Choices { hold: true, app: BTreeMap::new(), conversation: BTreeMap::new(), site: BTreeMap::new() }
634    }
635}
636
637impl Choices {
638    pub fn default_path() -> PathBuf {
639        crate::config::config_dir().join("app-notes.toml")
640    }
641
642    /// As kept; what does not read is no choice at all (everything as usual).
643    pub fn load(path: &Path) -> Choices {
644        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
645    }
646
647    pub fn save(&self, path: &Path) -> Result<(), String> {
648        write_whole(path, &toml::to_string(self).map_err(|e| e.to_string())?)
649    }
650
651    pub fn app(&self, package: &str) -> AppChoice {
652        self.app.get(package).cloned().unwrap_or_default()
653    }
654
655    pub fn through(&self, conversation: &str) -> Through {
656        self.conversation.get(conversation).map_or(Through::Usual, |c| c.through)
657    }
658}
659
660/// Written whole under another name, then renamed: never half a file.
661fn write_whole(path: &Path, text: &str) -> Result<(), String> {
662    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
663    if let Some(parent) = path.parent() {
664        std::fs::create_dir_all(parent).map_err(fail)?;
665    }
666    let temporary = path.with_extension(format!("toml.{}", std::process::id()));
667    std::fs::write(&temporary, text).map_err(fail)?;
668    std::fs::rename(&temporary, path).map_err(fail)
669}
670
671// ---------------------------------------------------------------- when
672
673/// What time it is at any moment (`reach::Clock`, through `Live`): sleep, a
674/// pause, free time, the hours, as they stand now.
675pub trait TimeSource {
676    fn mode(&self, at: &Zoned) -> Mode;
677    /// Where what time it is changes between `from` and `until`: each
678    /// frame's start, in order, after `from`.
679    fn starts(&self, from: &Zoned, until: &Zoned) -> Vec<Zoned>;
680}
681
682/// The clock as it stands now (`reach::Clock`): what a decision asks of it.
683#[derive(Debug, Clone)]
684pub struct Live {
685    pub clock: crate::reach::Clock,
686}
687
688impl TimeSource for Live {
689    fn mode(&self, at: &Zoned) -> Mode {
690        self.clock.mode(at)
691    }
692
693    fn starts(&self, from: &Zoned, until: &Zoned) -> Vec<Zoned> {
694        let stamp = from.timestamp().as_second();
695        self.clock.frames(from, until).into_iter().filter(|f| f.start > stamp).filter_map(|f| jiff::Timestamp::from_second(f.start).ok()).map(|t| t.to_zoned(from.time_zone().clone())).collect()
696    }
697}
698
699/// Do-not-disturb's switch or a focus session, holding now: a layer of the
700/// matrix (as usual, only Always through's messages come, docs/attention.md).
701pub struct Gate {
702    /// When it ends, when known (Unix seconds); else asked again in half an hour.
703    pub until: Option<i64>,
704}
705
706/// What a decision asks of the moment.
707pub struct Ask<'a> {
708    pub now: &'a Zoned,
709    pub clock: &'a dyn TimeSource,
710    /// What reaches you when (`attention`, `[attention]`).
711    pub attention: &'a Attention,
712    /// The gathered times, "09:00" (`[reminders] gathered`).
713    pub gathered: &'a [String],
714    /// What the app (or the address a mail came to) is for, when said.
715    pub area: Option<Area>,
716    pub gate: Option<Gate>,
717    /// One of today's slots of time for you then (`attention::Slots`): a layer of the matrix.
718    pub slot_at: &'a dyn Fn(&Zoned) -> bool,
719    /// Free time's "Nothing at all", for this Free time.
720    pub nothing: bool,
721    /// Where things change that the clock does not know (Unix seconds): the
722    /// ends of slots of time for you, of do-not-disturb's switch.
723    pub also: &'a [i64],
724    /// The notification's own rows in the matrix, its conversation's then its
725    /// app's (`attention::source_rows`): read over its usual row when set.
726    pub sources: &'a [String],
727    /// Reachable until then (Unix seconds; part 3 of "What reaches you"):
728    /// while it lasts, everyone not blocked comes at once (`attention::Now::reachable`).
729    pub reachable: Option<i64>,
730    /// Android silences this notification now (its ranking does not match
731    /// the interruption filter: Sioul's mode or yours): a text whose cell says
732    /// ◑ comes then, without a sound, and waits otherwise (`attention::Now::silenced`).
733    pub silenced: bool,
734}
735
736impl Ask<'_> {
737    fn stamp(&self) -> i64 {
738        self.now.timestamp().as_second()
739    }
740
741    /// Reachable holds now (`reachable`).
742    fn reachable_now(&self) -> bool {
743        self.reachable.is_some_and(|until| self.stamp() < until)
744    }
745
746    /// Do-not-disturb's switch or a focus session holding at `at`.
747    fn gated(&self, at: &Zoned) -> bool {
748        self.gate.as_ref().is_some_and(|g| at.timestamp().as_second() < g.until.unwrap_or(self.stamp() + GATE_RECHECK))
749    }
750
751    /// The moments after now where what may come can change: where what
752    /// time it is changes, the ends the clock does not know, the end of
753    /// do-not-disturb (or when to ask again, without one), in order.
754    fn moments(&self) -> Vec<Zoned> {
755        let stamp = self.stamp();
756        let Ok(until) = self.now.checked_add(jiff::Span::new().hours(HORIZON_DAYS * 24)) else { return Vec::new() };
757        let mut moments = self.clock.starts(self.now, &until);
758        let gate_end = self.gate.as_ref().map(|g| g.until.unwrap_or(stamp + GATE_RECHECK));
759        for second in self.also.iter().copied().chain(gate_end).chain(self.reachable).filter(|s| *s > stamp && *s < until.timestamp().as_second()) {
760            if let Ok(t) = jiff::Timestamp::from_second(second) {
761                moments.push(t.to_zoned(self.now.time_zone().clone()));
762            }
763        }
764        moments.sort_by_key(Zoned::timestamp);
765        moments.dedup_by_key(|z| z.timestamp());
766        moments
767    }
768
769    /// The moment at `at` as the matrix reads it: what time it is then, a
770    /// slot of time for you, do-not-disturb, Free time's "Nothing at all".
771    fn moment(&self, at: &Zoned, mode: &Mode) -> attention::Now {
772        let mut now = attention::Now::of(mode).layers((self.slot_at)(at), self.gated(at));
773        now.nothing = self.nothing && mode.free();
774        now.reachable = self.reachable.is_some_and(|until| at.timestamp().as_second() < until);
775        // Known for this moment alone: ahead, what the filter will be is not known.
776        now.silenced = self.silenced && at.timestamp().as_second() <= self.stamp();
777        now
778    }
779
780    /// What the matrix says of a kind at `at`.
781    fn level(&self, kind: attention::Kind, at: &Zoned, mode: &Mode) -> Level {
782        self.attention.level(attention::Row::Own(kind), &self.moment(at, mode))
783    }
784
785    /// Whether the app is for that time (its area), or says nothing of it.
786    fn area_fits(&self, mode: &Mode) -> bool {
787        self.area.is_none_or(|a| in_view(a, mode.time, mode.week))
788    }
789}
790
791/// Why a notification comes now or waits.
792#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
793#[serde(rename_all = "kebab-case")]
794pub enum Why {
795    /// Never held: calls, alarms, what runs, the reminders you set.
796    Untouched,
797    /// A code, a sign-in or a payment to approve.
798    Code,
799    /// As you chose for this app or conversation at this time (its own row in the matrix).
800    Times,
801    /// Holding is off.
802    Off,
803    /// The app or the site comes at once.
804    AtOnce,
805    /// Someone who may reach you now.
806    Allowed,
807    /// A conversation let through always.
808    Always,
809    /// Someone who may not reach you now: until they may.
810    Waiting,
811    /// From an automaton: at the next gathered time.
812    Gathered,
813    /// Held for good: blocked, or a conversation set to never.
814    Never,
815    /// Held again and again within minutes on its return: let through.
816    Again,
817    /// A conversation you answered from a computer in the last twenty
818    /// minutes (`replies::answered_lately`): let through, but in a pause.
819    Answered,
820    /// Held before a release you asked (Reachable pressed, or "Let what
821    /// your phone holds through" on a computer: part 3): let through once.
822    Released,
823}
824
825impl Why {
826    pub fn id(self) -> &'static str {
827        match self {
828            Why::Untouched => "untouched",
829            Why::Code => "code",
830            Why::Times => "times",
831            Why::Off => "off",
832            Why::AtOnce => "at-once",
833            Why::Allowed => "allowed",
834            Why::Always => "always",
835            Why::Waiting => "waiting",
836            Why::Gathered => "gathered",
837            Why::Never => "never",
838            Why::Again => "again",
839            Why::Answered => "answered",
840            Why::Released => "released",
841        }
842    }
843}
844
845/// Now, or held until then.
846#[derive(Debug, Clone, Copy, PartialEq, Eq)]
847pub struct Decision {
848    /// Unix seconds; none: it comes now.
849    pub until: Option<i64>,
850    pub why: Why,
851}
852
853impl Decision {
854    pub fn through(why: Why) -> Decision {
855        Decision { until: None, why }
856    }
857
858    pub fn held(until: i64, why: Why) -> Decision {
859        Decision { until: Some(until), why }
860    }
861
862    /// Of a conversation you answered from a computer within twenty minutes
863    /// (`replies::ANSWERED_MS`), outside a pause, which the caller says: what
864    /// would wait comes at once, their answer to your answer; held for good
865    /// (the blocked, a conversation set to never) stays held.
866    pub fn answered(self) -> Decision {
867        if self.until.is_some() && self.why != Why::Never { Decision::through(Why::Answered) } else { self }
868    }
869
870    /// Of a notification held at or before the last release applied
871    /// (`Ledger::released_before`; Reachable pressed, or "Let what your phone
872    /// holds through" on a computer: part 3): what would wait comes now, every
873    /// kind; held for good (the blocked, a conversation set to never) stays
874    /// held, and what was held after the release waits as decided.
875    pub fn released(self, held: &Held, ledger: &Ledger) -> Decision {
876        if self.until.is_some() && self.why != Why::Never && ledger.released_before(held) { Decision::through(Why::Released) } else { self }
877    }
878}
879
880/// The first moment from now on, within the coming days, when `fits`
881/// holds: now itself, else where something changes (`Ask::moments`).
882pub fn first_moment(ask: &Ask, fits: &dyn Fn(&Zoned, &Mode) -> bool) -> Option<Zoned> {
883    if fits(ask.now, &ask.clock.mode(ask.now)) {
884        return Some(ask.now.clone());
885    }
886    ask.moments().into_iter().find(|at| fits(at, &ask.clock.mode(at)))
887}
888
889/// The gathered times as times of day, in order; "8:30" reads as 08:30.
890/// None that reads: 09:00, 13:00 and 18:00.
891pub fn times_of(gathered: &[String]) -> Vec<jiff::civil::Time> {
892    let read = |text: &str| {
893        let (h, m) = text.trim().split_once([':', 'h', 'H'])?;
894        let (h, m) = (h.trim().parse::<i8>().ok()?, if m.trim().is_empty() { 0 } else { m.trim().parse::<i8>().ok()? });
895        jiff::civil::Time::new(h, m, 0, 0).ok()
896    };
897    let mut times: Vec<jiff::civil::Time> = gathered.iter().filter_map(|t| read(t)).collect();
898    if times.is_empty() {
899        times = [9, 13, 18].iter().filter_map(|h| jiff::civil::Time::new(*h, 0, 0, 0).ok()).collect();
900    }
901    times.sort();
902    times.dedup();
903    times
904}
905
906/// The next gathering from now in which an automaton's notification may
907/// come: one the notification matrix gathers at (as usual, not while you
908/// sleep, pause or take free time, nor in a slot of time for you or under
909/// do-not-disturb), within its app's area. One that began less than a minute
910/// ago counts. None within the coming days.
911pub fn next_gathering(ask: &Ask) -> Option<Zoned> {
912    let times = times_of(ask.gathered);
913    let zone = ask.now.time_zone().clone();
914    let stamp = ask.stamp();
915    for day in 0..=HORIZON_DAYS {
916        let Ok(date) = ask.now.date().checked_add(jiff::Span::new().days(day)) else { continue };
917        for time in &times {
918            let Ok(at) = date.to_datetime(*time).to_zoned(zone.clone()) else { continue };
919            if at.timestamp().as_second() + GRACE <= stamp {
920                continue;
921            }
922            let mode = ask.clock.mode(&at);
923            if ask.level(attention::Kind::AppAutomatons, &at, &mode) == Level::Gathered && ask.area_fits(&mode) {
924                return Some(at);
925            }
926        }
927    }
928    None
929}
930
931/// The gathered times from now on, within the coming days, in order; one
932/// that began less than a minute ago among them.
933fn gatherings(ask: &Ask) -> Vec<Zoned> {
934    let zone = ask.now.time_zone().clone();
935    let stamp = ask.stamp();
936    let times = times_of(ask.gathered);
937    let mut out = Vec::new();
938    for day in 0..=HORIZON_DAYS {
939        let Ok(date) = ask.now.date().checked_add(jiff::Span::new().days(day)) else { continue };
940        for time in &times {
941            if let Ok(at) = date.to_datetime(*time).to_zoned(zone.clone())
942                && at.timestamp().as_second() + GRACE > stamp
943            {
944                out.push(at);
945            }
946        }
947    }
948    out
949}
950
951/// The first moment from now on, within the coming days, when what the
952/// matrix says of a notification lets it come (`level_at`): at once (●) now
953/// or where something changes; at the gathered times (◎) at a gathered time.
954pub fn first_time(ask: &Ask, level_at: &dyn Fn(&Zoned, &Mode) -> Level) -> Option<Zoned> {
955    let mut candidates: Vec<(Zoned, bool)> = vec![(ask.now.clone(), false)];
956    candidates.extend(ask.moments().into_iter().map(|at| (at, false)));
957    candidates.extend(gatherings(ask).into_iter().map(|at| (at, true)));
958    candidates.sort_by_key(|(at, gathering)| (at.timestamp(), !*gathering));
959    candidates
960        .into_iter()
961        .find(|(at, gathering)| match level_at(at, &ask.clock.mode(at)) {
962            Level::Now => true,
963            Level::Gathered => *gathering,
964            _ => false,
965        })
966        .map(|(at, _)| at)
967}
968
969/// When a notification comes, by what the matrix says of it at each moment
970/// (`Attention::decide`, its app's and conversation's own rows read over its
971/// usual one): through now, or held until its first time (`first_time`), the
972/// next event tried when one finds none (its area left aside: nothing waits
973/// for good). Why: `now` or `later`, or as you chose for this time
974/// (`Why::Times`) when a source's own cell decides it now.
975fn by_matrix(ask: &Ask, events: &[Event], now: Why, later: Why) -> Decision {
976    let stamp = ask.stamp();
977    let Some(first) = events.first() else { return Decision::through(now) };
978    let chosen = ask.attention.decide(first, &ask.moment(ask.now, &ask.clock.mode(ask.now))).step == attention::Step::Chosen;
979    let why = |usual: Why| if chosen { Why::Times } else { usual };
980    let next = events.iter().find_map(|event| first_time(ask, &|at, mode| ask.attention.decide(event, &ask.moment(at, mode)).level));
981    match next {
982        Some(at) if at.timestamp().as_second() <= stamp => Decision::through(why(now)),
983        Some(at) => Decision::held(at.timestamp().as_second(), why(later)),
984        None => Decision::held(stamp + RECHECK, why(later)),
985    }
986}
987
988/// Gathered: held until the next gathered time the matrix gathers at (an
989/// automaton's row, as usual three a day but not while you sleep, pause,
990/// take Free time or have do-not-disturb on), within the app's area; through
991/// when it is this very one, or when the row says at once now (as the
992/// preset More reachable does at work). Its app's own row read over it.
993fn gathered(ask: &Ask) -> Decision {
994    let event = Event::own(attention::Kind::AppAutomatons).for_area(ask.area).from_sources(ask.sources.to_vec());
995    by_matrix(ask, &[event], Why::Gathered, Why::Gathered)
996}
997
998/// At once, at the times the matrix lets these apps come (as usual, as
999/// Sioul's own notifications come): else when it does. Its app's own row
1000/// read over it, when set.
1001fn at_once(ask: &Ask) -> Decision {
1002    if ask.attention.has_sources(ask.sources) {
1003        let event = Event::own(attention::Kind::AppAtOnce).for_area(ask.area).from_sources(ask.sources.to_vec());
1004        return by_matrix(ask, &[event], Why::AtOnce, Why::AtOnce);
1005    }
1006    match first_moment(ask, &|at, mode| ask.level(attention::Kind::AppAtOnce, at, mode) == Level::Now && ask.area_fits(mode)) {
1007        Some(at) if at.timestamp().as_second() <= ask.stamp() => Decision::through(Why::AtOnce),
1008        Some(at) => Decision::held(at.timestamp().as_second(), Why::AtOnce),
1009        None => Decision::held(ask.stamp() + RECHECK, Why::AtOnce),
1010    }
1011}
1012
1013/// Someone's message: now when the matrix lets them through
1014/// (`attention::Attention::decide`: their row on its channel, a group's own
1015/// row, Always through's, the time, its layers), the notification's area
1016/// fitting now for all but your safe people and Always through (as mail to an
1017/// address for another time, docs/areas.md); else at their next time.
1018fn person(talk: &Talk, who: Who, group: bool, always: bool, ask: &Ask) -> Decision {
1019    if who == Who::Blocked {
1020        return Decision::held(ask.stamp() + FOR_GOOD, Why::Never);
1021    }
1022    // Its app's or conversation's own row, set: at once, gathered or held as it says at each time.
1023    if ask.attention.has_sources(ask.sources) {
1024        let event = Event::of(Source::Message { via: talk.via, who, group, always }).from_sources(ask.sources.to_vec());
1025        return by_matrix(ask, &[event.clone().for_area(ask.area), event], Why::Allowed, Why::Waiting);
1026    }
1027    let open = |at: &Zoned, mode: &Mode, area: Option<Area>| ask.attention.decide(&Event::of(Source::Message { via: talk.via, who, group, always }).for_area(area), &ask.moment(at, mode)).told;
1028    // Its area and its times never meeting in the coming days, the matrix alone decides: nothing waits for good.
1029    let next = first_moment(ask, &|at, mode| open(at, mode, ask.area)).or_else(|| first_moment(ask, &|at, mode| open(at, mode, None)));
1030    match next {
1031        Some(at) if at.timestamp().as_second() <= ask.stamp() => Decision::through(Why::Allowed),
1032        Some(at) => Decision::held(at.timestamp().as_second(), Why::Waiting),
1033        None => Decision::held(ask.stamp() + RECHECK, Why::Waiting),
1034    }
1035}
1036
1037/// A conversation let through always: Always through's row (at once at any
1038/// time, as usual), read against who writes, or the group's row.
1039fn always(talk: &Talk, who: Who, group: bool, ask: &Ask) -> Decision {
1040    match person(talk, who, group, true, ask) {
1041        Decision { until: None, why: Why::Allowed } => Decision::through(Why::Always),
1042        other => other,
1043    }
1044}
1045
1046/// Which way a notification is decided (`route`): `decide` follows it, and
1047/// `cause` reads it again to say what held one.
1048enum Route<'a> {
1049    /// It comes now, whatever the time: never held, a code, holding off.
1050    Through(Why),
1051    /// The app or the site set to come at once.
1052    AtOnce,
1053    /// Gathered with the automatons.
1054    Gathered(Gathering),
1055    /// Held for good: a blocked sender, a conversation set to never.
1056    ForGood,
1057    /// Someone's message, by who wrote and the time: `always` on Always
1058    /// through's row; `conversation`, a conversation let through always.
1059    Person { talk: &'a Talk, who: Who, group: bool, always: bool, conversation: bool },
1060}
1061
1062/// The way `decide` takes: what the notification is, then who wrote and
1063/// what you chose for its conversation. The blocked never, whatever their
1064/// conversation says (blocked beats Always through, docs/attention.md, Q3).
1065fn route<'a>(kind: &'a Kind, who: Option<Who>, listed: bool, choices: &Choices) -> Route<'a> {
1066    match kind {
1067        Kind::Untouched => Route::Through(Why::Untouched),
1068        Kind::Code => Route::Through(Why::Code),
1069        _ if !choices.hold => Route::Through(Why::Off),
1070        Kind::AtOnce => Route::AtOnce,
1071        Kind::Automaton { .. } => Route::Gathered(Gathering::Automaton),
1072        Kind::People(talk) => {
1073            if who == Some(Who::Blocked) {
1074                return Route::ForGood;
1075            }
1076            // A group is judged as the conversation, on its own row: anyone in it may write.
1077            let group = talk.conversation.as_ref().is_some_and(|c| c.group);
1078            let through = talk.conversation.as_ref().map_or(Through::Usual, |c| choices.through(&c.key));
1079            let known = who.unwrap_or(Who::Stranger);
1080            match through {
1081                Through::Always => Route::Person { talk, who: known, group, always: true, conversation: true },
1082                Through::Gathered => Route::Gathered(Gathering::Conversation),
1083                Through::Never => Route::ForGood,
1084                // An SMS named only, whom no address book knows: a business's sender id.
1085                Through::Usual if talk.sms && who.is_none() && matches!(talk.sender, Sender::Named { .. }) => Route::Gathered(Gathering::Named),
1086                Through::Usual => Route::Person { talk, who: known, group, always: listed && !group, conversation: false },
1087            }
1088        }
1089    }
1090}
1091
1092/// A conversation you set "with the automatons": at their gathered times;
1093/// at once while Reachable lasts, as everyone not blocked comes then (review
1094/// of part 3, L3); one set to never stays held.
1095fn conversation_gathered(ask: &Ask) -> Decision {
1096    if ask.reachable_now() { Decision::through(Why::Allowed) } else { gathered(ask) }
1097}
1098
1099/// Whether a notification comes now, or until when it waits.
1100/// `who`: the sender as the address books and lists judge them (`named` for
1101/// a name), none when nobody knows them; `listed`: the Always through list
1102/// holds them. The blocked never, whatever their conversation says (blocked
1103/// beats Always through, docs/attention.md, Q3).
1104pub fn decide(kind: &Kind, who: Option<Who>, listed: bool, choices: &Choices, ask: &Ask) -> Decision {
1105    match route(kind, who, listed, choices) {
1106        Route::Through(why) => Decision::through(why),
1107        Route::AtOnce => at_once(ask),
1108        Route::Gathered(Gathering::Conversation) => conversation_gathered(ask),
1109        Route::Gathered(_) => gathered(ask),
1110        Route::ForGood => Decision::held(ask.stamp() + FOR_GOOD, Why::Never),
1111        Route::Person { talk, who, group, conversation: true, .. } => always(talk, who, group, ask),
1112        Route::Person { talk, who, group, always, .. } => person(talk, who, group, always, ask),
1113    }
1114}
1115
1116/// What gathers a notification with the automatons (`Cause`).
1117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1118pub enum Gathering {
1119    /// An automaton's: shops, news, a browser's site, a short number's text.
1120    Automaton,
1121    /// Its app, as you set it: "An automaton: gathered".
1122    App,
1123    /// Its conversation, as you set it: gathered.
1124    Conversation,
1125    /// A text whose sender came as a name alone, which no card has: taken
1126    /// for a business's sender id ("AMELI").
1127    Named,
1128    /// A text from a number nobody knows from a root ARCEP keeps for
1129    /// automated systems, while the call screening sends their calls to
1130    /// voicemail (the app's `machine_number`; review of part 2, F11).
1131    Machine,
1132}
1133
1134/// Which own row decides a notification now (`Cause`).
1135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1136pub enum Own {
1137    Conversation,
1138    /// A person's own row (decision 16).
1139    Person,
1140    App,
1141}
1142
1143/// What held a notification, at the moment it was held: for the
1144/// listener's line in the phone's log (docs/android.md, "In the phone's
1145/// log"). Never who wrote: no name, no number, no list.
1146#[derive(Debug, Clone, Default, PartialEq, Eq)]
1147pub struct Cause {
1148    /// Held for good: a blocked sender, or a conversation set to never.
1149    pub for_good: bool,
1150    /// Gathered, and what gathers it.
1151    pub gathered: Option<Gathering>,
1152    /// Its conversation's or its app's own row decides it now.
1153    pub own: Option<Own>,
1154    /// What it is for (its app's area, the address a mail came to), not now.
1155    pub area: Option<Area>,
1156    /// The columns of the matrix that hold it now (`Attention::holding`):
1157    /// the time's, each layer by itself.
1158    pub columns: Vec<attention::Column>,
1159    /// Free time's "Nothing at all" holds now.
1160    pub nothing: bool,
1161}
1162
1163/// What held a notification that `decide` holds, now (`Cause`): the way
1164/// `decide` took (`route`), then, of the event it asked the matrix about,
1165/// the own row that decides it, what it is for, and the columns that hold
1166/// it at this moment. None for one that comes now whatever the time.
1167/// `package`: its app's, for an app you set as an automaton.
1168pub fn cause(package: &str, kind: &Kind, who: Option<Who>, listed: bool, choices: &Choices, ask: &Ask) -> Option<Cause> {
1169    let mode = ask.clock.mode(ask.now);
1170    let moment = ask.moment(ask.now, &mode);
1171    let mut cause = Cause { nothing: moment.nothing, ..Cause::default() };
1172    let sources = ask.sources.to_vec();
1173    let event = match route(kind, who, listed, choices) {
1174        Route::Through(_) => return None,
1175        Route::ForGood => {
1176            cause.for_good = true;
1177            return Some(cause);
1178        }
1179        Route::Gathered(how) => {
1180            cause.gathered = Some(if how == Gathering::Automaton && choices.app(package).kind == AppKind::Automaton { Gathering::App } else { how });
1181            Event::own(attention::Kind::AppAutomatons)
1182        }
1183        Route::AtOnce => Event::own(attention::Kind::AppAtOnce),
1184        Route::Person { talk, who, group, always, .. } => Event::of(Source::Message { via: talk.via, who, group, always }),
1185    };
1186    let event = event.for_area(ask.area).from_sources(sources);
1187    let out = ask.attention.decide(&event, &moment);
1188    if out.step == attention::Step::Chosen {
1189        let rows = if matches!(event.source, Source::Message { always: true, .. }) { event.sources.iter().filter(|id| id.starts_with(attention::CONVERSATION_ROW)).cloned().collect() } else { event.sources.clone() };
1190        cause.own = ask.attention.chosen_source(&rows, &moment).map(|id| if id.starts_with(attention::CONVERSATION_ROW) { Own::Conversation } else if attention::is_person(id) { Own::Person } else { Own::App });
1191    }
1192    if out.step == attention::Step::Area {
1193        cause.area = ask.area;
1194    }
1195    cause.columns = ask.attention.holding(&event, &moment);
1196    // An app at once held at a rest by what it is for (`at_once`), which the matrix leaves to the hours.
1197    if cause.gathered.is_none() && cause.own.is_none() && cause.area.is_none() && cause.columns.is_empty() && !ask.area_fits(&mode) {
1198        cause.area = ask.area;
1199    }
1200    Some(cause)
1201}
1202
1203// ---------------------------------------------------------------- what Sioul held
1204
1205/// What a held notification waits for, to work its time out again when
1206/// something changes (a pause ends, the hours or the choices change: `review`).
1207#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1208pub struct Held {
1209    /// The app's package and name.
1210    pub app: String,
1211    pub label: String,
1212    /// "people", "gathered", "at-once", "never".
1213    pub kind: String,
1214    /// The channel (`Channel::id`) and who wrote (`Who::id`), for a message.
1215    #[serde(default, skip_serializing_if = "String::is_empty")]
1216    pub via: String,
1217    #[serde(default, skip_serializing_if = "String::is_empty")]
1218    pub who: String,
1219    /// The Always through list holds the sender.
1220    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1221    pub admitted: bool,
1222    /// A group's conversation: its own row, whoever writes.
1223    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1224    pub group: bool,
1225    #[serde(default, skip_serializing_if = "String::is_empty")]
1226    pub conversation: String,
1227    #[serde(default, skip_serializing_if = "String::is_empty")]
1228    pub site: String,
1229    #[serde(default, skip_serializing_if = "Option::is_none")]
1230    pub area: Option<Area>,
1231    pub since: i64,
1232    pub until: i64,
1233    /// When it was first held (Unix seconds), held again on its return
1234    /// since (`since` is the latest hold): what a release lets through is
1235    /// what was held before it, however often it came back held meanwhile, a
1236    /// pause first (review of part 3, F5). 0: held once (`since`).
1237    #[serde(default, skip_serializing_if = "is_zero_i64")]
1238    pub first: i64,
1239    /// Held again on its return so many times.
1240    #[serde(default, skip_serializing_if = "is_zero")]
1241    pub again: u32,
1242    /// The person's own rows it reads (`porch::Senders::person_rows`: a card's
1243    /// UID, a number's key, an address; never a name), for when it is worked
1244    /// out again: a row set since applies. Kept on the phone alone, as the
1245    /// ledger is.
1246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1247    pub persons: Vec<String>,
1248}
1249
1250impl Held {
1251    /// When it was first held (Unix seconds): `first`, else `since`.
1252    pub fn first_held(&self) -> i64 {
1253        if self.first > 0 { self.first } else { self.since }
1254    }
1255}
1256
1257fn is_zero(n: &u32) -> bool {
1258    *n == 0
1259}
1260
1261/// An app as Sioul saw it last: its name, when, what came of it, the channels that rang while held.
1262#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1263pub struct SeenApp {
1264    pub label: String,
1265    pub seen: i64,
1266    /// The last decision: `Why::id`, and until when when held.
1267    pub why: String,
1268    #[serde(default, skip_serializing_if = "is_zero_i64")]
1269    pub until: i64,
1270    /// Its channels that sounded while Sioul held their notification, by
1271    /// channel id: their names and when. Made silent, they are taken off.
1272    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1273    pub rang: BTreeMap<String, Rang>,
1274}
1275
1276fn is_zero_i64(n: &i64) -> bool {
1277    *n == 0
1278}
1279
1280#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1281pub struct Rang {
1282    pub name: String,
1283    pub at: i64,
1284}
1285
1286/// A conversation seen: to choose for it.
1287#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1288pub struct SeenTalk {
1289    pub app: String,
1290    pub label: String,
1291    pub title: String,
1292    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1293    pub group: bool,
1294    pub seen: i64,
1295}
1296
1297/// A browser's site seen: to choose for it.
1298#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1299pub struct SeenSite {
1300    pub app: String,
1301    pub seen: i64,
1302}
1303
1304/// What came back at its time, for the Porch.
1305#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1306pub struct Back {
1307    pub at: i64,
1308    pub apps: Vec<String>,
1309}
1310
1311/// What Sioul held and saw, on this phone (`$XDG_STATE_HOME/sioul/app-notes-seen.toml`).
1312#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1313pub struct Ledger {
1314    /// Held now, by `key_hash`.
1315    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1316    pub held: BTreeMap<String, Held>,
1317    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1318    pub apps: BTreeMap<String, SeenApp>,
1319    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1320    pub conversations: BTreeMap<String, SeenTalk>,
1321    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1322    pub sites: BTreeMap<String, SeenSite>,
1323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1324    pub back: Vec<Back>,
1325    /// When the listener last heard from Android (Unix seconds): the access works.
1326    #[serde(default, skip_serializing_if = "is_zero_i64")]
1327    pub heard: i64,
1328    /// The latest release applied before releases were kept by device (the
1329    /// build of 20 October 2026): every device's stamps up to it count as
1330    /// applied. Never written again.
1331    #[serde(default, skip_serializing_if = "is_zero_i64")]
1332    pub released: i64,
1333    /// The latest release applied, by the device that asked it
1334    /// (milliseconds, its stamp: `everywhere::Switch::release_stamps`): each
1335    /// device's stamps applied once, apart, so that one far ahead never
1336    /// stands in the way of another's (review of part 3, F3).
1337    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1338    pub released_by: BTreeMap<String, i64>,
1339    /// The stamp each of those marks was applied for (milliseconds, as it
1340    /// was asked), when it was ahead of this phone's clock: applied once,
1341    /// though the mark is the phone's now (check of a23cf0c, C-1).
1342    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1343    pub applied: BTreeMap<String, i64>,
1344    /// What the releases let through: what was held at or before then (Unix
1345    /// seconds, this phone's clock; a stamp ahead of it read as its own now
1346    /// when applied, never releasing what is held after).
1347    #[serde(default, skip_serializing_if = "is_zero_i64")]
1348    pub released_through: i64,
1349    /// Stamps passed over, more than `RELEASE_AHEAD` ahead of this phone's
1350    /// clock when first met, by device: said once in the log, and never
1351    /// applied later, when the phone's clock comes within a day of them
1352    /// (review of part 3, R-2).
1353    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1354    pub ahead: BTreeMap<String, i64>,
1355}
1356
1357/// A release stamp further ahead of this phone's clock than this (seconds)
1358/// is a clock gone wrong or a garbled table: passed over, said once.
1359pub const RELEASE_AHEAD: i64 = 24 * 3600;
1360
1361impl Ledger {
1362    /// The releases asked (`stamps`, by device, milliseconds, each device's
1363    /// clock), applied at `now` (Unix seconds), each device's once: whether
1364    /// one is new, and the devices whose stamp is passed over now, too far
1365    /// ahead (said once). What was held at or before a stamp's time, or
1366    /// before `now` for a stamp ahead of this phone's clock, then comes back
1367    /// (`released_before`). Never during a pause: the caller waits (F5).
1368    pub fn apply_releases(&mut self, stamps: &[(String, i64)], now: i64) -> (bool, Vec<String>) {
1369        let (mut applied, mut ahead) = (false, Vec::new());
1370        for (device, stamp) in stamps {
1371            let done = self.released_by.get(device).copied().unwrap_or(0).max(self.released);
1372            // Passed over once for being too far ahead: for good, never applied a day later (R-2);
1373            // applied already, kept as the stamp it was (C-1).
1374            if *stamp <= done || self.ahead.get(device) == Some(stamp) || self.applied.get(device) == Some(stamp) {
1375                continue;
1376            }
1377            if stamp / 1000 > now.saturating_add(RELEASE_AHEAD) {
1378                if self.ahead.get(device) != Some(stamp) {
1379                    self.ahead.insert(device.clone(), *stamp);
1380                    ahead.push(device.clone());
1381                }
1382                continue;
1383            }
1384            // Its mark at the phone's now at most, the stamp kept beside so that it applies once: a
1385            // device whose clock was hours ahead, once set right, is never shadowed by its own stamp
1386            // for those hours (check of a23cf0c, C-1).
1387            self.released_by.insert(device.clone(), (*stamp).min(now.saturating_mul(1000)));
1388            self.applied.insert(device.clone(), *stamp);
1389            self.released_through = self.released_through.max((stamp / 1000).min(now));
1390            applied = true;
1391        }
1392        (applied, ahead)
1393    }
1394
1395    /// Whether a notification held now was first held at or before the last release applied.
1396    pub fn released_before(&self, held: &Held) -> bool {
1397        self.released_through > 0 && held.first_held() <= self.released_through
1398    }
1399
1400    pub fn default_path() -> PathBuf {
1401        crate::config::state_dir().join("app-notes-seen.toml")
1402    }
1403
1404    /// As kept; empty when missing or unreadable (what was held comes back at its time all the same).
1405    pub fn load(path: &Path) -> Ledger {
1406        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
1407    }
1408
1409    pub fn save(&self, path: &Path) -> Result<(), String> {
1410        write_whole(path, &toml::to_string(self).map_err(|e| e.to_string())?)
1411    }
1412
1413    /// A notification decided: the app, its conversation or site seen; held, kept to be worked out again.
1414    pub fn note(&mut self, p: &Posted, kind: &Kind, who: Option<Who>, admitted: bool, area: Option<Area>, decision: &Decision, now: i64) {
1415        self.heard = now;
1416        let label = if p.app.trim().is_empty() { p.package.clone() } else { one_line(&p.app) };
1417        let app = self.apps.entry(p.package.clone()).or_default();
1418        app.label = label.clone();
1419        app.seen = now;
1420        app.why = decision.why.id().to_string();
1421        app.until = decision.until.unwrap_or(0);
1422        // A channel that sounded while held: the guide to make it silent. Made silent: no more.
1423        if !p.channel.is_empty() {
1424            if decision.until.is_some() && p.alerted && !p.intercepted {
1425                app.rang.insert(p.channel.clone(), Rang { name: one_line(&p.channel_name), at: now });
1426            } else if (0..3).contains(&p.importance) {
1427                app.rang.remove(&p.channel);
1428            }
1429        }
1430        match kind {
1431            Kind::People(talk) => {
1432                if let Some(c) = &talk.conversation {
1433                    self.conversations.insert(c.key.clone(), SeenTalk { app: p.package.clone(), label: label.clone(), title: c.title.clone(), group: c.group, seen: now });
1434                }
1435            }
1436            Kind::Automaton { site } if !site.is_empty() => {
1437                self.sites.insert(site.clone(), SeenSite { app: p.package.clone(), seen: now });
1438            }
1439            _ => {}
1440        }
1441        let hash = key_hash(&p.key);
1442        let Some(until) = decision.until else {
1443            self.held.remove(&hash);
1444            return;
1445        };
1446        let (kind_id, via, conversation, site) = match kind {
1447            Kind::People(talk) => ("people", talk.via.id(), talk.conversation.as_ref().map(|c| c.key.clone()).unwrap_or_default(), String::new()),
1448            Kind::Automaton { site } => ("gathered", "", String::new(), site.clone()),
1449            Kind::AtOnce => ("at-once", "", String::new(), String::new()),
1450            _ => ("gathered", "", String::new(), String::new()),
1451        };
1452        let kind_id = match decision.why {
1453            Why::Never => "never",
1454            Why::Gathered => "gathered",
1455            _ => kind_id,
1456        };
1457        // A group is judged as the conversation, on its own row, whoever wrote in it (`decide`);
1458        // the one who wrote kept, for the blocked.
1459        let group = matches!(kind, Kind::People(t) if t.conversation.as_ref().is_some_and(|c| c.group));
1460        let admitted = admitted && !group;
1461        let again = self.held.get(&hash).map_or(0, |h| h.again);
1462        let first = self.held.get(&hash).map_or(0, Held::first_held);
1463        self.held.insert(hash, Held { app: p.package.clone(), label, kind: kind_id.into(), via: via.to_string(), who: who.map(|w| w.id().to_string()).unwrap_or_default(), admitted, group, conversation, site, area, since: now, until, first, again, persons: Vec::new() });
1464    }
1465
1466    /// A held notification Android brought back and that comes now: no
1467    /// longer held, and said on the Porch with what came back with it.
1468    pub fn came_back(&mut self, hash: &str, now: i64) {
1469        let Some(held) = self.held.remove(hash) else { return };
1470        match self.back.last_mut() {
1471            Some(back) if now - back.at < 120 => {
1472                if !back.apps.contains(&held.label) {
1473                    back.apps.push(held.label);
1474                }
1475            }
1476            _ => self.back.push(Back { at: now, apps: vec![held.label] }),
1477        }
1478    }
1479
1480    /// Whether this notification is one Sioul held that Android brings back now (or a little before its time).
1481    pub fn returning(&self, hash: &str, now: i64) -> bool {
1482        self.held.get(hash).is_some_and(|h| now >= h.until - 120)
1483    }
1484
1485    /// The old forgotten: held marks long past their time (the app took the
1486    /// notification away meanwhile), apps, sites and conversations not seen
1487    /// for weeks (unless chosen for), what came back yesterday.
1488    pub fn forget_old(&mut self, now: i64, choices: &Choices) {
1489        self.held.retain(|_, h| h.until + 86_400 > now);
1490        self.apps.retain(|package, a| now - a.seen < SEEN_KEPT || choices.app.contains_key(package));
1491        self.sites.retain(|host, s| now - s.seen < SEEN_KEPT || choices.site.contains_key(host));
1492        self.conversations.retain(|key, c| now - c.seen < TALK_KEPT || choices.conversation.contains_key(key));
1493        if self.conversations.len() > TALKS_MAX {
1494            let mut by_age: Vec<(i64, String)> = self.conversations.iter().filter(|(k, _)| !choices.conversation.contains_key(*k)).map(|(k, c)| (c.seen, k.clone())).collect();
1495            by_age.sort();
1496            for (_, key) in by_age.into_iter().take(self.conversations.len() - TALKS_MAX) {
1497                self.conversations.remove(&key);
1498            }
1499        }
1500        for app in self.apps.values_mut() {
1501            app.rang.retain(|_, r| now - r.at < TALK_KEPT);
1502        }
1503        self.back.retain(|b| now - b.at < 86_400);
1504    }
1505}
1506
1507/// A held notification's time worked out again, as things stand now (a
1508/// pause ended, the hours or the choices changed): its new time, none when it
1509/// may come now. `who`, `admitted` and the rest as kept when it was held.
1510pub fn again(held: &Held, choices: &Choices, ask: &Ask) -> Decision {
1511    if !choices.hold {
1512        return Decision::through(Why::Off);
1513    }
1514    let app = choices.app(&held.app);
1515    if app.kind == AppKind::AtOnce || (!held.site.is_empty() && choices.site.get(&held.site).is_some_and(|s| s.at_once)) {
1516        return at_once(ask);
1517    }
1518    let who = Who::read(&held.who);
1519    // The blocked never, whatever their conversation says.
1520    if who == Some(Who::Blocked) {
1521        return Decision::held(held.until.max(ask.stamp() + 60), Why::Never);
1522    }
1523    let talk = Talk { via: Channel::read(&held.via).unwrap_or(Channel::Messages), sender: Sender::Unknown, conversation: None, to: String::new(), sms: false };
1524    if !held.conversation.is_empty() {
1525        match choices.through(&held.conversation) {
1526            Through::Always => return always(&talk, who.unwrap_or(Who::Stranger), held.group, ask),
1527            Through::Gathered => return conversation_gathered(ask),
1528            Through::Never => return Decision::held(held.until.max(ask.stamp() + 60), Why::Never),
1529            Through::Usual => {}
1530        }
1531    }
1532    match held.kind.as_str() {
1533        _ if app.kind == AppKind::Automaton => gathered(ask),
1534        "people" | "never" => person(&talk, who.unwrap_or(Who::Stranger), held.group, held.admitted, ask),
1535        _ => gathered(ask),
1536    }
1537}
1538
1539// ---------------------------------------------------------------- words
1540
1541/// "Discord", "Discord and Le Monde", "Discord, Le Monde and WhatsApp",
1542/// "Discord, Le Monde, WhatsApp and two other apps": numbers in words,
1543/// "many other apps" past twelve.
1544pub fn apps_in_words(tr: &Translator, labels: &[String]) -> String {
1545    let and = tr.text("word-and", None);
1546    match labels {
1547        [] => String::new(),
1548        [one] => one.clone(),
1549        [a, b] => format!("{a} {and} {b}"),
1550        [a, b, c] => format!("{a}, {b} {and} {c}"),
1551        [a, b, c, rest @ ..] => {
1552            let others = if rest.len() > 12 { tr.text("appnotes-others-many", None) } else { tr.text("appnotes-others", Some(&tr.counted(rest.len()))) };
1553            format!("{a}, {b}, {c} {and} {others}")
1554        }
1555    }
1556}
1557
1558/// The Porch's line on other apps (docs/porch.md): what waits and until
1559/// when, what came back lately. Names and words, never a count past twelve.
1560pub fn porch_line(tr: &Translator, ledger: &Ledger, now: &Zoned) -> String {
1561    let stamp = now.timestamp().as_second();
1562    let mut sentences = Vec::new();
1563    let mut waiting: Vec<&Held> = ledger.held.values().filter(|h| h.kind != "never" && h.until > stamp).collect();
1564    waiting.sort_by_key(|h| (h.until, h.label.clone()));
1565    if let Some(first) = waiting.first() {
1566        let mut labels: Vec<String> = Vec::new();
1567        for h in &waiting {
1568            if !labels.contains(&h.label) {
1569                labels.push(h.label.clone());
1570            }
1571        }
1572        let mut args = crate::i18n::args();
1573        args.set("apps", apps_in_words(tr, &labels));
1574        args.set("when", when_text(tr, first.until, now));
1575        let one_time = waiting.iter().all(|h| h.until == first.until);
1576        sentences.push(tr.text(if one_time { "appnotes-porch-held" } else { "appnotes-porch-held-first" }, Some(&args)));
1577    }
1578    if let Some(back) = ledger.back.iter().rev().find(|b| stamp - b.at < BACK_SAID && !b.apps.is_empty()) {
1579        let mut args = crate::i18n::args();
1580        args.set("apps", apps_in_words(tr, &back.apps));
1581        args.set("when", when_text(tr, back.at, now));
1582        sentences.push(tr.text("appnotes-porch-back", Some(&args)));
1583    }
1584    sentences.join(" ")
1585}
1586
1587/// "13:00", "tomorrow at 09:00", "Monday 12 October at 09:00".
1588fn when_text(tr: &Translator, at: i64, now: &Zoned) -> String {
1589    match jiff::Timestamp::from_second(at) {
1590        Ok(t) => crate::quiet::until_text(tr, &t.to_zoned(now.time_zone().clone()), now),
1591        Err(_) => String::new(),
1592    }
1593}
1594
1595/// Why an app's last notification came or waits, in a sentence (the setup page).
1596pub fn why_text(tr: &Translator, why: &str, until: i64, now: &Zoned) -> String {
1597    let mut args = crate::i18n::args();
1598    args.set("when", if until > 0 { when_text(tr, until, now) } else { String::new() });
1599    let held = until > now.timestamp().as_second();
1600    let id = match (why, held) {
1601        ("waiting", true) | ("gathered", true) | ("machine", true) | ("at-once", true) | ("never", true) | ("times", true) => format!("appnotes-why-{why}-held"),
1602        _ => format!("appnotes-why-{why}"),
1603    };
1604    tr.text(&id, Some(&args))
1605}
1606
1607#[cfg(test)]
1608mod tests {
1609    use super::*;
1610
1611    /// What a notification is, with the packs built in (French and English).
1612    fn classify(p: &Posted, choices: &Choices) -> Kind {
1613        super::classify(p, choices, crate::words::Words::builtin_ref())
1614    }
1615
1616    fn is_code(p: &Posted) -> bool {
1617        super::is_code(p, crate::words::Words::builtin_ref())
1618    }
1619    use crate::attention::{Column, Person, Row};
1620    use crate::needs::{Days, Needs};
1621    use crate::quiet::{Blocks, Overrides};
1622    use crate::window::AdminWindow;
1623
1624    fn at(text: &str) -> Zoned {
1625        text.parse().unwrap()
1626    }
1627
1628    /// Working hours Monday to Friday 09:00–17:00, Health's meals, naps and
1629    /// night (bed at 23:00, winding down from 22:00, waking at 07:00), the
1630    /// overrides given (a pause, free time).
1631    struct TestClock {
1632        overrides: Overrides,
1633    }
1634
1635    impl TestClock {
1636        fn new() -> TestClock {
1637            TestClock { overrides: Overrides::default() }
1638        }
1639    }
1640
1641    fn week() -> Vec<AdminWindow> {
1642        ["monday", "tuesday", "wednesday", "thursday", "friday"].iter().map(|d| AdminWindow { day: d.to_string(), start: "09:00".into(), end: Some("17:00".into()), minutes: 0, kind: None }).collect()
1643    }
1644
1645    impl TimeSource for TestClock {
1646        fn mode(&self, at: &Zoned) -> Mode {
1647            let needs = Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() };
1648            crate::quiet::mode(&week(), &[], &self.overrides, &Blocks::of(&needs, &Days::default(), &[], at), at)
1649        }
1650
1651        fn starts(&self, from: &Zoned, until: &Zoned) -> Vec<Zoned> {
1652            // Every five minutes, kept where what time it is changes.
1653            let mut out = Vec::new();
1654            let mut last = self.mode(from);
1655            let mut at = from.clone();
1656            while at.timestamp() < until.timestamp() {
1657                at = at.checked_add(jiff::Span::new().minutes(5)).unwrap();
1658                let mode = self.mode(&at);
1659                if (mode.time, mode.reason.clone()) != (last.time, last.reason.clone()) {
1660                    out.push(at.clone());
1661                    last = mode;
1662                }
1663            }
1664            out
1665        }
1666    }
1667
1668    fn never_slot(_: &Zoned) -> bool {
1669        false
1670    }
1671
1672    /// The matrix of what reaches you as usual, but the neutral in leisure on
1673    /// mail and messages too (as these tests' people come then).
1674    fn usual() -> &'static Attention {
1675        static USUAL: std::sync::OnceLock<Attention> = std::sync::OnceLock::new();
1676        USUAL.get_or_init(|| {
1677            let mut matrix = Attention::usual();
1678            for channel in [Channel::Mail, Channel::Messages] {
1679                matrix.set(Row::People(channel, Person::Neutral), Column::Leisure, Level::Now).unwrap();
1680            }
1681            matrix
1682        })
1683    }
1684
1685    fn ask<'a>(now: &'a Zoned, clock: &'a TestClock, area: Option<Area>, gate: Option<Gate>) -> Ask<'a> {
1686        static GATHERED: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
1687        let gathered = GATHERED.get_or_init(|| vec!["09:00".into(), "13:00".into(), "18:00".into()]);
1688        Ask { reachable: None, silenced: false, now, clock, attention: usual(), gathered, area, gate, slot_at: &never_slot, nothing: false, also: &[], sources: &[] }
1689    }
1690
1691    fn posted(json: &str) -> Posted {
1692        serde_json::from_str(json).unwrap()
1693    }
1694
1695    fn until_of(d: Decision) -> String {
1696        d.until.map(|u| jiff::Timestamp::from_second(u).unwrap().to_zoned(jiff::tz::TimeZone::get("Europe/Paris").unwrap()).datetime().to_string()).unwrap_or_default()
1697    }
1698
1699    // Fixtures, as AppNotes.java hands them (invented people and numbers).
1700    const SMS_NUMBER: &str = r#"{"key":"0|com.google.android.apps.messaging|1|sms|10001","package":"com.google.android.apps.messaging","app":"Messages","category":"msg","template":"android.app.Notification$MessagingStyle","title":"+262 6 39 98 01 01","text":"Tu es où ?","messages":[{"sender":{"name":"+262 6 39 98 01 01","key":"+262639980101"},"text":"Tu es où ?"}],"sms_app":true,"shortcut":"conv-41"}"#;
1701    const SMS_CONTACT: &str = r#"{"key":"0|com.google.android.apps.messaging|2|sms|10001","package":"com.google.android.apps.messaging","app":"Messages","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Maman","messages":[{"sender":{"name":"Maman","uri":"tel:%2B262639980102"},"text":"Rappelle-moi"}],"sms_app":true}"#;
1702    const SMS_SENDER_ID: &str = r#"{"key":"0|foundation.e.message|3|null|10002","package":"foundation.e.message","app":"Message","title":"AMELI","text":"Votre attestation est disponible dans votre compte.","messages":[{"sender":{"name":"AMELI"},"text":"Votre attestation est disponible dans votre compte."}],"sms_app":true}"#;
1703    const SMS_SHORT: &str = r#"{"key":"0|foundation.e.message|4|null|10002","package":"foundation.e.message","app":"Message","title":"38015","text":"Votre colis arrive demain entre 9h et 13h.","sms_app":true}"#;
1704    const SMS_CODE: &str = r#"{"key":"0|foundation.e.message|5|null|10002","package":"foundation.e.message","app":"Message","title":"BANQUE","text":"Votre code de vérification : 482913. Ne le communiquez à personne.","sms_app":true}"#;
1705    const WHATSAPP_PERSON: &str = r#"{"key":"0|com.whatsapp|1|262639980103@s.whatsapp.net|10100","package":"com.whatsapp","app":"WhatsApp","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Jeanne Martin","shortcut":"262639980103@s.whatsapp.net","is_conversation":true,"messages":[{"sender":{"name":"Jeanne Martin","key":"1","uri":"content://com.android.contacts/contacts/lookup/0r1-ABC/1","numbers":["+262 6 39 98 01 03"]},"text":"On se voit demain ?"}]}"#;
1706    const WHATSAPP_GROUP: &str = r#"{"key":"0|com.whatsapp|1|120363000000000001@g.us|10100","package":"com.whatsapp","app":"WhatsApp","category":"msg","template":"android.app.Notification$MessagingStyle","title":"École Jules Ferry","conversation":"École Jules Ferry","group_conversation":true,"shortcut":"120363000000000001@g.us","is_conversation":true,"messages":[{"sender":{"name":"Mme Durand"},"text":"Sortie annulée demain, les enfants restent à l'école."}]}"#;
1707    const DISCORD_SERVER: &str = r##"{"key":"0|com.discord|7|null|10200","package":"com.discord","app":"Discord","channel":"messages","channel_name":"Messages","category":"msg","group":"GROUP_MESSAGE_CREATE","template":"android.app.Notification$MessagingStyle","title":"Rust FR #général","conversation":"Rust FR #général","group_conversation":true,"shortcut":"110022003344556677","is_conversation":true,"messages":[{"sender":{"name":"ferris","key":"ferris"},"text":"Nouvelle version ce soir"}]}"##;
1708    const DISCORD_DM: &str = r#"{"key":"0|com.discord|8|null|10200","package":"com.discord","app":"Discord","channel":"dm","category":"msg","template":"android.app.Notification$MessagingStyle","title":"alice","shortcut":"110022003344556688","is_conversation":true,"messages":[{"sender":{"name":"alice","key":"alice"},"text":"tu as vu ?"}]}"#;
1709    const CHROME_SITE: &str = r#"{"key":"0|com.android.chrome|-1|p#https://forum.example.net#2|10300","package":"com.android.chrome","app":"Chrome","tag":"p#https://forum.example.net#2","channel":"web:https://forum.example.net;1759000000000","group":"Web:forum.example.net","title":"Nouvelle réponse","text":"Quelqu'un a répondu à votre sujet","sub":"forum.example.net"}"#;
1710    const FIREFOX_SITE: &str = r#"{"key":"0|org.mozilla.fennec_fdroid|5|a1b2|10301","package":"org.mozilla.fennec_fdroid","app":"Fennec","channel":"mozac.feature.webnotifications.generic.channel","title":"New post","text":"Your feed has news","sub":"news.example.com"}"#;
1711    const PROTON: &str = r#"{"key":"0|ch.protonmail.android|42|null|10400","package":"ch.protonmail.android","app":"Proton Mail","channel":"v7_email_channel_id","channel_name":"Emails","title":"Alice Martin","text":"Dîner vendredi","sub":"me@proton.example"}"#;
1712    const PROTON_LOGIN: &str = r#"{"key":"0|ch.protonmail.android|43|null|10400","package":"ch.protonmail.android","app":"Proton Mail","channel":"v7_login_channel_id","channel_name":"Login","title":"New sign-in to your account","text":"A new device signed in."}"#;
1713    const SHOP: &str = r#"{"key":"0|com.shop.example|1|null|10500","package":"com.shop.example","app":"Shop","category":"promo","channel":"offers","title":"-30 % sur les chaussures","text":"Jusqu'à dimanche seulement","alerted":true}"#;
1714    const BANK_CODE: &str = r#"{"key":"0|com.bank.example|1|null|10600","package":"com.bank.example","app":"Bank","title":"Your security code","text":"Your security code is 482913. It expires in 5 minutes."}"#;
1715    const BANK_APPROVAL: &str = r#"{"key":"0|com.bank.example|2|null|10600","package":"com.bank.example","app":"Banque","title":"Paiement en ligne","text":"Validez votre paiement de 42,00 € dans l'application."}"#;
1716    const REDACTED: &str = r#"{"key":"0|com.otp.example|1|null|10700","package":"com.otp.example","app":"Authenticator","title":"Authenticator","text":"Sensitive notification content hidden","redacted":true}"#;
1717
1718    #[test]
1719    fn what_a_notification_is() {
1720        let choices = Choices::default();
1721        let talk = |json: &str| match classify(&posted(json), &choices) {
1722            Kind::People(t) => t,
1723            other => panic!("{other:?}"),
1724        };
1725        // SMS: a number is someone, a contact's tel: URI too; a sender id or a short code is an automaton.
1726        let sms = talk(SMS_NUMBER);
1727        assert_eq!((sms.via, sms.sms), (Channel::Messages, true));
1728        assert_eq!(sms.sender, Sender::Known { numbers: vec!["+262 6 39 98 01 01".into()], emails: vec![], contact: false });
1729        assert_eq!(talk(SMS_CONTACT).sender, Sender::Known { numbers: vec!["+262639980102".into()], emails: vec![], contact: false });
1730        assert_eq!(talk(SMS_SENDER_ID).sender, Sender::Named { name: "AMELI".into(), book: true });
1731        assert_eq!(classify(&posted(SMS_SHORT), &choices), Kind::Automaton { site: String::new() });
1732        assert_eq!(classify(&posted(SMS_CODE), &choices), Kind::Code);
1733        // WhatsApp: a person by the phone's address book; a group by its conversation.
1734        let jeanne = talk(WHATSAPP_PERSON);
1735        assert_eq!(jeanne.sender, Sender::Known { numbers: vec!["+262 6 39 98 01 03".into()], emails: vec![], contact: true }, "the phone's contact");
1736        let school = talk(WHATSAPP_GROUP);
1737        let conversation = school.conversation.clone().unwrap();
1738        assert_eq!((conversation.title.as_str(), conversation.group), ("École Jules Ferry", true));
1739        assert_eq!(conversation.key, talk_key("com.whatsapp", "120363000000000001@g.us"));
1740        assert_eq!(school.sender, Sender::Named { name: "Mme Durand".into(), book: true });
1741        // A name alone, found in the phone's address book: WhatsApp's counts, Discord's does not.
1742        let by_name = r#"{"key":"k","package":"com.whatsapp","app":"WhatsApp","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Paul","messages":[{"sender":{"name":"Paul","by_name":["+262 6 39 98 01 04"]},"text":"Salut"}]}"#;
1743        assert_eq!(talk(by_name).sender, Sender::Known { numbers: vec!["+262 6 39 98 01 04".into()], emails: vec![], contact: true });
1744        let discord_by_name = by_name.replace("com.whatsapp", "com.discord");
1745        assert_eq!(talk(discord_by_name.as_str()).sender, Sender::Named { name: "Paul".into(), book: false });
1746        // A bot says it is one: an automaton. A six-digit short code is no phone number.
1747        let bot = r#"{"key":"b","package":"com.bank.example","app":"Bank","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Assistant","messages":[{"sender":{"name":"Assistant","bot":true},"text":"Votre relevé est prêt."}]}"#;
1748        assert_eq!(classify(&posted(bot), &choices), Kind::Automaton { site: String::new() });
1749        assert_eq!(classify(&posted(r#"{"key":"s6","package":"foundation.e.message","title":"123456","text":"Votre colis est arrivé.","sms_app":true}"#), &choices), Kind::Automaton { site: String::new() });
1750        assert_eq!(number_in("06 39 98 01"), Some("06 39 98 01".into()), "eight digits");
1751        // The person's own replies, named by the app: neither who wrote nor a second writer.
1752        let replied = talk(r#"{"key":"r","package":"com.discord","category":"msg","user":{"name":"Me","key":"me"},"messages":[{"sender":{"name":"alice","key":"alice"},"text":"tu viens ?"},{"sender":{"name":"Me","key":"me"},"text":"oui"}]}"#);
1753        assert_eq!(replied.sender, Sender::Named { name: "alice".into(), book: false });
1754        assert!(!replied.conversation.unwrap().group);
1755        // Two people writing in a conversation the app does not call a group: a group all the same.
1756        let two = talk(r#"{"key":"t","package":"org.example.chat","category":"msg","conversation":"Voisins","messages":[{"sender":{"name":"Ana"},"text":"Bonjour"},{"sender":{"name":"Bob"},"text":"Salut"}]}"#);
1757        assert!(two.conversation.unwrap().group);
1758        // A missed call: the phone's names are the address book's.
1759        let missed = talk(r#"{"key":"m","package":"com.android.dialer","category":"missed_call","title":"Maman"}"#);
1760        assert_eq!((missed.via, missed.sender), (Channel::Calls, Sender::Named { name: "Maman".into(), book: true }));
1761        // An SMS app's plain notification: its title is the conversation.
1762        let plain = talk(r#"{"key":"k2","package":"com.android.mms","app":"SMS","title":"Paul","text":"Salut"}"#);
1763        assert_eq!(plain.conversation.map(|c| c.key), Some(talk_key("com.android.mms", "Paul")));
1764        // Discord: names anyone can take; a server's channel is a group, a direct message is not.
1765        let server = talk(DISCORD_SERVER);
1766        assert!(server.conversation.as_ref().unwrap().group);
1767        assert_eq!(server.sender, Sender::Named { name: "ferris".into(), book: false });
1768        let dm = talk(DISCORD_DM);
1769        assert_eq!((dm.conversation.as_ref().unwrap().group, dm.conversation.as_ref().unwrap().title.as_str()), (false, "alice"));
1770        // Browsers: the site, by Chromium's tag and channel, by Firefox's sub-text.
1771        assert_eq!(classify(&posted(CHROME_SITE), &choices), Kind::Automaton { site: "forum.example.net".into() });
1772        assert_eq!(classify(&posted(FIREFOX_SITE), &choices), Kind::Automaton { site: "news.example.com".into() });
1773        // Proton Mail: mail, a sender's name, the address it came to; its sign-ins never held.
1774        let proton = talk(PROTON);
1775        assert_eq!((proton.via, proton.to.as_str()), (Channel::Mail, "me@proton.example"));
1776        assert_eq!(proton.sender, Sender::Named { name: "Alice Martin".into(), book: false });
1777        assert_eq!(classify(&posted(PROTON_LOGIN), &choices), Kind::Code);
1778        // A shop's promotion: an automaton. A bank's code, its approval, a redacted copy: at once.
1779        assert_eq!(classify(&posted(SHOP), &choices), Kind::Automaton { site: String::new() });
1780        for json in [BANK_CODE, BANK_APPROVAL, REDACTED] {
1781            assert_eq!(classify(&posted(json), &choices), Kind::Code, "{json}");
1782        }
1783        let asked = |text: &str| is_code(&Posted { title: "Alice".into(), text: text.into(), ..Posted::default() });
1784        assert!(asked("Is this you signing in from Lyon?") && asked("Est-ce bien vous qui tentez une connexion ?"));
1785        assert!(!asked("Is this you on the photo? Haha") && !asked("C'est bien vous sur la photo ?"));
1786        // Your words: a German bank's approval, once its words are added.
1787        let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n[words.approvals.phrases]\nadd = [\"Zahlung freigeben\"]\n").unwrap();
1788        let german = Posted { title: "Bank".into(), text: "Bitte Zahlung freigeben in der App.".into(), ..Posted::default() };
1789        assert!(!is_code(&german) && super::is_code(&german, &crate::words::Words::of(&config)));
1790        // Never held: a call, an alarm, what runs, a summary, a reminder you set, Sioul's own.
1791        for json in [
1792            r#"{"package":"com.whatsapp","category":"call","title":"Jeanne"}"#,
1793            r#"{"package":"org.fossify.clock","category":"alarm","full_screen":true}"#,
1794            r#"{"package":"org.example.music","ongoing":true,"title":"A song"}"#,
1795            r#"{"package":"com.whatsapp","summary":true,"title":"3 messages"}"#,
1796            r#"{"package":"ws.xsoh.etar","category":"event","title":"Dentist at 14:00"}"#,
1797            r#"{"package":"com.aurelienpierre.sioul","title":"Taken"}"#,
1798        ] {
1799            assert_eq!(classify(&posted(json), &choices), Kind::Untouched, "{json}");
1800        }
1801        // The person's word for an app or a site comes first.
1802        let mut chosen = Choices::default();
1803        chosen.app.insert("com.discord".into(), AppChoice { kind: AppKind::Automaton, ..AppChoice::default() });
1804        chosen.app.insert("com.shop.example".into(), AppChoice { kind: AppKind::AtOnce, ..AppChoice::default() });
1805        chosen.site.insert("forum.example.net".into(), SiteChoice { at_once: true });
1806        assert_eq!(classify(&posted(DISCORD_DM), &chosen), Kind::Automaton { site: String::new() });
1807        assert_eq!(classify(&posted(SHOP), &chosen), Kind::AtOnce);
1808        assert_eq!(classify(&posted(CHROME_SITE), &chosen), Kind::AtOnce);
1809        // An app said to carry messages between people: its plain notifications too.
1810        chosen.app.insert("org.example.old.sms".into(), AppChoice { kind: AppKind::People, ..AppChoice::default() });
1811        let old = classify(&posted(r#"{"package":"org.example.old.sms","title":"+262 6 39 98 01 05","text":"Coucou"}"#), &chosen);
1812        assert!(matches!(old, Kind::People(Talk { sender: Sender::Known { .. }, .. })), "{old:?}");
1813    }
1814
1815    #[test]
1816    fn names_anyone_can_take_only_hold_more() {
1817        assert_eq!(named(Some(Who::Safe), true), Who::Safe, "WhatsApp names people as the address book does");
1818        assert_eq!(named(Some(Who::Safe), false), Who::Stranger, "a Discord name is anyone's");
1819        assert_eq!(named(Some(Who::Neutral), false), Who::Stranger);
1820        assert_eq!(named(Some(Who::Restricted), false), Who::Restricted);
1821        assert_eq!(named(Some(Who::Blocked), false), Who::Blocked);
1822        assert_eq!(named(None, true), Who::Stranger);
1823    }
1824
1825    #[test]
1826    fn who_and_when_decide() {
1827        let clock = TestClock::new();
1828        let choices = Choices::default();
1829        let kind = |json: &str| classify(&posted(json), &choices);
1830        // Tuesday 6 October 2026, 10:00: working hours.
1831        let morning = at("2026-10-06T10:00[Europe/Paris]");
1832        let evening = at("2026-10-06T20:30[Europe/Paris]");
1833        let night = at("2026-10-06T23:30[Europe/Paris]");
1834        let decide_at = |json: &str, who: Option<Who>, now: &Zoned| decide(&kind(json), who, false, &choices, &ask(now, &clock, None, None));
1835        // A stranger's SMS: now in work time; in the evening, held until work starts on Wednesday.
1836        assert_eq!(decide_at(SMS_NUMBER, Some(Who::Stranger), &morning), Decision::through(Why::Allowed));
1837        let held = decide_at(SMS_NUMBER, Some(Who::Stranger), &evening);
1838        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T09:00:00"));
1839        // Someone in the address book: in the evening yes, during the night no, until… leisure at waking.
1840        assert_eq!(decide_at(WHATSAPP_PERSON, Some(Who::Neutral), &evening), Decision::through(Why::Allowed));
1841        let held = decide_at(WHATSAPP_PERSON, Some(Who::Neutral), &night);
1842        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1843        // Safe people in the evening; in the night held until waking (docs/attention.md, Q4: before, let through);
1844        // on the Always through list, at once; the blocked held for good, on the list or not.
1845        assert_eq!(decide_at(SMS_CONTACT, Some(Who::Safe), &evening), Decision::through(Why::Allowed));
1846        let held = decide_at(SMS_CONTACT, Some(Who::Safe), &night);
1847        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1848        assert_eq!(decide(&kind(SMS_CONTACT), Some(Who::Safe), true, &choices, &ask(&night, &clock, None, None)), Decision::through(Why::Allowed));
1849        assert_eq!(decide_at(SMS_CONTACT, Some(Who::Blocked), &morning).why, Why::Never);
1850        assert_eq!(decide(&kind(SMS_CONTACT), Some(Who::Blocked), true, &choices, &ask(&morning, &clock, None, None)).why, Why::Never);
1851        // A business's SMS id nobody knows: gathered; the same name found in a card: a person.
1852        let ameli = decide_at(SMS_SENDER_ID, None, &morning);
1853        assert_eq!((ameli.why, until_of(ameli).as_str()), (Why::Gathered, "2026-10-06T13:00:00"));
1854        assert_eq!(decide_at(SMS_SENDER_ID, Some(Who::Neutral), &morning), Decision::through(Why::Allowed));
1855        // A group: its own row, whoever wrote in it; in the evening at once (decision 14 of 18 October
1856        // 2026: a group of friends on a Saturday), in the night held until waking.
1857        assert_eq!(decide_at(WHATSAPP_GROUP, Some(Who::Safe), &evening), Decision::through(Why::Allowed));
1858        let group = decide_at(WHATSAPP_GROUP, Some(Who::Safe), &night);
1859        assert_eq!((group.why, until_of(group).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1860        // The school's group let through always: in the evening and in the night.
1861        let mut chosen = Choices::default();
1862        let school = talk_key("com.whatsapp", "120363000000000001@g.us");
1863        chosen.conversation.insert(school.clone(), TalkChoice { through: Through::Always, title: "École Jules Ferry".into(), app: "WhatsApp".into() });
1864        for now in [&evening, &night] {
1865            assert_eq!(decide(&kind(WHATSAPP_GROUP), None, false, &chosen, &ask(now, &clock, None, None)), Decision::through(Why::Always));
1866        }
1867        // Blocked beats Always through (Q3): a blocked person writing there is held (before: let through).
1868        assert_eq!(decide(&kind(WHATSAPP_GROUP), Some(Who::Blocked), false, &chosen, &ask(&evening, &clock, None, None)).why, Why::Never);
1869        // A noisy group gathered, another never.
1870        chosen.conversation.insert(school.clone(), TalkChoice { through: Through::Gathered, ..TalkChoice::default() });
1871        let gathered = decide(&kind(WHATSAPP_GROUP), None, false, &chosen, &ask(&morning, &clock, None, None));
1872        assert_eq!((gathered.why, until_of(gathered).as_str()), (Why::Gathered, "2026-10-06T13:00:00"));
1873        chosen.conversation.insert(school, TalkChoice { through: Through::Never, ..TalkChoice::default() });
1874        assert_eq!(decide(&kind(WHATSAPP_GROUP), None, false, &chosen, &ask(&morning, &clock, None, None)).why, Why::Never);
1875        // Codes and untouched ones come whatever the time; holding off, everything comes.
1876        assert_eq!(decide_at(BANK_CODE, None, &night), Decision::through(Why::Code));
1877        let off = Choices { hold: false, ..Choices::default() };
1878        assert_eq!(decide(&kind(SHOP), None, false, &off, &ask(&night, &clock, None, None)), Decision::through(Why::Off));
1879        // An app at once comes as Sioul's own notifications: now in the evening, at waking in the night.
1880        let mut at_once = Choices::default();
1881        at_once.app.insert("com.shop.example".into(), AppChoice { kind: AppKind::AtOnce, ..AppChoice::default() });
1882        let shop = classify(&posted(SHOP), &at_once);
1883        assert_eq!(decide(&shop, None, false, &at_once, &ask(&evening, &clock, None, None)), Decision::through(Why::AtOnce));
1884        let waking = decide(&shop, None, false, &at_once, &ask(&night, &clock, None, None));
1885        assert_eq!((waking.why, until_of(waking).as_str()), (Why::AtOnce, "2026-10-07T07:00:00"));
1886    }
1887
1888    #[test]
1889    fn mail_and_areas() {
1890        let clock = TestClock::new();
1891        let choices = Choices::default();
1892        let proton = classify(&posted(PROTON), &choices);
1893        let evening = at("2026-10-06T20:30[Europe/Paris]");
1894        let morning = at("2026-10-06T10:00[Europe/Paris]");
1895        // A name only, in Proton Mail: someone unknown; to a work address, in the evening: tomorrow's work.
1896        let who = named(Some(Who::Safe), false);
1897        let held = decide(&proton, Some(who), false, &choices, &ask(&evening, &clock, Some(Area::WORK), None));
1898        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T09:00:00"));
1899        // Someone known writing to a leisure address in work time: held until the evening's leisure.
1900        let held = decide(&proton, Some(Who::Neutral), false, &choices, &ask(&morning, &clock, Some(Area::LEISURE), None));
1901        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-06T17:00:00"));
1902        // Safe people to any address, any time.
1903        assert_eq!(decide(&proton, Some(Who::Safe), false, &choices, &ask(&morning, &clock, Some(Area::LEISURE), None)).why, Why::Allowed);
1904        // An automaton for work: its gatherings in work time only.
1905        let shop = classify(&posted(SHOP), &choices);
1906        let held = decide(&shop, None, false, &choices, &ask(&evening, &clock, Some(Area::WORK), None));
1907        assert_eq!(until_of(held), "2026-10-07T09:00:00");
1908        // An app for leisure in work time: the 18:00 gathering, after work.
1909        let held = decide(&shop, None, false, &choices, &ask(&morning, &clock, Some(Area::LEISURE), None));
1910        assert_eq!(until_of(held), "2026-10-06T18:00:00");
1911    }
1912
1913    #[test]
1914    fn gathered_times_across_midnight_sleep_and_pauses() {
1915        let clock = TestClock::new();
1916        let choices = Choices::default();
1917        let shop = classify(&posted(SHOP), &choices);
1918        let when = |now: &str, clock: &TestClock| until_of(decide(&shop, None, false, &choices, &ask(&at(now), clock, None, None)));
1919        // n times a day: 09:00, 13:00, 18:00.
1920        assert_eq!(when("2026-10-06T08:00[Europe/Paris]", &clock), "2026-10-06T09:00:00");
1921        assert_eq!(when("2026-10-06T09:30[Europe/Paris]", &clock), "2026-10-06T13:00:00");
1922        assert_eq!(when("2026-10-06T13:20[Europe/Paris]", &clock), "2026-10-06T18:00:00");
1923        // After the last one, across midnight, to the first of the next day.
1924        assert_eq!(when("2026-10-06T18:30[Europe/Paris]", &clock), "2026-10-07T09:00:00");
1925        // Arriving within the minute of a gathering: it comes with it.
1926        assert_eq!(decide(&shop, None, false, &choices, &ask(&at("2026-10-06T13:00:30[Europe/Paris]"), &clock, None, None)), Decision::through(Why::Gathered));
1927        // A gathering in the night, or at lunch, is skipped: never while you sleep; meals are leisure, so 13:00 is free.
1928        let late = vec!["06:30".to_string(), "13:00".into(), "22:30".into()];
1929        let ask_late = |now: &'static str| {
1930            let now = Box::leak(Box::new(at(now)));
1931            Ask { reachable: None, silenced: false, now, clock: &clock, attention: usual(), gathered: &late, area: None, gate: None, slot_at: &never_slot, nothing: false, also: &[], sources: &[] }
1932        };
1933        assert_eq!(until_of(decide(&shop, None, false, &choices, &ask_late("2026-10-06T20:00[Europe/Paris]"))), "2026-10-07T13:00:00", "22:30 and 06:30 are asleep");
1934        // A pause: nothing gathered until it ends; asked again six hours on, brought back when it ends (`again`).
1935        let paused = TestClock { overrides: Overrides { paused_since: Some(at("2026-10-06T08:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1936        let now = at("2026-10-06T08:30[Europe/Paris]");
1937        let held = decide(&shop, None, false, &choices, &ask(&now, &paused, None, None));
1938        assert_eq!((held.why, until_of(held).as_str()), (Why::Gathered, "2026-10-06T14:30:00"));
1939        // Free time: automatons wait for it to end (the night's start ends it), then the next gathering.
1940        let free = TestClock { overrides: Overrides { free_since: Some(at("2026-10-06T15:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1941        let held = decide(&shop, None, false, &choices, &ask(&at("2026-10-06T16:00[Europe/Paris]"), &free, None, None));
1942        assert_eq!(until_of(held), "2026-10-07T09:00:00", "18:00 falls in free time, which lasts until the night");
1943        // Do-not-disturb's switch until 18:30: the 18:00 gathering skipped.
1944        let gate = Gate { until: Some(at("2026-10-06T18:30[Europe/Paris]").timestamp().as_second()) };
1945        let held = decide(&shop, None, false, &choices, &ask(&at("2026-10-06T14:00[Europe/Paris]"), &clock, None, Some(gate)));
1946        assert_eq!(until_of(held), "2026-10-07T09:00:00");
1947        // A slot of time for you at 13:00: that gathering skipped.
1948        let slot = |z: &Zoned| z.date() == at("2026-10-06T00:00[Europe/Paris]").date() && z.hour() == 13;
1949        let now = at("2026-10-06T10:00[Europe/Paris]");
1950        let usual = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
1951        let with_slot = Ask { reachable: None, silenced: false, now: &now, clock: &clock, attention: self::usual(), gathered: &usual, area: None, gate: None, slot_at: &slot, nothing: false, also: &[], sources: &[] };
1952        assert_eq!(until_of(decide(&shop, None, false, &choices, &with_slot)), "2026-10-06T18:00:00");
1953        // No time that reads: three a day.
1954        assert_eq!(times_of(&["later".into()]).len(), 3);
1955        assert_eq!(times_of(&["8h30".into(), "08:30".into(), " 17:00 ".into()]).iter().map(|t| t.to_string()).collect::<Vec<_>>(), ["08:30:00", "17:00:00"]);
1956    }
1957
1958    #[test]
1959    fn do_not_disturb_lets_its_list_through() {
1960        let clock = TestClock::new();
1961        let choices = Choices::default();
1962        let sms = classify(&posted(SMS_CONTACT), &choices);
1963        let morning = at("2026-10-06T10:00[Europe/Paris]");
1964        let gate = || Some(Gate { until: Some(at("2026-10-06T11:00[Europe/Paris]").timestamp().as_second()) });
1965        // Mum is Always through: she comes during focus; someone neutral waits for its end.
1966        assert_eq!(decide(&sms, Some(Who::Neutral), true, &choices, &ask(&morning, &clock, None, gate())).why, Why::Allowed);
1967        let held = decide(&sms, Some(Who::Neutral), false, &choices, &ask(&morning, &clock, None, gate()));
1968        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-06T11:00:00"));
1969        // A focus session without a known end: asked again in half an hour.
1970        let open = Some(Gate { until: None });
1971        let held = decide(&sms, Some(Who::Neutral), false, &choices, &ask(&morning, &clock, None, open));
1972        assert_eq!(until_of(held), "2026-10-06T10:30:00");
1973    }
1974
1975    /// What held a notification, as the listener's line says it (`cause`):
1976    /// do-not-disturb by itself at work, the night, a text named only
1977    /// gathered, an automaton, an app or a conversation set to gathered, the
1978    /// blocked and Never for good, what it is for, its own row, Free time's
1979    /// "Nothing at all", a slot; nothing for what comes whatever the time. And
1980    /// whatever holds, something says why: at every hour, with or without
1981    /// do-not-disturb and a slot, for every fixture and every sender.
1982    #[test]
1983    fn what_held_one_is_said() {
1984        use crate::attention::Column as C;
1985        let clock = TestClock::new();
1986        let choices = Choices::default();
1987        let gathered = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
1988        let morning = at("2026-10-06T10:00[Europe/Paris]");
1989        let evening = at("2026-10-06T20:30[Europe/Paris]");
1990        let night = at("2026-10-06T23:30[Europe/Paris]");
1991        // What held a fixture, as the app asks it: its package, its kind with these choices.
1992        let said = |json: &str, who: Option<Who>, choices: &Choices, ask: &Ask| {
1993            let p = posted(json);
1994            cause(&p.package, &classify(&p, choices), who, false, choices, ask)
1995        };
1996        let columns = |columns: &[C]| Cause { columns: columns.to_vec(), ..Cause::default() };
1997        // A contact's text at work during a focus session: do-not-disturb alone holds it, until the session's end.
1998        let focus = ask(&morning, &clock, None, Some(Gate { until: Some(at("2026-10-06T10:50[Europe/Paris]").timestamp().as_second()) }));
1999        assert_eq!(until_of(decide(&classify(&posted(SMS_CONTACT), &choices), Some(Who::Neutral), false, &choices, &focus)), "2026-10-06T10:50:00");
2000        assert_eq!(said(SMS_CONTACT, Some(Who::Neutral), &choices, &focus), Some(columns(&[C::Dnd])));
2001        // In the night: the night. A stranger's text in the evening under do-not-disturb: both.
2002        assert_eq!(said(WHATSAPP_PERSON, Some(Who::Neutral), &choices, &ask(&night, &clock, None, None)), Some(columns(&[C::Sleep])));
2003        assert_eq!(said(SMS_NUMBER, Some(Who::Stranger), &choices, &ask(&evening, &clock, None, Some(Gate { until: None }))), Some(columns(&[C::Leisure, C::Dnd])));
2004        // A text named only that no card has: gathered as a business's, in the evening too; in the night, the night holds the gathering.
2005        let named = |columns: &[C]| Some(Cause { gathered: Some(Gathering::Named), columns: columns.to_vec(), ..Cause::default() });
2006        assert_eq!(said(SMS_SENDER_ID, None, &choices, &ask(&morning, &clock, None, None)), named(&[]));
2007        assert_eq!(said(SMS_SENDER_ID, None, &choices, &ask(&evening, &clock, None, None)), named(&[]));
2008        assert_eq!(said(SMS_SENDER_ID, None, &choices, &ask(&night, &clock, None, None)), named(&[C::Sleep]));
2009        // An automaton; an app you set as one; a conversation you set to gathered.
2010        let gathering = |json: &str, choices: &Choices| said(json, None, choices, &ask(&morning, &clock, None, None)).and_then(|c| c.gathered);
2011        assert_eq!(gathering(SHOP, &choices), Some(Gathering::Automaton));
2012        let mut set = Choices::default();
2013        set.app.insert("com.discord".into(), AppChoice { kind: AppKind::Automaton, ..AppChoice::default() });
2014        assert_eq!(gathering(DISCORD_DM, &set), Some(Gathering::App));
2015        let school = talk_key("com.whatsapp", "120363000000000001@g.us");
2016        set.conversation.insert(school.clone(), TalkChoice { through: Through::Gathered, ..TalkChoice::default() });
2017        assert_eq!(gathering(WHATSAPP_GROUP, &set), Some(Gathering::Conversation));
2018        // The blocked and a conversation set to never: for good, and nothing more.
2019        let for_good = Some(Cause { for_good: true, ..Cause::default() });
2020        assert_eq!(said(SMS_CONTACT, Some(Who::Blocked), &choices, &ask(&morning, &clock, None, None)), for_good);
2021        set.conversation.insert(school, TalkChoice { through: Through::Never, ..TalkChoice::default() });
2022        assert_eq!(said(WHATSAPP_GROUP, Some(Who::Safe), &set, &ask(&morning, &clock, None, None)), for_good);
2023        // A mail to an address for leisure, in work time: what it is for, no column.
2024        assert_eq!(said(PROTON, Some(Who::Neutral), &choices, &ask(&morning, &clock, Some(Area::LEISURE), None)), Some(Cause { area: Some(Area::LEISURE), ..Cause::default() }));
2025        // Codes and what is never held: no cause at all.
2026        assert_eq!(said(BANK_CODE, None, &choices, &ask(&night, &clock, None, None)), None);
2027        // Its own row: Discord held in the evening by its app's row, a server's channel at work by its conversation's.
2028        let key = |json: &str| match classify(&posted(json), &choices) {
2029            Kind::People(t) => t.conversation.map(|c| c.key).unwrap_or_default(),
2030            _ => String::new(),
2031        };
2032        let dm_rows = attention::source_rows("com.discord", &key(DISCORD_DM));
2033        let server_rows = attention::source_rows("com.discord", &key(DISCORD_SERVER));
2034        let mut rows = usual().clone();
2035        rows.set_source("app.com.discord", C::Leisure, Level::Later).unwrap();
2036        rows.set_source(&server_rows[0], C::Work, Level::Later).unwrap();
2037        let own = Ask { reachable: None, silenced: false, now: &evening, clock: &clock, attention: &rows, gathered: &gathered, area: None, gate: None, slot_at: &never_slot, nothing: false, also: &[], sources: &dm_rows };
2038        assert_eq!(said(DISCORD_DM, Some(Who::Safe), &choices, &own), Some(Cause { own: Some(Own::App), columns: vec![C::Leisure], ..Cause::default() }));
2039        let own = Ask { now: &morning, sources: &server_rows, ..own };
2040        assert_eq!(said(DISCORD_SERVER, Some(Who::Stranger), &choices, &own), Some(Cause { own: Some(Own::Conversation), columns: vec![C::Work], ..Cause::default() }));
2041        // A person's own row, between the conversation's and the app's: said as theirs (review of part 6, F6).
2042        let person_rows = attention::source_rows_with("com.discord", &key(DISCORD_DM), &["person.contact:card-1".to_string()]);
2043        let mut theirs = usual().clone();
2044        theirs.set_source("person.contact:card-1", C::Leisure, Level::Later).unwrap();
2045        let own = Ask { now: &evening, attention: &theirs, sources: &person_rows, ..own };
2046        assert_eq!(said(DISCORD_DM, Some(Who::Safe), &choices, &own), Some(Cause { own: Some(Own::Person), columns: vec![C::Leisure], ..Cause::default() }));
2047        // Free time's "Nothing at all": your safe people wait, and it says so.
2048        let free = TestClock { overrides: Overrides { free_since: Some(at("2026-10-06T15:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
2049        let afternoon = at("2026-10-06T16:00[Europe/Paris]");
2050        let nothing = Ask { reachable: None, silenced: false, now: &afternoon, clock: &free, attention: usual(), gathered: &gathered, area: None, gate: None, slot_at: &never_slot, nothing: true, also: &[], sources: &[] };
2051        assert_eq!(said(SMS_CONTACT, Some(Who::Safe), &choices, &nothing), Some(Cause { columns: vec![C::Free], nothing: true, ..Cause::default() }));
2052        // A slot of time for you at nine: an automaton waits it out.
2053        let in_slot = |z: &Zoned| z.hour() == 9;
2054        let nine = at("2026-10-06T09:20[Europe/Paris]");
2055        let slotted = Ask { reachable: None, silenced: false, now: &nine, clock: &clock, attention: usual(), gathered: &gathered, area: None, gate: None, slot_at: &in_slot, nothing: false, also: &[], sources: &[] };
2056        assert_eq!(said(SHOP, None, &choices, &slotted), Some(Cause { gathered: Some(Gathering::Automaton), columns: vec![C::Slot], ..Cause::default() }));
2057        // Whatever holds, something says why: every fixture, hour, sender, area, layer; an app at once
2058        // for work, which the matrix lets come in the night, held there by what it is for.
2059        let mut at_once = Choices::default();
2060        at_once.app.insert("com.shop.example".into(), AppChoice { kind: AppKind::AtOnce, area: Some(Area::WORK), ..AppChoice::default() });
2061        let mut awake = usual().clone();
2062        awake.set(Row::Own(attention::Kind::AppAtOnce), C::Sleep, Level::Now).unwrap();
2063        let fixtures = [SMS_NUMBER, SMS_CONTACT, SMS_SENDER_ID, SMS_SHORT, WHATSAPP_PERSON, WHATSAPP_GROUP, DISCORD_SERVER, DISCORD_DM, CHROME_SITE, FIREFOX_SITE, PROTON, SHOP];
2064        // The test clock's times with no change looked for ahead: whether one waits is decided at now alone.
2065        struct AtNow<'a>(&'a TestClock);
2066        impl TimeSource for AtNow<'_> {
2067            fn mode(&self, at: &Zoned) -> Mode {
2068                self.0.mode(at)
2069            }
2070            fn starts(&self, _: &Zoned, _: &Zoned) -> Vec<Zoned> {
2071                Vec::new()
2072            }
2073        }
2074        let quick = AtNow(&clock);
2075        let mut held = 0;
2076        for (choices, matrix) in [(&Choices::default(), usual()), (&at_once, &awake)] {
2077            for fixture in fixtures {
2078                let p = posted(fixture);
2079                let kind = classify(&p, choices);
2080                for hour in ["03:00", "08:30", "09:20", "10:00", "12:30", "13:00", "17:30", "20:00", "22:30"] {
2081                    let now = at(&format!("2026-10-06T{hour}[Europe/Paris]"));
2082                    for (dnd, slot) in [(false, false), (true, false), (false, true)] {
2083                        let slot_at = move |z: &Zoned| slot && z.hour() == 9;
2084                        for area in [None, Some(Area::WORK), Some(Area::LEISURE)] {
2085                            for who in [None, Some(Who::Safe), Some(Who::Neutral), Some(Who::Restricted), Some(Who::Stranger), Some(Who::Blocked)] {
2086                                let ask = Ask { now: &now, clock: &quick, attention: matrix, gathered: &gathered, area, gate: dnd.then_some(Gate { until: None }), slot_at: &slot_at, nothing: false, also: &[], sources: &[], reachable: None, silenced: false };
2087                                if decide(&kind, who, false, choices, &ask).until.is_none() {
2088                                    continue;
2089                                }
2090                                held += 1;
2091                                let c = cause(&p.package, &kind, who, false, choices, &ask).expect("a held one has a cause");
2092                                assert!(c.for_good || c.gathered.is_some() || c.own.is_some() || c.area.is_some() || !c.columns.is_empty(), "{fixture} {hour} {who:?} {area:?} dnd {dnd} slot {slot}: {c:?}");
2093                            }
2094                        }
2095                    }
2096                }
2097            }
2098        }
2099        assert!(held > 1_000, "{held} holds tried");
2100    }
2101
2102    #[test]
2103    fn the_matrix_changes_when() {
2104        let clock = TestClock::new();
2105        let choices = Choices::default();
2106        let sms = classify(&posted(SMS_CONTACT), &choices);
2107        let shop = classify(&posted(SHOP), &choices);
2108        let gathered = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
2109        let decided = |kind: &Kind, who: Option<Who>, listed: bool, matrix: &Attention, now: &Zoned, clock: &TestClock, gate: Option<Gate>| {
2110            decide(kind, who, listed, &choices, &Ask { reachable: None, silenced: false, now, clock, attention: matrix, gathered: &gathered, area: None, gate, slot_at: &never_slot, nothing: false, also: &[], sources: &[] })
2111        };
2112        let mut matrix = usual().clone();
2113        // Always through under do-not-disturb: at once whatever their row (Q2), as usual; their row's times with ☆.
2114        let evening = at("2026-10-06T20:30[Europe/Paris]");
2115        let gate = || Some(Gate { until: Some(at("2026-10-06T21:00[Europe/Paris]").timestamp().as_second()) });
2116        assert_eq!(decided(&sms, Some(Who::Stranger), true, &matrix, &evening, &clock, gate()).why, Why::Allowed);
2117        matrix.set(Row::People(Channel::Messages, Person::Always), Column::Leisure, Level::As).unwrap();
2118        matrix.set(Row::People(Channel::Messages, Person::Always), Column::Dnd, Level::Through).unwrap();
2119        assert_eq!(decided(&sms, Some(Who::Stranger), true, &matrix, &evening, &clock, gate()).why, Why::Waiting, "a stranger's row in the evening");
2120        assert_eq!(decided(&sms, Some(Who::Neutral), true, &matrix, &evening, &clock, gate()).why, Why::Allowed, "the neutral come in leisure");
2121        assert_eq!(decided(&sms, Some(Who::Safe), false, &matrix, &evening, &clock, gate()).why, Why::Waiting, "not on the list: do-not-disturb holds them");
2122        // Messages from people in Free time: your safe people's come, as usual; held when you say so, until it ends
2123        // at the night, and the night holds them too (Q4: before, they came at 22:00): at waking.
2124        let free = TestClock { overrides: Overrides { free_since: Some(at("2026-10-06T15:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
2125        let afternoon = at("2026-10-06T16:00[Europe/Paris]");
2126        assert_eq!(decided(&sms, Some(Who::Safe), false, &matrix, &afternoon, &free, None).why, Why::Allowed, "as usual");
2127        matrix.set(Row::People(Channel::Messages, Person::Safe), Column::Free, Level::Later).unwrap();
2128        let held = decided(&sms, Some(Who::Safe), false, &matrix, &afternoon, &free, None);
2129        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
2130        // Automatons not gathered in working hours: Tuesday's 09:00 and 13:00 skipped, 18:00 kept.
2131        let morning = at("2026-10-06T08:00[Europe/Paris]");
2132        assert_eq!(until_of(decided(&shop, None, false, &matrix, &morning, &clock, None)), "2026-10-06T09:00:00", "as usual");
2133        matrix.set(Row::Own(crate::attention::Kind::AppAutomatons), Column::Work, Level::Later).unwrap();
2134        assert_eq!(until_of(decided(&shop, None, false, &matrix, &morning, &clock, None)), "2026-10-06T18:00:00");
2135        // Time for you everywhere (Q12): the listener reads the slots, which hold automatons as the window's did.
2136        let in_slot = |z: &Zoned| z.hour() == 9;
2137        let slotted = Ask { reachable: None, silenced: false, now: &morning, clock: &clock, attention: usual(), gathered: &gathered, area: None, gate: None, slot_at: &in_slot, nothing: false, also: &[], sources: &[] };
2138        assert_eq!(until_of(decide(&shop, None, false, &choices, &slotted)), "2026-10-06T13:00:00");
2139        // "Nothing at all" in Free time: the safe wait too, Always through comes.
2140        let nothing = Ask { reachable: None, silenced: false, now: &afternoon, clock: &free, attention: usual(), gathered: &gathered, area: None, gate: None, slot_at: &never_slot, nothing: true, also: &[], sources: &[] };
2141        assert_eq!(decide(&sms, Some(Who::Safe), false, &choices, &nothing).why, Why::Waiting);
2142        assert_eq!(decide(&sms, Some(Who::Safe), true, &choices, &nothing).why, Why::Allowed);
2143        // A mail app's notification takes the Mail rows (Q5): a safe sender's at night waits for waking (before: let through).
2144        let proton = classify(&posted(PROTON), &choices);
2145        let night = at("2026-10-06T23:30[Europe/Paris]");
2146        let held = decide(&proton, Some(Who::Safe), false, &choices, &ask(&night, &clock, None, None));
2147        assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
2148        // A missed call by the Calls row (Q23): a stranger's in work time, never at night.
2149        let missed = classify(&posted(r#"{"key":"m","package":"com.android.dialer","category":"missed_call","title":"+262 6 39 98 01 07"}"#), &choices);
2150        assert_eq!(decide(&missed, Some(Who::Stranger), false, &choices, &ask(&at("2026-10-06T10:00[Europe/Paris]"), &clock, None, None)).why, Why::Allowed);
2151        assert_eq!(decide(&missed, Some(Who::Stranger), false, &choices, &ask(&night, &clock, None, None)).why, Why::Waiting);
2152    }
2153
2154    #[test]
2155    fn what_was_held_and_came_back() {
2156        let clock = TestClock::new();
2157        let choices = Choices::default();
2158        let now = at("2026-10-06T10:00[Europe/Paris]");
2159        let stamp = now.timestamp().as_second();
2160        let mut ledger = Ledger::default();
2161        for json in [SHOP, DISCORD_SERVER, CHROME_SITE, WHATSAPP_GROUP] {
2162            let p = posted(json);
2163            let kind = classify(&p, &choices);
2164            let decision = decide(&kind, Some(Who::Stranger), false, &choices, &ask(&now, &clock, None, None));
2165            ledger.note(&p, &kind, Some(Who::Stranger), false, None, &decision, stamp);
2166        }
2167        // The shop's sounded while held: its channel to make silent.
2168        assert_eq!(ledger.apps["com.shop.example"].rang.keys().collect::<Vec<_>>(), ["offers"]);
2169        assert_eq!(ledger.conversations.len(), 2, "Discord's channel and the school's group");
2170        assert!(ledger.sites.contains_key("forum.example.net"));
2171        assert_eq!(ledger.held.len(), 2, "the shop and the site wait for 13:00; strangers write in work time");
2172        let tr = Translator::new("en");
2173        assert_eq!(porch_line(&tr, &ledger, &now), "Notifications held until 13:00: Chrome and Shop.");
2174        let fr = Translator::new("fr");
2175        assert_eq!(porch_line(&fr, &ledger, &now), "Notifications retenues jusqu’à 13:00\u{202f}: Chrome et Shop.");
2176        // Android brings them back at 13:00: they come, and the Porch says so for two hours.
2177        let one = at("2026-10-06T13:00[Europe/Paris]");
2178        let hash = key_hash(&posted(SHOP).key);
2179        assert!(ledger.returning(&hash, one.timestamp().as_second()) && !ledger.returning(&hash, stamp));
2180        ledger.came_back(&hash, one.timestamp().as_second());
2181        ledger.came_back(&key_hash(&posted(CHROME_SITE).key), one.timestamp().as_second() + 5);
2182        assert!(ledger.held.is_empty());
2183        assert_eq!(porch_line(&tr, &ledger, &one), "Back in your notifications at 13:00: Shop and Chrome.");
2184        assert_eq!(porch_line(&tr, &ledger, &at("2026-10-06T15:30[Europe/Paris]")), "");
2185        // Nothing of what the notifications said is kept: names of apps and conversations only.
2186        let text = toml::to_string(&ledger).unwrap();
2187        for said in ["chaussures", "Sortie", "Nouvelle version", "répondu", "+33", "Mme Durand", "ferris", "120363"] {
2188            assert!(!text.contains(said), "{said} kept: {text}");
2189        }
2190        assert!(text.contains("École Jules Ferry") && text.contains("Rust FR #général"), "{text}");
2191        // Kept a day, then forgotten; a conversation unseen for a week forgotten unless chosen for.
2192        let mut chosen = Choices::default();
2193        let school = talk_key("com.whatsapp", "120363000000000001@g.us");
2194        chosen.conversation.insert(school.clone(), TalkChoice { through: Through::Always, ..TalkChoice::default() });
2195        ledger.forget_old(stamp + 8 * 86_400, &chosen);
2196        assert!(ledger.back.is_empty());
2197        assert_eq!(ledger.conversations.keys().collect::<Vec<_>>(), [&school]);
2198    }
2199
2200    #[test]
2201    fn held_ones_worked_out_again() {
2202        let choices = Choices::default();
2203        let paused = TestClock { overrides: Overrides { paused_since: Some(at("2026-10-06T08:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
2204        let back = TestClock { overrides: Overrides { paused_since: Some(at("2026-10-06T08:00[Europe/Paris]").timestamp().as_second()), paused_ended: Some(at("2026-10-06T10:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
2205        let held = Held { app: "com.shop.example".into(), label: "Shop".into(), kind: "gathered".into(), since: 0, until: at("2026-10-06T11:30[Europe/Paris]").timestamp().as_second(), ..Held::default() };
2206        let now = at("2026-10-06T10:00:30[Europe/Paris]");
2207        // Still paused: after the pause, asked again.
2208        assert_eq!(again(&held, &choices, &ask(&now, &paused, None, None)).why, Why::Gathered);
2209        // The pause over at 10:00: the next gathering, 13:00.
2210        assert_eq!(until_of(again(&held, &choices, &ask(&now, &back, None, None))), "2026-10-06T13:00:00");
2211        // A message from a stranger held in the evening; its conversation now let through always: now.
2212        let talk = Held { kind: "people".into(), via: "messages".into(), who: "stranger".into(), conversation: "c1".into(), ..held.clone() };
2213        let mut chosen = Choices::default();
2214        chosen.conversation.insert("c1".into(), TalkChoice { through: Through::Always, ..TalkChoice::default() });
2215        let evening = at("2026-10-06T20:30[Europe/Paris]");
2216        assert_eq!(again(&talk, &chosen, &ask(&evening, &TestClock::new(), None, None)), Decision::through(Why::Always));
2217        assert_eq!(until_of(again(&talk, &choices, &ask(&evening, &TestClock::new(), None, None))), "2026-10-07T09:00:00");
2218        // A group held in the night, a safe person writing in it: still a group's row on review.
2219        let clock = TestClock::new();
2220        let night = at("2026-10-06T23:30[Europe/Paris]");
2221        let mut ledger = Ledger::default();
2222        let p = posted(WHATSAPP_GROUP);
2223        let kind = classify(&p, &choices);
2224        let decision = decide(&kind, Some(Who::Safe), true, &choices, &ask(&night, &clock, None, None));
2225        ledger.note(&p, &kind, Some(Who::Safe), true, None, &decision, night.timestamp().as_second());
2226        let group = ledger.held.values().next().unwrap().clone();
2227        assert_eq!((group.who.as_str(), group.admitted, group.group), ("safe", false, true));
2228        assert_eq!(until_of(again(&group, &choices, &ask(&night, &clock, None, None))), "2026-10-07T07:00:00");
2229        // Its conversation set to gathered, then to always: back now.
2230        let mut gathered_then_always = Choices::default();
2231        gathered_then_always.conversation.insert(group.conversation.clone(), TalkChoice { through: Through::Always, ..TalkChoice::default() });
2232        let gathered_held = Held { kind: "gathered".into(), ..group.clone() };
2233        assert_eq!(again(&gathered_held, &gathered_then_always, &ask(&evening, &clock, None, None)), Decision::through(Why::Always));
2234        // Holding switched off: everything back now.
2235        let off = Choices { hold: false, ..Choices::default() };
2236        assert_eq!(again(&held, &off, &ask(&now, &paused, None, None)), Decision::through(Why::Off));
2237    }
2238
2239    /// Other apps by time (docs/attention.md, §1.3): Discord let through
2240    /// during work and held otherwise, a conversation's row over its app's,
2241    /// gathered, under do-not-disturb, worked out again; nothing changes for
2242    /// an app without a row of its own; the blocked and codes as before; an
2243    /// automaton the matrix lets through at once comes at once.
2244    /// Part 3, Reachable in the listener (decision 7): the people who write
2245    /// come at once at every hour while it lasts, the blocked never, an app's
2246    /// automatic notifications at their gathered times as before; once it is
2247    /// over, as usual again.
2248    #[test]
2249    fn reachable_lets_people_through_and_leaves_automatons_gathered() {
2250        let clock = TestClock::new();
2251        let choices = Choices::default();
2252        let sms = classify(&posted(SMS_CONTACT), &choices);
2253        let shop = classify(&posted(SHOP), &choices);
2254        for hour in ["03:00", "10:00", "12:30", "20:00", "23:30"] {
2255            let now = at(&format!("2026-10-06T{hour}[Europe/Paris]"));
2256            let until = now.timestamp().as_second() + 3600;
2257            for gated in [false, true] {
2258                let gate = || gated.then_some(Gate { until: None });
2259                let usual = ask(&now, &clock, None, gate());
2260                let reach = Ask { reachable: Some(until), ..ask(&now, &clock, None, gate()) };
2261                for who in [Who::Safe, Who::Neutral, Who::Restricted, Who::Stranger] {
2262                    let d = decide(&sms, Some(who), false, &choices, &reach);
2263                    assert_eq!(d.until, None, "{who:?} at {hour}, gated {gated}: {d:?}");
2264                }
2265                assert_eq!(decide(&sms, Some(Who::Blocked), false, &choices, &reach).why, Why::Never, "{hour}");
2266                assert_eq!(until_of(decide(&shop, None, false, &choices, &reach)), until_of(decide(&shop, None, false, &choices, &usual)), "an automaton keeps its gathered time at {hour}");
2267            }
2268        }
2269        // At night, over: a contact's text waits again.
2270        let night = at("2026-10-06T23:30[Europe/Paris]");
2271        let over = Ask { reachable: Some(night.timestamp().as_second() - 60), ..ask(&night, &clock, None, None) };
2272        assert!(decide(&sms, Some(Who::Neutral), false, &choices, &over).until.is_some(), "at night, after Reachable, a contact's text waits");
2273    }
2274
2275    /// Decision 13 in the listener: a contact's text during a focus session
2276    /// (do-not-disturb's layer) comes when Android silences it (Sioul's mode
2277    /// or yours: its ranking said so), without a sound, and waits for the
2278    /// session's end otherwise; a stranger's waits either way; at a review,
2279    /// the phone silenced now, what waited comes.
2280    #[test]
2281    fn a_text_in_the_shade_comes_when_the_phone_silences_it() {
2282        let clock = TestClock::new();
2283        let choices = Choices::default();
2284        let sms = classify(&posted(SMS_CONTACT), &choices);
2285        let morning = at("2026-10-06T10:00[Europe/Paris]");
2286        let end = morning.timestamp().as_second() + 1800;
2287        let gate = || Some(Gate { until: Some(end) });
2288        let loud = ask(&morning, &clock, None, gate());
2289        let silenced = Ask { silenced: true, ..ask(&morning, &clock, None, gate()) };
2290        assert_eq!(decide(&sms, Some(Who::Neutral), false, &choices, &loud).until, Some(end), "not silenced: it waits for the session's end");
2291        assert_eq!(decide(&sms, Some(Who::Neutral), false, &choices, &silenced), Decision::through(Why::Allowed));
2292        assert_eq!(decide(&sms, Some(Who::Safe), false, &choices, &silenced), Decision::through(Why::Allowed));
2293        assert!(decide(&sms, Some(Who::Restricted), false, &choices, &silenced).until.is_some(), "restricted: held, as before");
2294        let held = Held { app: "com.google.android.apps.messaging".into(), label: "Messages".into(), kind: "people".into(), via: "messages".into(), who: "neutral".into(), admitted: false, group: false, conversation: String::new(), site: String::new(), area: None, since: end - 3600, until: end, first: 0, again: 0, persons: Vec::new() };
2295        assert_eq!(again(&held, &choices, &silenced).until, None, "a review, the phone silenced now: it comes");
2296        assert_eq!(again(&held, &choices, &loud).until, Some(end));
2297    }
2298
2299    /// Re-check of part 3, R-2: a release stamped two days ahead is passed
2300    /// over, said once, and never applied a day later, when the phone's
2301    /// clock has come within a day of it.
2302    #[test]
2303    fn a_release_passed_over_as_ahead_is_passed_over_for_good() {
2304        let now = 1_790_000_000;
2305        let stamps = vec![("odd".to_string(), (now + 2 * 86_400) * 1000)];
2306        let mut ledger = Ledger::default();
2307        assert_eq!(ledger.apply_releases(&stamps, now), (false, vec!["odd".to_string()]));
2308        let later = now + 86_400 + 60;
2309        assert_eq!(ledger.apply_releases(&stamps, later), (false, vec![]), "never applied later");
2310        assert_eq!(ledger.released_through, 0);
2311        // Its next release, of another stamp, as any.
2312        assert!(ledger.apply_releases(&[("odd".to_string(), later * 1000)], later).0);
2313    }
2314
2315    /// Review of part 3, L3: under Reachable, a conversation you set "with
2316    /// the automatons" comes at once, as everyone not blocked does; one set
2317    /// to never stays held; Reachable over, it waits for its gathered time.
2318    #[test]
2319    fn under_reachable_a_conversation_gathered_comes_at_once() {
2320        let clock = TestClock::new();
2321        let mut choices = Choices::default();
2322        let talk = classify(&posted(WHATSAPP_GROUP), &choices);
2323        let key = match &talk {
2324            Kind::People(t) => t.conversation.as_ref().unwrap().key.clone(),
2325            _ => unreachable!(),
2326        };
2327        choices.conversation.insert(key.clone(), TalkChoice { through: Through::Gathered, ..TalkChoice::default() });
2328        let evening = at("2026-10-06T20:00[Europe/Paris]");
2329        let until = evening.timestamp().as_second() + 3600;
2330        let reach = Ask { reachable: Some(until), ..ask(&evening, &clock, None, None) };
2331        assert_eq!(decide(&talk, Some(Who::Neutral), false, &choices, &reach), Decision::through(Why::Allowed));
2332        let held = Held { app: "com.whatsapp".into(), label: "WhatsApp".into(), kind: "people".into(), via: "messages".into(), who: "neutral".into(), admitted: false, group: true, conversation: key.clone(), site: String::new(), area: None, since: until - 7200, until: until + 7200, first: 0, again: 0, persons: Vec::new() };
2333        assert_eq!(again(&held, &choices, &reach).until, None, "looked at again under Reachable: at once");
2334        let usual = ask(&evening, &clock, None, None);
2335        assert!(decide(&talk, Some(Who::Neutral), false, &choices, &usual).until.is_some(), "as usual: its gathered time");
2336        choices.conversation.insert(key, TalkChoice { through: Through::Never, ..TalkChoice::default() });
2337        assert_eq!(decide(&talk, Some(Who::Neutral), false, &choices, &reach).why, Why::Never, "set to never: held");
2338    }
2339
2340    /// Part 3, the release (3.3 of the proposal): each stamp applied once;
2341    /// what was held at or before it comes back, not what is held after; a
2342    /// stamp ahead of the phone's clock read as the phone's now.
2343    #[test]
2344    fn a_release_lets_through_what_was_held_before_it_once() {
2345        let held = |since: i64| Held { app: "com.example".into(), label: "Example".into(), kind: "people".into(), via: String::new(), who: String::new(), admitted: false, group: false, conversation: String::new(), site: String::new(), area: None, since, until: since + 3600, first: 0, again: 0, persons: Vec::new() };
2346        let mut ledger = Ledger::default();
2347        assert!(!ledger.released_before(&held(1_000)), "no release yet");
2348        let one = |stamp: i64| vec![("desk".to_string(), stamp)];
2349        assert!(ledger.apply_releases(&one(2_000_000), 5_000).0);
2350        assert!(ledger.released_before(&held(1_000)) && ledger.released_before(&held(2_000)));
2351        assert!(!ledger.released_before(&held(2_001)), "held after the press: not released");
2352        assert!(!ledger.apply_releases(&one(2_000_000), 6_000).0, "the same stamp, applied once");
2353        assert!(!ledger.apply_releases(&one(1_000_000), 6_000).0, "an older stamp: passed over");
2354        // A computer's clock ahead of the phone's: what the phone holds after its own now stays held.
2355        assert!(ledger.apply_releases(&one(9_000_000), 3_000 + 4_000).0);
2356        assert_eq!(ledger.released_through, 7_000);
2357        assert!(!ledger.released_before(&held(7_001)));
2358        // Kept across a restart.
2359        let back: Ledger = toml::from_str(&toml::to_string(&ledger).unwrap()).unwrap();
2360        assert_eq!((back.released_by.get("desk").copied(), back.applied.get("desk").copied(), back.released_through), (Some(7_000_000), Some(9_000_000), 7_000), "its mark the phone's now, the stamp kept beside (C-1)");
2361        assert!(!back.clone().apply_releases(&one(9_000_000), 8_000).0, "the same stamp ahead, once");
2362        // A ledger of the build that kept one stamp for all: every device's stamps up to it applied already.
2363        let older: Ledger = toml::from_str("released = 9000000\nreleased_through = 7000\n").unwrap();
2364        let mut older = older;
2365        assert!(!older.apply_releases(&[("phone".to_string(), 8_000_000)], 7_500).0);
2366        assert!(older.apply_releases(&[("phone".to_string(), 9_500_000)], 9_600).0);
2367        // What a release lets through: what waited, every kind, held before it; never what is held for good;
2368        // an automaton held after it keeps its gathered time; what comes already, as it is.
2369        let before = held(6_000);
2370        assert_eq!(Decision::held(9_000, Why::Gathered).released(&before, &ledger), Decision::through(Why::Released));
2371        assert_eq!(Decision::held(9_000, Why::Waiting).released(&before, &ledger), Decision::through(Why::Released));
2372        assert_eq!(Decision::held(i64::MAX, Why::Never).released(&before, &ledger), Decision::held(i64::MAX, Why::Never), "the blocked, a conversation set to never");
2373        assert_eq!(Decision::held(9_000, Why::Gathered).released(&held(7_500), &ledger), Decision::held(9_000, Why::Gathered), "held after the release: its gathered time");
2374        assert_eq!(Decision::through(Why::Allowed).released(&before, &ledger), Decision::through(Why::Allowed));
2375        assert_eq!(Decision::held(9_000, Why::Gathered).released(&before, &Ledger::default()), Decision::held(9_000, Why::Gathered), "no release");
2376    }
2377
2378    #[test]
2379    fn an_app_s_own_row_decides_when() {
2380        fn with<'a>(now: &'a Zoned, clock: &'a TestClock, attention: &'a Attention, gathered: &'a [String], sources: &'a [String], gate: Option<Gate>) -> Ask<'a> {
2381            Ask { reachable: None, silenced: false, now, clock, attention, gathered, area: None, gate, slot_at: &never_slot, nothing: false, also: &[], sources }
2382        }
2383        let clock = TestClock::new();
2384        let choices = Choices::default();
2385        let gathered = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
2386        let (morning, evening) = (at("2026-10-06T10:00[Europe/Paris]"), at("2026-10-06T20:00[Europe/Paris]"));
2387        let dm = classify(&posted(DISCORD_DM), &choices);
2388        let server = classify(&posted(DISCORD_SERVER), &choices);
2389        let key = |k: &Kind| match k {
2390            Kind::People(t) => t.conversation.as_ref().map(|c| c.key.clone()).unwrap_or_default(),
2391            _ => String::new(),
2392        };
2393        let dm_rows = attention::source_rows("com.discord", &key(&dm));
2394        let server_rows = attention::source_rows("com.discord", &key(&server));
2395        assert_eq!(server_rows, vec![format!("conversation.{}", key(&server)), "app.com.discord".to_string()]);
2396        // Nothing set: every fixture at every hour as before, its rows read or not.
2397        for fixture in [SMS_NUMBER, SMS_SENDER_ID, WHATSAPP_PERSON, WHATSAPP_GROUP, DISCORD_SERVER, DISCORD_DM, CHROME_SITE, PROTON, SHOP, BANK_CODE] {
2398            let p = posted(fixture);
2399            let kind = classify(&p, &choices);
2400            let rows = attention::source_rows(&p.package, &key(&kind));
2401            for hour in ["03:00", "08:30", "10:00", "12:30", "17:30", "20:00", "22:30"] {
2402                let now = at(&format!("2026-10-06T{hour}[Europe/Paris]"));
2403                for who in [None, Some(Who::Safe), Some(Who::Stranger)] {
2404                    assert_eq!(decide(&kind, who, false, &choices, &with(&now, &clock, usual(), &gathered, &rows, None)), decide(&kind, who, false, &choices, &with(&now, &clock, usual(), &gathered, &[], None)), "{fixture} {hour} {who:?}");
2405                }
2406            }
2407        }
2408        // The owner's example: Discord through during work, held otherwise.
2409        let mut work_only = usual().clone();
2410        for column in Column::ALL {
2411            work_only.set_source("app.com.discord", column, if column == Column::Work { Level::Now } else { Level::Later }).unwrap();
2412        }
2413        assert_eq!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, None)), Decision::through(Why::Times));
2414        let held = decide(&dm, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &work_only, &gathered, &dm_rows, None));
2415        assert_eq!((held.why, until_of(held).as_str()), (Why::Times, "2026-10-07T09:00:00"));
2416        // A safe friend's message too, which comes in the evening as usual.
2417        assert_eq!(decide(&dm, Some(Who::Safe), false, &choices, &with(&evening, &clock, usual(), &gathered, &dm_rows, None)), Decision::through(Why::Allowed));
2418        assert_eq!(until_of(decide(&dm, Some(Who::Safe), false, &choices, &with(&evening, &clock, &work_only, &gathered, &dm_rows, None))), "2026-10-07T09:00:00");
2419        // The blocked never, a code always, whatever the row says.
2420        assert_eq!(decide(&dm, Some(Who::Blocked), false, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, None)).why, Why::Never);
2421        let code = classify(&posted(BANK_CODE), &choices);
2422        let mut code_held = usual().clone();
2423        code_held.set_source("app.com.bank.example", Column::Work, Level::Later).unwrap();
2424        let code_rows = attention::source_rows("com.bank.example", "");
2425        assert_eq!(decide(&code, None, false, &choices, &with(&morning, &clock, &code_held, &gathered, &code_rows, None)), Decision::through(Why::Code));
2426        // A conversation's choice wins over its app's: the server comes in the evening, the rest of Discord waits.
2427        let mut server_evening = work_only.clone();
2428        server_evening.set_source(&server_rows[0], Column::Leisure, Level::Now).unwrap();
2429        assert_eq!(decide(&server, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &server_evening, &gathered, &server_rows, None)), Decision::through(Why::Times));
2430        assert_eq!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &server_evening, &gathered, &dm_rows, None)).why, Why::Times);
2431        assert!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &server_evening, &gathered, &dm_rows, None)).until.is_some());
2432        // Gathered by day: the next gathered time.
2433        let mut by_day = usual().clone();
2434        for column in [Column::Work, Column::Admin, Column::Meals] {
2435            by_day.set_source("app.com.discord", column, Level::Gathered).unwrap();
2436        }
2437        let held = decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &by_day, &gathered, &dm_rows, None));
2438        assert_eq!((held.why, until_of(held).as_str()), (Why::Times, "2026-10-06T13:00:00"));
2439        // Under do-not-disturb's switch until 11:00: its cell holds (held here), as usual too; set at once, through.
2440        let gate = || Some(Gate { until: Some(at("2026-10-06T11:00[Europe/Paris]").timestamp().as_second()) });
2441        assert_eq!(until_of(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, gate()))), "2026-10-06T11:00:00");
2442        let mut through_dnd = work_only.clone();
2443        through_dnd.set_source("app.com.discord", Column::Dnd, Level::As).unwrap();
2444        assert_eq!(until_of(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &through_dnd, &gathered, &dm_rows, gate()))), "2026-10-06T11:00:00");
2445        through_dnd.set_source("app.com.discord", Column::Dnd, Level::Now).unwrap();
2446        assert_eq!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &through_dnd, &gathered, &dm_rows, gate())), Decision::through(Why::Times));
2447        // An automaton's app at once in the evening, as usual held until the next gathering.
2448        let shop = classify(&posted(SHOP), &choices);
2449        let shop_rows = attention::source_rows("com.shop.example", "");
2450        let mut shop_evening = usual().clone();
2451        shop_evening.set_source("app.com.shop.example", Column::Leisure, Level::Now).unwrap();
2452        assert_eq!(decide(&shop, None, false, &choices, &with(&evening, &clock, &shop_evening, &gathered, &shop_rows, None)), Decision::through(Why::Times));
2453        assert_eq!(until_of(decide(&shop, None, false, &choices, &with(&evening, &clock, usual(), &gathered, &shop_rows, None))), "2026-10-07T09:00:00");
2454        // The automatons' row at once at work (More reachable): at once, not at the next gathering.
2455        let reachable = crate::attention::Preset::Reachable.matrix();
2456        assert_eq!(decide(&shop, None, false, &choices, &with(&morning, &clock, &reachable, &gathered, &[], None)), Decision::through(Why::Gathered));
2457        // Worked out again: a message held in the evening comes once work begins, or now when its row says so.
2458        let kept = Held { app: "com.discord".into(), label: "Discord".into(), kind: "people".into(), via: "messages".into(), who: "stranger".into(), conversation: key(&dm), since: 0, until: 0, ..Held::default() };
2459        assert_eq!(again(&kept, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, None)), Decision::through(Why::Times));
2460        assert_eq!(until_of(again(&kept, &choices, &with(&evening, &clock, &work_only, &gathered, &dm_rows, None))), "2026-10-07T09:00:00");
2461        let mut dm_evening = work_only.clone();
2462        dm_evening.set_source(&dm_rows[0], Column::Leisure, Level::Now).unwrap();
2463        assert_eq!(again(&kept, &choices, &with(&evening, &clock, &dm_evening, &gathered, &dm_rows, None)), Decision::through(Why::Times));
2464    }
2465
2466    #[test]
2467    fn choices_and_ledger_read_and_written() {
2468        let dir = std::env::temp_dir().join(format!("sioul-appnotes-{}", std::process::id()));
2469        let _ = std::fs::remove_dir_all(&dir);
2470        let mut choices = Choices::default();
2471        assert!(choices.hold, "holding is on until switched off");
2472        choices.app.insert("com.discord".into(), AppChoice { kind: AppKind::Automaton, area: Some(Area::LEISURE), label: "Discord".into() });
2473        choices.conversation.insert("c1".into(), TalkChoice { through: Through::Always, title: "École".into(), app: "WhatsApp".into() });
2474        let path = dir.join("app-notes.toml");
2475        choices.save(&path).unwrap();
2476        assert_eq!(Choices::load(&path), choices);
2477        let text = std::fs::read_to_string(&path).unwrap();
2478        assert!(text.contains("kind = \"automaton\"") && text.contains("area = \"leisure\"") && text.contains("through = \"always\""), "{text}");
2479        assert_eq!(Choices::load(&dir.join("none.toml")), Choices::default());
2480        std::fs::write(&path, "hold = false\n").unwrap();
2481        assert!(!Choices::load(&path).hold);
2482        std::fs::write(&path, "hold = [broken").unwrap();
2483        assert_eq!(Choices::load(&path), Choices::default(), "a file that does not read is no choice at all");
2484        let _ = std::fs::remove_dir_all(&dir);
2485        assert_eq!(AppKind::read("at-once"), Some(AppKind::AtOnce));
2486        assert_eq!(Through::read("never"), Some(Through::Never));
2487    }
2488
2489    #[test]
2490    fn words_in_both_languages() {
2491        let now = at("2026-10-06T10:00[Europe/Paris]");
2492        let names = |n: usize| (1..=n).map(|i| format!("App{i}")).collect::<Vec<_>>();
2493        for (language, n, said) in [
2494            ("en", 1, "App1"),
2495            ("en", 2, "App1 and App2"),
2496            ("en", 3, "App1, App2 and App3"),
2497            ("en", 4, "App1, App2, App3 and one other app"),
2498            ("en", 6, "App1, App2, App3 and three other apps"),
2499            ("en", 15, "App1, App2, App3 and twelve other apps"),
2500            ("en", 16, "App1, App2, App3 and many other apps"),
2501            ("fr", 2, "App1 et App2"),
2502            ("fr", 4, "App1, App2, App3 et une autre application"),
2503            ("fr", 5, "App1, App2, App3 et deux autres applications"),
2504            ("fr", 20, "App1, App2, App3 et beaucoup d’autres applications"),
2505        ] {
2506            assert_eq!(apps_in_words(&Translator::new(language), &names(n)), said, "{language} {n}");
2507        }
2508        let tr = Translator::new("en");
2509        let at13 = at("2026-10-06T13:00[Europe/Paris]").timestamp().as_second();
2510        assert_eq!(why_text(&tr, "gathered", at13, &now), "From an automaton: held until 13:00, a gathered time.");
2511        assert_eq!(why_text(&tr, "allowed", 0, &now), "From someone who may reach you now: let through.");
2512        let fr = Translator::new("fr");
2513        assert_eq!(why_text(&fr, "gathered", at13, &now), "D’un automate\u{202f}: retenue jusqu’à 13:00, une heure de regroupement.");
2514        assert_eq!(why_text(&tr, "times", at13, &now), "As you chose for this time: held until 13:00.");
2515        // A text gathered because its number is one ARCEP keeps for machines: said so (review of part 2, F11).
2516        assert_eq!(why_text(&tr, "machine", at13, &now), "From a number kept for automated systems: held until 13:00, a gathered time.");
2517        assert_eq!(why_text(&tr, "released", 0, &now), "Held before you asked your phone to let what it held through: let through.");
2518        for why in ["untouched", "code", "off", "at-once", "allowed", "always", "waiting", "gathered", "machine", "never", "again", "times", "released"] {
2519            for tr in [&tr, &fr] {
2520                let said = why_text(tr, why, 0, &now);
2521                assert!(!said.starts_with("appnotes-"), "{why}: {said}");
2522                let held = why_text(tr, why, at13, &now);
2523                assert!(!held.starts_with("appnotes-"), "{why}: {held}");
2524            }
2525        }
2526    }
2527}