Skip to main content

sioul_core/
attention.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What reaches you, and when: one model for everything Sioul shows, tells,
5//! holds or silences (docs/attention.md). Sioul spends one thing four ways,
6//! your attention: what it shows (the Porch, the pages), what it tells (its
7//! own notifications), what it holds of the system's (other apps'
8//! notifications, calls), and what it silences (the system's do-not-disturb).
9//!
10//! **One matrix**: a row per who on each channel (mail, calls, messages from
11//! other apps: Always through, safe, neutral, restricted, strangers, hidden
12//! numbers for calls, groups for messages, the blocked fixed at never), and a
13//! row per kind of Sioul's own and of the automated sources (codes, doses,
14//! reminders, sites, other apps' automatons…). Nine columns: the seven times
15//! (work, admin, leisure, meals, sleep, the pause, Free time) and two layers
16//! on top of them (time for you, do-not-disturb from its switch or a focus
17//! session). A cell holds a level, from the least strict to the strictest:
18//! ● at once, ◑ shown, not told, ◐ when its event falls then, ◎ at the
19//! gathered times, ○ later, – not at all; and on the Always through row
20//! alone, = as their own row says, ☆ through this layer at their own row's
21//! times. Two times open together (work and admin) give the less strict of
22//! their cells; a layer that holds gives the stricter of it and the time.
23//!
24//! **The pipeline** (`Attention::decide`): what an event is; the emergency
25//! numbers (calls); who wrote, proven or not; the blocked, never, whatever
26//! else says (blocked beats Always through); the content (a code, a lane never
27//! told); the floors (a second call, Expecting a call); the exceptions
28//! (Always through, a conversation, a site); the matrix; what the source is
29//! for (its area); the named holds (the Porch resting after a pause, a
30//! meeting, the chat limit). Each caller asks it, and stays where it is.
31//!
32//! **Kept in the configuration**: `[attention]`, one key per row in the words
33//! `[notify]` used: a bare column is ●, `column:level` another level ("now",
34//! "quiet", "event", "gathered", "later", "never", "as", "through"); a row
35//! not written keeps its usual cells, a fixed cell keeps its value. While no
36//! `[attention]` is written, the older `[reach]`, `[notify]` and the switches
37//! they replaced seed it, once: what you changed there is kept, the rest is
38//! the usual; the first row written writes them all and takes the older keys
39//! out (`apply`).
40//!
41//! **Time for you everywhere**: today's slots are written to
42//! `state/slots.toml` by the window that lays out the day (`Slots`); every
43//! process reads them (the window, the listener of other apps, a phone's
44//! background step, `sioul remind --watch`, the calls' table).
45
46use crate::areas::{Area, Time, Week, in_view};
47use crate::config::{Config, SettingValue};
48use crate::i18n::Translator;
49use crate::quiet::Mode;
50use crate::reach::{Channel, Who};
51use serde::{Deserialize, Serialize};
52use std::collections::BTreeMap;
53use std::path::{Path, PathBuf};
54
55// ---------------------------------------------------------------- the objects
56
57/// Who, as a row of a people channel (mail, calls, messages).
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum Person {
61    /// Always through (« Passent toujours »): the people on that list, one
62    /// list on every device (`everywhere::People`), and a conversation set so.
63    Always,
64    Safe,
65    Neutral,
66    Restricted,
67    /// In no address book and on no list; mail nothing proves is theirs.
68    Stranger,
69    /// Calls only: a call that shows no number.
70    Hidden,
71    /// Messages only: a conversation where several people write.
72    Groups,
73    /// Never, on any channel: a fixed row.
74    Blocked,
75}
76
77impl Person {
78    pub const ALL: [Person; 8] = [Person::Always, Person::Safe, Person::Neutral, Person::Restricted, Person::Stranger, Person::Hidden, Person::Groups, Person::Blocked];
79
80    pub fn id(self) -> &'static str {
81        match self {
82            Person::Always => "always",
83            Person::Safe => "safe",
84            Person::Neutral => "neutral",
85            Person::Restricted => "restricted",
86            Person::Stranger => "stranger",
87            Person::Hidden => "hidden",
88            Person::Groups => "groups",
89            Person::Blocked => "blocked",
90        }
91    }
92
93    pub fn read(text: &str) -> Option<Person> {
94        let text = text.trim().to_ascii_lowercase();
95        match text.as_str() {
96            "strangers" => Some(Person::Stranger),
97            "group" => Some(Person::Groups),
98            _ => Person::ALL.into_iter().find(|p| p.id() == text),
99        }
100    }
101
102    /// The row of one of the five states.
103    pub fn of(who: Who) -> Person {
104        match who {
105            Who::Safe => Person::Safe,
106            Who::Neutral => Person::Neutral,
107            Who::Restricted => Person::Restricted,
108            Who::Stranger => Person::Stranger,
109            Who::Blocked => Person::Blocked,
110        }
111    }
112
113    /// One of the states' rows: neither Always through nor the blocked.
114    pub fn state(self) -> bool {
115        !matches!(self, Person::Always | Person::Blocked)
116    }
117}
118
119/// The rows of a channel, in the order they are shown.
120pub fn persons(channel: Channel) -> &'static [Person] {
121    use Person::*;
122    match channel {
123        Channel::Mail => &[Always, Safe, Neutral, Restricted, Stranger, Blocked],
124        Channel::Calls => &[Always, Safe, Neutral, Restricted, Stranger, Hidden, Blocked],
125        Channel::Messages => &[Always, Safe, Neutral, Restricted, Stranger, Groups, Blocked],
126    }
127}
128
129/// Whether a row is the call screening's (`screening`), apart from the
130/// matrix: numbers nobody knows and hidden numbers, on calls. Kept in the
131/// matrix for the screening's first values and for when a declined call is
132/// listed (`listed`), never shown in its grid nor said by its cards. No
133/// preset nor any write of the matrix sets them, and no count of changes
134/// counts them (review of part 2, 19 October 2026, F2).
135pub fn screened(row: Row) -> bool {
136    matches!(row, Row::People(Channel::Calls, Person::Stranger | Person::Hidden))
137}
138
139/// Sioul's own and the automated sources: a row each, with no who.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
141#[serde(rename_all = "kebab-case")]
142pub enum Kind {
143    /// The codes and links you just asked a site for.
144    Codes,
145    /// A dose's reminder, and the question on doses due while Sioul was closed.
146    Doses,
147    /// The alarm at waking, on a phone.
148    Wake,
149    /// The alarms an event carries.
150    Alarms,
151    /// Sioul's own reminder before an event.
152    Before,
153    /// An event, the working day before.
154    DayBefore,
155    /// Dates asked, waits over, payments, papers and contracts, the money watch.
156    Dates,
157    /// Health's notices of a meal, a nap or the night.
158    Needs,
159    /// The pause to move.
160    Move,
161    /// "Work hours are over", with "Close the work day".
162    WorkOver,
163    /// The time running: a focus session's own notification.
164    Time,
165    /// What your sites notified, gathered (a computer).
166    Sites,
167    /// A site in real time (a computer).
168    SitesLive,
169    /// A call in a site (a computer).
170    SiteCalls,
171    /// Other apps' automatons, on a phone.
172    AppAutomatons,
173    /// Other apps and browser sites set to come at once, on a phone.
174    AppAtOnce,
175}
176
177impl Kind {
178    pub const ALL: [Kind; 16] = [
179        Kind::Codes,
180        Kind::Doses,
181        Kind::Wake,
182        Kind::Alarms,
183        Kind::Before,
184        Kind::DayBefore,
185        Kind::Dates,
186        Kind::Needs,
187        Kind::Move,
188        Kind::WorkOver,
189        Kind::Time,
190        Kind::Sites,
191        Kind::SitesLive,
192        Kind::SiteCalls,
193        Kind::AppAutomatons,
194        Kind::AppAtOnce,
195    ];
196
197    pub fn id(self) -> &'static str {
198        match self {
199            Kind::Codes => "codes",
200            Kind::Doses => "doses",
201            Kind::Wake => "wake",
202            Kind::Alarms => "alarms",
203            Kind::Before => "before",
204            Kind::DayBefore => "day-before",
205            Kind::Dates => "dates",
206            Kind::Needs => "needs",
207            Kind::Move => "move",
208            Kind::WorkOver => "work-over",
209            Kind::Time => "time",
210            Kind::Sites => "sites",
211            Kind::SitesLive => "sites-live",
212            Kind::SiteCalls => "site-calls",
213            Kind::AppAutomatons => "app-automatons",
214            Kind::AppAtOnce => "app-at-once",
215        }
216    }
217
218    pub fn read(text: &str) -> Option<Kind> {
219        let text = text.trim().to_ascii_lowercase().replace('_', "-");
220        Kind::ALL.into_iter().find(|k| k.id() == text)
221    }
222
223    /// The group it is shown in: what you set or asked for, reminders, your
224    /// day, sites (a computer), other apps (a phone).
225    pub fn group(self) -> &'static str {
226        match self {
227            Kind::Codes | Kind::Doses | Kind::Wake | Kind::Alarms => "asked",
228            Kind::Before | Kind::DayBefore | Kind::Dates => "reminders",
229            Kind::Needs | Kind::Move | Kind::WorkOver | Kind::Time => "day",
230            Kind::Sites | Kind::SitesLive | Kind::SiteCalls => "sites",
231            Kind::AppAutomatons | Kind::AppAtOnce => "apps",
232        }
233    }
234}
235
236/// A row of the matrix: a who on a channel, or a kind.
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
238pub enum Row {
239    People(Channel, Person),
240    Own(Kind),
241}
242
243impl Row {
244    /// Every row, in the order they are shown: mail's, calls', messages', then the kinds.
245    pub const ALL: [Row; 36] = [
246        Row::People(Channel::Mail, Person::Always),
247        Row::People(Channel::Mail, Person::Safe),
248        Row::People(Channel::Mail, Person::Neutral),
249        Row::People(Channel::Mail, Person::Restricted),
250        Row::People(Channel::Mail, Person::Stranger),
251        Row::People(Channel::Mail, Person::Blocked),
252        Row::People(Channel::Calls, Person::Always),
253        Row::People(Channel::Calls, Person::Safe),
254        Row::People(Channel::Calls, Person::Neutral),
255        Row::People(Channel::Calls, Person::Restricted),
256        Row::People(Channel::Calls, Person::Stranger),
257        Row::People(Channel::Calls, Person::Hidden),
258        Row::People(Channel::Calls, Person::Blocked),
259        Row::People(Channel::Messages, Person::Always),
260        Row::People(Channel::Messages, Person::Safe),
261        Row::People(Channel::Messages, Person::Neutral),
262        Row::People(Channel::Messages, Person::Restricted),
263        Row::People(Channel::Messages, Person::Stranger),
264        Row::People(Channel::Messages, Person::Groups),
265        Row::People(Channel::Messages, Person::Blocked),
266        Row::Own(Kind::Codes),
267        Row::Own(Kind::Doses),
268        Row::Own(Kind::Wake),
269        Row::Own(Kind::Alarms),
270        Row::Own(Kind::Before),
271        Row::Own(Kind::DayBefore),
272        Row::Own(Kind::Dates),
273        Row::Own(Kind::Needs),
274        Row::Own(Kind::Move),
275        Row::Own(Kind::WorkOver),
276        Row::Own(Kind::Time),
277        Row::Own(Kind::Sites),
278        Row::Own(Kind::SitesLive),
279        Row::Own(Kind::SiteCalls),
280        Row::Own(Kind::AppAutomatons),
281        Row::Own(Kind::AppAtOnce),
282    ];
283
284    /// "mail.safe", "calls.hidden", "messages.groups", "codes", "day-before".
285    pub fn id(self) -> String {
286        match self {
287            Row::People(channel, person) => format!("{}.{}", channel.id(), person.id()),
288            Row::Own(kind) => kind.id().to_string(),
289        }
290    }
291
292    /// A row's id as the configuration and Settings write it; none when it is
293    /// no row (a person a channel does not have: hidden mail).
294    pub fn read(text: &str) -> Option<Row> {
295        let text = text.trim();
296        if let Some((channel, person)) = text.split_once('.') {
297            let (channel, person) = (Channel::read(channel)?, Person::read(person)?);
298            return persons(channel).contains(&person).then_some(Row::People(channel, person));
299        }
300        Kind::read(text).map(Row::Own)
301    }
302
303    fn index(self) -> usize {
304        Row::ALL.iter().position(|r| *r == self).unwrap_or(0)
305    }
306
307    /// The group it is shown in: its channel ("mail", "calls", "messages"), or its kind's.
308    pub fn group(self) -> &'static str {
309        match self {
310            Row::People(channel, _) => channel.id(),
311            Row::Own(kind) => kind.group(),
312        }
313    }
314
315    /// Always through's row: its own values (= and ☆).
316    pub fn always(self) -> bool {
317        matches!(self, Row::People(_, Person::Always))
318    }
319}
320
321/// A column: one of the seven times, or a layer above them.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
323#[serde(rename_all = "lowercase")]
324pub enum Column {
325    Work,
326    Admin,
327    Leisure,
328    Meals,
329    /// The night from winding down to waking, and naps.
330    Sleep,
331    /// The pause (« En pause »), whatever the time.
332    Pause,
333    /// Free time (« Temps libre »).
334    Free,
335    /// A slot of time for you in today's plan: a layer.
336    Slot,
337    /// Do-not-disturb from its switch or a focus session: a layer.
338    Dnd,
339}
340
341impl Column {
342    pub const ALL: [Column; 9] = [Column::Work, Column::Admin, Column::Leisure, Column::Meals, Column::Sleep, Column::Pause, Column::Free, Column::Slot, Column::Dnd];
343    /// The seven times, without the layers.
344    pub const TIMES: [Column; 7] = [Column::Work, Column::Admin, Column::Leisure, Column::Meals, Column::Sleep, Column::Pause, Column::Free];
345
346    pub fn id(self) -> &'static str {
347        match self {
348            Column::Work => "work",
349            Column::Admin => "admin",
350            Column::Leisure => "leisure",
351            Column::Meals => "meals",
352            Column::Sleep => "sleep",
353            Column::Pause => "pause",
354            Column::Free => "free",
355            Column::Slot => "slot",
356            Column::Dnd => "dnd",
357        }
358    }
359
360    /// A word of the configuration: its id, or the words older settings and
361    /// French used ("Repas", "En pause", "Temps libre", "do-not-disturb").
362    pub fn read(word: &str) -> Option<Column> {
363        Some(match word.trim().to_lowercase().as_str() {
364            "work" | "travail" => Column::Work,
365            "admin" | "démarches" | "demarches" => Column::Admin,
366            "leisure" | "loisirs" | "rest" | "personal" | "perso" => Column::Leisure,
367            "meals" | "meal" | "repas" => Column::Meals,
368            "sleep" | "sommeil" | "night" | "nuit" => Column::Sleep,
369            "pause" | "paused" | "en pause" => Column::Pause,
370            "free" | "free-time" | "free time" | "temps libre" => Column::Free,
371            "slot" | "for-you" | "time-for-you" | "time for you" | "du temps pour vous" => Column::Slot,
372            "dnd" | "do-not-disturb" | "do not disturb" | "ne pas déranger" => Column::Dnd,
373            _ => return None,
374        })
375    }
376
377    /// One of the seven times, not a layer.
378    pub fn is_time(self) -> bool {
379        !matches!(self, Column::Slot | Column::Dnd)
380    }
381
382    /// Whether its end is known while it holds, for "when its event falls
383    /// then": not the pause's, which lasts until you come back, nor a layer's.
384    pub fn ends(self) -> bool {
385        !matches!(self, Column::Pause | Column::Slot | Column::Dnd)
386    }
387
388    fn index(self) -> usize {
389        Column::ALL.iter().position(|c| *c == self).unwrap_or(0)
390    }
391}
392
393/// What a cell says, from the least strict to the strictest (the first six);
394/// the last two belong to the Always through row alone.
395#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
396#[serde(rename_all = "kebab-case")]
397pub enum Level {
398    /// ● It comes now: shown, told, let through, rings.
399    Now,
400    /// ◑ It is in Sioul now, if you look (the Porch); nothing tells you. It is
401    /// told when a time comes in which it is told.
402    Quiet,
403    /// ◐ An event's own: at once when its event begins within this time; else later.
404    Event,
405    /// ◎ Told, or let through, at the next gathered time that allows it.
406    Gathered,
407    /// ○ It waits out of sight, and comes when a time it may come in begins.
408    Later,
409    /// – Never told; what Sioul keeps stays in its place, a message or a call never let through.
410    Never,
411    /// = Always through's row: as their own row says.
412    As,
413    /// ☆ Always through's row, on a layer: the layer does not hold them; their own row's times do.
414    Through,
415}
416
417impl Level {
418    pub const ALL: [Level; 8] = [Level::Now, Level::Quiet, Level::Event, Level::Gathered, Level::Later, Level::Never, Level::As, Level::Through];
419
420    pub fn id(self) -> &'static str {
421        match self {
422            Level::Now => "now",
423            Level::Quiet => "quiet",
424            Level::Event => "event",
425            Level::Gathered => "gathered",
426            Level::Later => "later",
427            Level::Never => "never",
428            Level::As => "as",
429            Level::Through => "through",
430        }
431    }
432
433    pub fn read(text: &str) -> Option<Level> {
434        let text = text.trim().to_ascii_lowercase();
435        match text.as_str() {
436            "shown" => Some(Level::Quiet),
437            _ => Level::ALL.into_iter().find(|l| l.id() == text),
438        }
439    }
440
441    /// Its mark in a grid; at once on the Always through row is ★ ("whatever their list").
442    pub fn mark(self, always: bool) -> &'static str {
443        match self {
444            Level::Now if always => "★",
445            Level::Now => "●",
446            Level::Quiet => "◑",
447            Level::Event => "◐",
448            Level::Gathered => "◎",
449            Level::Later => "○",
450            Level::Never => "–",
451            Level::As => "=",
452            Level::Through => "☆",
453        }
454    }
455}
456
457// ---------------------------------------------------------------- the usual matrix, choices, locks
458
459/// The usual cell of a row: what Sioul does unless you change it. Today's
460/// behaviour, with the owner's decisions of 7 October 2026 (docs/attention.md,
461/// §9): Always through at once everywhere (mail shown, not told, in sleep and
462/// a pause); messages from people held in sleep and a pause; codes at once in
463/// every time; calls carry the layers; groups a row of their own.
464pub fn usual(row: Row, column: Column) -> Level {
465    use Column::*;
466    use Level::*;
467    let hours = matches!(column, Work | Admin | Leisure | Meals);
468    let rests = matches!(column, Sleep | Pause | Free);
469    match row {
470        Row::People(_, Person::Blocked) => Never,
471        Row::People(Channel::Mail, Person::Always) => if matches!(column, Sleep | Pause) { Quiet } else { Now },
472        Row::People(_, Person::Always) => Now,
473        Row::People(Channel::Mail, Person::Safe) => if hours { Now } else { Quiet },
474        Row::People(Channel::Mail, Person::Neutral | Person::Stranger) => match column {
475            Work | Admin => Now,
476            Slot | Dnd => Quiet,
477            _ => Later,
478        },
479        Row::People(Channel::Mail, Person::Restricted) => match column {
480            Work => Now,
481            Slot | Dnd => Quiet,
482            _ => Later,
483        },
484        // Under do-not-disturb, the texts of your safe people, your contacts and
485        // groups come in the shade without a sound (◑), their calls to
486        // voicemail (decisions 13 and 14 of 18 October 2026).
487        Row::People(Channel::Messages, Person::Safe | Person::Neutral | Person::Groups) if column == Dnd => Quiet,
488        Row::People(Channel::Calls | Channel::Messages, Person::Safe) => if matches!(column, Sleep | Pause | Dnd) { Later } else { Now },
489        // People in your address books: at work and in your admin, and in your
490        // leisure and meals too (a friend's text on a weekend is not held until
491        // Monday); held while you sleep, pause or take free time. A group of
492        // friends or family writing on a Saturday too (decision 14).
493        Row::People(Channel::Calls | Channel::Messages, Person::Neutral) | Row::People(Channel::Messages, Person::Groups) => if matches!(column, Work | Admin | Leisure | Meals | Slot) { Now } else { Later },
494        Row::People(Channel::Calls, Person::Hidden) => if matches!(column, Work | Admin | Slot) { Now } else { Later },
495        Row::People(Channel::Messages, Person::Stranger) => if matches!(column, Work | Admin | Slot) { Now } else { Later },
496        Row::People(Channel::Calls | Channel::Messages, Person::Restricted) => if matches!(column, Work | Slot) { Now } else { Later },
497        Row::People(Channel::Calls, Person::Stranger) => if column == Slot { Now } else { Later },
498        Row::People(_, _) => Later,
499        Row::Own(kind) => match kind {
500            Kind::Codes | Kind::Doses | Kind::Wake | Kind::Time => Now,
501            Kind::Alarms | Kind::Before => if matches!(column, Sleep | Free) { Event } else { Now },
502            Kind::DayBefore | Kind::Dates => if rests { Later } else { Now },
503            Kind::Needs => if rests { Never } else { Now },
504            Kind::Move => if hours { Now } else { Never },
505            Kind::WorkOver => if column == Work || rests { Never } else { Now },
506            Kind::Sites | Kind::AppAutomatons => if hours { Gathered } else { Later },
507            Kind::SitesLive | Kind::AppAtOnce => if hours { Now } else { Later },
508            Kind::SiteCalls => if hours || column == Slot { Now } else { Later },
509        },
510    }
511}
512
513/// The values a cell may take, the usual first among them where it can.
514pub fn choices(row: Row, column: Column) -> &'static [Level] {
515    use Level::*;
516    let layer = !column.is_time();
517    match row {
518        Row::People(_, Person::Blocked) => &[Never],
519        Row::People(Channel::Mail, Person::Always) if layer => &[Now, Quiet, Through, As],
520        Row::People(Channel::Mail, Person::Always) => &[Now, Quiet, As],
521        Row::People(_, Person::Always) if layer => &[Now, Through, As],
522        Row::People(_, Person::Always) => &[Now, As],
523        Row::People(Channel::Mail, _) => &[Now, Quiet, Later, Never],
524        // A text in the shade without a sound, under do-not-disturb alone: Android silences it then.
525        Row::People(Channel::Messages, _) if column == Column::Dnd => &[Now, Quiet, Later],
526        Row::People(_, _) => &[Now, Later],
527        Row::Own(kind) => match kind {
528            Kind::Codes => &[Now, Never],
529            Kind::Doses => &[Now, Later],
530            Kind::Wake => &[Now],
531            Kind::Alarms | Kind::Before if column.ends() => &[Now, Event, Later],
532            Kind::Alarms | Kind::Before => &[Now, Later],
533            Kind::DayBefore | Kind::Dates | Kind::SitesLive | Kind::SiteCalls | Kind::AppAtOnce => &[Now, Later],
534            Kind::Needs | Kind::Move | Kind::WorkOver | Kind::Time => &[Now, Never],
535            Kind::Sites => &[Gathered, Later],
536            Kind::AppAutomatons => &[Now, Gathered, Later],
537        },
538    }
539}
540
541/// Why a cell is fixed, as the id of its sentence; none when it can change.
542/// A dose comes at its time but in sleep and a pause, your choice; a code you
543/// just asked for at once, but in sleep, where "On the Porch only" stays a
544/// choice; an event's alarms in a pause and under do-not-disturb, as both
545/// promise; the alarm at waking always; the blocked never.
546pub fn lock(row: Row, column: Column) -> Option<&'static str> {
547    match row {
548        Row::People(_, Person::Blocked) => Some("attention-lock-blocked"),
549        Row::Own(Kind::Doses) if !matches!(column, Column::Sleep | Column::Pause) => Some("attention-lock-doses"),
550        Row::Own(Kind::Codes) if column != Column::Sleep => Some("attention-lock-codes"),
551        Row::Own(Kind::Alarms) if matches!(column, Column::Pause | Column::Dnd) => Some("attention-lock-alarms"),
552        Row::Own(Kind::Wake) => Some("attention-lock-wake"),
553        _ => None,
554    }
555}
556
557// ---------------------------------------------------------------- a source's own rows
558
559/// A source's own row, on a phone (docs/attention.md, §1.3): an app's
560/// (`app.<package>`) or a conversation's in one (`conversation.<key>`, the key
561/// `appnotes::talk_key` makes). A row of the matrix like the others, a cell
562/// per column, but each cell says "as usual" (=) until set, read then as the
563/// row the notification takes otherwise (a person's on Messages, a group's,
564/// the automatons', the apps set to come at once), as Always through's =
565/// reads their own row. A conversation's cell set wins over its app's; a
566/// source's cell set for a time is your word for it, so no area holds it
567/// then. Blocked senders, codes and what is never held stay as they are.
568pub const APP_ROW: &str = "app.";
569pub const CONVERSATION_ROW: &str = "conversation.";
570/// A person's own row (decision 16 of 18 October 2026; the proposal's 3.7):
571/// `person.contact:<the card's UID>` for a card, which covers all its numbers
572/// and addresses; else `person.tel:<a number's key>` or `person.<address>`.
573/// Nine cells, for their calls and their messages, each At once, Held or
574/// As their list (=), and under do-not-disturb In the shade for their texts.
575/// Read after a conversation's own row and before an app's, the blocked
576/// first; never for someone on Always through, who has no row of their own.
577/// No name is ever written: the card gives it.
578pub const PERSON_ROW: &str = "person.";
579/// A source row's word for its name, written for your other devices
580/// ("name=Discord"); never a column, so an older Sioul leaves it aside.
581pub const NAME: &str = "name=";
582
583/// The levels a source's cell takes: at once, at the gathered times, held
584/// (later), as usual.
585pub const SOURCE_CHOICES: [Level; 4] = [Level::Now, Level::Gathered, Level::Later, Level::As];
586
587/// The levels a person's own cell takes: at once, held, as their list; under
588/// do-not-disturb, in the shade too (their texts; a call then goes to voicemail).
589pub fn person_choices(column: Column) -> &'static [Level] {
590    if column == Column::Dnd { &[Level::Now, Level::Quiet, Level::Later, Level::As] } else { &[Level::Now, Level::Later, Level::As] }
591}
592
593/// Whether an id names a source's row: `app.<package>`, `conversation.<key>` or `person.<who>`.
594pub fn is_source(id: &str) -> bool {
595    [APP_ROW, CONVERSATION_ROW, PERSON_ROW].iter().any(|prefix| id.strip_prefix(prefix).is_some_and(|rest| !rest.is_empty() && !rest.contains(char::is_whitespace)))
596}
597
598/// Whether an id names a person's own row.
599pub fn is_person(id: &str) -> bool {
600    id.starts_with(PERSON_ROW) && is_source(id)
601}
602
603/// A person's row id: `who` as the lists write it (`contact:<UID>`, `tel:<key>`, an address).
604pub fn person_row(who: &str) -> String {
605    format!("{PERSON_ROW}{}", who.trim())
606}
607
608/// The source rows a notification reads, the most precise first: its
609/// conversation's when it has one, then its app's.
610pub fn source_rows(package: &str, conversation: &str) -> Vec<String> {
611    source_rows_with(package, conversation, &[])
612}
613
614/// The source rows a notification reads, the most precise first: its
615/// conversation's, then the person's own rows (`persons`: their ids, the card's
616/// first), then its app's (the proposal's 3.7).
617pub fn source_rows_with(package: &str, conversation: &str, persons: &[String]) -> Vec<String> {
618    let (package, conversation) = (package.trim(), conversation.trim());
619    let mut rows = Vec::new();
620    if !conversation.is_empty() {
621        rows.push(format!("{CONVERSATION_ROW}{conversation}"));
622    }
623    rows.extend(persons.iter().filter(|id| is_person(id)).cloned());
624    if !package.is_empty() {
625        rows.push(format!("{APP_ROW}{package}"));
626    }
627    rows
628}
629
630/// What a source's row is read over: a row of the matrix, or a person on a
631/// channel, on the Always through list or not.
632#[derive(Debug, Clone, Copy, PartialEq, Eq)]
633enum Base {
634    Row(Row),
635    Person(Channel, Person, bool),
636}
637
638// ---------------------------------------------------------------- the moment
639
640/// What holds now, beside the time and its layers: the named holds kept
641/// outside the matrix (docs/attention.md, §1.7), as the caller knows them.
642#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
643pub struct Holds {
644    /// After a pause, the Porch rests until the next admin hours: nothing of mail is told.
645    pub porch_rests: bool,
646    /// An event goes on: no meal's notice, no "Work hours are over".
647    pub meeting: bool,
648    /// The chat limit is reached: chat sites wait.
649    pub chats: bool,
650}
651
652/// The moment as the matrix reads it.
653#[derive(Debug, Clone, PartialEq, Eq)]
654pub struct Now {
655    /// The time's columns: one; work and admin when both hold (no hours set,
656    /// or both open at once).
657    pub times: Vec<Column>,
658    /// What now is for, and the week's hours: for what a source is for (areas)
659    /// and for lending work's cells to admin and back.
660    pub time: Time,
661    pub week: Week,
662    /// In a slot of time for you (`Slots`).
663    pub slot: bool,
664    /// Do-not-disturb from its switch or a focus session.
665    pub dnd: bool,
666    /// Free time's "Nothing at all": the states' rows wait, Always through aside.
667    pub nothing: bool,
668    /// The time now, from when to when (Unix seconds), for "when its event falls then".
669    pub span: (i64, i64),
670    /// This instant is a gathered time (`[reminders] gathered`).
671    pub gathering: bool,
672    /// The Porch's Real time box: every site in real time.
673    pub realtime: bool,
674    /// Expecting a call (once "Let every call through"), while a phone screens calls:
675    /// numbers nobody knows ring, so the modes let anyone's call through.
676    pub through: bool,
677    /// Reachable (« Joignable », the owner's decisions 6 to 12 of 18 October
678    /// 2026): everyone not blocked writes, the people you know call and mail
679    /// comes at once, whatever the time, its layers and what an address is
680    /// for; numbers nobody knows stay the call screening's, a conversation set
681    /// to never stays held, and Sioul's own rows are read as they are.
682    pub reachable: bool,
683    /// The phone's own do-not-disturb silences what comes now (Android's
684    /// filter, Sioul's mode or yours): a message whose cell says ◑, in the
685    /// shade without a sound, is let through then, and held otherwise, so that
686    /// nothing rings where the cell says silent (decision 13 of 18 October
687    /// 2026). Never on a computer, which holds no message.
688    pub silenced: bool,
689    pub holds: Holds,
690}
691
692impl Now {
693    /// From what now is for (`quiet::mode`): the pause, sleep, Free time, then
694    /// the time itself; no layer, no hold.
695    pub fn of(mode: &Mode) -> Now {
696        let times = if mode.paused() {
697            vec![Column::Pause]
698        } else if mode.sleeps() {
699            vec![Column::Sleep]
700        } else if mode.free() {
701            vec![Column::Free]
702        } else {
703            match mode.time {
704                Time::Work => vec![Column::Work],
705                Time::Admin => vec![Column::Admin],
706                Time::Leisure => vec![Column::Leisure],
707                Time::Meals => vec![Column::Meals],
708                Time::Sleep => vec![Column::Sleep],
709                Time::Several(open) if open.work != open.admin => vec![if open.work { Column::Work } else { Column::Admin }],
710                Time::Any | Time::Several(_) => vec![Column::Work, Column::Admin],
711            }
712        };
713        let end = if mode.paused() { i64::MAX } else { mode.until.as_ref().map_or(i64::MAX, |u| u.timestamp().as_second()) };
714        Now { times, time: mode.time, week: mode.week, slot: false, dnd: false, nothing: false, span: (i64::MIN, end), gathering: false, realtime: false, through: false, reachable: false, silenced: false, holds: Holds::default() }
715    }
716
717    /// From a clock's moment (`reach::Clock`, ahead: the calls' frames, other
718    /// apps' times): the same columns as `of`, Free time's "Nothing at all" said.
719    pub fn of_moment(moment: &crate::reach::Moment) -> Now {
720        let times = if moment.paused {
721            vec![Column::Pause]
722        } else if moment.time == Time::Sleep {
723            vec![Column::Sleep]
724        } else if moment.free {
725            vec![Column::Free]
726        } else {
727            match moment.time {
728                Time::Work => vec![Column::Work],
729                Time::Admin => vec![Column::Admin],
730                Time::Meals => vec![Column::Meals],
731                Time::Several(open) if open.work != open.admin => vec![if open.work { Column::Work } else { Column::Admin }],
732                Time::Any | Time::Several(_) => vec![Column::Work, Column::Admin],
733                _ => vec![Column::Leisure],
734            }
735        };
736        Now { times, time: moment.time, week: moment.week, slot: false, dnd: false, nothing: moment.free && moment.nothing, span: (i64::MIN, i64::MAX), gathering: false, realtime: false, through: false, reachable: false, silenced: false, holds: Holds::default() }
737    }
738
739    /// One time alone, no layer: for what has no hours (tests, a grid's card).
740    pub fn time(column: Column) -> Now {
741        let time = match column {
742            Column::Work => Time::Work,
743            Column::Admin => Time::Admin,
744            Column::Meals => Time::Meals,
745            Column::Sleep | Column::Pause => Time::Sleep,
746            _ => Time::Leisure,
747        };
748        let week = Week { work_hours: true, admin_hours: true, meals: true, sleep: true };
749        Now { times: vec![column], time, week, slot: false, dnd: false, nothing: false, span: (i64::MIN, i64::MAX), gathering: false, realtime: false, through: false, reachable: false, silenced: false, holds: Holds::default() }
750    }
751
752    /// The same moment with its two layers: a slot of time for you, do-not-disturb.
753    pub fn layers(mut self, slot: bool, dnd: bool) -> Now {
754        self.slot = slot;
755        self.dnd = dnd;
756        self
757    }
758
759    /// Whether one of the time's columns is `column`.
760    pub fn is(&self, column: Column) -> bool {
761        self.times.contains(&column)
762    }
763}
764
765// ---------------------------------------------------------------- the matrix
766
767/// The matrix: a level in each cell, as set; and the sources' own rows, an
768/// app's or a conversation's on a phone, by id (`app.<package>`,
769/// `conversation.<key>`), each with its name when written with one.
770#[derive(Debug, Clone, PartialEq, Eq)]
771pub struct Attention {
772    cells: [[Level; 9]; 36],
773    sources: BTreeMap<String, [Level; 9]>,
774    names: BTreeMap<String, String>,
775}
776
777impl Default for Attention {
778    fn default() -> Attention {
779        Attention::usual()
780    }
781}
782
783impl Attention {
784    /// What Sioul does unless you change it (`usual`).
785    pub fn usual() -> Attention {
786        let mut cells = [[Level::Now; 9]; 36];
787        for row in Row::ALL {
788            for column in Column::ALL {
789                cells[row.index()][column.index()] = usual(row, column);
790            }
791        }
792        Attention { cells, sources: BTreeMap::new(), names: BTreeMap::new() }
793    }
794
795    /// As the configuration says: the usual cells; while no `[attention]` is
796    /// written, what the older keys changed (`seeded`); then each row's words.
797    /// A fixed cell, a value not offered there and a word not understood are
798    /// left aside; of two words for one column, the later counts.
799    pub fn of(config: &Config) -> Attention {
800        let Some(settings) = config.attention.as_ref() else { return seeded(config) };
801        let mut attention = Attention::usual();
802        for row in Row::ALL {
803            for (column, level) in settings.words(&row.id()).unwrap_or_default().iter().filter_map(|w| word(w)) {
804                let _ = attention.set(row, column, level);
805            }
806        }
807        // The sources' own rows: their cells set, their name.
808        for (id, words) in settings.sources() {
809            for text in &words {
810                match text.strip_prefix(NAME) {
811                    Some(name) => attention.name_source(&id, name),
812                    None => {
813                        if let Some((column, level)) = word(text) {
814                            let _ = attention.set_source(&id, column, level);
815                        }
816                    }
817                }
818            }
819        }
820        let sources = &attention.sources;
821        attention.names.retain(|id, _| sources.contains_key(id));
822        attention
823    }
824
825    pub fn cell(&self, row: Row, column: Column) -> Level {
826        self.cells[row.index()][column.index()]
827    }
828
829    /// A cell changed; refused when it is fixed, or when the value is not one
830    /// it may take (`choices`).
831    pub fn set(&mut self, row: Row, column: Column, level: Level) -> Result<(), String> {
832        if self.cell(row, column) == level {
833            return Ok(());
834        }
835        if lock(row, column).is_some() {
836            return Err(format!("attention.{}: {} is fixed", row.id(), column.id()));
837        }
838        if !choices(row, column).contains(&level) {
839            let offered: Vec<&str> = choices(row, column).iter().map(|l| l.id()).collect();
840            return Err(format!("attention.{}: {}: {}", row.id(), column.id(), offered.join(", ")));
841        }
842        self.cells[row.index()][column.index()] = level;
843        Ok(())
844    }
845
846    /// A row as the configuration writes it, whole: "work" for at once,
847    /// "sleep:later" for another value.
848    pub fn words(&self, row: Row) -> Vec<String> {
849        Column::ALL
850            .iter()
851            .map(|column| match self.cell(row, *column) {
852                Level::Now => column.id().to_string(),
853                other => format!("{}:{}", column.id(), other.id()),
854            })
855            .collect()
856    }
857
858    /// How many cells differ from another matrix's (a preset's); the
859    /// sources' own rows aside, which no preset sets, and the call screening's
860    /// (`screened`).
861    pub fn changes_from(&self, other: &Attention) -> usize {
862        Row::ALL.iter().filter(|r| !screened(**r)).flat_map(|r| Column::ALL.iter().map(move |c| (*r, *c))).filter(|(r, c)| self.cell(*r, *c) != other.cell(*r, *c)).count()
863    }
864
865    /// A source's own row (`app.<package>`, `conversation.<key>`): its nine
866    /// cells; none while every one says as usual.
867    pub fn source(&self, id: &str) -> Option<[Level; 9]> {
868        self.sources.get(id).copied()
869    }
870
871    /// The sources' rows set, by id.
872    pub fn sources(&self) -> &BTreeMap<String, [Level; 9]> {
873        &self.sources
874    }
875
876    /// The name a source's row was written with (an app's, for your other
877    /// devices, which never saw it); "" when none.
878    pub fn source_name(&self, id: &str) -> &str {
879        self.names.get(id).map_or("", String::as_str)
880    }
881
882    /// A source's cell changed; refused when the id names no source, or the
883    /// value is not one a source's cell takes (`SOURCE_CHOICES`). A row as
884    /// usual everywhere is no row: taken out.
885    pub fn set_source(&mut self, id: &str, column: Column, level: Level) -> Result<(), String> {
886        if !is_source(id) {
887            return Err(format!("attention.{id}: no such row"));
888        }
889        let offered: &[Level] = if is_person(id) { person_choices(column) } else { &SOURCE_CHOICES };
890        if !offered.contains(&level) {
891            let offered: Vec<&str> = offered.iter().map(|l| l.id()).collect();
892            return Err(format!("attention.{id}: {}: {}", column.id(), offered.join(", ")));
893        }
894        let mut cells = self.source(id).unwrap_or([Level::As; 9]);
895        cells[column.index()] = level;
896        if cells.iter().all(|l| *l == Level::As) {
897            self.sources.remove(id);
898        } else {
899            self.sources.insert(id.to_string(), cells);
900        }
901        Ok(())
902    }
903
904    /// A source's name, kept beside its row ("" takes it out); never a
905    /// person's, whose card gives it (no name in the configuration).
906    pub fn name_source(&mut self, id: &str, name: &str) {
907        let name = name.trim();
908        if name.is_empty() || is_person(id) {
909            self.names.remove(id);
910        } else {
911            self.names.insert(id.to_string(), name.to_string());
912        }
913    }
914
915    /// A source's row as the configuration writes it: its cells set ("work"
916    /// for at once, "admin:later"), as usual unsaid, then its name
917    /// ("name=Discord"); nothing when as usual everywhere.
918    pub fn source_words(&self, id: &str) -> Vec<String> {
919        let Some(cells) = self.source(id) else { return Vec::new() };
920        let mut words: Vec<String> = Column::ALL
921            .iter()
922            .filter(|c| cells[c.index()] != Level::As)
923            .map(|c| match cells[c.index()] {
924                Level::Now => c.id().to_string(),
925                other => format!("{}:{}", c.id(), other.id()),
926            })
927            .collect();
928        if let Some(name) = self.names.get(id).filter(|n| !n.is_empty()) {
929            words.push(format!("{NAME}{name}"));
930        }
931        words
932    }
933
934    /// Whether one of these sources has a row set: a notification from them reads it.
935    pub fn has_sources(&self, ids: &[String]) -> bool {
936        ids.iter().any(|id| self.sources.contains_key(id))
937    }
938
939    /// The rows set among these sources, the most precise first.
940    fn source_cells(&self, ids: &[String]) -> Vec<[Level; 9]> {
941        ids.iter().filter_map(|id| self.source(id)).collect()
942    }
943
944    /// What a row, or a person on a channel, gives at one column, as `level`
945    /// and `person` read it (= and ☆ resolved, "Nothing at all" holding the
946    /// states' rows in Free time): what a source's "as usual" stands for.
947    fn base_at(&self, base: Base, column: Column, now: &Now) -> Level {
948        let free_nothing = column == Column::Free && now.nothing;
949        match base {
950            Base::Row(row) => {
951                if free_nothing && matches!(row, Row::People(_, person) if person.state()) {
952                    return Level::Later;
953                }
954                match self.lent(row, column, now.week) {
955                    Level::As => Level::Later,
956                    Level::Through => Level::Now,
957                    other => other,
958                }
959            }
960            Base::Person(channel, person, always) => {
961                if person == Person::Blocked {
962                    return Level::Never;
963                }
964                let own = Row::People(channel, if person == Person::Always { Person::Safe } else { person });
965                if !always && person != Person::Always {
966                    return self.base_at(Base::Row(own), column, now);
967                }
968                let always_row = Row::People(channel, Person::Always);
969                let level = match self.lent(always_row, column, now.week) {
970                    Level::As => self.lent(own, column, now.week),
971                    Level::Through => Level::Now,
972                    level => level,
973                };
974                if free_nothing && self.cell(always_row, Column::Free) == Level::As { level.max(Level::Later) } else { level }
975            }
976        }
977    }
978
979    /// A row, or a person, read with sources' rows over it (`sources`, the
980    /// most precise first: a conversation's, then its app's): at each column
981    /// the first cell set, else the row's own (`base_at`); then as `level`
982    /// reads a moment, the least strict of the times, the stricter of that
983    /// and each layer that holds. With it, whether a source's cell said any
984    /// of the columns read now.
985    fn over(&self, base: Base, sources: &[[Level; 9]], now: &Now) -> (Level, bool) {
986        let at = |column: Column| match sources.iter().map(|cells| cells[column.index()]).find(|l| *l != Level::As) {
987            Some(level) => (level, true),
988            None => (self.base_at(base, column, now), false),
989        };
990        let times: Vec<(Level, bool)> = now.times.iter().map(|c| at(*c)).collect();
991        let mut level = times.iter().map(|(l, _)| *l).min().unwrap_or(Level::Now);
992        let mut chosen = times.iter().any(|(_, set)| *set);
993        for (holds, column) in [(now.slot, Column::Slot), (now.dnd, Column::Dnd)] {
994            if holds {
995                let (layer, set) = at(column);
996                level = level.max(layer);
997                chosen |= set;
998            }
999        }
1000        (level, chosen)
1001    }
1002
1003    /// A row's cell at a time column, lent as the week says for the people's
1004    /// rows: while admin has no hours of its own, work time takes admin's
1005    /// cell too; while work has none, admin time takes work's (`areas::in_view`).
1006    fn lent(&self, row: Row, column: Column, week: Week) -> Level {
1007        let own = self.cell(row, column);
1008        if !matches!(row, Row::People(..)) {
1009            return own;
1010        }
1011        match column {
1012            Column::Work if !week.admin_hours => own.min(self.cell(row, Column::Admin)),
1013            Column::Admin if !week.work_hours => own.min(self.cell(row, Column::Work)),
1014            _ => own,
1015        }
1016    }
1017
1018    /// A row now: the least strict of the time's columns, then the stricter of
1019    /// that and each layer that holds. A state's row waits in a Free time of
1020    /// "Nothing at all". Always through's own values read here as what the row
1021    /// alone lets through (= waits, ☆ comes); `person` reads them against a
1022    /// person's own row.
1023    pub fn level(&self, row: Row, now: &Now) -> Level {
1024        // Reachable: the states' rows at once, whatever the time and its layers; the call screening's apart.
1025        if let Row::People(_, person) = row
1026            && person.state()
1027            && now.reachable
1028            && !screened(row)
1029        {
1030            return Level::Now;
1031        }
1032        if let Row::People(_, person) = row
1033            && person.state()
1034            && now.nothing
1035            && now.is(Column::Free)
1036        {
1037            return Level::Later;
1038        }
1039        let read = |level: Level| match level {
1040            Level::As => Level::Later,
1041            Level::Through => Level::Now,
1042            other => other,
1043        };
1044        let mut level = now.times.iter().map(|c| read(self.lent(row, *c, now.week))).min().unwrap_or(Level::Now);
1045        if now.slot {
1046            level = level.max(read(self.cell(row, Column::Slot)));
1047        }
1048        if now.dnd {
1049            level = level.max(read(self.cell(row, Column::Dnd)));
1050        }
1051        level
1052    }
1053
1054    /// Someone on a channel now: the blocked never (blocked beats Always
1055    /// through); on the Always through list, that row, its = read as their own
1056    /// row, its ☆ as a layer that does not hold them; else their own row.
1057    pub fn person(&self, channel: Channel, person: Person, always: bool, now: &Now) -> Level {
1058        if person == Person::Blocked {
1059            return Level::Never;
1060        }
1061        let own = Row::People(channel, if person == Person::Always { Person::Safe } else { person });
1062        if !always && person != Person::Always {
1063            return self.level(own, now);
1064        }
1065        if now.reachable {
1066            return Level::Now;
1067        }
1068        let always_row = Row::People(channel, Person::Always);
1069        let at = |column: Column| match self.lent(always_row, column, now.week) {
1070            Level::As => self.lent(own, column, now.week),
1071            Level::Through => Level::Now,
1072            level => level,
1073        };
1074        let mut level = now.times.iter().map(|c| at(*c)).min().unwrap_or(Level::Now);
1075        // "Nothing at all" holds the states' rows: what Always through reads of them too.
1076        if now.nothing && now.is(Column::Free) && self.cell(always_row, Column::Free) == Level::As {
1077            level = level.max(Level::Later);
1078        }
1079        for (holds, column) in [(now.slot, Column::Slot), (now.dnd, Column::Dnd)] {
1080            if holds {
1081                level = level.max(at(column));
1082            }
1083        }
1084        level
1085    }
1086
1087    /// A call after the fact (declined, on the Porch's list; a missed call's
1088    /// notification): when its caller's Calls row lets them through now
1089    /// (docs/attention.md, Q23); a row that rings at no time at all
1090    /// (strangers, as usual) in work and admin time, the Porch's own hours,
1091    /// so that no call is never listed. The blocked never.
1092    pub fn listed(&self, person: Person, always: bool, now: &Now) -> bool {
1093        if person == Person::Blocked {
1094            return false;
1095        }
1096        if self.person(Channel::Calls, person, always, now) == Level::Now {
1097            return true;
1098        }
1099        let row = Row::People(Channel::Calls, if person == Person::Always { Person::Safe } else { person });
1100        let never_rings = !always && Column::TIMES.iter().all(|c| self.cell(row, *c) != Level::Now);
1101        never_rings && !now.dnd && now.times.iter().any(|c| matches!(c, Column::Work | Column::Admin))
1102    }
1103
1104    /// Whether a person's row lets them through at some time of the week
1105    /// that an area is for: when none, the area holds nothing back for good.
1106    fn meets(&self, channel: Channel, person: Person, area: Area, week: Week) -> bool {
1107        Time::STATES.into_iter().filter(|t| week.has(*t)).any(|t| {
1108            let column = match t {
1109                Time::Work => Column::Work,
1110                Time::Admin => Column::Admin,
1111                Time::Leisure => Column::Leisure,
1112                Time::Meals => Column::Meals,
1113                _ => Column::Sleep,
1114            };
1115            matches!(self.lent(Row::People(channel, person), column, week), Level::Now | Level::Quiet) && in_view(area, t, week)
1116        })
1117    }
1118}
1119
1120/// One word of a row: "sleep" (at once) or "sleep:later"; none when either
1121/// half is not understood.
1122pub fn word(text: &str) -> Option<(Column, Level)> {
1123    match text.rsplit_once(':') {
1124        Some((column, value)) => Some((Column::read(column)?, Level::read(value)?)),
1125        None => Some((Column::read(text)?, Level::Now)),
1126    }
1127}
1128
1129// ---------------------------------------------------------------- the pipeline
1130
1131/// What happened, as its caller knows it (docs/attention.md, §2, step 1).
1132#[derive(Debug, Clone, PartialEq, Eq)]
1133pub struct Event {
1134    pub source: Source,
1135    /// What its source is for (an address, an app, a site): it waits outside
1136    /// that area's times, but for your safe senders and Always through.
1137    pub area: Option<Area>,
1138    /// An event's own reminder: when its event begins (Unix seconds), for ◐.
1139    pub starts: Option<i64>,
1140    /// The sources' own rows it reads, the most precise first (`source_rows`:
1141    /// another app's notification, its conversation's and its app's).
1142    pub sources: Vec<String>,
1143}
1144
1145impl Event {
1146    pub fn own(kind: Kind) -> Event {
1147        Event { source: Source::Own(kind), area: None, starts: None, sources: Vec::new() }
1148    }
1149
1150    pub fn of(source: Source) -> Event {
1151        Event { source, area: None, starts: None, sources: Vec::new() }
1152    }
1153
1154    /// Read with these sources' own rows over its own (`source_rows`).
1155    pub fn from_sources(mut self, sources: Vec<String>) -> Event {
1156        self.sources = sources;
1157        self
1158    }
1159
1160    /// For an area's times only.
1161    pub fn for_area(mut self, area: Option<Area>) -> Event {
1162        self.area = area;
1163        self
1164    }
1165
1166    /// An event's own reminder, its event starting then.
1167    pub fn starting(mut self, at: Option<i64>) -> Event {
1168        self.starts = at;
1169        self
1170    }
1171}
1172
1173/// Where an event comes from, with what decides it.
1174#[derive(Debug, Clone, PartialEq, Eq)]
1175pub enum Source {
1176    /// Sioul's own, and the automated sources: a kind's row.
1177    Own(Kind),
1178    /// A message come to one of your addresses. `who` as the Porch weighs it:
1179    /// set aside or unproven, a stranger's (a blocked sender stays blocked);
1180    /// `always`, on the Always through list.
1181    Mail { who: Who, always: bool, lane: Lane },
1182    /// A phone call; `who` none for a hidden number. `emergency`: an
1183    /// emergency number or its callback, or within a day of calling one;
1184    /// `repeat`: a second call within 15 minutes.
1185    Call { who: Option<Who>, always: bool, emergency: bool, repeat: bool },
1186    /// Another app's message between people (a phone): its channel (a mail
1187    /// app's: mail; a missed call: calls), who wrote, a group.
1188    Message { via: Channel, who: Who, group: bool, always: bool },
1189    /// A site's notification (a computer): a call in it; in real time; a
1190    /// person a chat site names, as your cards know their name.
1191    Site { call: bool, live: bool, named: Option<Who> },
1192}
1193
1194/// What the Porch's lanes decide of mail (docs/attention.md, §2, step 6).
1195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1196pub enum Lane {
1197    /// Told as its row says.
1198    Usual,
1199    /// A code asked for: on top of the Porch at any time; told as the Codes row says.
1200    Code,
1201    /// What you sent yourself: among People at any hour, never told.
1202    Own,
1203    /// Never told, shown as its row says: set aside, the review queue,
1204    /// hostile, less important accounts, newsletters unless asked, read already.
1205    Untold,
1206}
1207
1208/// Which step answered (docs/attention.md, §2).
1209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1210#[serde(rename_all = "kebab-case")]
1211pub enum Step {
1212    /// An emergency number, or within a day of calling one (calls).
1213    Emergency,
1214    /// Blocked: never, whatever else says.
1215    Blocked,
1216    /// A lane, a code.
1217    Content,
1218    /// A floor: a second call within 15 minutes, Expecting a call.
1219    Floor,
1220    /// The matrix, the time and its layers.
1221    Matrix,
1222    /// A source's own row, an app's or a conversation's, as you set it.
1223    Chosen,
1224    /// What its source is for holds it: work's things in your evening.
1225    Area,
1226    /// A named hold: the Porch resting after a pause, a meeting, the chat limit.
1227    Hold,
1228}
1229
1230/// What comes of an event now.
1231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1232pub struct Output {
1233    /// The level now, its event resolved (never ◐, =, ☆).
1234    pub level: Level,
1235    /// Shown in Sioul now (the Porch, a page).
1236    pub shown: bool,
1237    /// Told now: a notification, let through, rings.
1238    pub told: bool,
1239    /// Told through the system's do-not-disturb: critical urgency on a
1240    /// computer, a channel that passes on a phone (codes, doses, alarms,
1241    /// Always through).
1242    pub pierce: bool,
1243    pub step: Step,
1244}
1245
1246impl Output {
1247    fn at(level: Level, step: Step) -> Output {
1248        Output { level, shown: level != Level::Later, told: level == Level::Now, pierce: false, step }
1249    }
1250}
1251
1252impl Attention {
1253    /// What comes of an event now, step by step (docs/attention.md, §2):
1254    /// the blocked first, then the content, the floors, the exceptions, the
1255    /// matrix, what the source is for, the named holds. Which device tells it
1256    /// and how stays the caller's (§8).
1257    pub fn decide(&self, event: &Event, now: &Now) -> Output {
1258        match &event.source {
1259            Source::Own(kind) => self.own(*kind, event, now),
1260            Source::Mail { who, always, lane } => self.letter(*who, *always, *lane, event, now),
1261            Source::Call { who, always, emergency, repeat } => self.call(*who, *always, *emergency, *repeat, event, now),
1262            Source::Message { via, who, group, always } => self.message(*via, *who, *group, *always, event, now),
1263            Source::Site { call, live, named } => self.site(*call, *live, *named, event, now),
1264        }
1265    }
1266
1267    /// What holds an event now, for a line that says why it waits (the
1268    /// phone's log, `appnotes::cause`): the time's columns when they hold it
1269    /// (two times open together hold it only together, so both are said),
1270    /// then each layer that holds it by itself, as `silence` reads a layer
1271    /// alone. What its source is for is left aside (`Step::Area` says it).
1272    /// Empty when the matrix lets it come now, or when it only waits for a
1273    /// gathered time.
1274    pub fn holding(&self, event: &Event, now: &Now) -> Vec<Column> {
1275        let event = Event { area: None, ..event.clone() };
1276        let holds = |moment: &Now| self.decide(&event, moment).level >= Level::Later;
1277        let mut columns = Vec::new();
1278        if holds(&Now { slot: false, dnd: false, ..now.clone() }) {
1279            columns.extend(now.times.iter().copied());
1280        }
1281        for (on, layer) in [(now.slot, Column::Slot), (now.dnd, Column::Dnd)] {
1282            if on && holds(&Now { times: Vec::new(), slot: layer == Column::Slot, dnd: layer == Column::Dnd, nothing: false, ..now.clone() }) {
1283                columns.push(layer);
1284            }
1285        }
1286        columns
1287    }
1288
1289    /// The first of these sources' own rows (the most precise first) with a
1290    /// cell set at a column read now (the time's, each layer that holds):
1291    /// the row that decides the event now, when one does (`Step::Chosen`).
1292    pub fn chosen_source<'a>(&self, ids: &'a [String], now: &Now) -> Option<&'a str> {
1293        let mut read: Vec<Column> = now.times.clone();
1294        read.extend([(now.slot, Column::Slot), (now.dnd, Column::Dnd)].into_iter().filter(|(on, _)| *on).map(|(_, c)| c));
1295        ids.iter().find(|id| self.source(id).is_some_and(|cells| read.iter().any(|c| cells[c.index()] != Level::As))).map(String::as_str)
1296    }
1297
1298    /// A message as the Porch judged it, now: its sender's row as the Porch
1299    /// weighs it (set aside or unproven, a stranger's), Always through when
1300    /// the list holds its proven address, what the address it came to is for.
1301    /// Shown says whether the Porch shows it now, told whether new mail's
1302    /// notification may name it (its lane aside: `mailnote::never`).
1303    pub fn mail(&self, t: &crate::porch::Triaged, senders: &crate::porch::Senders, always: &crate::everywhere::People, area: Area, now: &Now) -> Output {
1304        use crate::porch::{Lane as PorchLane, Reason};
1305        let unproven = t.lane == PorchLane::SetAside || t.reasons.contains(&Reason::NotAuthenticated);
1306        let who = if unproven { Who::Stranger } else { senders.who_of(&t.card) };
1307        let always = !unproven && t.card.from_address.as_deref().is_some_and(|address| always.admits_address(address));
1308        let lane = if t.lane == PorchLane::RightNow {
1309            Lane::Code
1310        } else if t.reasons.contains(&Reason::FromYourself) {
1311            Lane::Own
1312        } else {
1313            Lane::Usual
1314        };
1315        self.decide(&Event::of(Source::Mail { who, always, lane }).for_area(Some(area)), now)
1316    }
1317
1318    fn own(&self, kind: Kind, event: &Event, now: &Now) -> Output {
1319        // Another app's, read with its app's and conversation's own rows over the kind's.
1320        let sources = self.source_cells(&event.sources);
1321        let (mut level, chosen) = if sources.is_empty() { (self.level(Row::Own(kind), now), false) } else { self.over(Base::Row(Row::Own(kind)), &sources, now) };
1322        if level == Level::Event {
1323            level = if event.starts.is_some_and(|s| now.span.0 <= s && s < now.span.1) { Level::Now } else { Level::Later };
1324        }
1325        let mut step = if chosen { Step::Chosen } else { Step::Matrix };
1326        // What a thing is for, not the matrix: work's dates wait while work rests, in
1327        // the hours' times (asleep, paused or in Free time, the cells say it already).
1328        // A source's cell set for this time is your word for it: no area holds it.
1329        let hours = !now.times.iter().any(|c| matches!(c, Column::Sleep | Column::Pause | Column::Free));
1330        if !chosen && hours && matches!(level, Level::Now | Level::Gathered) && event.area.is_some_and(|area| !in_view(area, now.time, now.week)) {
1331            (level, step) = (Level::Later, Step::Area);
1332        }
1333        // In a meeting: no meal's notice, no "Work hours are over".
1334        if now.holds.meeting && matches!(kind, Kind::Needs | Kind::WorkOver) && level == Level::Now {
1335            (level, step) = (Level::Never, Step::Hold);
1336        }
1337        let told = level == Level::Now || (level == Level::Gathered && now.gathering);
1338        Output { level, shown: true, told, pierce: told && matches!(kind, Kind::Codes | Kind::Doses | Kind::Wake | Kind::Alarms), step }
1339    }
1340
1341    fn letter(&self, who: Who, always: bool, lane: Lane, event: &Event, now: &Now) -> Output {
1342        // Step 6, content: a code has its own row, what you sent yourself is shown and never told.
1343        match lane {
1344            Lane::Code => {
1345                let told = self.level(Row::Own(Kind::Codes), now) == Level::Now;
1346                return Output { level: if told { Level::Now } else { Level::Quiet }, shown: true, told, pierce: told, step: Step::Content };
1347            }
1348            Lane::Own => return Output { level: Level::Quiet, shown: true, told: false, pierce: false, step: Step::Content },
1349            _ => {}
1350        }
1351        let person = Person::of(who);
1352        if person == Person::Blocked {
1353            return Output { level: Level::Never, shown: false, told: false, pierce: false, step: Step::Blocked };
1354        }
1355        let mut level = self.person(Channel::Mail, person, always, now);
1356        let mut step = Step::Matrix;
1357        // Step 10: mail to an address for another time waits, but your safe
1358        // senders' and Always through's; and but when its row and the address
1359        // never meet in your week, so that nothing waits for good.
1360        if let Some(area) = event.area
1361            && !now.reachable
1362            && matches!(level, Level::Now | Level::Quiet)
1363            && person != Person::Safe
1364            && !always
1365            && !in_view(area, now.time, now.week)
1366            && self.meets(Channel::Mail, person, area, now.week)
1367        {
1368            (level, step) = (Level::Later, Step::Area);
1369        }
1370        let mut out = Output::at(level, step);
1371        out.told = level == Level::Now && lane == Lane::Usual;
1372        // Reachable lifts the Porch's rest after a pause too.
1373        if out.told && now.holds.porch_rests && !now.reachable {
1374            (out.told, out.step) = (false, Step::Hold);
1375        }
1376        out.pierce = out.told && always;
1377        out
1378    }
1379
1380    fn call(&self, who: Option<Who>, always: bool, emergency: bool, repeat: bool, event: &Event, now: &Now) -> Output {
1381        let rings = |step: Step| Output { level: Level::Now, shown: true, told: true, pierce: true, step };
1382        if emergency {
1383            return rings(Step::Emergency);
1384        }
1385        let person = who.map_or(Person::Hidden, Person::of);
1386        if person == Person::Blocked {
1387            return Output { level: Level::Never, shown: false, told: false, pierce: false, step: Step::Blocked };
1388        }
1389        // Expecting a call opens only to numbers nobody knows (the call screening's, `calls::decide`); a second call, to anyone known by number.
1390        if (now.through && matches!(person, Person::Stranger | Person::Hidden)) || (repeat && who.is_some()) {
1391            return rings(Step::Floor);
1392        }
1393        // Their own row, set (someone you know, not on Always through, who has none: the proposal's
1394        // 3.7); numbers nobody knows and hidden numbers stay the call screening's.
1395        // Reachable: the people you know ring, their own row as their list (review of part 6, F1).
1396        let own = if always || now.reachable || matches!(person, Person::Stranger | Person::Hidden) {
1397            Vec::new()
1398        } else {
1399            self.source_cells(&event.sources.iter().filter(|id| is_person(id)).cloned().collect::<Vec<_>>())
1400        };
1401        let (level, chosen) = if own.is_empty() { (self.person(Channel::Calls, person, always && who.is_some(), now), false) } else { self.over(Base::Person(Channel::Calls, person, false), &own, now) };
1402        // A call never rings in the shade: to voicemail.
1403        let level = if level == Level::Quiet { Level::Later } else { level };
1404        let mut out = Output::at(level, if chosen { Step::Chosen } else { Step::Matrix });
1405        out.pierce = out.told && always;
1406        out
1407    }
1408
1409    fn message(&self, via: Channel, who: Who, group: bool, always: bool, event: &Event, now: &Now) -> Output {
1410        let person = if who == Who::Blocked { Person::Blocked } else if group && via == Channel::Messages { Person::Groups } else { Person::of(who) };
1411        if person == Person::Blocked {
1412            return Output { level: Level::Never, shown: false, told: false, pierce: false, step: Step::Blocked };
1413        }
1414        // Read with its conversation's, the person's own and its app's rows over the person's
1415        // list, when set, in that order (the proposal's 3.7); someone Always through stays so
1416        // whatever an app's row says, and has no row of their own: only a conversation's own row
1417        // changes them.
1418        let sources = if always { self.source_cells(&event.sources.iter().filter(|id| id.starts_with(CONVERSATION_ROW)).cloned().collect::<Vec<_>>()) } else { self.source_cells(&event.sources) };
1419        // A missed call: as a declined call is listed, by the Calls row.
1420        let (mut level, chosen) = if !sources.is_empty() {
1421            self.over(Base::Person(via, person, always), &sources, now)
1422        } else if via == Channel::Calls {
1423            (if self.listed(person, always, now) { Level::Now } else { Level::Later }, false)
1424        } else {
1425            (self.person(via, person, always, now), false)
1426        };
1427        // Reachable: everyone not blocked at once, strangers and groups too (a conversation set to
1428        // never is the listener's choice, kept there: `appnotes::decide`).
1429        if now.reachable && level != Level::Never && !(via == Channel::Calls && screened(Row::People(Channel::Calls, person))) {
1430            level = Level::Now;
1431        }
1432        // ◑, in the shade without a sound: a text let through while the phone silences it
1433        // (Android's filter, Sioul's mode or yours), held otherwise, so that nothing rings where
1434        // the cell says silent (decision 13). A mail app's notification stays held: mail is
1435        // unchanged (proposal, 3.8; review of part 4, S-1). A phone cannot show what it holds: a
1436        // message never stays ◑.
1437        if level == Level::Quiet {
1438            level = if now.silenced && via == Channel::Messages { Level::Now } else { Level::Later };
1439        }
1440        let mut step = if chosen { Step::Chosen } else { Step::Matrix };
1441        if !chosen && !now.reachable && level == Level::Now && person != Person::Safe && !always && event.area.is_some_and(|area| !in_view(area, now.time, now.week)) {
1442            (level, step) = (Level::Later, Step::Area);
1443        }
1444        let mut out = Output::at(level, step);
1445        out.pierce = out.told && always;
1446        out
1447    }
1448
1449    fn site(&self, call: bool, live: bool, named: Option<Who>, event: &Event, now: &Now) -> Output {
1450        let kind = if call {
1451            Kind::SiteCalls
1452        } else if live || now.realtime {
1453            Kind::SitesLive
1454        } else {
1455            Kind::Sites
1456        };
1457        let mut level = self.level(Row::Own(kind), now);
1458        let mut step = Step::Matrix;
1459        // A chat site naming someone your cards know: their messages' row, which may only hold more.
1460        if let Some(who) = named {
1461            let theirs = self.person(Channel::Messages, Person::of(who), false, now);
1462            if theirs > level {
1463                level = if theirs == Level::Never { Level::Never } else { Level::Later };
1464                step = if who == Who::Blocked { Step::Blocked } else { Step::Matrix };
1465            }
1466        }
1467        if matches!(level, Level::Now | Level::Gathered) && event.area.is_some_and(|area| !in_view(area, now.time, now.week)) {
1468            (level, step) = (Level::Later, Step::Area);
1469        }
1470        if matches!(level, Level::Now | Level::Gathered) && now.holds.chats {
1471            (level, step) = (Level::Later, Step::Hold);
1472        }
1473        let told = level == Level::Now || (level == Level::Gathered && now.gathering);
1474        Output { level, shown: level != Level::Never, told, pierce: false, step }
1475    }
1476}
1477
1478// ---------------------------------------------------------------- what the system lets through
1479
1480/// Whom one of Sioul's modes lets through the system's do-not-disturb, on
1481/// a phone (Android names nobody, starred contacts, contacts, or anyone).
1482#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1483#[serde(rename_all = "lowercase")]
1484pub enum Senders {
1485    None,
1486    Starred,
1487    Contacts,
1488    Anyone,
1489}
1490
1491/// What one of Sioul's modes lets through (Android's `ZenPolicy`), from the
1492/// matrix at its column: the phone's mode follows the matrix as far as
1493/// Android lets it (docs/attention.md, §3.3).
1494#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1495pub struct Silence {
1496    pub calls: Senders,
1497    pub messages: Senders,
1498    /// A second call from the same number within 15 minutes.
1499    pub repeat: bool,
1500    /// Conversations marked important in Android (Always through conversations).
1501    pub conversations: bool,
1502    /// Android's own alarms, always.
1503    pub alarms: bool,
1504    /// Sioul's doses, on their own channel.
1505    pub doses: bool,
1506    /// An event's alarms, on a channel of their own that passes.
1507    pub events: bool,
1508}
1509
1510/// What this phone does itself.
1511#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1512pub struct Phone {
1513    /// It screens calls: Sioul has declined what the matrix and the call
1514    /// screening hold already.
1515    pub screens: bool,
1516    /// While it screens calls, the call screening lets numbers nobody knows,
1517    /// or hidden ones, ring then (`screening::Screening::rings`): their calls
1518    /// go through the mode too, where your contacts may ring then (never
1519    /// beyond them: review of part 2, F3). The Calls rows of strangers and
1520    /// hidden numbers are no part of it any more: the screening is apart.
1521    pub unknown: bool,
1522}
1523
1524impl Attention {
1525    /// What a mode lets through at a moment (`Now`): as `level` reads it,
1526    /// the times together the least strict, a layer (do-not-disturb over the
1527    /// time now) the stricter, "Nothing at all" holding the states' rows.
1528    /// Calls: where this phone screens them, every contact's, Sioul having
1529    /// declined the others already, and anyone's when the call screening lets
1530    /// numbers nobody knows ring then (`Phone::unknown`); else the starred,
1531    /// Android's nearest to Always through; anyone's while Expecting a call
1532    /// holds (`Now::through`), so that the mode lets ring what the screening
1533    /// lets through. Messages: the starred when the matrix lets anyone write
1534    /// then, never wider, though Sioul may hold other apps' notifications:
1535    /// Android sounds a notification before any listener hears of it
1536    /// (docs/android.md, "Sound"), so a contact's message let through the
1537    /// mode, then held by Sioul, would ring once; the mode alone keeps a
1538    /// held message silent.
1539    pub fn silence_at(&self, now: &Now, phone: Phone) -> Silence {
1540        let passes = |channel: Channel, person: Person| self.person(channel, person, false, now) == Level::Now;
1541        let always = |channel: Channel| self.person(channel, Person::Always, true, now) == Level::Now;
1542        let senders = |channel: Channel, own: bool| {
1543            // While this phone screens calls, numbers nobody knows are the screening's, apart from the matrix,
1544            // and never ring where your contacts' row declines (review of part 2, F3).
1545            let strangers = if channel == Channel::Calls && own { phone.unknown && passes(channel, Person::Neutral) } else { [Person::Stranger, Person::Hidden, Person::Groups].iter().any(|p| persons(channel).contains(p) && passes(channel, *p)) };
1546            let known = [Person::Safe, Person::Neutral, Person::Restricted].iter().any(|p| passes(channel, *p));
1547            match (own, strangers, known || always(channel)) {
1548                (true, true, _) => Senders::Anyone,
1549                (true, false, true) => Senders::Contacts,
1550                (false, _, true) | (false, true, _) => Senders::Starred,
1551                _ => Senders::None,
1552            }
1553        };
1554        let calls = if now.through { Senders::Anyone } else { senders(Channel::Calls, phone.screens) };
1555        Silence {
1556            calls,
1557            messages: senders(Channel::Messages, false),
1558            repeat: calls != Senders::None,
1559            conversations: always(Channel::Messages),
1560            alarms: true,
1561            doses: self.level(Row::Own(Kind::Doses), now) == Level::Now,
1562            events: matches!(self.level(Row::Own(Kind::Alarms), now), Level::Now | Level::Event),
1563        }
1564    }
1565
1566    /// The same for columns (the pause's, Free time's, sleep's; a layer, the
1567    /// switch's): the times among them together, the layers above them, as
1568    /// `silence_at` reads them, every hours set. A layer alone says what it
1569    /// lets through by itself.
1570    pub fn silence(&self, columns: &[Column], nothing: bool, phone: Phone) -> Silence {
1571        let mut now = Now::time(Column::Leisure);
1572        now.times = columns.iter().copied().filter(|c| c.is_time()).collect();
1573        now.slot = columns.contains(&Column::Slot);
1574        now.dnd = columns.contains(&Column::Dnd);
1575        now.nothing = nothing;
1576        self.silence_at(&now, phone)
1577    }
1578}
1579
1580// ---------------------------------------------------------------- presets
1581
1582/// Where you start from (docs/attention.md, Q19): as Sioul does now, quieter,
1583/// more reachable. A preset never touches a fixed cell.
1584#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1585#[serde(rename_all = "kebab-case")]
1586pub enum Preset {
1587    Usual,
1588    Quieter,
1589    Reachable,
1590}
1591
1592impl Preset {
1593    pub const ALL: [Preset; 3] = [Preset::Usual, Preset::Quieter, Preset::Reachable];
1594
1595    pub fn id(self) -> &'static str {
1596        match self {
1597            Preset::Usual => "usual",
1598            Preset::Quieter => "quieter",
1599            Preset::Reachable => "reachable",
1600        }
1601    }
1602
1603    pub fn read(text: &str) -> Option<Preset> {
1604        Preset::ALL.into_iter().find(|p| p.id() == text.trim())
1605    }
1606
1607    /// Its matrix: the usual, changed as it says.
1608    pub fn matrix(self) -> Attention {
1609        use Column::*;
1610        use Level::*;
1611        let mut matrix = Attention::usual();
1612        let changes: &[(Row, &[Column], Level)] = match self {
1613            Preset::Usual => &[],
1614            Preset::Quieter => &[
1615                (Row::People(Channel::Mail, Person::Safe), &[Leisure, Meals], Quiet),
1616                (Row::People(Channel::Calls, Person::Neutral), &[Admin, Leisure, Meals], Later),
1617                (Row::People(Channel::Messages, Person::Neutral), &[Leisure, Meals], Later),
1618                (Row::People(Channel::Messages, Person::Groups), &[Leisure, Meals], Later),
1619                (Row::People(Channel::Messages, Person::Safe), &[Free], Later),
1620                // Under do-not-disturb, every text held, as before the shade (decision 13).
1621                (Row::People(Channel::Messages, Person::Safe), &[Dnd], Later),
1622                (Row::People(Channel::Messages, Person::Neutral), &[Dnd], Later),
1623                (Row::People(Channel::Messages, Person::Groups), &[Dnd], Later),
1624                (Row::Own(Kind::Dates), &[Leisure, Meals], Later),
1625                (Row::Own(Kind::SitesLive), &[Leisure, Meals], Later),
1626            ],
1627            Preset::Reachable => &[
1628                (Row::People(Channel::Mail, Person::Neutral), &[Leisure, Meals], Now),
1629                (Row::People(Channel::Mail, Person::Stranger), &[Leisure], Quiet),
1630                (Row::People(Channel::Calls, Person::Neutral), &[Leisure, Meals], Now),
1631                (Row::People(Channel::Messages, Person::Neutral), &[Leisure, Meals], Now),
1632                (Row::Own(Kind::AppAutomatons), &[Work, Admin], Now),
1633            ],
1634        };
1635        for (row, columns, level) in changes {
1636            for column in *columns {
1637                let _ = matrix.set(*row, *column, *level);
1638            }
1639        }
1640        matrix
1641    }
1642}
1643
1644// ---------------------------------------------------------------- the configuration
1645
1646/// `[attention]` as written: each row's words, read leniently (a row that is
1647/// no list of words is left aside, never the whole configuration).
1648#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
1649#[serde(transparent)]
1650pub struct Settings(pub BTreeMap<String, toml::Value>);
1651
1652impl Settings {
1653    /// A row's words, by its id: a list of words, or one text of words
1654    /// separated by commas; none when the row is not written.
1655    pub fn words(&self, row: &str) -> Option<Vec<String>> {
1656        let value = self.0.iter().find(|(key, _)| Row::read(key).is_some_and(|r| r.id() == row)).map(|(_, v)| v)?;
1657        Some(match value {
1658            toml::Value::Array(list) => list.iter().filter_map(|w| w.as_str().map(str::to_string)).collect(),
1659            toml::Value::String(text) => text.split(',').map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect(),
1660            _ => Vec::new(),
1661        })
1662    }
1663
1664    /// The sources' own rows written (`app.<package>`, `conversation.<key>`),
1665    /// each with its words, as `words` reads a row's.
1666    pub fn sources(&self) -> Vec<(String, Vec<String>)> {
1667        self.0
1668            .iter()
1669            .filter(|(key, _)| is_source(key.trim()))
1670            .map(|(key, value)| {
1671                let words: Vec<String> = match value {
1672                    toml::Value::Array(list) => list.iter().filter_map(|w| w.as_str().map(str::to_string)).collect(),
1673                    toml::Value::String(text) => text.split(',').map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect(),
1674                    _ => Vec::new(),
1675                };
1676                // A person's row names nobody: a name written there by hand is left aside.
1677                let words = if is_person(key.trim()) { words.into_iter().filter(|w| !w.starts_with(NAME)).collect() } else { words };
1678                (key.trim().to_string(), words)
1679            })
1680            .collect()
1681    }
1682}
1683
1684/// `[notify]` as an older Sioul wrote it (what each kind of notification did
1685/// at each time, before `[attention]`): each row's words, read once to seed
1686/// the matrix (`seeded`), never written again.
1687#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
1688#[serde(transparent)]
1689pub struct NotifySettings(pub BTreeMap<String, toml::Value>);
1690
1691impl NotifySettings {
1692    /// A row's words, by its id ("day-before"; "Day_Before" too): a list of
1693    /// words, or one text of words separated by commas; none when not written.
1694    pub fn words(&self, row: &str) -> Option<Vec<String>> {
1695        let value = self.0.iter().find(|(key, _)| key.trim().to_ascii_lowercase().replace('_', "-") == row).map(|(_, v)| v)?;
1696        Some(match value {
1697            toml::Value::Array(list) => list.iter().filter_map(|w| w.as_str().map(str::to_string)).collect(),
1698            toml::Value::String(text) => text.split(',').map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect(),
1699            _ => Vec::new(),
1700        })
1701    }
1702}
1703
1704/// The older keys `[attention]` replaced, taken out when it is first written.
1705pub const OLDER: &[&str] = &["reach", "notify", "reminders.doses_in_sleep", "pause.doses", "pause.people", "dnd.people"];
1706
1707/// The matrix while no `[attention]` is written: the usual, but where the
1708/// older keys said something else than their own usual (`[reach]`,
1709/// `[notify]`, the doses' switches, the people on the list getting through):
1710/// what you changed is kept, the decided changes hold elsewhere.
1711pub fn seeded(config: &Config) -> Attention {
1712    let (then, then_usual) = (older(config), older(&Config::default()));
1713    let mut attention = Attention::usual();
1714    for row in Row::ALL {
1715        for column in Column::ALL {
1716            let (was, usual_then) = (then[row.index()][column.index()], then_usual[row.index()][column.index()]);
1717            if was != usual_then {
1718                let _ = attention.set(row, column, was);
1719            }
1720        }
1721    }
1722    // "The people on my list get through" off: the list changes nothing, on
1723    // any channel; the pause's "Starred contacts get through" off: nobody
1724    // through the pause's mode, the list neither.
1725    let always = |channel: Channel| Row::People(channel, Person::Always);
1726    if !config.dnd.people {
1727        for channel in Channel::ALL {
1728            for column in Column::ALL {
1729                let _ = attention.set(always(channel), column, Level::As);
1730            }
1731        }
1732    } else if !config.pause.people {
1733        for channel in [Channel::Calls, Channel::Messages] {
1734            let _ = attention.set(always(channel), Column::Pause, Level::As);
1735        }
1736    }
1737    attention
1738}
1739
1740/// What the older keys made Sioul do, written as cells (docs/attention.md, §5).
1741fn older(config: &Config) -> [[Level; 9]; 36] {
1742    use Column::*;
1743    let mut cells = [[Level::Now; 9]; 36];
1744    // `[notify]`: each kind's row, as `column[:value]` words.
1745    let notify = |row: &str, column: Column| -> &'static str {
1746        let usual = older_notify(row, column);
1747        let mut value = usual;
1748        for text in config.notify.words(row).unwrap_or_default() {
1749            let (col, val) = match text.rsplit_once(':') {
1750                Some((c, v)) => (Column::read(c), v.trim().to_ascii_lowercase()),
1751                None => (Column::read(&text), "now".to_string()),
1752            };
1753            if col == Some(column)
1754                && let Some(known) = ["now", "list-any", "list", "event", "gathered", "later", "never"].into_iter().find(|k| *k == val.replace('_', "-"))
1755            {
1756                value = known;
1757            }
1758        }
1759        value
1760    };
1761    let doses = |column: Column| match column {
1762        Sleep if config.notify.words("doses").is_none_or(|w| !w.iter().any(|w| w.starts_with("sleep"))) => if config.reminders.doses_in_sleep { "now" } else { "later" },
1763        Pause if config.notify.words("doses").is_none_or(|w| !w.iter().any(|w| w.starts_with("pause"))) => if config.pause.doses { "now" } else { "later" },
1764        _ => notify("doses", column),
1765    };
1766    let level = |value: &str| match value {
1767        "now" | "list-any" => Level::Now,
1768        "event" => Level::Event,
1769        "gathered" => Level::Gathered,
1770        "never" => Level::Never,
1771        _ => Level::Later,
1772    };
1773    for kind in Kind::ALL {
1774        for column in Column::ALL {
1775            let value = if kind == Kind::Doses { doses(column) } else { notify(kind.id(), column) };
1776            cells[Row::Own(kind).index()][column.index()] = level(value);
1777        }
1778    }
1779    // `[reach]`: the columns ticked for each row (mail's at its top, calls' and messages' in their tables).
1780    let reach = &config.reach;
1781    let before = reach.stranger.is_none();
1782    let ticks = |words: &[String]| -> [bool; 6] {
1783        let mut on = [false; 6];
1784        for column in words.iter().filter_map(|w| Column::read(w)) {
1785            if let Some(at) = [Work, Admin, Leisure, Meals, Sleep, Pause].iter().position(|c| *c == column) {
1786                on[at] = true;
1787            }
1788        }
1789        on
1790    };
1791    let mail_usual = |person: Person| -> [bool; 6] {
1792        match person {
1793            Person::Safe => [true; 6],
1794            Person::Neutral | Person::Stranger => [true, true, false, false, false, false],
1795            _ => [true, false, false, false, false, false],
1796        }
1797    };
1798    let mail_row = |set: &Option<Vec<String>>, person: Person| -> [bool; 6] {
1799        match set {
1800            None => mail_usual(person),
1801            Some(words) => {
1802                let mut on = ticks(words);
1803                // Written before the five states: the pause was sleep's.
1804                if before {
1805                    on[5] = on[4];
1806                }
1807                on
1808            }
1809        }
1810    };
1811    let neutral = mail_row(&reach.neutral, Person::Neutral);
1812    let mail: BTreeMap<Person, [bool; 6]> = [
1813        (Person::Safe, mail_row(&reach.safe, Person::Safe)),
1814        (Person::Neutral, neutral),
1815        (Person::Restricted, mail_row(&reach.restricted, Person::Restricted)),
1816        (Person::Stranger, if before { neutral } else { mail_row(&reach.stranger, Person::Stranger) }),
1817    ]
1818    .into_iter()
1819    .collect();
1820    let calls_usual = |person: Person| -> [bool; 6] {
1821        match person {
1822            Person::Safe => [true, true, true, true, false, false],
1823            Person::Neutral | Person::Hidden => [true, true, false, false, false, false],
1824            Person::Restricted => [true, false, false, false, false, false],
1825            _ => [false; 6],
1826        }
1827    };
1828    let row_of = |set: &crate::config::RowSettings, person: Person| match person {
1829        Person::Safe => set.safe.clone(),
1830        Person::Neutral => set.neutral.clone(),
1831        Person::Restricted => set.restricted.clone(),
1832        Person::Stranger => set.stranger.clone(),
1833        Person::Hidden => set.hidden.clone(),
1834        _ => None,
1835    };
1836    let times6 = [Work, Admin, Leisure, Meals, Sleep, Pause];
1837    for person in [Person::Safe, Person::Neutral, Person::Restricted, Person::Stranger] {
1838        // Mail: ticked and told now, at once; ticked and told later, shown, not told; unticked, later.
1839        let on = mail[&person];
1840        let row = Row::People(Channel::Mail, person).index();
1841        let told = |value: &str| match value {
1842            "now" => Level::Now,
1843            "never" => Level::Never,
1844            _ => Level::Quiet,
1845        };
1846        for (at, column) in times6.iter().enumerate() {
1847            cells[row][column.index()] = if on[at] { told(notify("mail", *column)) } else { Level::Later };
1848        }
1849        cells[row][Free.index()] = if person == Person::Safe && on[2] { told(notify("mail", Free)) } else { Level::Later };
1850        cells[row][Slot.index()] = told(notify("mail", Slot));
1851        cells[row][Dnd.index()] = match notify("mail", Dnd) {
1852            "now" => Level::Now,
1853            "never" => Level::Never,
1854            _ => Level::Quiet,
1855        };
1856        // Messages: their own ticks, else mail's; told now at once, else later.
1857        let on = row_of(&reach.messages, person).map_or(on, |w| ticks(&w));
1858        let row = Row::People(Channel::Messages, person).index();
1859        let now_or_later = |value: &str| if value == "now" { Level::Now } else { Level::Later };
1860        for (at, column) in times6.iter().enumerate() {
1861            cells[row][column.index()] = if on[at] { now_or_later(notify("app-people", *column)) } else { Level::Later };
1862        }
1863        cells[row][Free.index()] = if person == Person::Safe && on[2] { now_or_later(notify("app-people", Free)) } else { Level::Later };
1864        cells[row][Slot.index()] = now_or_later(notify("app-people", Slot));
1865        cells[row][Dnd.index()] = now_or_later(notify("app-people", Dnd));
1866        if person == Person::Stranger {
1867            cells[Row::People(Channel::Messages, Person::Groups).index()] = cells[row];
1868        }
1869    }
1870    // Calls: ticked, it rings; unticked, voicemail; Free time the safe at leisure's tick; no layer.
1871    for person in [Person::Safe, Person::Neutral, Person::Restricted, Person::Stranger, Person::Hidden] {
1872        let on = row_of(&reach.calls, person).map_or(calls_usual(person), |w| ticks(&w));
1873        let row = Row::People(Channel::Calls, person).index();
1874        for (at, column) in times6.iter().enumerate() {
1875            cells[row][column.index()] = if on[at] { Level::Now } else { Level::Later };
1876        }
1877        cells[row][Free.index()] = if person == Person::Safe && on[2] { Level::Now } else { Level::Later };
1878        cells[row][Slot.index()] = Level::Now;
1879        cells[row][Dnd.index()] = Level::Now;
1880    }
1881    // Always through: the do-not-disturb list. Mail and messages as their own
1882    // row, through do-not-disturb as `[notify]` said; calls at any time, a floor.
1883    for channel in Channel::ALL {
1884        let row = Row::People(channel, Person::Always).index();
1885        cells[row] = [Level::As; 9];
1886        if !config.dnd.people {
1887            continue;
1888        }
1889        match channel {
1890            Channel::Calls => cells[row] = [Level::Now; 9],
1891            Channel::Mail | Channel::Messages => {
1892                cells[row][Dnd.index()] = match notify(if channel == Channel::Mail { "mail" } else { "app-people" }, Dnd) {
1893                    "list" => Level::Through,
1894                    "list-any" => Level::Now,
1895                    _ => Level::As,
1896                };
1897            }
1898        }
1899        // The pause's mode let nobody through: the list's calls neither.
1900        if !config.pause.people && channel != Channel::Mail {
1901            cells[row][Pause.index()] = Level::As;
1902        }
1903    }
1904    for channel in Channel::ALL {
1905        cells[Row::People(channel, Person::Blocked).index()] = [Level::Never; 9];
1906    }
1907    cells
1908}
1909
1910/// `[notify]`'s usual values, as it had them (notify.rs at 2e9bbf1).
1911fn older_notify(row: &str, column: Column) -> &'static str {
1912    use Column::*;
1913    let hours = matches!(column, Work | Admin | Leisure | Meals);
1914    let held = matches!(column, Sleep | Pause | Free);
1915    match row {
1916        "codes" => if matches!(column, Sleep | Pause) { "never" } else { "now" },
1917        "doses" | "wake" | "time" => "now",
1918        "alarms" | "before" => if matches!(column, Sleep | Free) { "event" } else { "now" },
1919        "day-before" | "dates" => if held { "later" } else { "now" },
1920        "needs" => if held { "never" } else { "now" },
1921        "move" => if hours { "now" } else { "never" },
1922        "work-over" => if column == Work || held { "never" } else { "now" },
1923        "mail" => match column {
1924            Dnd => "list",
1925            _ if hours => "now",
1926            _ => "later",
1927        },
1928        "sites" | "app-automatons" => if hours { "gathered" } else { "later" },
1929        "sites-live" | "app-at-once" => if hours { "now" } else { "later" },
1930        "site-calls" => if hours || column == Slot { "now" } else { "later" },
1931        "app-people" => if column == Dnd { "list" } else { "now" },
1932        _ => "now",
1933    }
1934}
1935
1936/// A row changed from Settings (`attention.<row>`, its words; the older
1937/// grids' `notify.<kind>` and the senders' ticks come here too, `reach.<row>`):
1938/// each word read, a fixed cell or a value not offered there refused, a word
1939/// not understood refused; the row then written whole, or taken out when it
1940/// says the usual. The first time, every row the older keys seeded is
1941/// written, and they are taken out.
1942///
1943/// Read, changed and written under the configuration's lock, which the
1944/// sharing takes too, from the configuration as it is then (`config` is
1945/// what the caller read: the file is read again under the lock).
1946pub fn apply(path: &Path, config: &Config, key: &str, value: &SettingValue) -> Result<(), String> {
1947    crate::filelock::with_lock(path, || {
1948        let fresh = if path.exists() { Config::load(path)? } else { config.clone() };
1949        apply_locked(path, &fresh, key, value)
1950    })
1951}
1952
1953/// A row changed from the window: only the cells the window changed, from
1954/// what its row showed (`shown`, its words then) to `value`, set over the
1955/// row as the configuration holds it now; a cell another device changed
1956/// meanwhile stays. Without `shown`, every word of `value`.
1957pub fn apply_change(path: &Path, key: &str, shown: Option<&SettingValue>, value: &SettingValue) -> Result<(), String> {
1958    let shown: Option<&[String]> = match shown {
1959        Some(SettingValue::Texts(words)) => Some(words),
1960        _ => None,
1961    };
1962    let value = match (value, shown) {
1963        (SettingValue::Texts(words), Some(shown)) if !key.starts_with("reach.") => SettingValue::Texts(words.iter().filter(|w| !shown.contains(w)).cloned().collect()),
1964        _ => value.clone(),
1965    };
1966    apply(path, &Config::default(), key, &value)
1967}
1968
1969fn apply_locked(path: &Path, config: &Config, key: &str, value: &SettingValue) -> Result<(), String> {
1970    let words: &[String] = match value {
1971        SettingValue::Texts(words) => words,
1972        SettingValue::Ints(none) if none.is_empty() => &[],
1973        _ => return Err(format!("{key}: a list of columns expected")),
1974    };
1975    let mut attention = Attention::of(config);
1976    if let Some(rest) = key.strip_prefix("reach.") {
1977        // The older grid of who may reach you: ticks across work…pause.
1978        let (channel, person) = rest.split_once('.').unwrap_or(("mail", rest));
1979        let row = Row::read(&format!("{channel}.{person}")).filter(|r| matches!(r, Row::People(_, p) if p.state())).ok_or_else(|| format!("{key}: no such row"))?;
1980        if screened(row) {
1981            return Err(screened_refusal(key));
1982        }
1983        let ticked: Vec<Column> = words.iter().filter_map(|w| Column::read(w)).collect();
1984        for column in [Column::Work, Column::Admin, Column::Leisure, Column::Meals, Column::Sleep, Column::Pause] {
1985            let comes = matches!(attention.cell(row, column), Level::Now | Level::Quiet);
1986            let level = match (ticked.contains(&column), comes) {
1987                (true, true) => continue,
1988                (true, false) if matches!(row, Row::People(Channel::Mail, _)) && matches!(column, Column::Sleep | Column::Pause) => Level::Quiet,
1989                (true, false) => Level::Now,
1990                (false, _) => Level::Later,
1991            };
1992            attention.set(row, column, level)?;
1993        }
1994        return write(path, config, &attention, &[row]);
1995    }
1996    // A source's own row (an app's, a conversation's): its cells, and its name ("name=…").
1997    if let Some(id) = key.strip_prefix("attention.").filter(|id| is_source(id)) {
1998        for text in words {
1999            if let Some(name) = text.strip_prefix(NAME) {
2000                attention.name_source(id, name);
2001                continue;
2002            }
2003            let (column, level) = word(text).ok_or_else(|| format!("{key}: {text}: not understood"))?;
2004            attention.set_source(id, column, level)?;
2005        }
2006        return write_with(path, config, &attention, &[], &[id.to_string()]);
2007    }
2008    let rest = key.strip_prefix("attention.").or_else(|| key.strip_prefix("notify.")).ok_or_else(|| format!("{key}: no such row"))?;
2009    let row = Row::read(rest).ok_or_else(|| format!("{key}: no such row"))?;
2010    if screened(row) {
2011        return Err(screened_refusal(key));
2012    }
2013    for text in words {
2014        let (column, level) = word(text).ok_or_else(|| format!("{key}: {text}: not understood"))?;
2015        attention.set(row, column, level)?;
2016    }
2017    write(path, config, &attention, &[row])
2018}
2019
2020/// The refusal said when a write would set one of the call screening's rows.
2021fn screened_refusal(key: &str) -> String {
2022    format!("{key}: the call screening's own (Settings ▸ This phone ▸ Calls ▸ Call screening: screening.unknown, screening.hidden)")
2023}
2024
2025/// A preset chosen: every row written as it says (the usual taken out),
2026/// under the configuration's lock, from the file as it is then; the call
2027/// screening's rows left as they are (`screened`).
2028pub fn apply_preset(path: &Path, config: &Config, preset: Preset) -> Result<(), String> {
2029    crate::filelock::with_lock(path, || {
2030        let fresh = if path.exists() { Config::load(path)? } else { config.clone() };
2031        let matrix = preset.matrix();
2032        write(path, &fresh, &matrix, &Row::ALL)
2033    })
2034}
2035
2036/// `rows` written as `attention` has them, each whole or taken out when
2037/// usual; the first time, every row that is not usual too, and the older keys out.
2038fn write(path: &Path, config: &Config, attention: &Attention, rows: &[Row]) -> Result<(), String> {
2039    write_with(path, config, attention, rows, &[])
2040}
2041
2042/// `write`, with sources' own rows (`sources`, by id) written too, each as
2043/// set or taken out when as usual everywhere; the other sources' rows stay.
2044/// The call screening's rows are never set (`screened`): left as they are;
2045/// the first time, written as the older keys seeded them, so that taking
2046/// those keys out moves neither them nor the screening's first values.
2047fn write_with(path: &Path, config: &Config, attention: &Attention, rows: &[Row], sources: &[String]) -> Result<(), String> {
2048    let usual = Attention::usual();
2049    let first = config.attention.is_none();
2050    let now = Attention::of(config);
2051    let rows: Vec<Row> = if first { Row::ALL.to_vec() } else { rows.iter().copied().filter(|r| !screened(*r)).collect() };
2052    let mut said: Vec<(String, Option<Vec<String>>)> = rows
2053        .iter()
2054        .filter(|r| !matches!(r, Row::People(_, Person::Blocked)))
2055        .map(|row| {
2056            let attention = if screened(*row) { &now } else { attention };
2057            let same = Column::ALL.iter().all(|c| attention.cell(*row, *c) == usual.cell(*row, *c));
2058            (row.id(), (!same).then(|| attention.words(*row)))
2059        })
2060        .collect();
2061    for id in sources {
2062        let words = attention.source_words(id);
2063        said.push((id.clone(), (!words.is_empty()).then_some(words)));
2064    }
2065    crate::config::set_table(path, "attention", &said, if first { OLDER } else { &[] })
2066}
2067
2068// ---------------------------------------------------------------- time for you
2069
2070/// The slots file, in the state folder: this device's own.
2071pub const SLOTS_FILE: &str = "slots.toml";
2072
2073/// Today's slots of time for you (docs/capacity.md, G18b), as the window
2074/// that laid out the day wrote them: every process reads them, so that Time
2075/// for you holds everywhere.
2076#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2077pub struct Slots {
2078    /// The day they are for; none, no slot.
2079    #[serde(default, skip_serializing_if = "Option::is_none")]
2080    pub day: Option<jiff::civil::Date>,
2081    /// From, to, Unix seconds.
2082    #[serde(default)]
2083    pub slots: Vec<(i64, i64)>,
2084}
2085
2086impl Slots {
2087    pub fn default_path() -> PathBuf {
2088        crate::config::state_dir().join(SLOTS_FILE)
2089    }
2090
2091    /// As kept; none when missing or unreadable.
2092    pub fn load(path: &Path) -> Slots {
2093        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
2094    }
2095
2096    /// Written whole beside, then put in place, only when it changed.
2097    pub fn save(&self, path: &Path) -> Result<bool, String> {
2098        if Slots::load(path) == *self && path.exists() {
2099            return Ok(false);
2100        }
2101        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
2102        if let Some(parent) = path.parent() {
2103            std::fs::create_dir_all(parent).map_err(fail)?;
2104        }
2105        let fresh = path.with_extension(format!("toml.{}", std::process::id()));
2106        std::fs::write(&fresh, toml::to_string(self).map_err(|e| e.to_string())?).map_err(fail)?;
2107        std::fs::rename(&fresh, path).map_err(fail)?;
2108        Ok(true)
2109    }
2110
2111    /// Whether `at` is in one of the slots of its own day.
2112    pub fn at(&self, at: &jiff::Zoned) -> bool {
2113        self.day == Some(at.date()) && self.holds(at.timestamp().as_second())
2114    }
2115
2116    /// Whether `stamp` (Unix seconds) is in one of the slots.
2117    pub fn holds(&self, stamp: i64) -> bool {
2118        self.slots.iter().any(|&(from, to)| from <= stamp && stamp < to)
2119    }
2120
2121    /// Where Time for you begins or ends (Unix seconds): where what may come can change.
2122    pub fn edges(&self) -> Vec<i64> {
2123        let mut edges: Vec<i64> = self.slots.iter().flat_map(|&(from, to)| [from, to]).collect();
2124        edges.sort_unstable();
2125        edges.dedup();
2126        edges
2127    }
2128}
2129
2130/// Whether do-not-disturb holds now from its switch or a focus session, as
2131/// the shared files say (`everywhere`): its layer, for what runs without the
2132/// window (`sioul remind --watch`, `sioul watch`, a phone's alarms and listener).
2133/// The switch and the reasons read as the window reads them: the devices
2134/// gone from the sharing as As usual (`everywhere::read_switch`), the pause,
2135/// Free time, the nights and focus (`everywhere::sources_from_files`), so that
2136/// a pause ends Reachable here too (review of part 3, F7).
2137pub fn dnd_from_files(config: &Config, now: i64) -> bool {
2138    use crate::everywhere;
2139    let zoned = jiff::Timestamp::from_second(now).map_or_else(|_| jiff::Zoned::now(), |t| t.to_zoned(jiff::tz::TimeZone::system()));
2140    let sources = everywhere::sources_from_files(&zoned);
2141    everywhere::now(&config.dnd, &sources, everywhere::read_switch("").latest().as_ref(), now).gates()
2142}
2143
2144// ---------------------------------------------------------------- words
2145
2146/// An id and its words.
2147#[derive(Debug, Clone, PartialEq, Serialize)]
2148pub struct Named {
2149    pub id: String,
2150    pub label: String,
2151}
2152
2153/// A value, its mark, and its words.
2154#[derive(Debug, Clone, PartialEq, Serialize)]
2155pub struct Mark {
2156    pub id: String,
2157    pub mark: String,
2158    pub label: String,
2159}
2160
2161/// A row as Settings draws it: its words, its group's, a sentence on it, the
2162/// values its cells may take in its own words, its cells, and whether it
2163/// differs from the preset it is compared with.
2164#[derive(Debug, Clone, PartialEq, Serialize)]
2165pub struct GridRow {
2166    pub id: String,
2167    pub label: String,
2168    pub group: String,
2169    /// The group's id: "mail", "calls", "messages", "asked", "reminders", "day", "sites", "apps".
2170    pub group_id: String,
2171    pub help: String,
2172    pub choices: Vec<Mark>,
2173    pub cells: Vec<GridCell>,
2174    /// A cell differs from the preset compared with.
2175    pub changed: bool,
2176}
2177
2178/// A cell: its column, its value, those it may take; why it is fixed, when it
2179/// is; its value differs from the preset's; and the cell in a sentence.
2180#[derive(Debug, Clone, PartialEq, Serialize)]
2181pub struct GridCell {
2182    pub column: String,
2183    pub value: String,
2184    pub choices: Vec<String>,
2185    #[serde(skip_serializing_if = "String::is_empty")]
2186    pub locked: String,
2187    #[serde(skip_serializing_if = "std::ops::Not::not")]
2188    pub changed: bool,
2189    pub said: String,
2190}
2191
2192/// A preset, and how far the matrix is from it.
2193#[derive(Debug, Clone, PartialEq, Serialize)]
2194pub struct PresetView {
2195    pub id: String,
2196    pub label: String,
2197    /// The matrix is this one.
2198    pub current: bool,
2199    /// Cells that differ from it.
2200    pub changes: usize,
2201}
2202
2203/// The matrix in words, as Settings draws it: the columns (a layer flagged),
2204/// the levels with their marks (the legend), the rows, the presets, and the
2205/// moment now in a sentence when one is given.
2206#[derive(Debug, Clone, PartialEq, Serialize)]
2207pub struct Grid {
2208    pub columns: Vec<Named>,
2209    /// The two layers' ids, after the seven times.
2210    pub layers: Vec<String>,
2211    pub marks: Vec<Mark>,
2212    pub rows: Vec<GridRow>,
2213    pub presets: Vec<PresetView>,
2214    /// "Changes from As Sioul does now", said; "" when none.
2215    pub changed: String,
2216}
2217
2218/// A column's words.
2219pub fn column_label(tr: &Translator, column: Column) -> String {
2220    tr.text(&format!("attention-column-{}", column.id()), None)
2221}
2222
2223/// A row's words: its person's or its kind's.
2224pub fn row_label(tr: &Translator, row: Row) -> String {
2225    match row {
2226        Row::People(_, person) => tr.text(&format!("attention-person-{}", person.id()), None),
2227        Row::Own(kind) => tr.text(&format!("attention-row-{}", kind.id()), None),
2228    }
2229}
2230
2231/// A value in a row's own words: a code not told stays on the Porch;
2232/// Always through's at once is whatever their list; mail not at all stays in its lane.
2233pub fn level_label(tr: &Translator, row: Row, level: Level) -> String {
2234    let own = match (row, level) {
2235        (Row::Own(Kind::Codes), Level::Never) => Some("attention-level-never-codes"),
2236        (Row::People(_, Person::Always), Level::Now) => Some("attention-level-now-always"),
2237        (Row::People(Channel::Mail, _), Level::Never) => Some("attention-level-never-mail"),
2238        (Row::People(Channel::Calls, _), Level::Later) => Some("attention-level-later-calls"),
2239        (Row::People(Channel::Messages, _), Level::Quiet) => Some("attention-level-quiet-messages"),
2240        _ => None,
2241    };
2242    tr.text(own.unwrap_or(&format!("attention-level-{}", level.id())), None)
2243}
2244
2245/// The grid, in `tr`'s words, of `rows` (all of them, or a part: a
2246/// channel's, the kinds'), compared with `preset`. The moment now is said by
2247/// the window, which knows the devices and the hours (`reaches::now_sentences`).
2248pub fn grid(attention: &Attention, tr: &Translator, rows: &[Row], preset: Preset) -> Grid {
2249    let compared = preset.matrix();
2250    let columns = Column::ALL.iter().map(|c| Named { id: c.id().to_string(), label: column_label(tr, *c) }).collect();
2251    let marks = Level::ALL.iter().map(|l| Mark { id: l.id().to_string(), mark: l.mark(false).to_string(), label: tr.text(&format!("attention-level-{}", l.id()), None) }).collect();
2252    let rows: Vec<GridRow> = rows
2253        .iter()
2254        .map(|row| {
2255            let mut offered: Vec<Level> = Vec::new();
2256            for column in Column::ALL {
2257                for level in choices(*row, column) {
2258                    if !offered.contains(level) {
2259                        offered.push(*level);
2260                    }
2261                }
2262            }
2263            offered.sort();
2264            let label = row_label(tr, *row);
2265            let group = tr.text(&format!("attention-group-{}", row.group()), None);
2266            let cells: Vec<GridCell> = Column::ALL
2267                .iter()
2268                .map(|column| {
2269                    let value = attention.cell(*row, *column);
2270                    let locked = lock(*row, *column).map(|id| tr.text(id, None)).unwrap_or_default();
2271                    let mut args = crate::i18n::args();
2272                    args.set("row", if matches!(row, Row::People(..)) { format!("{group}, {label}") } else { label.clone() });
2273                    args.set("column", column_label(tr, *column));
2274                    args.set("value", level_label(tr, *row, value));
2275                    let said = tr.text("attention-cell", Some(&args));
2276                    GridCell {
2277                        column: column.id().to_string(),
2278                        value: value.id().to_string(),
2279                        choices: choices(*row, *column).iter().map(|l| l.id().to_string()).collect(),
2280                        changed: value != compared.cell(*row, *column),
2281                        said: if locked.is_empty() { said } else { format!("{said}. {locked}") },
2282                        locked,
2283                    }
2284                })
2285                .collect();
2286            let help = match row {
2287                Row::People(_, person) => tr.text(&format!("attention-person-{}-help", person.id()), None),
2288                Row::Own(kind) => tr.text(&format!("attention-row-{}-help", kind.id()), None),
2289            };
2290            GridRow {
2291                id: row.id(),
2292                group: group.clone(),
2293                group_id: row.group().to_string(),
2294                help,
2295                choices: offered.iter().map(|l| Mark { id: l.id().to_string(), mark: l.mark(row.always()).to_string(), label: level_label(tr, *row, *l) }).collect(),
2296                changed: cells.iter().any(|c| c.changed),
2297                label,
2298                cells,
2299            }
2300        })
2301        .collect();
2302    let presets: Vec<PresetView> = Preset::ALL
2303        .iter()
2304        .map(|p| {
2305            let changes = attention.changes_from(&p.matrix());
2306            PresetView { id: p.id().to_string(), label: tr.text(&format!("attention-preset-{}", p.id()), None), current: changes == 0, changes }
2307        })
2308        .collect();
2309    let from_usual = attention.changes_from(&Attention::usual());
2310    let changed = if from_usual == 0 { String::new() } else { tr.text("attention-changes", Some(&tr.counted(from_usual))) };
2311    Grid {
2312        columns,
2313        layers: vec![Column::Slot.id().to_string(), Column::Dnd.id().to_string()],
2314        marks,
2315        rows,
2316        presets,
2317        changed,
2318    }
2319}
2320
2321/// The rows of a channel, the blocked last.
2322pub fn channel_rows(channel: Channel) -> Vec<Row> {
2323    persons(channel).iter().map(|p| Row::People(channel, *p)).collect()
2324}
2325
2326/// The kinds' rows: Sioul's own and the automated sources.
2327pub fn kind_rows() -> Vec<Row> {
2328    Kind::ALL.iter().map(|k| Row::Own(*k)).collect()
2329}
2330
2331/// A source's row to draw (`source_grid`): its id (`app.<package>`,
2332/// `conversation.<key>`), its words (an app's name; a conversation's title
2333/// and its app's), and the group it shows in, in words and by id.
2334#[derive(Debug, Clone, PartialEq, Eq)]
2335pub struct SourceLine {
2336    pub id: String,
2337    pub label: String,
2338    pub group: String,
2339    pub group_id: String,
2340}
2341
2342/// The sources' own rows as Settings draws them (`AttentionGrid.qml`): each
2343/// a row of the nine columns, its cells as set or as usual, each in a
2344/// sentence; a cell set carries the grid's dot. The values a source's cell
2345/// takes, in their own words: at once, at the gathered times, held, as usual.
2346pub fn source_grid(attention: &Attention, tr: &Translator, lines: &[SourceLine]) -> Grid {
2347    let columns = Column::ALL.iter().map(|c| Named { id: c.id().to_string(), label: column_label(tr, *c) }).collect();
2348    let label = |level: Level| tr.text(&format!("attention-source-{}", level.id()), None);
2349    let choices: Vec<Mark> = SOURCE_CHOICES.iter().map(|l| Mark { id: l.id().to_string(), mark: l.mark(false).to_string(), label: label(*l) }).collect();
2350    let offered: Vec<String> = SOURCE_CHOICES.iter().map(|l| l.id().to_string()).collect();
2351    let rows = lines
2352        .iter()
2353        .map(|line| {
2354            let set = attention.source(&line.id).unwrap_or([Level::As; 9]);
2355            let cells: Vec<GridCell> = Column::ALL
2356                .iter()
2357                .map(|column| {
2358                    let value = set[column.index()];
2359                    let mut args = crate::i18n::args();
2360                    args.set("row", line.label.clone());
2361                    args.set("column", column_label(tr, *column));
2362                    args.set("value", label(value));
2363                    GridCell {
2364                        column: column.id().to_string(),
2365                        value: value.id().to_string(),
2366                        choices: offered.clone(),
2367                        locked: String::new(),
2368                        changed: value != Level::As,
2369                        said: tr.text("attention-cell", Some(&args)),
2370                    }
2371                })
2372                .collect();
2373            GridRow {
2374                id: line.id.clone(),
2375                label: line.label.clone(),
2376                group: line.group.clone(),
2377                group_id: line.group_id.clone(),
2378                help: tr.text("attention-source-help", None),
2379                choices: choices.clone(),
2380                changed: cells.iter().any(|c| c.changed),
2381                cells,
2382            }
2383        })
2384        .collect();
2385    Grid {
2386        columns,
2387        layers: vec![Column::Slot.id().to_string(), Column::Dnd.id().to_string()],
2388        marks: choices,
2389        rows,
2390        presets: Vec::new(),
2391        changed: String::new(),
2392    }
2393}
2394
2395/// A source's row in one sentence: where it comes at once, where at the
2396/// gathered times, where it is held, and as usual the rest of the time; "As
2397/// usual at every time." when nothing is set. "At once: Work and Do not
2398/// disturb; held: Leisure and Sleep; as usual the rest of the time."
2399pub fn source_sentence(attention: &Attention, tr: &Translator, id: &str) -> String {
2400    let Some(cells) = attention.source(id) else { return tr.text("attention-source-usual", None) };
2401    let and = tr.text("word-and", None);
2402    let mut parts: Vec<String> = [Level::Now, Level::Gathered, Level::Later]
2403        .iter()
2404        .filter_map(|level| {
2405            let columns: Vec<String> = Column::ALL.iter().filter(|c| cells[c.index()] == *level).map(|c| column_label(tr, *c)).collect();
2406            let columns = match columns.as_slice() {
2407                [] => return None,
2408                [one] => one.clone(),
2409                [rest @ .., last] => format!("{} {and} {last}", rest.join(", ")),
2410            };
2411            let mut args = crate::i18n::args();
2412            args.set("columns", columns);
2413            Some(tr.text(&format!("attention-source-said-{}", level.id()), Some(&args)))
2414        })
2415        .collect();
2416    if cells.contains(&Level::As) {
2417        parts.push(tr.text("attention-source-said-as", None));
2418    }
2419    let mut args = crate::i18n::args();
2420    args.set("parts", parts.join(&tr.text("attention-source-said-join", None)));
2421    let said = tr.text("attention-source-said", Some(&args));
2422    let mut letters = said.chars();
2423    letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default()
2424}
2425
2426/// A state with the times its mail comes or shows, for a list's choice:
2427/// "Your contacts: work, admin", "Safe: any time", "Blocked: never"; `one`: as
2428/// said of one person (French says "Sûr" of a person, "Sûrs" of the list).
2429pub fn list_choice(tr: &Translator, who: Who, attention: &Attention, one: bool) -> String {
2430    let times: Vec<Column> = if who == Who::Blocked {
2431        Vec::new()
2432    } else {
2433        Column::TIMES.iter().copied().filter(|c| *c != Column::Free).filter(|c| matches!(attention.cell(Row::People(Channel::Mail, Person::of(who)), *c), Level::Now | Level::Quiet)).collect()
2434    };
2435    let words = if times.len() == 6 {
2436        tr.text("reach-any", None)
2437    } else if times.is_empty() {
2438        tr.text("reach-never", None)
2439    } else {
2440        times.iter().map(|c| tr.text(&format!("reach-word-{}", c.id()), None)).collect::<Vec<_>>().join(", ")
2441    };
2442    let mut args = crate::i18n::args();
2443    args.set("list", tr.text(&format!("sender-{}-{}", if one { "one" } else { "list" }, who.id()), None));
2444    args.set("times", words);
2445    tr.text("sender-list-times", Some(&args))
2446}
2447
2448#[cfg(test)]
2449mod tests {
2450    use super::*;
2451    use crate::areas::Area;
2452    use crate::quiet::Reason;
2453
2454    fn mode(time: Time, reason: Reason) -> Mode {
2455        Mode { quiet: !(time.works() || time == Time::Any), time, week: Week { work_hours: true, admin_hours: true, meals: true, sleep: true }, reason, until: None, back: None, label: String::new() }
2456    }
2457
2458    /// Every time `quiet::mode` gives, each with its reason.
2459    fn modes() -> Vec<Mode> {
2460        vec![
2461            mode(Time::Work, Reason::Working),
2462            mode(Time::Work, Reason::WorkingLate),
2463            mode(Time::Admin, Reason::AdminTime),
2464            mode(Time::Several(Area::MIXED), Reason::Working),
2465            mode(Time::Leisure, Reason::Evening),
2466            mode(Time::Leisure, Reason::DayOff),
2467            mode(Time::Leisure, Reason::DoneForTheDay),
2468            mode(Time::Meals, Reason::Meal),
2469            mode(Time::Any, Reason::NoHours),
2470            mode(Time::Sleep, Reason::Sleep),
2471            mode(Time::Sleep, Reason::WindingDown),
2472            mode(Time::Sleep, Reason::Nap),
2473            mode(Time::Leisure, Reason::FreeTime),
2474            mode(Time::Sleep, Reason::Paused),
2475        ]
2476    }
2477
2478    fn row(id: &str) -> Row {
2479        Row::read(id).unwrap_or_else(|| panic!("{id}"))
2480    }
2481
2482    /// The usual matrix, written out: a test that fails here changes what
2483    /// Sioul does by default. Marks for brevity, in the columns' order
2484    /// (work admin leisure meals sleep pause free | slot dnd).
2485    #[test]
2486    fn the_usual_matrix_as_decided() {
2487        let expected = [
2488            ("mail.always", "●●●●◑◑●●●"),
2489            ("mail.safe", "●●●●◑◑◑◑◑"),
2490            ("mail.neutral", "●●○○○○○◑◑"),
2491            ("mail.restricted", "●○○○○○○◑◑"),
2492            ("mail.stranger", "●●○○○○○◑◑"),
2493            ("mail.blocked", "–––––––––"),
2494            ("calls.always", "●●●●●●●●●"),
2495            ("calls.safe", "●●●●○○●●○"),
2496            ("calls.neutral", "●●●●○○○●○"),
2497            ("calls.restricted", "●○○○○○○●○"),
2498            ("calls.stranger", "○○○○○○○●○"),
2499            ("calls.hidden", "●●○○○○○●○"),
2500            ("calls.blocked", "–––––––––"),
2501            ("messages.always", "●●●●●●●●●"),
2502            ("messages.safe", "●●●●○○●●◑"),
2503            ("messages.neutral", "●●●●○○○●◑"),
2504            ("messages.restricted", "●○○○○○○●○"),
2505            ("messages.stranger", "●●○○○○○●○"),
2506            ("messages.groups", "●●●●○○○●◑"),
2507            ("messages.blocked", "–––––––––"),
2508            ("codes", "●●●●●●●●●"),
2509            ("doses", "●●●●●●●●●"),
2510            ("wake", "●●●●●●●●●"),
2511            ("alarms", "●●●●◐●◐●●"),
2512            ("before", "●●●●◐●◐●●"),
2513            ("day-before", "●●●●○○○●●"),
2514            ("dates", "●●●●○○○●●"),
2515            ("needs", "●●●●–––●●"),
2516            ("move", "●●●●–––––"),
2517            ("work-over", "–●●●–––●●"),
2518            ("time", "●●●●●●●●●"),
2519            ("sites", "◎◎◎◎○○○○○"),
2520            ("sites-live", "●●●●○○○○○"),
2521            ("site-calls", "●●●●○○○●○"),
2522            ("app-automatons", "◎◎◎◎○○○○○"),
2523            ("app-at-once", "●●●●○○○○○"),
2524        ];
2525        let usual = Attention::usual();
2526        assert_eq!(expected.len(), Row::ALL.len());
2527        for (id, marks) in expected {
2528            let r = row(id);
2529            let said: String = Column::ALL.iter().map(|c| usual.cell(r, *c).mark(false)).collect();
2530            assert_eq!(said, marks, "{id}");
2531            for column in Column::ALL {
2532                // Each usual value is one its cell may take, fixed cells included; a fixed cell holds its usual.
2533                assert!(choices(r, column).contains(&usual.cell(r, column)), "{id} {column:?}");
2534            }
2535            assert_eq!(r.id(), id, "ids both ways");
2536        }
2537        assert_eq!(Attention::of(&Config::default()), usual, "an empty configuration is the usual matrix");
2538        assert_eq!(Row::read("calls.groups"), None, "calls have no groups' row");
2539        assert_eq!(Row::read("Day_Before"), Some(Row::Own(Kind::DayBefore)));
2540    }
2541
2542    #[test]
2543    fn fixed_cells_and_choices() {
2544        let mut a = Attention::usual();
2545        assert!(a.set(row("doses"), Column::Dnd, Level::Later).is_err(), "a dose comes during do-not-disturb");
2546        assert!(a.set(row("doses"), Column::Sleep, Level::Never).is_err(), "a dose is never dropped");
2547        assert!(a.set(row("doses"), Column::Pause, Level::Later).is_ok());
2548        assert!(a.set(row("codes"), Column::Pause, Level::Never).is_err(), "a code in a pause comes, fixed");
2549        assert!(a.set(row("codes"), Column::Sleep, Level::Never).is_ok(), "On the Porch only stays a choice for sleep");
2550        assert!(a.set(row("alarms"), Column::Pause, Level::Later).is_err() && a.set(row("alarms"), Column::Dnd, Level::Later).is_err());
2551        assert!(a.set(row("mail.blocked"), Column::Work, Level::Now).is_err(), "the blocked never");
2552        assert!(a.set(row("mail.always"), Column::Work, Level::Through).is_err(), "☆ is a layer's");
2553        assert!(a.set(row("mail.always"), Column::Dnd, Level::Through).is_ok());
2554        assert!(a.set(row("calls.safe"), Column::Sleep, Level::Quiet).is_err(), "a phone cannot show a call it holds");
2555        assert!(a.set(row("mail.neutral"), Column::Leisure, Level::Quiet).is_ok());
2556        assert!(a.set(row("before"), Column::Pause, Level::Event).is_err(), "a pause has no known end");
2557        for r in Row::ALL {
2558            for column in Column::ALL {
2559                assert!(!choices(r, column).is_empty(), "{r:?} {column:?}");
2560                if lock(r, column).is_some() {
2561                    assert_eq!(choices(r, column).first(), Some(&usual(r, column)), "{r:?} {column:?}");
2562                }
2563            }
2564        }
2565    }
2566
2567    #[test]
2568    fn rows_from_the_configuration_and_their_words() {
2569        let config: Config = toml::from_str(
2570            "[attention]\n\"mail.neutral\" = [\"leisure\", \"Repas:quiet\", \"sleep:never\", \"nonsense\", \"work:gathered\"]\n\
2571             codes = \"sleep:never, pause:never\"\n\
2572             \"calls.groups\" = [\"work\"]\n\
2573             \"messages.always\" = [\"dnd:through\", \"sleep:as\"]\n\
2574             day_before = [\"free\"]\n",
2575        )
2576        .unwrap();
2577        let a = Attention::of(&config);
2578        // Read leniently: French words, a value not offered left aside (gathered mail), a fixed cell kept.
2579        assert_eq!(a.words(row("mail.neutral")), ["work", "admin", "leisure", "meals:quiet", "sleep:never", "pause:later", "free:later", "slot:quiet", "dnd:quiet"]);
2580        assert_eq!((a.cell(row("codes"), Column::Sleep), a.cell(row("codes"), Column::Pause)), (Level::Never, Level::Now));
2581        assert_eq!((a.cell(row("messages.always"), Column::Dnd), a.cell(row("messages.always"), Column::Sleep)), (Level::Through, Level::As));
2582        assert_eq!(a.cell(row("day-before"), Column::Free), Level::Now);
2583        // A row that is no list is left aside, never the configuration.
2584        let odd: Config = toml::from_str("[attention]\n\"mail.safe\" = 3\n").unwrap();
2585        assert_eq!(Attention::of(&odd), Attention::usual());
2586        assert_eq!(word("Temps libre:quiet"), Some((Column::Free, Level::Quiet)));
2587        assert_eq!((word("sleep:soon"), word("night-shift")), (None, None));
2588    }
2589
2590    #[test]
2591    fn times_together_layers_and_lending() {
2592        let a = Attention::usual();
2593        // No hours set: work and admin together, the least strict; restricted mail comes (work).
2594        let any = Now::of(&mode(Time::Any, Reason::NoHours));
2595        assert_eq!(any.times, vec![Column::Work, Column::Admin]);
2596        assert_eq!(a.level(row("mail.restricted"), &any), Level::Now);
2597        // A layer: the strictest wins.
2598        let work = Now::time(Column::Work).layers(true, false);
2599        assert_eq!((a.level(row("site-calls"), &work), a.level(row("sites-live"), &work)), (Level::Now, Level::Later));
2600        assert_eq!(a.level(row("site-calls"), &work.clone().layers(true, true)), Level::Later);
2601        // Lending: without admin hours, work time takes admin's cell (people's rows only).
2602        let mut admin_only = Attention::usual();
2603        admin_only.set(row("mail.restricted"), Column::Admin, Level::Now).unwrap();
2604        admin_only.set(row("mail.restricted"), Column::Work, Level::Later).unwrap();
2605        let mut no_admin = Now::time(Column::Work);
2606        no_admin.week.admin_hours = false;
2607        assert_eq!((admin_only.level(row("mail.restricted"), &no_admin), admin_only.level(row("mail.restricted"), &Now::time(Column::Work))), (Level::Now, Level::Later));
2608        let mut kinds = Attention::usual();
2609        kinds.set(row("dates"), Column::Work, Level::Later).unwrap();
2610        assert_eq!(kinds.level(row("dates"), &no_admin), Level::Later, "kinds are never lent");
2611        // The pause and Free time come before the hours; the night before a meal.
2612        assert_eq!(Now::of(&mode(Time::Sleep, Reason::Paused)).times, vec![Column::Pause]);
2613        assert_eq!(Now::of(&mode(Time::Leisure, Reason::FreeTime)).times, vec![Column::Free]);
2614        assert_eq!(Now::of(&mode(Time::Several(Area::ADMIN), Reason::AdminTime)).times, vec![Column::Admin]);
2615    }
2616
2617    #[test]
2618    fn always_through_reads_their_own_row_as_said() {
2619        let mut a = Attention::usual();
2620        let leisure = Now::time(Column::Leisure);
2621        // At once whatever their list: a neutral person on the list, in leisure.
2622        assert_eq!((a.person(Channel::Mail, Person::Neutral, true, &leisure), a.person(Channel::Mail, Person::Neutral, false, &leisure)), (Level::Now, Level::Later));
2623        // As their own row: then their row says.
2624        a.set(row("mail.always"), Column::Leisure, Level::As).unwrap();
2625        assert_eq!(a.person(Channel::Mail, Person::Neutral, true, &leisure), Level::Later);
2626        assert_eq!(a.person(Channel::Mail, Person::Safe, true, &leisure), Level::Now);
2627        // ☆ on a layer: the layer does not hold them; their row's time does.
2628        a.set(row("mail.always"), Column::Dnd, Level::Through).unwrap();
2629        let dnd = Now::time(Column::Work).layers(false, true);
2630        assert_eq!((a.person(Channel::Mail, Person::Restricted, true, &dnd), a.person(Channel::Mail, Person::Restricted, false, &dnd)), (Level::Now, Level::Quiet));
2631        let dnd_leisure = leisure.clone().layers(false, true);
2632        assert_eq!(a.person(Channel::Mail, Person::Neutral, true, &dnd_leisure), Level::Later, "☆ at their own row's times");
2633        // Blocked beats Always through, on every channel.
2634        for channel in Channel::ALL {
2635            assert_eq!(a.person(channel, Person::Blocked, true, &leisure), Level::Never);
2636        }
2637        // "Nothing at all" holds the states' rows, never Always through.
2638        let mut free = Now::of(&mode(Time::Leisure, Reason::FreeTime));
2639        free.nothing = true;
2640        let usual = Attention::usual();
2641        assert_eq!((usual.person(Channel::Calls, Person::Safe, false, &free), usual.person(Channel::Calls, Person::Safe, true, &free)), (Level::Later, Level::Now));
2642    }
2643
2644    // ------------------------------------------------------------ the oracle: what the code did before
2645
2646    /// `quiet::mail_in_view` as it was, the matrix as usual: whether mail shows.
2647    fn shown_before(who: Who, time: Time, paused: bool, free: bool) -> bool {
2648        if time == Time::Any && !paused && !free {
2649            return true;
2650        }
2651        let ticks: [bool; 6] = match who {
2652            Who::Safe => [true; 6],
2653            Who::Neutral | Who::Stranger => [true, true, false, false, false, false],
2654            Who::Restricted => [true, false, false, false, false, false],
2655            Who::Blocked => [false; 6],
2656        };
2657        if paused {
2658            return ticks[5];
2659        }
2660        if free {
2661            return who == Who::Safe && ticks[2];
2662        }
2663        match time {
2664            Time::Work => ticks[0],
2665            Time::Admin => ticks[1],
2666            Time::Leisure => ticks[2],
2667            Time::Meals => ticks[3],
2668            Time::Sleep => ticks[4],
2669            Time::Several(_) | Time::Any => ticks[0] || ticks[1],
2670        }
2671    }
2672
2673    /// New mail told as it was (`mailnote::Seen::tells`, notify's mail row, the list as usual).
2674    fn told_before(who: Who, listed: bool, m: &Mode, slot: bool, dnd: bool) -> bool {
2675        let cell_now = !(m.sleeps() || m.paused() || m.free()) && !slot;
2676        let grid = shown_before(who, m.time, m.paused(), m.free());
2677        if !cell_now {
2678            return false;
2679        }
2680        if dnd { grid && listed } else { grid }
2681    }
2682
2683    #[test]
2684    fn the_usual_matrix_decides_as_the_code_did_but_where_decided() {
2685        let a = Attention::usual();
2686        for m in modes() {
2687            for (slot, dnd) in [(false, false), (true, false), (false, true), (true, true)] {
2688                let now = Now::of(&m).layers(slot, dnd);
2689                let said = format!("{:?} {:?} slot {slot} dnd {dnd}", m.time, m.reason);
2690                for who in [Who::Safe, Who::Neutral, Who::Restricted, Who::Stranger] {
2691                    // Mail shown on the Porch: as before, but no hours set now read as work and admin (the same, as usual).
2692                    let mail = a.decide(&Event::of(Source::Mail { who, always: false, lane: Lane::Usual }), &now);
2693                    assert_eq!(mail.shown, shown_before(who, m.time, m.paused(), m.free()), "shown {who:?} {said}");
2694                    assert_eq!(mail.told, told_before(who, false, &m, slot, dnd), "told {who:?} {said}");
2695                    // The list's people (Q2): before, told under do-not-disturb when their row let them through,
2696                    // else as their row; now at once whatever their row, but shown, not told, in sleep and a pause.
2697                    let listed = a.decide(&Event::of(Source::Mail { who, always: true, lane: Lane::Usual }), &now);
2698                    assert!(!told_before(who, true, &m, slot, dnd) || listed.told, "nothing told before is held now: {who:?} {said}");
2699                    assert_eq!(listed.told, !(m.sleeps() || m.paused()), "listed {who:?} {said}");
2700                    assert_eq!(listed.pierce, listed.told, "Always through mail passes the system's do-not-disturb {said}");
2701                }
2702                // Kinds of Sioul's own, as notify's usual decided, but codes at once in sleep and a pause (Q6).
2703                let comes = |kind: Kind| a.decide(&Event::own(kind), &now).told;
2704                assert!(comes(Kind::Codes), "codes {said}");
2705                assert!(comes(Kind::Doses) && comes(Kind::Wake) && comes(Kind::Time), "{said}");
2706                let quiet_ish = m.sleeps() || m.free() || m.paused();
2707                assert_eq!(comes(Kind::Move), !quiet_ish && !slot && !dnd, "move {said}");
2708                assert_eq!(comes(Kind::Needs), !quiet_ish, "needs {said}");
2709                assert_eq!(comes(Kind::SitesLive), !quiet_ish && !slot && !dnd, "sites live {said}");
2710                assert_eq!(comes(Kind::SiteCalls), !quiet_ish && !dnd, "site calls {said}");
2711            }
2712        }
2713    }
2714
2715    #[test]
2716    fn messages_calls_and_the_decided_changes() {
2717        let a = Attention::usual();
2718        let night = Now::of(&mode(Time::Sleep, Reason::Sleep));
2719        let paused = Now::of(&mode(Time::Sleep, Reason::Paused));
2720        let free = Now::of(&mode(Time::Leisure, Reason::FreeTime));
2721        let message = |who: Who, always: bool, now: &Now| a.decide(&Event::of(Source::Message { via: Channel::Messages, who, group: false, always }), now);
2722        // Q4: a safe friend's message at 03:00 waits for waking (before: let through); in Free time it comes.
2723        assert!(!message(Who::Safe, false, &night).told && !message(Who::Safe, false, &paused).told);
2724        assert!(message(Who::Safe, false, &free).told);
2725        // Q2: Always through: messages and calls at once, sleep and pauses included.
2726        assert!(message(Who::Neutral, true, &night).told && message(Who::Neutral, true, &paused).told);
2727        // Q3: blocked beats Always through.
2728        assert_eq!(message(Who::Blocked, true, &night).step, Step::Blocked);
2729        let call = |who: Option<Who>, always: bool, now: &Now| a.decide(&Event::of(Source::Call { who, always, emergency: false, repeat: false }), now);
2730        assert!(call(Some(Who::Blocked), true, &Now::time(Column::Work)).level == Level::Never);
2731        assert!(call(Some(Who::Stranger), true, &night).told, "on the list, at any time");
2732        // Q11: calls carry the layers: a safe caller under the switch goes to voicemail, the list's rings.
2733        let dnd = Now::time(Column::Leisure).layers(false, true);
2734        assert!(!call(Some(Who::Safe), false, &dnd).told && call(Some(Who::Safe), true, &dnd).told);
2735        // Floors after the blocked: Expecting a call (numbers nobody knows only, never the people you know beyond their row), a second call.
2736        let mut through = night.clone();
2737        through.through = true;
2738        assert!(call(Some(Who::Stranger), false, &through).told && call(None, false, &through).told && !call(Some(Who::Blocked), false, &through).told);
2739        assert!(!call(Some(Who::Neutral), false, &through).told, "the people you know as their row says");
2740        let repeat = a.decide(&Event::of(Source::Call { who: None, always: false, emergency: false, repeat: true }), &night);
2741        assert!(!repeat.told, "hidden numbers cannot be told apart: no second call");
2742        assert!(a.decide(&Event::of(Source::Call { who: Some(Who::Blocked), always: false, emergency: true, repeat: false }), &night).told, "an emergency number rings, even blocked");
2743        // Q5: a mail app's notification takes the Mail rows, shown-not-told held: a safe sender at 03:00 waits.
2744        let mail_app = a.decide(&Event::of(Source::Message { via: Channel::Mail, who: Who::Safe, group: false, always: false }), &night);
2745        assert_eq!((mail_app.level, mail_app.told), (Level::Later, false));
2746        // Q22: groups have a row of their own, as strangers until changed.
2747        let mut groups = Attention::usual();
2748        groups.set(row("messages.groups"), Column::Work, Level::Later).unwrap();
2749        let work = Now::time(Column::Work);
2750        let group = groups.decide(&Event::of(Source::Message { via: Channel::Messages, who: Who::Safe, group: true, always: false }), &work);
2751        let stranger = groups.decide(&Event::of(Source::Message { via: Channel::Messages, who: Who::Stranger, group: false, always: false }), &work);
2752        assert!(!group.told && stranger.told);
2753        // Q15: Free time as cells: a neutral row's Free cell let through (before: code, the safe only).
2754        let mut cells = Attention::usual();
2755        cells.set(row("mail.neutral"), Column::Free, Level::Now).unwrap();
2756        assert!(cells.decide(&Event::of(Source::Mail { who: Who::Neutral, always: false, lane: Lane::Usual }), &free).told);
2757        // Q14: no hours set: mail reads work and admin; a neutral row held in both is held (before: everything came).
2758        let mut held = Attention::usual();
2759        held.set(row("mail.neutral"), Column::Work, Level::Later).unwrap();
2760        held.set(row("mail.neutral"), Column::Admin, Level::Later).unwrap();
2761        assert!(!held.decide(&Event::of(Source::Mail { who: Who::Neutral, always: false, lane: Lane::Usual }), &Now::of(&mode(Time::Any, Reason::NoHours))).shown);
2762        // Q13: a chat site naming someone your cards know: their row, which may only hold more.
2763        let site = |named: Option<Who>, now: &Now| a.decide(&Event::of(Source::Site { call: false, live: true, named }), now);
2764        assert!(site(None, &Now::time(Column::Leisure)).told);
2765        assert!(site(Some(Who::Neutral), &Now::time(Column::Leisure)).told, "someone of your address books comes in your leisure");
2766        assert!(!site(Some(Who::Neutral), &Now::time(Column::Sleep)).told, "and waits while you sleep");
2767        assert!(site(Some(Who::Safe), &Now::time(Column::Leisure)).told);
2768        assert_eq!(site(Some(Who::Blocked), &Now::time(Column::Work)).level, Level::Never);
2769    }
2770
2771    /// A message from `from` as the Porch judges it; `headers` before the subject.
2772    fn message(from: &str, headers: &str, subject: &str, senders: &crate::porch::Senders) -> crate::porch::Triaged {
2773        let raw = format!("From: {from}\r\n{headers}Subject: {subject}\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\n\r\n{subject}.\r\n");
2774        let known = crate::porch::SenderList::default();
2775        let trusted = ["mx.example.net".to_string()];
2776        let own = ["me@example.net".to_string()];
2777        let ctx = crate::porch::Context { projects: None, known: &known, senders, trusted_ids: &trusted, now: None, priority: Default::default(), own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &own, spam: None };
2778        crate::porch::triage(crate::card::Card::from_bytes(raw.as_bytes()).unwrap(), &ctx)
2779    }
2780
2781    /// The Porch's mail by who wrote and where it came (what `quiet::mail_in_view` decided before).
2782    #[test]
2783    fn mail_shows_by_who_wrote_and_where_it_came() {
2784        use crate::porch::{SenderList, Senders};
2785        let usual = Attention::usual();
2786        let senders = Senders { safe: SenderList::parse("jane@example.org"), restricted: SenderList::parse("*@company.example"), ..Senders::default() };
2787        let set = Week { work_hours: true, admin_hours: true, meals: true, sleep: true };
2788        let nobody = crate::everywhere::People::default();
2789        let at = |time: Time, week: Week| Now::of(&Mode { quiet: false, time, week, reason: if time == Time::Sleep { Reason::Sleep } else { Reason::Working }, until: None, back: None, label: String::new() });
2790        let comes_in = |t: &crate::porch::Triaged, matrix: &Attention, account: Area, time: Time, week: Week| matrix.mail(t, &senders, &nobody, account, &at(time, week)).shown;
2791        let comes = |t: &crate::porch::Triaged, matrix: &Attention, account: Area, time: Time| comes_in(t, matrix, account, time, set);
2792        let at_times = |t: &crate::porch::Triaged, matrix: &Attention, account: Area| Time::STATES.into_iter().filter(|time| comes(t, matrix, account, *time)).map(Time::id).collect::<Vec<_>>();
2793        // Safe: to any address, at every time (in sleep shown, not told); held in sleep, it waits.
2794        let jane = message("Jane <jane@example.org>", "", "Hello", &senders);
2795        assert!(Time::STATES.into_iter().all(|time| comes(&jane, &usual, Area::WORK, time)));
2796        let mut no_sleep = Attention::usual();
2797        no_sleep.set(row("mail.safe"), Column::Sleep, Level::Later).unwrap();
2798        assert!(!comes(&jane, &no_sleep, Area::WORK, Time::Sleep) && comes(&jane, &no_sleep, Area::WORK, Time::Meals));
2799        // A stranger, to the work address: work time only (the address is work's, the row says work and admin).
2800        let stranger = message("Someone <someone@elsewhere.example>", "", "A question", &senders);
2801        assert_eq!(senders.who_of(&stranger.card), Who::Stranger);
2802        assert_eq!(at_times(&stranger, &usual, Area::WORK), ["work"]);
2803        assert_eq!(at_times(&stranger, &usual, Area::PERSONAL), ["admin"]);
2804        // The address and the row never meet: the row alone decides, so nothing waits for good.
2805        assert_eq!(at_times(&stranger, &usual, Area::LEISURE), ["work", "admin"]);
2806        // Jane's address on mail failing SPF and DKIM: nothing proves it is hers, a stranger's times.
2807        let unproven = message("Jane <jane@example.org>", "Authentication-Results: mx.example.net; spf=fail smtp.mailfrom=example.org; dkim=fail header.d=example.org\r\n", "Hello", &senders);
2808        assert_eq!(at_times(&unproven, &usual, Area::WORK), ["work"]);
2809        // Strangers held at every time: they wait while the neutral come.
2810        let mut no_strangers = Attention::usual();
2811        for column in [Column::Work, Column::Admin] {
2812            no_strangers.set(row("mail.stranger"), column, Level::Later).unwrap();
2813        }
2814        assert_eq!(at_times(&stranger, &no_strangers, Area::WORK), Vec::<&str>::new());
2815        let boss = message("Boss <boss@company.example>", "", "Monday", &senders);
2816        assert_eq!(at_times(&boss, &usual, Area::PERSONAL), ["work"], "restricted to work, writing to a personal address");
2817        assert_eq!(at_times(&boss, &usual, Area::WORK), ["work"]);
2818        // Without admin hours, work time takes admin's cells; without work hours, admin time takes work's.
2819        let mut admin_only = Attention::usual();
2820        admin_only.set(row("mail.stranger"), Column::Work, Level::Later).unwrap();
2821        assert!(comes_in(&stranger, &admin_only, Area::MIXED, Time::Work, Week { admin_hours: false, ..set }));
2822        assert!(!comes_in(&stranger, &admin_only, Area::MIXED, Time::Work, set));
2823        assert!(comes_in(&boss, &usual, Area::WORK, Time::Admin, Week { work_hours: false, ..set }));
2824        // Codes asked for and what you send yourself: shown asleep too; no hours set: work and admin together.
2825        let code = message("Bank <codes@bank.example>", "", "Your verification code: 482913", &senders);
2826        assert_eq!(code.lane, crate::porch::Lane::RightNow);
2827        assert!(comes(&code, &no_strangers, Area::WORK, Time::Sleep));
2828        let mine = message("Me <me@example.net>", "Authentication-Results: mx.example.net; dmarc=pass header.from=example.net\r\n", "A file", &senders);
2829        assert!(mine.reasons.contains(&crate::porch::Reason::FromYourself), "{:?}", mine.reasons);
2830        assert!(comes(&mine, &no_strangers, Area::WORK, Time::Sleep));
2831        assert!(comes(&stranger, &usual, Area::LEISURE, Time::Any));
2832        // Forged in a safe sender's name: set aside, and weighed as a stranger's.
2833        let forged = message("Jane <jane@example.org>", "Authentication-Results: mx.example.net; dmarc=fail (p=reject) header.from=example.org\r\n", "Hello", &senders);
2834        assert_eq!(forged.lane, crate::porch::Lane::SetAside);
2835        assert!(!comes(&forged, &usual, Area::PERSONAL, Time::Leisure) && comes(&forged, &usual, Area::PERSONAL, Time::Admin));
2836        assert!(!comes(&forged, &no_strangers, Area::PERSONAL, Time::Admin), "on the strangers' row");
2837        // Always through: the stranger on the list comes in leisure, told; forged in Jane's name, never as hers.
2838        let mut list = crate::everywhere::People::default();
2839        list.add(crate::everywhere::Person { name: "Someone".into(), emails: vec!["someone@elsewhere.example".into(), "jane@example.org".into()], ..Default::default() }, None);
2840        let leisure = at(Time::Leisure, set);
2841        let listed = usual.mail(&stranger, &senders, &list, Area::WORK, &leisure);
2842        assert!(listed.shown && listed.told && listed.pierce);
2843        assert!(!usual.mail(&forged, &senders, &list, Area::PERSONAL, &leisure).shown);
2844    }
2845
2846    /// Q23: a declined call is listed by its Calls row (before: its Calls or its Mail row).
2847    #[test]
2848    fn declined_calls_are_listed_by_their_calls_row() {
2849        let a = Attention::usual();
2850        let (work, admin, leisure) = (Now::time(Column::Work), Now::time(Column::Admin), Now::time(Column::Leisure));
2851        // The neutral when they may ring: work, admin, and your leisure too.
2852        assert!(a.listed(Person::Neutral, false, &work) && a.listed(Person::Neutral, false, &admin) && a.listed(Person::Neutral, false, &leisure));
2853        assert!(!a.listed(Person::Neutral, false, &Now::time(Column::Sleep)));
2854        // The safe in leisure, as they ring then; Always through at any time; the blocked never.
2855        assert!(a.listed(Person::Safe, false, &leisure) && a.listed(Person::Stranger, true, &Now::time(Column::Sleep)));
2856        assert!(!a.listed(Person::Blocked, true, &work));
2857        // Strangers ring at no time: listed in work and admin time, never in leisure; under do-not-disturb, later.
2858        assert!(a.listed(Person::Stranger, false, &work) && a.listed(Person::Stranger, false, &admin) && !a.listed(Person::Stranger, false, &leisure));
2859        assert!(!a.listed(Person::Stranger, false, &work.clone().layers(false, true)));
2860        // The safe while you sleep: before, listed then by their Mail row (it ticks sleep); now at waking, as they ring.
2861        assert!(!a.listed(Person::Safe, false, &Now::time(Column::Sleep)));
2862        assert!(a.listed(Person::Restricted, false, &work) && !a.listed(Person::Restricted, false, &admin));
2863        // Strangers set to ring in leisure: then only, as their row says.
2864        let mut ringing = Attention::usual();
2865        ringing.set(row("calls.stranger"), Column::Leisure, Level::Now).unwrap();
2866        assert!(ringing.listed(Person::Stranger, false, &leisure) && !ringing.listed(Person::Stranger, false, &work));
2867    }
2868
2869    #[test]
2870    fn areas_and_holds() {
2871        let a = Attention::usual();
2872        let evening = Now::of(&mode(Time::Leisure, Reason::Evening));
2873        let work_mail = |who: Who| a.decide(&Event::of(Source::Mail { who, always: false, lane: Lane::Usual }).for_area(Some(Area::WORK)), &Now::of(&mode(Time::Admin, Reason::AdminTime)));
2874        // A stranger to the work address in admin time: held for work; a safe sender comes.
2875        assert_eq!((work_mail(Who::Stranger).step, work_mail(Who::Safe).told), (Step::Area, true));
2876        // Work's dates wait while work rests.
2877        assert_eq!(a.decide(&Event::own(Kind::Dates).for_area(Some(Area::WORK)), &evening).step, Step::Area);
2878        assert!(a.decide(&Event::own(Kind::Dates), &evening).told);
2879        // An event's own: at once when its event falls in the time now.
2880        let mut night = Now::of(&mode(Time::Sleep, Reason::Sleep));
2881        night.span = (1_000, 2_000);
2882        assert!(a.decide(&Event::own(Kind::Alarms).starting(Some(1_500)), &night).told);
2883        assert!(!a.decide(&Event::own(Kind::Alarms).starting(Some(2_500)), &night).told);
2884        // The Porch resting after a pause: shown, not told.
2885        let mut rests = Now::time(Column::Work);
2886        rests.holds.porch_rests = true;
2887        let resting = a.decide(&Event::of(Source::Mail { who: Who::Safe, always: false, lane: Lane::Usual }), &rests);
2888        assert_eq!((resting.shown, resting.told, resting.step), (true, false, Step::Hold));
2889        // A meeting: no meal's notice; the chat limit: chat sites wait.
2890        let mut meeting = Now::time(Column::Work);
2891        meeting.holds.meeting = true;
2892        assert_eq!(a.decide(&Event::own(Kind::Needs), &meeting).step, Step::Hold);
2893        // Codes: on top of the Porch, told as their row; your own mail never told.
2894        let code = a.decide(&Event::of(Source::Mail { who: Who::Stranger, always: false, lane: Lane::Code }), &Now::of(&mode(Time::Sleep, Reason::Sleep)));
2895        assert!(code.shown && code.told && code.pierce);
2896        let own = a.decide(&Event::of(Source::Mail { who: Who::Safe, always: false, lane: Lane::Own }), &Now::time(Column::Work));
2897        assert!(own.shown && !own.told);
2898    }
2899
2900    /// Part 3, Reachable (decision 7): everyone not blocked writes and the
2901    /// people you know call at once, at every time and under each layer;
2902    /// mail of every address comes and is told, the Porch's rest lifted;
2903    /// numbers nobody knows stay the call screening's; the blocked never;
2904    /// Sioul's own rows as they are.
2905    #[test]
2906    fn reachable_lets_everyone_not_blocked_through_at_every_time() {
2907        let a = Attention::usual();
2908        for column in Column::ALL {
2909            for (slot, dnd) in [(false, false), (true, false), (false, true)] {
2910                let mut now = Now::time(column).layers(slot, dnd);
2911                now.reachable = true;
2912                now.holds.porch_rests = true;
2913                for (channel, persons) in [(Channel::Mail, &[Person::Safe, Person::Neutral, Person::Restricted, Person::Stranger][..]), (Channel::Messages, &[Person::Safe, Person::Neutral, Person::Restricted, Person::Stranger, Person::Groups][..]), (Channel::Calls, &[Person::Safe, Person::Neutral, Person::Restricted][..])] {
2914                    for person in persons {
2915                        assert_eq!(a.person(channel, *person, false, &now), Level::Now, "{channel:?} {person:?} at {column:?} slot {slot} dnd {dnd}");
2916                    }
2917                    assert_eq!(a.person(channel, Person::Blocked, false, &now), Level::Never);
2918                    assert_eq!(a.person(channel, Person::Always, true, &now), Level::Now);
2919                }
2920                // The call screening's rows: as they are, never opened by Reachable.
2921                for person in [Person::Stranger, Person::Hidden] {
2922                    let mut usual = now.clone();
2923                    usual.reachable = false;
2924                    assert_eq!(a.person(Channel::Calls, person, false, &now), a.person(Channel::Calls, person, false, &usual), "{person:?} at {column:?}");
2925                }
2926                // Mail to an address for another time, from a stranger: shown and told, the Porch resting or not.
2927                let mail = a.decide(&Event::of(Source::Mail { who: Who::Stranger, always: false, lane: Lane::Usual }).for_area(Some(Area::WORK)), &now);
2928                assert!(mail.shown && mail.told && mail.level == Level::Now, "{column:?}: {mail:?}");
2929                // A stranger's message and a group's: at once.
2930                let message = |who: Who, group: bool| a.decide(&Event::of(Source::Message { via: Channel::Messages, who, group, always: false }), &now).level;
2931                assert_eq!((message(Who::Stranger, false), message(Who::Neutral, true)), (Level::Now, Level::Now));
2932                assert_eq!(message(Who::Blocked, false), Level::Never);
2933            }
2934        }
2935        // A conversation's own row set to later comes (one set to never is the listener's choice, kept: appnotes).
2936        let mut rows = Attention::usual();
2937        rows.set_source("conversation.c2", Column::Work, Level::Later).unwrap();
2938        let mut now = Now::time(Column::Work);
2939        now.reachable = true;
2940        let talk = |id: &str| rows.decide(&Event { sources: vec![id.to_string()], ..Event::of(Source::Message { via: Channel::Messages, who: Who::Neutral, group: false, always: false }) }, &now).level;
2941        assert_eq!(talk("conversation.c2"), Level::Now);
2942        // Sioul's own rows as they are: dates at night still wait.
2943        let mut night = Now::time(Column::Sleep);
2944        night.reachable = true;
2945        assert_eq!(a.level(Row::Own(Kind::Dates), &night), a.level(Row::Own(Kind::Dates), &Now::time(Column::Sleep)));
2946    }
2947
2948    /// F3 (review of part 2): while this phone screens calls, a mode lets
2949    /// anyone's call through only where the call screening lets numbers
2950    /// nobody knows ring and your contacts may ring then: never in a focus
2951    /// session at work, nor at night, where the people you know wait.
2952    #[test]
2953    fn a_mode_lets_nobody_unknown_through_where_your_contacts_wait() {
2954        let a = Attention::usual();
2955        let unknown = Phone { screens: true, unknown: true };
2956        assert_eq!(a.silence(&[Column::Work], false, unknown).calls, Senders::Anyone);
2957        assert_eq!(a.silence(&[Column::Work, Column::Dnd], false, unknown).calls, Senders::Contacts);
2958        assert_eq!(a.silence(&[Column::Dnd], false, unknown).calls, Senders::Contacts);
2959        assert_ne!(a.silence(&[Column::Sleep], false, unknown).calls, Senders::Anyone);
2960    }
2961
2962    /// F2 (review of part 2): the matrix's writes leave the call screening's
2963    /// rows alone (calls.stranger, calls.hidden): no preset sets them, no count
2964    /// of changes counts them, and setting one is refused, said.
2965    #[test]
2966    fn the_matrix_s_writes_leave_the_screening_s_rows_alone() {
2967        let hidden = Row::People(Channel::Calls, Person::Hidden);
2968        for preset in Preset::ALL {
2969            for column in Column::ALL {
2970                assert_eq!(preset.matrix().cell(hidden, column), Attention::usual().cell(hidden, column), "{preset:?} {column:?}");
2971            }
2972        }
2973        let mut moved = Attention::usual();
2974        moved.set(hidden, Column::Leisure, Level::Now).unwrap();
2975        assert_eq!(moved.changes_from(&Attention::usual()), 0);
2976        let dir = std::env::temp_dir().join(format!("sioul-attention-screened-{}", std::process::id()));
2977        let _ = std::fs::remove_dir_all(&dir);
2978        std::fs::create_dir_all(&dir).unwrap();
2979        let path = dir.join("config.toml");
2980        std::fs::write(&path, "").unwrap();
2981        let words = SettingValue::Texts(vec!["leisure".into()]);
2982        for key in ["attention.calls.hidden", "attention.calls.stranger", "reach.calls.stranger"] {
2983            assert!(apply(&path, &Config::default(), key, &words).unwrap_err().contains("screening"), "{key}");
2984        }
2985        apply(&path, &Config::default(), "attention.calls.safe", &SettingValue::Texts(vec!["leisure".into()])).unwrap();
2986        let _ = std::fs::remove_dir_all(&dir);
2987    }
2988
2989    #[test]
2990    fn what_each_mode_lets_through() {
2991        let a = Attention::usual();
2992        let phone = Phone { screens: true, unknown: false };
2993        // The pause: Always through only, their calls through the mode as contacts' while this phone screens; doses and alarms pass.
2994        let pause = a.silence(&[Column::Pause], false, phone);
2995        assert_eq!((pause.calls, pause.messages, pause.repeat, pause.conversations, pause.doses, pause.events), (Senders::Contacts, Senders::Starred, true, true, true, true));
2996        // Messages never wider than the starred, though this phone holds other apps' notifications and anyone may
2997        // write (work): Android sounds a message before Sioul can hold it, so the mode alone keeps a held one silent.
2998        assert_eq!(a.silence(&[Column::Work], false, phone).messages, Senders::Starred);
2999        // Q9: Free time, while this phone screens calls: every contact's call (before: starred only).
3000        let free = a.silence(&[Column::Free], false, phone);
3001        assert_eq!(free.calls, Senders::Contacts);
3002        assert_eq!(a.silence(&[Column::Free], false, Phone::default()).calls, Senders::Starred, "without screening, starred only, as before");
3003        // "Nothing at all": the list's still (Always through), nobody else.
3004        let nothing = a.silence(&[Column::Free], true, Phone::default());
3005        assert_eq!(nothing.calls, Senders::Starred);
3006        // Q7: an event's alarms pass a pause, on a channel of their own.
3007        assert!(a.silence(&[Column::Pause], false, phone).events);
3008        // Always through as their own row everywhere: nobody through the pause.
3009        let mut none = Attention::usual();
3010        for channel in Channel::ALL {
3011            for column in Column::ALL {
3012                none.set(Row::People(channel, Person::Always), column, Level::As).unwrap();
3013            }
3014        }
3015        let shut = none.silence(&[Column::Pause], false, phone);
3016        assert_eq!((shut.calls, shut.repeat, shut.conversations), (Senders::None, false, false));
3017        // Let every call through: anyone's call through every mode, so that the mode lets ring what the screening
3018        // lets through (before: contacts, a stranger let through by the screening then silenced by the mode).
3019        for (columns, nothing) in [(&[Column::Pause][..], false), (&[Column::Free][..], true), (&[Column::Leisure, Column::Dnd][..], false)] {
3020            let mut now = Now::time(Column::Leisure);
3021            now.times = columns.iter().copied().filter(|c| c.is_time()).collect();
3022            now.dnd = columns.contains(&Column::Dnd);
3023            now.nothing = nothing;
3024            assert_eq!(none.silence_at(&now, phone).calls, Senders::None, "{columns:?}");
3025            now.through = true;
3026            for phone in [phone, Phone::default()] {
3027                let through = none.silence_at(&now, phone);
3028                assert_eq!((through.calls, through.repeat, through.messages), (Senders::Anyone, true, Senders::None), "{columns:?}");
3029            }
3030        }
3031        // While this phone screens calls, numbers nobody knows are the call screening's, apart: anyone's call
3032        // through the mode when it lets them ring and your contacts may ring then (before: the Calls rows of
3033        // strangers and hidden numbers).
3034        let unknown = Phone { screens: true, unknown: true };
3035        assert_eq!(a.silence(&[Column::Work], false, unknown).calls, Senders::Anyone);
3036        assert_eq!(a.silence(&[Column::Pause], false, unknown).calls, Senders::Contacts, "never where your contacts wait");
3037        assert_eq!(a.silence(&[Column::Pause], false, phone).calls, Senders::Contacts);
3038        let mut loose = Attention::usual();
3039        loose.set(row("calls.stranger"), Column::Dnd, Level::Now).unwrap();
3040        assert_eq!(loose.silence(&[Column::Dnd], false, phone).calls, Senders::Contacts, "the strangers' row is the screening's now");
3041        assert_eq!(Attention::usual().silence(&[Column::Work], false, Phone { screens: false, unknown: true }).calls, Senders::Starred, "without screening, starred only");
3042        // Do-not-disturb over the time now, as `level` reads a layer (before: the layer's column alone): a cell
3043        // loosened under the switch lets no more through than the time does. Restricted alone: work only.
3044        let mut only = none.clone();
3045        for column in Column::ALL {
3046            for person in [Person::Safe, Person::Neutral] {
3047                only.set(Row::People(Channel::Calls, person), column, Level::Later).unwrap();
3048            }
3049        }
3050        let mut loosened = only.clone();
3051        loosened.set(row("calls.restricted"), Column::Dnd, Level::Now).unwrap();
3052        assert_eq!(loosened.silence(&[Column::Dnd], false, phone).calls, Senders::Contacts, "the layer by itself");
3053        assert_eq!(loosened.silence(&[Column::Leisure, Column::Dnd], false, phone).calls, Senders::None, "over leisure, where the restricted wait");
3054        // Two times at once (no hours set: work and admin), the least strict; a layer above them, the stricter.
3055        assert_eq!(only.silence(&[Column::Admin], false, phone).calls, Senders::None);
3056        assert_eq!(only.silence(&[Column::Work, Column::Admin], false, phone).calls, Senders::Contacts);
3057        assert_eq!(only.silence(&[Column::Work, Column::Admin, Column::Dnd], false, phone).calls, Senders::None);
3058        // The same moment as the window reads it: no work hours, admin time takes work's cells (people's rows).
3059        let mut admin = Now::time(Column::Admin);
3060        assert_eq!(only.silence_at(&admin, phone).calls, Senders::None);
3061        admin.week.work_hours = false;
3062        assert_eq!(only.silence_at(&admin, phone).calls, Senders::Contacts);
3063    }
3064
3065    #[test]
3066    fn older_keys_seed_what_was_changed_and_nothing_else() {
3067        // Nothing written: the usual, decided changes included.
3068        assert_eq!(seeded(&Config::default()), Attention::usual());
3069        // Neutral mail in leisure ticked, the list's mail through whatever their row, doses silent in sleep,
3070        // codes held in the pause (now fixed: at once), strangers' calls in work, the list getting through nobody.
3071        let config: Config = toml::from_str(
3072            "[reach]\nsafe = [\"work\", \"admin\", \"leisure\", \"meals\", \"sleep\", \"pause\"]\nneutral = [\"work\", \"admin\", \"leisure\"]\nrestricted = [\"work\"]\nstranger = [\"work\", \"admin\"]\n\
3073             [reach.calls]\nstranger = [\"work\"]\n\
3074             [notify]\nmail = [\"dnd:list-any\"]\ncodes = [\"pause:never\"]\nsites = [\"work:later\"]\n\
3075             [reminders]\ndoses_in_sleep = false\n",
3076        )
3077        .unwrap();
3078        let a = Attention::of(&config);
3079        assert_eq!(a.cell(row("mail.neutral"), Column::Leisure), Level::Now);
3080        assert_eq!(a.cell(row("mail.always"), Column::Dnd), Level::Now);
3081        assert_eq!(a.cell(row("doses"), Column::Sleep), Level::Later);
3082        assert_eq!(a.cell(row("codes"), Column::Pause), Level::Now, "fixed now");
3083        assert_eq!(a.cell(row("calls.stranger"), Column::Work), Level::Now);
3084        assert_eq!(a.cell(row("sites"), Column::Work), Level::Later);
3085        // What was not changed takes the decided values: safe messages held in sleep.
3086        assert_eq!(a.cell(row("messages.safe"), Column::Sleep), Level::Later);
3087        // Before the five states: strangers as the neutral, the pause as sleep.
3088        let older: Config = toml::from_str("[reach]\nneutral = [\"work\", \"admin\", \"meals\"]\n").unwrap();
3089        let a = Attention::of(&older);
3090        assert_eq!(a.cell(row("mail.stranger"), Column::Meals), Level::Now);
3091        // The list getting through nobody: Always through as their own row.
3092        let nobody: Config = toml::from_str("[dnd]\npeople = false\n").unwrap();
3093        let a = Attention::of(&nobody);
3094        assert_eq!((a.cell(row("calls.always"), Column::Sleep), a.cell(row("mail.always"), Column::Free)), (Level::As, Level::As));
3095        // Written, the [attention] counts alone.
3096        let written: Config = toml::from_str("[dnd]\npeople = false\n[attention]\n").unwrap();
3097        assert_eq!(Attention::of(&written), Attention::usual());
3098    }
3099
3100    #[test]
3101    fn written_whole_seeded_once_and_the_older_keys_taken_out() {
3102        let dir = std::env::temp_dir().join(format!("sioul-attention-{}", std::process::id()));
3103        let _ = std::fs::remove_dir_all(&dir);
3104        std::fs::create_dir_all(&dir).unwrap();
3105        let path = dir.join("config.toml");
3106        std::fs::write(&path, "# Mine.\nlanguage = \"fr\"\n[reach]\nneutral = [\"work\", \"admin\", \"leisure\"]\nstranger = [\"work\", \"admin\"]\n[notify]\nsites = [\"work:later\"]\n[dnd]\nfocus = true\npeople = true\n").unwrap();
3107        let config = Config::load(&path).unwrap();
3108        // One row changed: every seeded row written, the older keys out, the comment kept.
3109        apply(&path, &config, "attention.codes", &SettingValue::Texts(vec!["sleep:never".into()])).unwrap();
3110        let text = std::fs::read_to_string(&path).unwrap();
3111        assert!(text.contains("# Mine.") && text.contains("[attention]") && !text.contains("[reach]") && !text.contains("[notify]") && !text.contains("people"), "{text}");
3112        assert!(text.contains("focus = true"), "the rest of [dnd] kept: {text}");
3113        let config = Config::load(&path).unwrap();
3114        let a = Attention::of(&config);
3115        assert_eq!((a.cell(row("codes"), Column::Sleep), a.cell(row("mail.neutral"), Column::Leisure), a.cell(row("sites"), Column::Work)), (Level::Never, Level::Now, Level::Later));
3116        // Refused: a fixed cell, a value not offered, a word not understood, a row that is none.
3117        assert!(apply(&path, &config, "attention.doses", &SettingValue::Texts(vec!["work:later".into()])).is_err());
3118        assert!(apply(&path, &config, "attention.sites", &SettingValue::Texts(vec!["work".into()])).is_err());
3119        assert!(apply(&path, &config, "attention.mail.safe", &SettingValue::Texts(vec!["teatime".into()])).is_err());
3120        assert!(apply(&path, &config, "attention.mail.hidden", &SettingValue::Texts(vec!["work".into()])).is_err());
3121        // Back to the usual: the row taken out.
3122        apply(&path, &config, "notify.codes", &SettingValue::Texts(vec!["sleep".into()])).unwrap();
3123        let config = Config::load(&path).unwrap();
3124        assert!(config.attention.as_ref().unwrap().words("codes").is_none());
3125        // The senders' older ticks: unticked waits, ticked comes (mail shown, not told, in sleep).
3126        apply(&path, &config, "reach.safe", &SettingValue::Texts(vec!["work".into(), "admin".into(), "sleep".into()])).unwrap();
3127        let a = Attention::of(&Config::load(&path).unwrap());
3128        assert_eq!((a.cell(row("mail.safe"), Column::Leisure), a.cell(row("mail.safe"), Column::Sleep)), (Level::Later, Level::Quiet));
3129        // The call screening's rows: never set from here (review of part 2, F2).
3130        assert!(apply(&path, &config, "reach.calls.stranger", &SettingValue::Texts(vec!["work".into()])).is_err());
3131        assert_eq!(Attention::of(&Config::load(&path).unwrap()).cell(row("calls.stranger"), Column::Work), Level::Later);
3132        // A preset: Quieter written whole.
3133        apply_preset(&path, &Config::load(&path).unwrap(), Preset::Quieter).unwrap();
3134        assert_eq!(Attention::of(&Config::load(&path).unwrap()), Preset::Quieter.matrix());
3135        let _ = std::fs::remove_dir_all(&dir);
3136    }
3137
3138    /// Q3: blocked beats Always through, and one list takes them off the other.
3139    #[test]
3140    fn always_through_takes_them_off_the_blocked_list() {
3141        let dir = std::env::temp_dir().join(format!("sioul-unblock-{}", std::process::id()));
3142        let _ = std::fs::remove_dir_all(&dir);
3143        std::fs::create_dir_all(&dir).unwrap();
3144        let blocked = dir.join("blocked.txt");
3145        std::fs::write(&blocked, "alice@example.org\ntel:+33199000001\ncontact:uid-bob\nmallory@example.org\n").unwrap();
3146        let config: Config = toml::from_str(&format!("blocked_senders = \"{}\"\n[contacts]\nregion = \"FR\"\n", blocked.display().to_string().replace('\\', "/"))).unwrap();
3147        assert!(crate::porch::unblock_person(&config, &["Alice <alice@example.org>".into()], &["01 99 00 00 01".into()], "uid-bob").unwrap());
3148        assert_eq!(std::fs::read_to_string(&blocked).unwrap().lines().filter(|l| !l.trim().is_empty()).collect::<Vec<_>>(), ["mallory@example.org"]);
3149        assert!(!crate::porch::unblock_person(&config, &["carol@example.org".into()], &[], "").unwrap(), "nothing of theirs there");
3150        let _ = std::fs::remove_dir_all(&dir);
3151    }
3152
3153    #[test]
3154    fn slots_are_read_by_every_process() {
3155        let dir = std::env::temp_dir().join(format!("sioul-slots-{}", std::process::id()));
3156        let _ = std::fs::remove_dir_all(&dir);
3157        let path = dir.join(SLOTS_FILE);
3158        let today: jiff::Zoned = "2026-10-07T10:00[Europe/Paris]".parse().unwrap();
3159        let start = today.timestamp().as_second() + 3600;
3160        let slots = Slots { day: Some(today.date()), slots: vec![(start, start + 1800)] };
3161        assert!(slots.save(&path).unwrap());
3162        assert!(!slots.save(&path).unwrap(), "written only when it changed");
3163        let read = Slots::load(&path);
3164        let at = |offset: i64| jiff::Timestamp::from_second(start + offset).unwrap().to_zoned(today.time_zone().clone());
3165        assert!(read.at(&at(60)) && !read.at(&at(-60)) && !read.at(&at(1800)));
3166        assert_eq!(read.edges(), vec![start, start + 1800]);
3167        // Another day's slots hold nothing today.
3168        let old = Slots { day: Some(today.date().yesterday().unwrap()), slots: vec![(start, start + 1800)] };
3169        assert!(!old.at(&at(60)));
3170        assert_eq!(Slots::load(&dir.join("none.toml")), Slots::default());
3171        let _ = std::fs::remove_dir_all(&dir);
3172    }
3173
3174    /// Decision 13 of 18 October 2026: under do-not-disturb, the texts of
3175    /// your safe people, your contacts and groups (◑) come while the phone is
3176    /// silenced (Sioul's mode or yours), without a sound, and wait otherwise;
3177    /// at night they wait whatever; calls stay voicemail; the phone's modes
3178    /// still let them ring no more than before. Decision 14: groups at once in
3179    /// leisure and meals.
3180    #[test]
3181    fn texts_in_the_shade_come_while_the_phone_is_silenced() {
3182        let a = Attention::usual();
3183        let message = |person: Who, group: bool, now: &Now| a.decide(&Event::of(Source::Message { via: Channel::Messages, who: person, group, always: false }), now).level;
3184        let mut dnd = Now::time(Column::Work).layers(false, true);
3185        for (who, group) in [(Who::Safe, false), (Who::Neutral, false), (Who::Neutral, true)] {
3186            assert_eq!(message(who, group, &dnd), Level::Later, "{who:?} {group}: not silenced, it waits");
3187        }
3188        dnd.silenced = true;
3189        for (who, group) in [(Who::Safe, false), (Who::Neutral, false), (Who::Neutral, true)] {
3190            assert_eq!(message(who, group, &dnd), Level::Now, "{who:?} {group}: silenced, it comes without a sound");
3191        }
3192        assert_eq!(message(Who::Restricted, false, &dnd), Level::Later, "restricted: held under do-not-disturb, as before");
3193        let mut night = Now::time(Column::Sleep).layers(false, true);
3194        night.silenced = true;
3195        assert_eq!(message(Who::Neutral, false, &night), Level::Later, "at night, whatever");
3196        // A mail app's notification, its cell ◑ (safe senders at night, under do-not-disturb): held
3197        // on the phone, silenced or not (review of part 4, S-1).
3198        let mail = |now: &Now| a.decide(&Event::of(Source::Message { via: Channel::Mail, who: Who::Safe, group: false, always: false }), now).level;
3199        for column in [Column::Sleep, Column::Pause, Column::Work] {
3200            let mut now = Now::time(column).layers(false, column == Column::Work);
3201            now.silenced = true;
3202            assert_eq!(a.cell(Row::People(Channel::Mail, Person::Safe), if column == Column::Work { Column::Dnd } else { column }), Level::Quiet);
3203            assert_eq!(mail(&now), Level::Later, "mail at {column:?}, the phone silenced: held, as before");
3204        }
3205        let call = a.decide(&Event::of(Source::Call { who: Some(Who::Neutral), always: false, emergency: false, repeat: false }), &dnd);
3206        assert!(!call.told, "a call under do-not-disturb: voicemail");
3207        // The phone's mode under do-not-disturb lets no more texts ring than before: the starred at most.
3208        let silence = a.silence_at(&Now::time(Column::Work).layers(false, true), Phone::default());
3209        assert!(!matches!(silence.messages, Senders::Anyone | Senders::Contacts), "{silence:?}");
3210        // Groups in leisure and meals.
3211        for column in [Column::Leisure, Column::Meals] {
3212            assert_eq!(message(Who::Neutral, true, &Now::time(column)), Level::Now, "{column:?}");
3213        }
3214        // ◑ a choice for texts under do-not-disturb alone.
3215        assert!(choices(Row::People(Channel::Messages, Person::Stranger), Column::Dnd).contains(&Level::Quiet));
3216        assert!(!choices(Row::People(Channel::Messages, Person::Neutral), Column::Sleep).contains(&Level::Quiet));
3217        assert!(!choices(Row::People(Channel::Calls, Person::Neutral), Column::Dnd).contains(&Level::Quiet));
3218        let tr = Translator::new("en");
3219        assert_eq!(level_label(&tr, Row::People(Channel::Messages, Person::Neutral), Level::Quiet), "In the shade, without a sound");
3220    }
3221
3222    /// Decision 16 of 18 October 2026 (the proposal's 3.7): a person's own
3223    /// row, read in this order: the blocked never; a conversation's own row;
3224    /// the person's; the app's; their list's. Someone on Always through has no
3225    /// row of their own. It holds their calls too, a call never in the shade;
3226    /// numbers nobody knows stay the screening's. No name is written.
3227    #[test]
3228    fn a_person_s_own_row_is_read_in_its_place() {
3229        let mut rows = Attention::usual();
3230        let person = person_row("contact:card-1");
3231        rows.set_source(&person, Column::Work, Level::Later).unwrap();
3232        rows.set_source(&person, Column::Leisure, Level::Now).unwrap();
3233        rows.set_source("app.org.example.chat", Column::Work, Level::Now).unwrap();
3234        rows.set_source("app.org.example.chat", Column::Sleep, Level::Now).unwrap();
3235        let work = Now::time(Column::Work);
3236        let text = |rows: &Attention, who: Who, always: bool, sources: Vec<String>, now: &Now| rows.decide(&Event::of(Source::Message { via: Channel::Messages, who, group: false, always }).from_sources(sources), now).level;
3237        let with = |conversation: &str| source_rows_with("org.example.chat", conversation, std::slice::from_ref(&person));
3238        assert_eq!(with("c1"), vec!["conversation.c1".to_string(), person.clone(), "app.org.example.chat".to_string()], "conversation, person, app");
3239        assert_eq!(text(&rows, Who::Neutral, false, with(""), &work), Level::Later, "the person's row over the app's");
3240        assert_eq!(text(&rows, Who::Neutral, false, with(""), &Now::time(Column::Sleep)), Level::Now, "the app's where the person's says nothing");
3241        rows.set_source("conversation.c1", Column::Work, Level::Now).unwrap();
3242        assert_eq!(text(&rows, Who::Neutral, false, with("c1"), &work), Level::Now, "the conversation's over the person's");
3243        assert_eq!(text(&rows, Who::Blocked, false, with("c1"), &work), Level::Never, "the blocked first");
3244        assert_eq!(text(&rows, Who::Neutral, true, with(""), &work), rows.decide(&Event::of(Source::Message { via: Channel::Messages, who: Who::Neutral, group: false, always: true }), &work).level, "Always through: no row of their own");
3245        // Their calls: held at work, at once in leisure (the list's usual otherwise).
3246        let call = |rows: &Attention, who: Option<Who>, now: &Now| rows.decide(&Event::of(Source::Call { who, always: false, emergency: false, repeat: false }).from_sources(vec![person.clone()]), now).told;
3247        assert!(!call(&rows, Some(Who::Neutral), &work), "held at work by their row");
3248        // Reachable: the people you know ring, their own row as their list, as their texts come (review of part 6, F1).
3249        let mut reachable = Now::time(Column::Work);
3250        reachable.reachable = true;
3251        assert!(call(&rows, Some(Who::Neutral), &reachable), "Reachable lifts their row for calls");
3252        assert_eq!(text(&rows, Who::Neutral, false, vec![person.clone()], &reachable), Level::Now, "and for texts");
3253        assert!(call(&rows, Some(Who::Neutral), &Now::time(Column::Leisure)));
3254        assert!(!call(&rows, Some(Who::Stranger), &Now::time(Column::Leisure)), "a number nobody knows: the screening's");
3255        // In the shade under do-not-disturb: a text without a sound while silenced, a call to voicemail.
3256        rows.set_source(&person, Column::Dnd, Level::Quiet).unwrap();
3257        let mut dnd = Now::time(Column::Leisure).layers(false, true);
3258        dnd.silenced = true;
3259        assert_eq!(text(&rows, Who::Neutral, false, vec![person.clone()], &dnd), Level::Now);
3260        assert!(!call(&rows, Some(Who::Neutral), &dnd));
3261        assert!(rows.set_source(&person, Column::Work, Level::Quiet).is_err(), "the shade under do-not-disturb alone");
3262        assert!(rows.set_source(&person, Column::Work, Level::Gathered).is_err());
3263        // No name: neither set nor read.
3264        rows.name_source(&person, "Jane");
3265        assert_eq!(rows.source_name(&person), "");
3266        assert!(rows.source_words(&person).iter().all(|w| !w.starts_with(NAME)), "{:?}", rows.source_words(&person));
3267        let config: Config = toml::from_str("[attention]\n\"person.contact:card-2\" = [\"work:later\", \"name=Jane\"]\n").unwrap();
3268        let read = Attention::of(&config);
3269        assert_eq!(read.source_name("person.contact:card-2"), "");
3270        assert_eq!(read.source("person.contact:card-2").map(|c| c[Column::Work.index()]), Some(Level::Later));
3271    }
3272
3273    /// The presets checked against the new defaults: Quieter holds at least
3274    /// as much as the usual everywhere, Reachable lets at least as much
3275    /// through, cell by cell (As and Through, Always through's, aside).
3276    #[test]
3277    fn the_presets_go_their_own_way_only() {
3278        let usual = Attention::usual();
3279        let (quieter, reachable) = (Preset::Quieter.matrix(), Preset::Reachable.matrix());
3280        for row in Row::ALL {
3281            for column in Column::ALL {
3282                let u = usual.cell(row, column);
3283                if matches!(u, Level::As | Level::Through) {
3284                    continue;
3285                }
3286                assert!(quieter.cell(row, column) >= u, "Quieter lets more through at {} {column:?}", row.id());
3287                assert!(reachable.cell(row, column) <= u, "Reachable holds more at {} {column:?}", row.id());
3288            }
3289        }
3290        // What the new defaults let through, Quieter holds: groups in leisure, texts under do-not-disturb.
3291        assert_eq!(quieter.cell(Row::People(Channel::Messages, Person::Groups), Column::Leisure), Level::Later);
3292        assert_eq!(quieter.cell(Row::People(Channel::Messages, Person::Neutral), Column::Dnd), Level::Later);
3293    }
3294
3295    #[test]
3296    fn presets_never_touch_a_fixed_cell() {
3297        for preset in Preset::ALL {
3298            let matrix = preset.matrix();
3299            for r in Row::ALL {
3300                for column in Column::ALL {
3301                    if lock(r, column).is_some() {
3302                        assert_eq!(matrix.cell(r, column), usual(r, column), "{preset:?} {r:?} {column:?}");
3303                    }
3304                }
3305            }
3306        }
3307        assert!(Preset::Quieter.matrix().changes_from(&Attention::usual()) > 0 && Preset::Reachable.matrix().changes_from(&Attention::usual()) > 0);
3308    }
3309
3310    #[test]
3311    fn the_grid_in_both_languages() {
3312        for language in ["en", "fr"] {
3313            let tr = Translator::new(language);
3314            let mut a = Attention::usual();
3315            a.set(row("mail.neutral"), Column::Leisure, Level::Now).unwrap();
3316            let grid = grid(&a, &tr, &Row::ALL, Preset::Usual);
3317            assert_eq!((grid.columns.len(), grid.rows.len(), grid.marks.len(), grid.presets.len()), (9, 36, 8, 3));
3318            assert!(grid.rows.iter().find(|r| r.id == "mail.neutral").unwrap().changed && !grid.presets[0].current && grid.presets[0].changes == 1);
3319            assert!(!grid.changed.is_empty());
3320            let words = grid
3321                .columns
3322                .iter()
3323                .map(|c| c.label.clone())
3324                .chain(grid.marks.iter().map(|m| m.label.clone()))
3325                .chain(grid.presets.iter().map(|p| p.label.clone()))
3326                .chain([grid.changed.clone()])
3327                .chain(grid.rows.iter().flat_map(|r| [r.label.clone(), r.group.clone(), r.help.clone()].into_iter().chain(r.choices.iter().map(|c| c.label.clone())).chain(r.cells.iter().flat_map(|c| [c.locked.clone(), c.said.clone()]))));
3328            for said in words {
3329                assert!(!said.starts_with("attention-") && !said.contains('{') && !said.contains("sorte"), "{language}: {said}");
3330                if language == "fr" {
3331                    for mark in [" :", " ;", " !", " ?", "« ", " »", "'"] {
3332                        assert!(!said.contains(mark), "{language}: typography in {said:?}");
3333                    }
3334                }
3335            }
3336            let codes = grid.rows.iter().find(|r| r.id == "codes").unwrap();
3337            assert!(codes.cells[4].locked.is_empty() && !codes.cells[5].locked.is_empty(), "sleep free, the pause fixed");
3338            let always = grid.rows.iter().find(|r| r.id == "mail.always").unwrap();
3339            assert_eq!(always.choices.iter().find(|c| c.id == "now").unwrap().mark, "★");
3340            assert!(!list_choice(&tr, Who::Neutral, &a, true).contains('{'));
3341        }
3342        assert_eq!(Translator::new("fr").text("attention-column-free", None), "Temps libre");
3343        assert_eq!(list_choice(&Translator::new("en"), Who::Neutral, &Attention::usual(), false), "Your contacts: work, admin");
3344        assert_eq!(list_choice(&Translator::new("en"), Who::Safe, &Attention::usual(), false), "Safe: any time");
3345    }
3346
3347    /// A source's own row (an app's, a conversation's: §1.3), each cell as
3348    /// set or, as usual, the row the notification takes otherwise; at each
3349    /// time, under the layers, a conversation's over its app's; nothing
3350    /// changes for an app until a cell of its row is set.
3351    #[test]
3352    fn a_source_s_row_is_read_over_its_usual_row() {
3353        let app = "app.com.example.chat".to_string();
3354        let talk = "conversation.0123456789abcdef".to_string();
3355        let both = vec![talk.clone(), app.clone()];
3356        let message = |who: Who, group: bool| Event::of(Source::Message { via: Channel::Messages, who, group, always: false });
3357        let at = |column: Column| Now::time(column);
3358        let usual = Attention::usual();
3359        // Nothing set: every person, every time, layer or none, as before.
3360        for who in [Who::Safe, Who::Neutral, Who::Restricted, Who::Stranger, Who::Blocked] {
3361            for group in [false, true] {
3362                for column in Column::TIMES {
3363                    for (slot, dnd) in [(false, false), (true, false), (false, true)] {
3364                        let now = at(column).layers(slot, dnd);
3365                        assert_eq!(usual.decide(&message(who, group).from_sources(both.clone()), &now), usual.decide(&message(who, group), &now), "{who:?} {group} {column:?}");
3366                    }
3367                }
3368            }
3369        }
3370        // The owner's example: through during work, held otherwise.
3371        let mut a = Attention::usual();
3372        for column in Column::ALL {
3373            a.set_source(&app, column, if column == Column::Work { Level::Now } else { Level::Later }).unwrap();
3374        }
3375        let stranger = message(Who::Stranger, false).from_sources(both.clone());
3376        assert_eq!(a.decide(&stranger, &at(Column::Work)), Output { level: Level::Now, shown: true, told: true, pierce: false, step: Step::Chosen });
3377        for column in [Column::Admin, Column::Leisure, Column::Meals, Column::Sleep, Column::Pause, Column::Free] {
3378            assert_eq!(a.decide(&stranger, &at(column)).level, Level::Later, "{column:?}");
3379        }
3380        // A safe friend too: the app's row says it, whoever writes; the blocked never.
3381        assert_eq!(a.decide(&message(Who::Safe, false).from_sources(both.clone()), &at(Column::Leisure)).level, Level::Later);
3382        assert_eq!(a.decide(&message(Who::Blocked, false).from_sources(both.clone()), &at(Column::Work)).step, Step::Blocked);
3383        // Under do-not-disturb: held, as its layer's cell says.
3384        assert_eq!(a.decide(&stranger, &at(Column::Work).layers(false, true)).level, Level::Later);
3385        // As usual where not set: the person's own row (a safe friend comes in leisure, a stranger waits).
3386        let mut b = Attention::usual();
3387        b.set_source(&app, Column::Work, Level::Later).unwrap();
3388        assert_eq!(b.decide(&message(Who::Safe, false).from_sources(both.clone()), &at(Column::Leisure)), usual.decide(&message(Who::Safe, false), &at(Column::Leisure)));
3389        assert_eq!(b.decide(&message(Who::Stranger, false).from_sources(both.clone()), &at(Column::Leisure)).step, Step::Matrix);
3390        assert_eq!(b.decide(&message(Who::Safe, false).from_sources(both.clone()), &at(Column::Work)).level, Level::Later);
3391        // A layer as usual holds as it does: at once at work, but do-not-disturb holds a stranger; set at once, it does not.
3392        let mut c = Attention::usual();
3393        c.set_source(&app, Column::Leisure, Level::Now).unwrap();
3394        assert_eq!(c.decide(&stranger, &at(Column::Leisure)).level, Level::Now);
3395        assert_eq!(c.decide(&stranger, &at(Column::Leisure).layers(false, true)).level, Level::Later);
3396        c.set_source(&app, Column::Dnd, Level::Now).unwrap();
3397        assert_eq!(c.decide(&stranger, &at(Column::Leisure).layers(false, true)).level, Level::Now);
3398        // Someone Always through stays so, whatever the app's row says.
3399        let listed = Event::of(Source::Message { via: Channel::Messages, who: Who::Neutral, group: false, always: true }).from_sources(both.clone());
3400        assert_eq!(a.decide(&listed, &at(Column::Sleep)).level, Level::Now);
3401        // A conversation's choice wins over its app's.
3402        a.set_source(&talk, Column::Leisure, Level::Now).unwrap();
3403        assert_eq!(a.decide(&stranger, &at(Column::Leisure)).level, Level::Now);
3404        assert_eq!(a.decide(&message(Who::Stranger, false).from_sources(vec![app.clone()]), &at(Column::Leisure)).level, Level::Later);
3405        // Gathered: an app's automatons and its messages alike.
3406        let mut d = Attention::usual();
3407        d.set_source(&app, Column::Work, Level::Gathered).unwrap();
3408        assert_eq!(d.decide(&Event::own(Kind::AppAutomatons).from_sources(both.clone()), &at(Column::Work)).level, Level::Gathered);
3409        assert_eq!(d.decide(&stranger, &at(Column::Work)).level, Level::Gathered);
3410        assert_eq!(d.decide(&Event::own(Kind::AppAutomatons).from_sources(both.clone()), &at(Column::Leisure)), usual.decide(&Event::own(Kind::AppAutomatons), &at(Column::Leisure)));
3411        // Your word for a time: no area holds it then; as usual, the area does.
3412        let leisure_app = Event::own(Kind::AppAtOnce).for_area(Some(Area::WORK)).from_sources(both.clone());
3413        let mut e = Attention::usual();
3414        e.set_source(&app, Column::Leisure, Level::Now).unwrap();
3415        assert_eq!(e.decide(&leisure_app, &at(Column::Leisure)).step, Step::Chosen);
3416        assert_eq!(usual.decide(&leisure_app, &at(Column::Leisure)).step, Step::Area);
3417        // Free time's "Nothing at all": as usual, it holds a state's row; set, the cell says.
3418        let mut nothing = at(Column::Free);
3419        nothing.nothing = true;
3420        assert_eq!(b.decide(&message(Who::Safe, false).from_sources(both.clone()), &nothing).level, Level::Later);
3421        e.set_source(&app, Column::Free, Level::Now).unwrap();
3422        assert_eq!(e.decide(&message(Who::Safe, false).from_sources(both.clone()), &nothing).level, Level::Now);
3423        // Only its own values; a row as usual everywhere is no row; ids that are no source refused.
3424        assert!(a.set_source(&app, Column::Work, Level::Quiet).is_err() && a.set_source("app.", Column::Work, Level::Now).is_err() && a.set_source("mail.safe", Column::Work, Level::Now).is_err());
3425        let mut f = Attention::usual();
3426        f.set_source(&app, Column::Work, Level::Now).unwrap();
3427        f.set_source(&app, Column::Work, Level::As).unwrap();
3428        assert!(f.source(&app).is_none() && f.sources().is_empty() && !f.has_sources(&both));
3429        assert_eq!(source_rows("com.example.chat", "0123456789abcdef"), both);
3430        assert_eq!(source_rows("com.example.chat", " "), vec![app.clone()]);
3431        // In words, both languages.
3432        for language in ["en", "fr"] {
3433            let tr = Translator::new(language);
3434            let lines = vec![SourceLine { id: app.clone(), label: "Chat".into(), group: "Apps".into(), group_id: "app-rows".into() }];
3435            let grid = source_grid(&a, &tr, &lines);
3436            assert_eq!((grid.columns.len(), grid.rows.len(), grid.marks.len(), grid.rows[0].cells.len()), (9, 1, 4, 9));
3437            assert!(grid.rows[0].changed && grid.rows[0].cells.iter().all(|c| c.choices.len() == 4 && c.locked.is_empty()));
3438            let said = source_sentence(&a, &tr, &app);
3439            let usual_said = source_sentence(&Attention::usual(), &tr, &app);
3440            let words = grid.marks.iter().map(|m| m.label.clone()).chain(grid.rows[0].cells.iter().map(|c| c.said.clone())).chain([grid.rows[0].help.clone(), said.clone(), usual_said, source_sentence(&b, &tr, &app)]);
3441            for text in words {
3442                assert!(!text.starts_with("attention-") && !text.contains('{') && !text.contains("sorte"), "{language}: {text}");
3443                if language == "fr" {
3444                    for mark in [" :", " ;", " !", " ?", "« ", " »", "'"] {
3445                        assert!(!text.contains(mark), "{language}: typography in {text:?}");
3446                    }
3447                }
3448            }
3449        }
3450        assert_eq!(source_sentence(&a, &Translator::new("en"), &app), "At once: Work; held: Admin, Leisure, Meals, Sleep, Pause, Free time, Time for you and Do not disturb.");
3451        assert_eq!(source_sentence(&b, &Translator::new("en"), &app), "Held: Work; as usual the rest of the time.");
3452        assert_eq!(source_sentence(&b, &Translator::new("fr"), &app), "Retenues\u{202f}: Travail\u{202f}; comme d’habitude le reste du temps.");
3453    }
3454
3455    /// A source's row written with the settings, read back, its name kept for
3456    /// the other devices; as usual again, taken out; the other rows untouched.
3457    #[test]
3458    fn source_rows_written_and_read() {
3459        let dir = std::env::temp_dir().join(format!("sioul-attention-sources-{}", std::process::id()));
3460        let _ = std::fs::remove_dir_all(&dir);
3461        std::fs::create_dir_all(&dir).unwrap();
3462        let path = dir.join("config.toml");
3463        std::fs::write(&path, "[notify]\nsites = [\"work:later\"]\n").unwrap();
3464        let config = Config::load(&path).unwrap();
3465        let words = |w: &[&str]| SettingValue::Texts(w.iter().map(|s| s.to_string()).collect());
3466        // The first row written is a source's: the older keys seed the matrix as for any row.
3467        apply(&path, &config, "attention.app.com.example.chat", &words(&["work", "admin:later", "leisure:as", "dnd:gathered", "name=Example Chat"])).unwrap();
3468        let text = std::fs::read_to_string(&path).unwrap();
3469        assert!(text.contains("\"app.com.example.chat\" = [\"work\", \"admin:later\", \"dnd:gathered\", \"name=Example Chat\"]") && !text.contains("[notify]"), "{text}");
3470        let a = Attention::of(&Config::load(&path).unwrap());
3471        assert_eq!(a.cell(row("sites"), Column::Work), Level::Later, "seeded from [notify]");
3472        let cells = a.source("app.com.example.chat").unwrap();
3473        assert_eq!((cells[0], cells[1], cells[2], cells[8]), (Level::Now, Level::Later, Level::As, Level::Gathered));
3474        assert_eq!(a.source_name("app.com.example.chat"), "Example Chat");
3475        // Another row changed: the source's stays.
3476        apply(&path, &Config::load(&path).unwrap(), "attention.codes", &words(&["sleep:never"])).unwrap();
3477        assert!(Attention::of(&Config::load(&path).unwrap()).source("app.com.example.chat").is_some());
3478        // Refused: a value a source's cell does not take, a word not understood.
3479        assert!(apply(&path, &Config::load(&path).unwrap(), "attention.app.com.example.chat", &words(&["work:quiet"])).is_err());
3480        assert!(apply(&path, &Config::load(&path).unwrap(), "attention.app.com.example.chat", &words(&["teatime"])).is_err());
3481        // As usual everywhere: taken out, name and all.
3482        apply(&path, &Config::load(&path).unwrap(), "attention.app.com.example.chat", &words(&["work:as", "admin:as", "dnd:as"])).unwrap();
3483        let text = std::fs::read_to_string(&path).unwrap();
3484        assert!(!text.contains("example.chat") && Attention::of(&Config::load(&path).unwrap()).sources().is_empty(), "{text}");
3485        // An older Sioul's reading leaves it aside: no row of the matrix is a source's.
3486        assert!(Row::read("app.com.example.chat").is_none() && Row::read("conversation.0123").is_none());
3487        let _ = std::fs::remove_dir_all(&dir);
3488    }
3489}