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