Skip to main content

sioul_core/
screening.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The call screening (docs/android.md, "Calls"): what becomes of a call
5//! from a number nobody knows. The owner's decision of 14 October 2026:
6//! "call screening is not only distraction protection, it's spam protection
7//! too, so it needs to be separated from don't disturb mode and might get
8//! enabled 24/7"; its rules are the decisions 3 to 5 he took on 18 October
9//! 2026 (docs/attention.md, §10).
10//!
11//! **Apart**: the people you know (your lists, your address books, the
12//! phone's contacts, a number you called lately, Always through) are decided
13//! by the matrix of what reaches you, its Calls rows, the time and its layers
14//! (`attention`); everyone else by the screening alone, which neither
15//! do-not-disturb nor any state of yours switches off. What always rings
16//! (emergency numbers and their callback, the day after you call one, a
17//! second call within 15 minutes) and the blocked come before both
18//! (`calls::decide`).
19//!
20//! **What it screens**, each kind on its own: a number in no address book
21//! (`unknown`), a hidden number (`hidden`), an automated caller (`automated`:
22//! ARCEP's roots for machines), a number that cannot be a caller's
23//! (`impossible`: one the plan forbids as a caller ID, 089), a number whose
24//! verification failed (`unverified`). The last three it keeps out at all
25//! hours, a second call too, while they go to voicemail. A number you called
26//! in the last 14 days counts as your contacts do (`called`). Its hours: at
27//! all hours, or only outside your work and admin hours (`hours`), for
28//! numbers nobody knows and hidden numbers. Whatever it lets ring by itself
29//! never rings where your contacts' row declines (do-not-disturb, the night,
30//! a pause): only Expecting a call opens beyond (`calls::decide`; review of
31//! part 2, 19 October 2026). ARCEP's list of the blocks it had attributed to
32//! no operator on a day decides nothing: a block attributed since may hold a
33//! new line. A declined call's line only says it, while the list counts
34//! (`unattributed_since`).
35//!
36//! **Kept** in `[screening]`, shared with the settings; each key unsaid takes
37//! its first value: unknown and hidden numbers from your Calls rows of
38//! strangers and hidden numbers as they were before the screening was apart
39//! (never ringing where the row declined), the rest as decided. Written a key
40//! at a time, only when you change one; the first written also fixes unknown
41//! and hidden numbers at their first values, which the Calls rows then move
42//! no more (`set`). No preset nor any write of the matrix touches those rows
43//! (`attention::screened`). An older Sioul keeps the section as it is. The
44//! numbers known by their kind: `data/screening-numbers.toml`.
45
46use crate::attention::{Attention, Column, Level, Person, Row};
47use crate::config::{Config, SettingValue};
48use crate::reach::Channel;
49use serde::{Deserialize, Serialize};
50use std::collections::BTreeMap;
51use std::path::Path;
52
53/// A number you called in the last this many days counts as your contacts do
54/// (a guess, research 8.3, step 6).
55pub const CALLED_DAYS: i64 = 14;
56
57/// When the screening screens.
58#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum Hours {
61    /// At all hours (the default).
62    #[default]
63    Always,
64    /// Only outside your work and admin hours: in them, numbers nobody knows
65    /// and hidden numbers ring where your contacts may; what it keeps out as
66    /// spam stays out.
67    OutsideWork,
68}
69
70impl Hours {
71    pub const ALL: [Hours; 2] = [Hours::Always, Hours::OutsideWork];
72
73    pub fn id(self) -> &'static str {
74        match self {
75            Hours::Always => "always",
76            Hours::OutsideWork => "outside-work",
77        }
78    }
79
80    pub fn read(text: &str) -> Option<Hours> {
81        Hours::ALL.into_iter().find(|h| h.id() == text.trim())
82    }
83}
84
85/// What becomes of a call from a number in no address book, or of a hidden number.
86#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum Rule {
89    /// To your voicemail: declined, as you would by hand.
90    #[default]
91    Voicemail,
92    /// It rings in your work and admin hours (a hospital's switchboard calls
93    /// then), and goes to your voicemail otherwise.
94    Work,
95    /// It rings.
96    Ring,
97}
98
99impl Rule {
100    pub const ALL: [Rule; 3] = [Rule::Voicemail, Rule::Work, Rule::Ring];
101
102    pub fn id(self) -> &'static str {
103        match self {
104            Rule::Voicemail => "voicemail",
105            Rule::Work => "work",
106            Rule::Ring => "ring",
107        }
108    }
109
110    pub fn read(text: &str) -> Option<Rule> {
111        Rule::ALL.into_iter().find(|r| r.id() == text.trim())
112    }
113}
114
115/// What becomes of an automated caller, a number that cannot be a caller's,
116/// a number whose verification failed.
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "kebab-case")]
119pub enum Spam {
120    /// To your voicemail, even while you expect a call.
121    #[default]
122    Voicemail,
123    /// As any number nobody knows.
124    Unknown,
125}
126
127impl Spam {
128    pub const ALL: [Spam; 2] = [Spam::Voicemail, Spam::Unknown];
129
130    pub fn id(self) -> &'static str {
131        match self {
132            Spam::Voicemail => "voicemail",
133            Spam::Unknown => "unknown",
134        }
135    }
136
137    pub fn read(text: &str) -> Option<Spam> {
138        Spam::ALL.into_iter().find(|s| s.id() == text.trim())
139    }
140}
141
142/// `[screening]` as written, key by key: read leniently, so that a word
143/// this version does not know (a later version's) never stops the
144/// configuration from reading; that key then takes its first value.
145#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
146#[serde(transparent)]
147pub struct Settings(pub BTreeMap<String, toml::Value>);
148
149impl Settings {
150    fn word(&self, key: &str) -> Option<&str> {
151        self.0.get(key).and_then(toml::Value::as_str)
152    }
153
154    fn flag(&self, key: &str) -> Option<bool> {
155        self.0.get(key).and_then(toml::Value::as_bool)
156    }
157}
158
159/// The screening as it stands: `[screening]`, each key unsaid at its first value.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
161pub struct Screening {
162    /// Screening at all: off, every number nobody knows rings (the blocked aside).
163    pub on: bool,
164    pub hours: Hours,
165    /// A number in none of your address books, Sioul's or the phone's.
166    pub unknown: Rule,
167    /// A call that shows no number.
168    pub hidden: Rule,
169    /// From ARCEP's roots for automated callers.
170    pub automated: Spam,
171    /// A number the plan forbids as a caller ID, or from a block attributed to nobody.
172    pub impossible: Spam,
173    /// A number whose verification by the network failed.
174    pub unverified: Spam,
175    /// A number you called in the last `CALLED_DAYS` days counts as your
176    /// contacts do, and never rings less than a number nobody knows.
177    pub called: bool,
178}
179
180impl Default for Screening {
181    fn default() -> Screening {
182        Screening::of_rows(&Attention::usual(), None)
183    }
184}
185
186impl Screening {
187    /// As your configuration says: `[screening]`, each key unsaid at its first
188    /// value (`first_rule` of your Calls rows for unknown and hidden numbers).
189    pub fn of(config: &Config) -> Screening {
190        Screening::of_rows(&Attention::of(config), config.screening.as_ref())
191    }
192
193    fn of_rows(attention: &Attention, set: Option<&Settings>) -> Screening {
194        let word = |key: &str| set.and_then(|s| s.word(key));
195        let flag = |key: &str| set.and_then(|s| s.flag(key));
196        Screening {
197            on: flag("on").unwrap_or(true),
198            hours: word("hours").and_then(Hours::read).unwrap_or_default(),
199            unknown: word("unknown").and_then(Rule::read).unwrap_or_else(|| first_rule(attention, Person::Stranger)),
200            hidden: word("hidden").and_then(Rule::read).unwrap_or_else(|| first_rule(attention, Person::Hidden)),
201            automated: word("automated").and_then(Spam::read).unwrap_or_default(),
202            impossible: word("impossible").and_then(Spam::read).unwrap_or_default(),
203            unverified: word("unverified").and_then(Spam::read).unwrap_or_default(),
204            called: flag("called").unwrap_or(true),
205        }
206    }
207
208    /// Whether a number nobody knows, or a hidden one, rings at a time of the
209    /// day (`hours`: work or admin hours then), as the screening says by
210    /// itself: off, outside its hours, or its rule letting it ring. What it
211    /// screens as spam aside, and Expecting a call, which holds for a while;
212    /// never beyond your contacts' row then (`calls::decide`).
213    pub fn rings(&self, hidden: bool, hours: bool) -> bool {
214        if !self.on || (self.hours == Hours::OutsideWork && hours) {
215            return true;
216        }
217        match if hidden { self.hidden } else { self.unknown } {
218            Rule::Ring => true,
219            Rule::Work => hours,
220            Rule::Voicemail => false,
221        }
222    }
223}
224
225/// A rule's first value, from your Calls row of strangers or hidden numbers
226/// as it was before the screening was apart: the closest rule that never
227/// rings where the row declined. Ringing at every hour only when it rang at
228/// every time; ringing in work and admin hours when it rang in either;
229/// voicemail otherwise (review of part 2, F2: a row that rang in leisure alone
230/// once gave ringing at night). Its layers (time for you, do-not-disturb) are
231/// no part of the screening.
232pub fn first_rule(attention: &Attention, person: Person) -> Rule {
233    let row = Row::People(Channel::Calls, person);
234    let rings = |column: Column| attention.cell(row, column) == Level::Now;
235    if Column::TIMES.into_iter().all(|column| rings(column)) {
236        Rule::Ring
237    } else if rings(Column::Work) || rings(Column::Admin) {
238        Rule::Work
239    } else {
240        Rule::Voicemail
241    }
242}
243
244/// The keys of `[screening]` and the words each takes ("bool": on or off).
245pub const KEYS: [(&str, &[&str]); 8] = [
246    ("on", &["bool"]),
247    ("hours", &["always", "outside-work"]),
248    ("unknown", &["voicemail", "work", "ring"]),
249    ("hidden", &["voicemail", "work", "ring"]),
250    ("automated", &["voicemail", "unknown"]),
251    ("impossible", &["voicemail", "unknown"]),
252    ("unverified", &["voicemail", "unknown"]),
253    ("called", &["bool"]),
254];
255
256/// One setting of the screening written (Settings ▸ This phone ▸ Call
257/// screening), `key` as "screening.unknown": its value checked, then only that
258/// key, in place (`config::set_value`); the others keep their first values
259/// until you change them. Refused, said, for a key or a word it does not take.
260pub fn set(path: &Path, key: &str, value: &SettingValue) -> Result<(), String> {
261    let name = key.strip_prefix("screening.").unwrap_or(key);
262    let Some((_, words)) = KEYS.iter().find(|(k, _)| *k == name) else { return Err(format!("{key}: no such setting of the call screening")) };
263    let boolean = words.len() == 1 && words[0] == "bool";
264    let checked = match value {
265        SettingValue::Bool(on) if boolean => SettingValue::Bool(*on),
266        SettingValue::Text(word) if !boolean && words.contains(&word.trim()) => SettingValue::Text(word.trim().to_string()),
267        _ if boolean => return Err(format!("{key}: on or off")),
268        _ => return Err(format!("{key}: {}", words.join(", "))),
269    };
270    // The first setting written fixes unknown and hidden numbers at their
271    // first values: the Calls rows they come from move them no more.
272    if path.exists() {
273        let config = Config::load(path)?;
274        let now = Screening::of(&config);
275        for (unsaid, rule) in [("unknown", now.unknown), ("hidden", now.hidden)] {
276            if unsaid != name && config.screening.as_ref().and_then(|s| s.word(unsaid)).and_then(Rule::read).is_none() {
277                crate::config::set_value(path, &format!("screening.{unsaid}"), &SettingValue::Text(rule.id().to_string()))?;
278            }
279        }
280    }
281    crate::config::set_value(path, &format!("screening.{name}"), &checked)
282}
283
284// ---------------------------------------------------------------- the numbers known by their kind
285
286const NUMBERS: &str = include_str!("../data/screening-numbers.toml");
287
288/// The numbers the screening knows by their kind (`data/screening-numbers.toml`).
289#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
290pub struct Numbers {
291    #[serde(default)]
292    pub group: Vec<Group>,
293}
294
295/// One group of prefixes, as the plan or ARCEP's data says them.
296#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
297pub struct Group {
298    /// "automated", "never-shown", "unattributed".
299    pub kind: String,
300    #[serde(default)]
301    pub territory: String,
302    /// In national form ("0162").
303    #[serde(default)]
304    pub prefixes: Vec<String>,
305    /// The rule in the source's own words, when quoted.
306    #[serde(default)]
307    pub about: String,
308    pub source: String,
309    /// The day it was read (YYYY-MM-DD).
310    pub checked: String,
311    /// It counts only so many days after `checked` (ARCEP's attributions move).
312    #[serde(default)]
313    pub valid_days: Option<i64>,
314}
315
316impl Group {
317    /// Whether it counts on `today`: always, or within its days after it was read.
318    pub fn counts(&self, today: jiff::civil::Date) -> bool {
319        let Some(days) = self.valid_days else { return true };
320        let Ok(read) = self.checked.parse::<jiff::civil::Date>() else { return false };
321        read.checked_add(jiff::Span::new().days(days)).is_ok_and(|end| today < end) && today >= read
322    }
323}
324
325impl Numbers {
326    /// As Sioul ships it; none when the file does not read (the tests say it does).
327    pub fn built_in() -> Numbers {
328        toml::from_str(NUMBERS).unwrap_or_default()
329    }
330
331    fn of(&self, kinds: &[&str], today: jiff::civil::Date) -> Vec<String> {
332        let mut out: Vec<String> = self.group.iter().filter(|g| kinds.contains(&g.kind.as_str()) && g.counts(today)).flat_map(|g| g.prefixes.iter().map(|p| p.trim().to_string())).filter(|p| p.len() >= 2 && p.starts_with('0') && p.bytes().all(|b| b.is_ascii_digit())).collect();
333        out.sort();
334        out.dedup();
335        out
336    }
337
338    /// The automated callers' roots, in national form.
339    pub fn automated(&self, today: jiff::civil::Date) -> Vec<String> {
340        self.of(&["automated"], today)
341    }
342
343    /// What can never be a caller's number, in national form: what the plan
344    /// forbids as a caller ID (089). The blocks attributed to nobody on a day
345    /// are no part of it: a block attributed since may hold a new line
346    /// (review of part 2, F1).
347    pub fn never_shown(&self, today: jiff::civil::Date) -> Vec<String> {
348        self.of(&["never-shown"], today)
349    }
350
351    /// The day ARCEP's list was read, when a number's key falls in a block it
352    /// had attributed to no operator then and that list still counts on
353    /// `today`: said on a declined call's line, deciding nothing.
354    pub fn unattributed_since(&self, key: &str, today: jiff::civil::Date) -> Option<jiff::civil::Date> {
355        let national = national(key)?;
356        self.group.iter().filter(|g| g.kind == "unattributed" && g.counts(today)).find(|g| starts_with_any(&national, &g.prefixes)).and_then(|g| g.checked.parse().ok())
357    }
358}
359
360/// The numbers as Sioul ships them, read once.
361pub fn shipped() -> &'static Numbers {
362    static SHIPPED: std::sync::OnceLock<Numbers> = std::sync::OnceLock::new();
363    SHIPPED.get_or_init(Numbers::built_in)
364}
365
366/// A number's key (`phones::key`, "+33199001234") in the French plan's
367/// national form ("0199001234"), its overseas departments' included (+262,
368/// +590, +594, +596: "+262262123456" is "0262123456"); none for any other.
369pub fn national(key: &str) -> Option<String> {
370    ["+33", "+262", "+590", "+594", "+596"].iter().find_map(|code| key.strip_prefix(code).filter(|rest| rest.len() == 9 && rest.bytes().all(|b| b.is_ascii_digit())).map(|rest| format!("0{rest}")))
371}
372
373/// Whether a national number starts with one of these prefixes.
374pub fn starts_with_any(national: &str, prefixes: &[String]) -> bool {
375    prefixes.iter().any(|p| !p.is_empty() && national.starts_with(p.as_str()))
376}
377
378// ---------------------------------------------------------------- the calls' table
379
380/// What the calls' table says of the screening (version 2), as Java reads it
381/// (Calls.Screening): the screening as it stands, and the prefixes it needs.
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383pub struct TablePart {
384    pub on: bool,
385    pub hours: Hours,
386    pub unknown: Rule,
387    pub hidden: Rule,
388    pub automated: Spam,
389    pub impossible: Spam,
390    pub unverified: Spam,
391    /// A number called in the last so many days counts as your contacts do; 0: none.
392    pub called_days: i64,
393    /// The automated callers' roots, in national form.
394    #[serde(default)]
395    pub automated_prefixes: Vec<String>,
396    /// What can never be a caller's number, in national form: what the plan
397    /// forbids as a caller ID (089).
398    #[serde(default)]
399    pub impossible_prefixes: Vec<String>,
400}
401
402impl TablePart {
403    /// The screening and the numbers that count on `today`.
404    pub fn of(screening: &Screening, numbers: &Numbers, today: jiff::civil::Date) -> TablePart {
405        TablePart {
406            on: screening.on,
407            hours: screening.hours,
408            unknown: screening.unknown,
409            hidden: screening.hidden,
410            automated: screening.automated,
411            impossible: screening.impossible,
412            unverified: screening.unverified,
413            called_days: if screening.called { CALLED_DAYS } else { 0 },
414            automated_prefixes: numbers.automated(today),
415            impossible_prefixes: numbers.never_shown(today),
416        }
417    }
418
419    /// The screening it says, without its prefixes.
420    pub fn screening(&self) -> Screening {
421        Screening { on: self.on, hours: self.hours, unknown: self.unknown, hidden: self.hidden, automated: self.automated, impossible: self.impossible, unverified: self.unverified, called: self.called_days > 0 }
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use crate::attention::Column as C;
429
430    fn day(text: &str) -> jiff::civil::Date {
431        text.parse().unwrap()
432    }
433
434    /// The shipped file reads; every group names its kind, its source with an
435    /// address and the day it was read; prefixes in national form; ARCEP's
436    /// roots for machines as the plan says them; 089 never shown; the
437    /// never-attributed blocks counting 90 days only.
438    #[test]
439    fn screening_numbers_are_well_formed_and_sourced() {
440        let numbers: Numbers = toml::from_str(NUMBERS).expect("data/screening-numbers.toml reads");
441        assert!(numbers.group.len() >= 7, "{} groups", numbers.group.len());
442        for g in &numbers.group {
443            assert!(["automated", "never-shown", "unattributed"].contains(&g.kind.as_str()), "{}", g.kind);
444            assert!(g.source.contains("ARCEP") && g.source.contains("https://"), "a source with its address: {}", g.source);
445            assert!(g.checked.parse::<jiff::civil::Date>().is_ok(), "{}: the day read", g.checked);
446            assert!(!g.territory.is_empty() && !g.prefixes.is_empty());
447            for p in &g.prefixes {
448                assert!(p.len() >= 3 && p.len() <= 6 && p.starts_with('0') && p.bytes().all(|b| b.is_ascii_digit()), "{p}");
449            }
450        }
451        let today = day("2026-10-20");
452        let automated = numbers.automated(today);
453        for root in ["0162", "0163", "0270", "0271", "0377", "0378", "0424", "0425", "0568", "0569", "0948", "0949", "05987", "09475", "05988", "09476", "05989", "09477", "02688", "02689", "09478", "09479"] {
454            assert!(automated.contains(&root.to_string()), "{root}");
455        }
456        assert_eq!(automated.len(), 22, "nothing else: the public-interest roots stay out");
457        // What can never be a caller's: the plan's own rule, 089, and nothing of the snapshot (review of part 2, F1).
458        assert_eq!(numbers.never_shown(today), vec!["089".to_string()]);
459        let unattributed = numbers.group.iter().find(|g| g.kind == "unattributed").unwrap();
460        // The public-interest multi-use numbers (plan § 2.3.10 a): a category of their own, as any number nobody
461        // knows (CS12), never said attributed to nobody though most of their blocks are yet; nor machines.
462        for root in ["01510", "02810", "03410", "04410", "05410", "09410", "059850", "059864", "094700", "094709", "02687", "050860"] {
463            assert!(!unattributed.prefixes.iter().chain(&automated).any(|p| p.starts_with(root) || root.starts_with(p.as_str())), "{root}");
464        }
465        assert!(unattributed.prefixes.len() > 1_000, "the blocks attributed to nobody on the day read");
466        // No root for machines among the numbers never shown.
467        assert!(!automated.iter().any(|a| a.starts_with("089")));
468        // The blocks attributed to nobody are said 90 days after the day read, then no more.
469        assert_eq!(unattributed.valid_days, Some(90));
470        let read = day(&unattributed.checked);
471        assert!(unattributed.counts(read) && unattributed.counts(read.checked_add(jiff::Span::new().days(89)).unwrap()));
472        assert!(!unattributed.counts(read.checked_add(jiff::Span::new().days(90)).unwrap()), "past its days");
473        assert!(!unattributed.counts(read.yesterday().unwrap()), "a clock before the day read: not counted");
474        assert_eq!(numbers.unattributed_since("+33353011234", read), Some(read), "a fiction block attributed to nobody that day");
475        assert_eq!(numbers.unattributed_since("+33353011234", read.checked_add(jiff::Span::new().days(90)).unwrap()), None);
476        assert_eq!(numbers.unattributed_since("+441632960000", read), None, "another country's");
477        assert_eq!(Numbers::built_in(), numbers);
478    }
479
480    #[test]
481    fn numbers_in_national_form() {
482        assert_eq!(national("+33199001234").as_deref(), Some("0199001234"));
483        assert_eq!(national("+262262123456").as_deref(), Some("0262123456"), "La Réunion's fixed numbers");
484        assert_eq!(national("+590590123456").as_deref(), Some("0590123456"));
485        assert_eq!(national("+3319900123"), None, "a digit short");
486        assert_eq!(national("+441632960000"), None, "another country's");
487        assert_eq!(national("0199001234"), None, "not a key");
488        assert_eq!(national(""), None);
489        let prefixes = vec!["0465".to_string(), "089".to_string()];
490        assert!(starts_with_any("0465711234", &prefixes) && starts_with_any("0899123456", &prefixes) && !starts_with_any("0199001234", &prefixes));
491        assert!(!starts_with_any("0199001234", &[String::new()]), "an empty prefix holds nothing");
492    }
493
494    /// The first values: your Calls rows of strangers and hidden numbers as
495    /// they were, never screening more than they did; each key once written
496    /// is yours, a word not known reads as unsaid; the decided defaults.
497    #[test]
498    fn first_values_from_the_calls_rows_and_each_key_once_written() {
499        let usual = Screening::default();
500        assert_eq!(usual, Screening { on: true, hours: Hours::Always, unknown: Rule::Voicemail, hidden: Rule::Work, automated: Spam::Voicemail, impossible: Spam::Voicemail, unverified: Spam::Voicemail, called: true });
501        // The owner's: hidden numbers held at every time.
502        let config: Config = toml::from_str("[attention]\n\"calls.hidden\" = [\"work:later\", \"admin:later\", \"leisure:later\", \"meals:later\", \"sleep:later\", \"pause:later\", \"free:later\", \"slot:later\", \"dnd:later\"]\n").unwrap();
503        assert_eq!(Screening::of(&config).hidden, Rule::Voicemail);
504        // Strangers let ring at work and admin: ringing then; at leisure too: still only then, never at night.
505        let mut attention = Attention::usual();
506        attention.set(Row::People(Channel::Calls, Person::Stranger), C::Work, Level::Now).unwrap();
507        attention.set(Row::People(Channel::Calls, Person::Stranger), C::Admin, Level::Now).unwrap();
508        assert_eq!(first_rule(&attention, Person::Stranger), Rule::Work);
509        attention.set(Row::People(Channel::Calls, Person::Stranger), C::Leisure, Level::Now).unwrap();
510        assert_eq!(first_rule(&attention, Person::Stranger), Rule::Work, "never ringing where the row declined");
511        // A layer is no part of it: time for you's cell lets strangers through as usual, voicemail all the same.
512        assert_eq!(attention.cell(Row::People(Channel::Calls, Person::Stranger), C::Slot), Level::Now);
513        assert_eq!(first_rule(&Attention::usual(), Person::Stranger), Rule::Voicemail);
514        // Written keys are yours; a word not known reads as unsaid; a wrong type too.
515        let config: Config = toml::from_str("[screening]\non = false\nhours = \"outside-work\"\nunknown = \"ring\"\nautomated = \"unknown\"\nimpossible = \"later-word\"\ncalled = \"yes\"\n").unwrap();
516        let read = Screening::of(&config);
517        assert_eq!((read.on, read.hours, read.unknown, read.hidden, read.automated, read.impossible, read.called), (false, Hours::OutsideWork, Rule::Ring, Rule::Work, Spam::Unknown, Spam::Voicemail, true));
518    }
519
520    /// The owner's row of hidden numbers, held at every time, as the review
521    /// of part 2 tried it (19 October 2026).
522    const OWNER_HIDDEN: &str = "[attention]\n\"calls.hidden\" = [\"work:later\", \"admin:later\", \"leisure:later\", \"meals:later\", \"sleep:later\", \"pause:later\", \"free:later\", \"slot:later\", \"dnd:later\"]\n";
523
524    /// A configuration file of its own, in a folder of its own.
525    fn scratch(name: &str, text: &str) -> (std::path::PathBuf, std::path::PathBuf) {
526        let dir = std::env::temp_dir().join(format!("sioul-screening-{name}-{}", std::process::id()));
527        let _ = std::fs::remove_dir_all(&dir);
528        std::fs::create_dir_all(&dir).unwrap();
529        let path = dir.join("config.toml");
530        std::fs::write(&path, text).unwrap();
531        (dir, path)
532    }
533
534    /// F2 (review of part 2): no preset moves the call screening. Each leaves
535    /// the owner's hidden numbers to voicemail at all hours, his row as it
536    /// was, and the screening as it stood.
537    #[test]
538    fn the_presets_never_move_the_screening() {
539        for preset in crate::attention::Preset::ALL {
540            let (dir, path) = scratch(preset.id(), OWNER_HIDDEN);
541            let before = Screening::of(&Config::load(&path).unwrap());
542            crate::attention::apply_preset(&path, &Config::load(&path).unwrap(), preset).unwrap();
543            let after = Screening::of(&Config::load(&path).unwrap());
544            assert_eq!((after, after.hidden), (before, Rule::Voicemail), "{preset:?}");
545            assert!(std::fs::read_to_string(&path).unwrap().contains("\"calls.hidden\" = [\"work:later\""), "{preset:?}: the row as it was");
546            let _ = std::fs::remove_dir_all(&dir);
547        }
548    }
549
550    /// F2: the first setting of the screening written fixes unknown and hidden
551    /// numbers at their first values: a Calls row changed afterwards (an
552    /// older device's grid) moves the screening no more.
553    #[test]
554    fn the_first_key_written_fixes_unknown_and_hidden() {
555        let (dir, path) = scratch("first", OWNER_HIDDEN);
556        set(&path, "screening.on", &SettingValue::Bool(true)).unwrap();
557        let written = Config::load(&path).unwrap().screening.unwrap();
558        assert_eq!((written.word("unknown"), written.word("hidden")), (Some("voicemail"), Some("voicemail")));
559        let text = std::fs::read_to_string(&path).unwrap().replace("\"calls.hidden\" = [\"work:later\", \"admin:later\"", "\"calls.hidden\" = [\"work\", \"admin\"");
560        std::fs::write(&path, text).unwrap();
561        assert_eq!(Screening::of(&Config::load(&path).unwrap()).hidden, Rule::Voicemail);
562        let _ = std::fs::remove_dir_all(&dir);
563    }
564
565    /// F2: a row that rang in some hours out of work never makes its numbers
566    /// ring at every hour: ringing in work and admin hours when it rang then,
567    /// else voicemail; at every hour only when it rang at every time.
568    #[test]
569    fn a_row_that_rang_out_of_work_never_rings_at_all_hours() {
570        let stranger = Row::People(Channel::Calls, Person::Stranger);
571        let mut leisure = Attention::usual();
572        leisure.set(stranger, C::Leisure, Level::Now).unwrap();
573        assert_eq!(first_rule(&leisure, Person::Stranger), Rule::Voicemail);
574        let mut both = leisure.clone();
575        both.set(stranger, C::Work, Level::Now).unwrap();
576        assert_eq!(first_rule(&both, Person::Stranger), Rule::Work);
577        let mut every = Attention::usual();
578        for column in C::TIMES {
579            every.set(stranger, column, Level::Now).unwrap();
580        }
581        assert_eq!(first_rule(&every, Person::Stranger), Rule::Ring);
582    }
583
584    /// Whether a number nobody knows rings by the screening alone, at each
585    /// hour: off, outside its hours, its rule.
586    #[test]
587    fn what_rings_by_the_screening_alone() {
588        let usual = Screening::default();
589        assert!(!usual.rings(false, true) && !usual.rings(false, false), "unknown numbers: voicemail at all hours");
590        assert!(usual.rings(true, true) && !usual.rings(true, false), "hidden numbers: in work and admin hours");
591        let off = Screening { on: false, ..usual };
592        assert!(off.rings(false, false) && off.rings(true, false));
593        let outside = Screening { hours: Hours::OutsideWork, hidden: Rule::Voicemail, ..usual };
594        assert!(outside.rings(false, true) && outside.rings(true, true) && !outside.rings(false, false) && !outside.rings(true, false));
595        let ring = Screening { unknown: Rule::Ring, ..usual };
596        assert!(ring.rings(false, false));
597    }
598
599    /// Written a key at a time, checked: the others stay unsaid; a key or a
600    /// word it does not take is refused, said; the rest of the file kept.
601    #[test]
602    fn settings_written_a_key_at_a_time() {
603        let dir = std::env::temp_dir().join(format!("sioul-screening-{}", std::process::id()));
604        let _ = std::fs::remove_dir_all(&dir);
605        std::fs::create_dir_all(&dir).unwrap();
606        let path = dir.join("config.toml");
607        std::fs::write(&path, "# mine\n[dnd]\nfocus = true\n\n[screening]\nlater = \"a later version's\"\n").unwrap();
608        set(&path, "screening.hidden", &SettingValue::Text("voicemail".into())).unwrap();
609        set(&path, "screening.on", &SettingValue::Bool(true)).unwrap();
610        assert!(set(&path, "screening.hidden", &SettingValue::Text("sometimes".into())).unwrap_err().contains("voicemail, work, ring"));
611        assert!(set(&path, "screening.on", &SettingValue::Text("yes".into())).is_err());
612        assert!(set(&path, "screening.color", &SettingValue::Text("red".into())).is_err());
613        let text = std::fs::read_to_string(&path).unwrap();
614        assert!(text.contains("# mine") && text.contains("focus = true") && text.contains("later = \"a later version's\""), "{text}");
615        let config = Config::load(&path).unwrap();
616        let read = Screening::of(&config);
617        assert_eq!((read.on, read.hidden, read.unknown), (true, Rule::Voicemail, Rule::Voicemail));
618        // The first key written fixed unknown numbers at their first value too; nothing else is written.
619        let written = &config.screening.as_ref().unwrap().0;
620        assert_eq!(written.get("unknown").and_then(toml::Value::as_str), Some("voicemail"));
621        assert!(!written.contains_key("automated") && !written.contains_key("hours"), "only the keys changed, and the two fixed");
622        let _ = std::fs::remove_dir_all(&dir);
623    }
624}