Skip to main content

sioul_core/
calls.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Calls screened on a phone (docs/android.md, "Calls";
5//! docs/research/call-screening.md, section 8).
6//!
7//! Android asks Sioul about each call, as its "Caller ID & spam app", and
8//! waits for the answer before the phone rings: the answer must come in
9//! milliseconds, from Java alone (android/…/Calls.java), never from Sioul's
10//! library. So Sioul writes ahead, in its state folder, a **table**
11//! (`calls/table.json`): who each number is (by `phones::key`), the floors
12//! that always ring, Expecting a call as your devices last said it,
13//! and the coming days cut into **frames**, each with whether each row of the
14//! people you know rings then (the Calls rows of the matrix of what reaches
15//! you, `attention`: the times, Free time's cells, and the layers, today's
16//! slots of time for you and do-not-disturb from its switch or a focus
17//! session) and whether it is work or admin time; and, from version 2, the
18//! call screening (`screening`): what becomes of the numbers nobody knows,
19//! apart from the matrix, from do-not-disturb and from every state of yours.
20//! Java decides in this order (version 2): what always rings (an emergency
21//! number or its callback; a day after you call an emergency number); your
22//! blocked numbers, refused; a second call within 15 minutes, which rings;
23//! the people you know, by the frame for now; everyone else by the
24//! screening, its hours and Expecting a call. No table, or past its frames:
25//! it rings. Always through (`everywhere::People`) is no floor: its people's
26//! numbers are written as rows of their own ("always-safe", their own row
27//! read where Always through says "as their list"), never a blocked one:
28//! blocked beats Always through (docs/attention.md, Q3). A table of version 1
29//! (an older Sioul's, until this one writes its own) is still read in its own
30//! order: "Let every call through" before a second call, then strangers and
31//! hidden numbers by their frame's rows as everyone else.
32//!
33//! A refused call goes to your voicemail. Java writes **every incoming call it
34//! screened**, declined or let ring (`rang`), in a file of its own
35//! (`calls/held.jsonl`); Rust copies each into this phone's own **log**,
36//! `calls/log/<device>.jsonl` (`carry_into`), which the sharing carries,
37//! sealed, a line at a time, to your other devices (the part "Calls"). Every
38//! device reads every phone's log (`read_logs`): the Porch lists the declined
39//! calls calmly, at the next time those callers may reach you, never counted
40//! (`lines`); a person's sheet shows their calls of the month (`history`).
41//! **Seen** is each device's own log of lines (`calls/seen/<device>.jsonl`,
42//! `mark_seen`), every device's read together (`read_seen`): seen on one,
43//! gone from all. A call is kept `KEPT_DAYS`: each device takes its own lines
44//! out after that (`trim_own`), and the sharing takes them out everywhere.
45//!
46//! **Expecting a call** (once "Let every call through", the same press) is
47//! pressed on any device: each device's own table in do-not-disturb's switch
48//! file (`state/do-not-disturb.toml`, shared) carries its last press,
49//! `[device.<id>.calls]`; the latest press anywhere holds. While it lasts,
50//! numbers nobody knows and hidden numbers ring; never the blocked, an
51//! automated caller, a number that cannot be a caller's or one whose
52//! verification failed. The phone's notification writes its press in a file
53//! of its own (`calls/through-here.json`) that Java reads at once and Rust
54//! carries into the switch (`merge_local`).
55
56use crate::attention::{self, Attention, Level, Person};
57use crate::everywhere::{Device, People, Switch};
58use crate::i18n::Translator;
59use crate::phones::{self, Region};
60use crate::reach::{Channel, Clock, Who};
61use crate::screening::{self, Spam};
62use jiff::Zoned;
63use serde::{Deserialize, Serialize};
64use std::collections::{BTreeMap, BTreeSet};
65use std::path::{Path, PathBuf};
66
67/// The calls' folder, in the state folder.
68pub const FOLDER: &str = "calls";
69/// The table Java decides from.
70pub const TABLE: &str = "table.json";
71/// The calls screened, declined or let ring, one JSON line each (Java's,
72/// this phone's alone: Rust copies them into its log, `LOG`).
73pub const HELD: &str = "held.jsonl";
74/// The folder of the phones' logs of the calls they screened, one file per
75/// phone (`<device>.jsonl`), written by that phone alone; shared.
76pub const LOG: &str = "log";
77/// The folder of the calls marked seen, one file per device, written by
78/// that device alone; shared.
79pub const SEEN_LOG: &str = "seen";
80/// This phone's press of Expecting a call (once "Let every call through") on the notification (Java's).
81pub const THROUGH_HERE: &str = "through-here.json";
82/// When this phone last called an emergency number (Java's).
83pub const EMERGENCY: &str = "emergency.json";
84/// The numbers this phone called lately, by their key, with when (ms; Java's,
85/// never shared): kept only while the screening counts them, and only for
86/// the days they count (`prune_called`).
87pub const CALLED: &str = "called.json";
88/// The calls seen on the list, as this phone kept them before Seen was
89/// shared: read once into its own seen log, then gone (`adopt_older_seen`).
90pub const SEEN: &str = "seen.toml";
91/// The table's shape: Java reads this one and version 1 (another one: every
92/// call rings). Version 2 carries the screening apart (`screening`), Expecting
93/// a call (`expecting`), and frames with the people you know only.
94pub const VERSION: u32 = 2;
95/// A second call within this many minutes rings (research 7.3).
96pub const REPEAT_MINUTES: i64 = 15;
97/// Every call rings this many hours after you call an emergency number (research 7.1).
98pub const AFTER_EMERGENCY_HOURS: i64 = 24;
99/// How many days the table's frames cover, from today's midnight: it is
100/// written again long before, at each change and each step.
101pub const TABLE_DAYS: i64 = 4;
102/// A call refused stays on the list this long at most (days), seen or not.
103pub const LISTED_DAYS: i64 = 14;
104/// A call is kept this long (days), on every device: its phone takes it out
105/// of its log after that, and the sharing takes it out everywhere.
106pub const KEPT_DAYS: i64 = 31;
107/// Another device's line kept this long past `KEPT_DAYS` (days) is taken out
108/// by any device: its phone, gone for good, never will (`trim_others`).
109pub const LATE_DAYS: i64 = 14;
110/// A day, in milliseconds.
111const DAY_MS: i64 = 86_400_000;
112/// The key of the calls' own part in a device's table of the switch's file.
113const DEVICE_PART: &str = "calls";
114/// The French plan's overseas numbers, by the three digits after the 0 (as `phones` reads them).
115const FRENCH_OVERSEAS: [(&str, &str); 14] = [
116    ("262", "262"),
117    ("263", "262"),
118    ("269", "262"),
119    ("639", "262"),
120    ("692", "262"),
121    ("693", "262"),
122    ("590", "590"),
123    ("690", "590"),
124    ("691", "590"),
125    ("594", "594"),
126    ("694", "594"),
127    ("596", "596"),
128    ("696", "596"),
129    ("697", "596"),
130];
131
132/// The calls' folder.
133pub fn folder() -> PathBuf {
134    crate::config::state_dir().join(FOLDER)
135}
136
137// ---------------------------------------------------------------- the table
138
139/// What Java reads (Calls.Table). Times are Unix milliseconds.
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141pub struct Table {
142    pub v: u32,
143    pub made: i64,
144    /// How the country of numbers written without one writes them; none: as written.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub region: Option<RegionRules>,
147    /// The country codes whose numbers lose a trunk "0" written after them ("+33 06…").
148    #[serde(default)]
149    pub trunk_zero: Vec<String>,
150    /// Each number known, by its key: "safe", "neutral", "restricted", "blocked".
151    #[serde(default)]
152    pub numbers: BTreeMap<String, String>,
153    /// The prefixes on your lists (`tel:+3346571*`), for a number no card and no
154    /// entry of its own names: the longest first, then blocked before
155    /// restricted before neutral before safe (`porch::Senders::judge_number`).
156    #[serde(default)]
157    pub prefixes: Vec<Prefix>,
158    /// Who a number in the phone's own contacts is, when this table does not know it (research CS6).
159    pub phone_contacts: String,
160    pub floors: Floors,
161    /// "Let every call through", the latest press of your devices (version 1).
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub through: Option<Through>,
164    /// Expecting a call, the latest press of your devices (version 2): numbers
165    /// nobody knows and hidden numbers ring while it lasts.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub expecting: Option<Through>,
168    /// The call screening (version 2): what becomes of numbers nobody knows.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub screening: Option<screening::TablePart>,
171    /// A person's own row (decision 16 of 18 October 2026), by each number it
172    /// covers: its row in the frames ("own-0", "own-1"…), read before their
173    /// list's. No name: rows by number. An older Java reads their list's row.
174    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
175    pub own: BTreeMap<String, String>,
176    pub frames: Vec<Frame>,
177    pub repeat_minutes: i64,
178    pub emergency_hours: i64,
179}
180
181/// A country's way of writing numbers, as Java needs it (`phones::Region`).
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct RegionRules {
184    pub code: String,
185    pub calling: String,
186    pub trunk: String,
187    pub international: String,
188    pub digits: [usize; 2],
189    #[serde(default)]
190    pub french: bool,
191    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
192    pub overseas: BTreeMap<String, String>,
193}
194
195impl RegionRules {
196    pub fn of(region: &Region) -> RegionRules {
197        RegionRules {
198            code: region.code.to_string(),
199            calling: region.calling.to_string(),
200            trunk: region.trunk.to_string(),
201            international: region.international.to_string(),
202            digits: [region.digits.0, region.digits.1],
203            french: region.french,
204            overseas: if region.french { FRENCH_OVERSEAS.iter().map(|(p, c)| (p.to_string(), c.to_string())).collect() } else { BTreeMap::new() },
205        }
206    }
207}
208
209/// A prefix on a list, and who a number starting with it is.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct Prefix {
212    /// A key's start: "+3346571".
213    pub prefix: String,
214    pub who: String,
215}
216
217/// The numbers that always ring.
218#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
219pub struct Floors {
220    /// Emergency numbers and the emergency services' callback numbers, every country's.
221    pub emergency: Vec<String>,
222    /// Numbers that ring whatever else says, the blocked's aside: none written
223    /// since Always through has rows of its own; kept for the Java that reads it.
224    pub people: Vec<String>,
225}
226
227/// A press of Expecting a call (once "Let every call through"): when (ms, the pressing device's
228/// hybrid clock), on or off, until when (ms; 0: until turned off).
229#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
230pub struct Through {
231    #[serde(default)]
232    pub pressed: i64,
233    #[serde(default)]
234    pub on: bool,
235    #[serde(default)]
236    pub until: i64,
237}
238
239impl Through {
240    /// Whether it lets every call through at `now` (ms).
241    pub fn holds(&self, now: i64) -> bool {
242        self.on && (self.until <= 0 || now < self.until)
243    }
244}
245
246/// A stretch of time and, for each row of the Calls matrix, whether it rings.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct Frame {
249    pub from: i64,
250    pub until: i64,
251    /// "work", "admin", "leisure", "meals", "sleep", "pause", "free"; a layer
252    /// above them, "slot", "dnd": for the list's words.
253    pub column: String,
254    /// Work or admin time then, whatever its layers (version 2): for the
255    /// screening's hours.
256    #[serde(default)]
257    pub hours: bool,
258    /// `ROWS` → rings.
259    pub ring: BTreeMap<String, bool>,
260}
261
262/// The rows a frame says, as Java looks a caller up (version 2): the people
263/// you know, and Always through read against each state ("always-neutral":
264/// someone on the list whose own row is neutral; "always-stranger": someone
265/// on the list in none of your address books, known by the list). Numbers
266/// nobody knows and hidden numbers are the screening's. A table of version 1
267/// also said "stranger" and "hidden".
268pub const ROWS: [&str; 7] = ["safe", "neutral", "restricted", "always-safe", "always-neutral", "always-restricted", "always-stranger"];
269
270/// A frame's row: its person, on the Always through list or not.
271fn person_of(row: &str) -> (Person, bool) {
272    let (always, own) = match row.strip_prefix("always-") {
273        Some(own) => (true, own),
274        None => (false, row),
275    };
276    (Person::read(own).unwrap_or(Person::Stranger), always)
277}
278
279/// What holds on top of the clock's times, ahead (docs/attention.md, §1.6):
280/// today's slots of time for you (`attention::Slots`), and do-not-disturb
281/// from its switch or a focus session, from, and until when (none: until
282/// turned off; the table is written again when it changes).
283#[derive(Debug, Clone, Default, PartialEq, Eq)]
284pub struct Layers {
285    pub slots: Vec<(i64, i64)>,
286    pub dnd: Option<(i64, Option<i64>)>,
287    /// Reachable, from when, until when (Unix seconds; none: until turned
288    /// off): the people you know ring then, whatever the time (part 3).
289    pub reachable: Option<(i64, Option<i64>)>,
290}
291
292/// The column a moment is said by: a layer that holds, else the time's (the
293/// pause, sleep, Free time, the hours; work when work and admin are both
294/// open, or no hours are set).
295fn column_of(now: &attention::Now) -> &'static str {
296    if now.reachable {
297        "reachable"
298    } else if now.dnd {
299        "dnd"
300    } else if now.slot {
301        "slot"
302    } else {
303        now.times.first().map_or("", |c| c.id())
304    }
305}
306
307/// The frames from `from` to `until`, each with whether each row rings then
308/// (`ROWS`): the clock's own frames (`reach::Clock::frames`), cut where a
309/// layer begins or ends; two in a row that say the same are one.
310pub fn frames(attention: &Attention, clock: &Clock, layers: &Layers, from: &Zoned, until: &Zoned) -> Vec<Frame> {
311    frames_with(attention, clock, layers, from, until, &[])
312}
313
314/// A row of the calls' table for the people's own rows: its name in the
315/// frames ("own-0"), the own rows a number reads in their order
316/// (`porch::Senders::person_rows` of that number: its cards' first, then the
317/// number's), and who its list makes that number. Numbers that read the same
318/// rows with the same list share one.
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct OwnRow {
321    pub row: String,
322    pub ids: Vec<String>,
323    pub who: Who,
324}
325
326/// The people's own rows for the calls' table (decision 16; review of part 6,
327/// F2 to F4), number by number, as a text from that number reads them: each
328/// number a person's row covers (a card's every one, a `tel:` row's), judged
329/// alone (`judge_number`), the rows it reads (`person_rows`, those set), one
330/// table row per distinct rows and list. A blocked number, or one nobody
331/// knows, drops only itself. In the numbers' order: never a map's.
332pub fn own_rows(attention: &Attention, senders: &crate::porch::Senders) -> (Vec<(String, String)>, Vec<OwnRow>) {
333    let set: BTreeSet<&String> = attention.sources().keys().filter(|id| attention::is_person(id)).collect();
334    let keys: BTreeSet<String> = set.iter().flat_map(|id| senders.numbers_of_person(id.strip_prefix(attention::PERSON_ROW).unwrap_or_default())).collect();
335    let (mut numbers, mut rows): (Vec<(String, String)>, Vec<OwnRow>) = (Vec::new(), Vec::new());
336    for key in keys {
337        let who = senders.judge_number(&key).who;
338        if matches!(who, Who::Blocked | Who::Stranger) {
339            continue;
340        }
341        let ids: Vec<String> = senders.person_rows(std::slice::from_ref(&key), &[]).into_iter().filter(|id| set.contains(id)).collect();
342        if ids.is_empty() {
343            continue;
344        }
345        let row = match rows.iter().find(|r| r.ids == ids && r.who == who) {
346            Some(r) => r.row.clone(),
347            None => {
348                let row = format!("own-{}", rows.len());
349                rows.push(OwnRow { row: row.clone(), ids, who });
350                row
351            }
352        };
353        numbers.push((key, row));
354    }
355    (numbers, rows)
356}
357
358/// `frames`, with a row for each person's own row too (`own`): whether their
359/// call rings then, their row read over their list's (`Attention::decide`).
360pub fn frames_with(attention: &Attention, clock: &Clock, layers: &Layers, from: &Zoned, until: &Zoned, own: &[OwnRow]) -> Vec<Frame> {
361    let mut out: Vec<Frame> = Vec::new();
362    for f in clock.frames(from, until) {
363        let mut cuts = vec![f.start, f.end];
364        let edges = layers.slots.iter().flat_map(|&(a, b)| [a, b]).chain([layers.dnd, layers.reachable].into_iter().flatten().flat_map(|(a, b)| [Some(a), b]).flatten());
365        cuts.extend(edges.filter(|t| *t > f.start && *t < f.end));
366        cuts.sort_unstable();
367        cuts.dedup();
368        for pair in cuts.windows(2) {
369            let (start, end) = (pair[0], pair[1]);
370            let slot = layers.slots.iter().any(|&(a, b)| a <= start && start < b);
371            let dnd = layers.dnd.is_some_and(|(a, b)| a <= start && b.is_none_or(|b| start < b));
372            let mut now = attention::Now::of_moment(&f.moment).layers(slot, dnd);
373            now.reachable = layers.reachable.is_some_and(|(a, b)| a <= start && b.is_none_or(|b| start < b));
374            let hours = now.times.iter().any(|c| matches!(c, attention::Column::Work | attention::Column::Admin));
375            let mut ring: BTreeMap<String, bool> = ROWS
376                .iter()
377                .map(|row| {
378                    let (person, always) = person_of(row);
379                    (row.to_string(), attention.person(Channel::Calls, person, always, &now) == Level::Now)
380                })
381                .collect();
382            for o in own {
383                let call = attention::Event::of(attention::Source::Call { who: Some(o.who), always: false, emergency: false, repeat: false }).from_sources(o.ids.clone());
384                ring.insert(o.row.clone(), attention.decide(&call, &now).level == Level::Now);
385            }
386            let column = column_of(&now).to_string();
387            let (from_ms, until_ms) = (start.saturating_mul(1000), end.saturating_mul(1000));
388            match out.last_mut() {
389                Some(last) if last.until == from_ms && last.column == column && last.hours == hours && last.ring == ring => last.until = until_ms,
390                _ => out.push(Frame { from: from_ms, until: until_ms, column, hours, ring }),
391            }
392        }
393    }
394    out
395}
396
397/// The numbers of the people Always through, each as a row of its own read
398/// against who the number is (`judge`: "always-safe"), whole numbers only;
399/// never a blocked number: blocked beats Always through.
400pub fn always_numbers(people: &People, region: Option<&Region>, judge: &dyn Fn(&str) -> Who) -> Vec<(String, String)> {
401    people_keys(people, region).into_iter().filter_map(|key| match judge(&key) {
402        Who::Blocked => None,
403        who => Some((key, format!("always-{}", who.id()))),
404    }).collect()
405}
406
407/// What a table is made of.
408pub struct Made {
409    /// When (ms).
410    pub made: i64,
411    pub region: Option<&'static Region>,
412    /// Every number known, by key, and who it is ("safe"…, "blocked"; `porch::Senders::numbers`).
413    pub numbers: Vec<(String, String)>,
414    /// The lists' prefixes ("+3346571*") and who they make a number (`porch::Senders::prefixes`), in the order ties are decided.
415    pub prefixes: Vec<(String, String)>,
416    /// The Always through list's numbers, as rows of their own (`always_numbers`): after `numbers`, they win.
417    pub always: Vec<(String, String)>,
418    /// A person's own row by each of its numbers ("own-0"; `frames_with`); none on Always through, none blocked.
419    pub own: Vec<(String, String)>,
420    /// Expecting a call, the latest press of your devices.
421    pub expecting: Option<Through>,
422    /// The screening and the numbers it needs (`screening::TablePart::of`);
423    /// none: a table of version 1, as an older Sioul wrote it (tests).
424    pub screening: Option<screening::TablePart>,
425    pub frames: Vec<Frame>,
426}
427
428/// The table, as Java reads it.
429pub fn table(made: Made) -> Table {
430    let mut numbers = BTreeMap::new();
431    for (key, who) in made.numbers {
432        if !key.is_empty() && !who.is_empty() && who != "stranger" {
433            numbers.insert(key, who);
434        }
435    }
436    // Always through: its people's numbers, as their rows; the blocked kept blocked.
437    for (key, row) in made.always {
438        if !key.is_empty() && numbers.get(&key).is_none_or(|who| who != "blocked") {
439            numbers.insert(key, row);
440        }
441    }
442    // The longest first; at one length, as given (blocked before restricted before neutral before safe).
443    let mut prefixes: Vec<Prefix> = made.prefixes.iter().filter_map(|(p, who)| Some(Prefix { prefix: p.trim_end_matches('*').to_string(), who: who.clone() }).filter(|p| !p.prefix.is_empty() && !p.prefix.contains('*') && !p.who.is_empty() && p.who != "stranger")).collect();
444    prefixes.sort_by_key(|p| std::cmp::Reverse(p.prefix.len()));
445    let older = made.screening.is_none();
446    // A person's own row, for a number their list knows, neither blocked nor Always through.
447    let own: BTreeMap<String, String> = made.own.into_iter().filter(|(key, row)| !key.is_empty() && !row.is_empty() && numbers.get(key).is_some_and(|who| !who.starts_with("always-") && who != "blocked")).collect();
448    Table {
449        v: if older { 1 } else { VERSION },
450        made: made.made,
451        region: made.region.map(RegionRules::of),
452        trunk_zero: trunk_zero(),
453        numbers,
454        prefixes,
455        phone_contacts: "neutral".to_string(),
456        floors: Floors { emergency: emergency_keys(made.region), people: Vec::new() },
457        through: if older { made.expecting } else { None },
458        expecting: if older { None } else { made.expecting },
459        screening: made.screening,
460        own,
461        frames: made.frames,
462        repeat_minutes: REPEAT_MINUTES,
463        emergency_hours: AFTER_EMERGENCY_HOURS,
464    }
465}
466
467/// The table as written, without its time: two tables that say the same are the same.
468pub fn content(table: &Table) -> String {
469    serde_json::to_string(&Table { made: 0, ..table.clone() }).unwrap_or_default()
470}
471
472/// The country codes whose numbers drop a trunk "0" (`phones`' table).
473pub fn trunk_zero() -> Vec<String> {
474    let mut codes: Vec<String> = phones::REGIONS.iter().filter(|r| r.trunk == "0").map(|r| r.calling.to_string()).collect();
475    codes.sort();
476    codes.dedup();
477    codes
478}
479
480/// Emergency numbers, crisis lines and callback numbers of every country
481/// Sioul knows (`data/crisis-lines.toml`), keyed as each country writes them,
482/// and as the person's country would.
483pub fn emergency_keys(region: Option<&Region>) -> Vec<String> {
484    let lines = crate::pause::CrisisLines::built_in();
485    let mut keys = BTreeSet::new();
486    for country in &lines.country {
487        let own = phones::region_named(&country.code);
488        for line in &country.line {
489            for key in [phones::key(&line.number, own.or(region)), phones::key(&line.number, region), line.number.chars().filter(char::is_ascii_digit).collect()] {
490                if !key.is_empty() {
491                    keys.insert(key);
492                }
493            }
494        }
495    }
496    keys.into_iter().collect()
497}
498
499/// The numbers of the people on the do-not-disturb list, whole numbers only (`People::admits_number`).
500pub fn people_keys(people: &People, region: Option<&Region>) -> Vec<String> {
501    let keys: BTreeSet<String> = people.people.iter().flat_map(|p| p.phones.iter()).map(|n| phones::key(n, region)).filter(|k| phones::is_whole(k)).collect();
502    keys.into_iter().collect()
503}
504
505/// Written beside, then put in place: Java never reads half a table.
506pub fn write(path: &Path, table: &Table) -> Result<(), String> {
507    let text = serde_json::to_string(table).map_err(|e| e.to_string())?;
508    write_whole(path, &text)
509}
510
511fn write_whole(path: &Path, text: &str) -> Result<(), String> {
512    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
513    if let Some(parent) = path.parent() {
514        std::fs::create_dir_all(parent).map_err(fail)?;
515    }
516    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
517    let fresh = path.with_file_name(format!(".{name}.{}.new", std::process::id()));
518    std::fs::write(&fresh, text).map_err(fail)?;
519    std::fs::rename(&fresh, path).map_err(|e| {
520        let _ = std::fs::remove_file(&fresh);
521        fail(e)
522    })
523}
524
525// ---------------------------------------------------------------- the decision, as Java makes it
526
527/// A call as Java sees it (Calls.decide), what it knows beside the table.
528#[derive(Debug, Clone, Default, PartialEq, Eq)]
529pub struct Incoming {
530    /// The caller ID as the network gave it; "" for none.
531    pub number: String,
532    /// No number shown (restricted, unknown, payphone, or no digits).
533    pub hidden: bool,
534    /// Placed by the person, not received.
535    pub outgoing: bool,
536    /// Now (ms).
537    pub now: i64,
538    /// When the person last called an emergency number (ms), as this phone saw it; 0 never.
539    pub emergency_at: i64,
540    /// This phone's own press of Expecting a call (`through-here.json`).
541    pub local: Option<Through>,
542    /// The number's last call before this one (ms); 0 none.
543    pub last_call: i64,
544    /// The number is in the phone's own contacts (Android's), whatever Sioul's address books say.
545    pub phone_contact: bool,
546    /// Android's own list of emergency numbers has it (`TelephonyManager.isEmergencyNumber`).
547    pub system_emergency: bool,
548    /// The network's verification of the number (Android 11 and later):
549    /// "passed", "failed"; "" not verified, or not said.
550    pub verified: String,
551    /// When the person last called this number from this phone (ms); 0 never.
552    pub called: i64,
553}
554
555/// What Java answers, and why.
556#[derive(Debug, Clone, PartialEq, Eq)]
557pub struct Decision {
558    pub refuse: bool,
559    /// "outgoing", "no-table", "emergency", "after-emergency", "people",
560    /// "blocked", "repeat", "no-frame", "no-row", "matrix", "own" (their own
561    /// row decided, decision 16); the screening's
562    /// (version 2): "screening-off", "work-hours", "impossible", "unverified",
563    /// "automated", "expecting", "screening"; version 1's "through".
564    pub why: &'static str,
565    /// The row decided by: "safe", "neutral", "restricted", "stranger", "hidden", "blocked"; "" before.
566    pub who: String,
567    pub column: String,
568    /// Judged as your contacts are because you called this number lately
569    /// (version 2); beyond their row, let ring only while you expect a call.
570    pub called: bool,
571}
572
573impl Table {
574    /// Who a prefix on your lists makes this number (the longest first).
575    pub fn by_prefix(&self, key: &str) -> Option<&str> {
576        self.prefixes.iter().find(|p| key.starts_with(&p.prefix)).map(|p| p.who.as_str())
577    }
578
579    /// The frame `now` (ms) falls in; none before the first or past the last.
580    pub fn frame_at(&self, now: i64) -> Option<&Frame> {
581        self.frames.iter().find(|f| f.from <= now && now < f.until)
582    }
583}
584
585/// Java's decision (Calls.decide), step for step: the order's reference,
586/// tested here. Outgoing: rings (to an emergency number, Java marks the
587/// moment). Incoming, version 2: what always rings (an emergency number, a
588/// day after you called one, an older table's do-not-disturb list); the
589/// blocked; for a number nobody knows, what the screening keeps out whatever
590/// else (a number never shown as a caller's, a failed verification, an
591/// automated caller); a second call within 15 minutes; the people you know
592/// (your lists, your address books, the phone's contacts, a number you called
593/// lately, Always through), by the frame for now, a number called lately
594/// beyond its row only while you expect a call; everyone else by the
595/// screening: Expecting a call, then what the screening lets ring by itself
596/// (off, resting in your work and admin hours, its rule), never where your
597/// contacts' row declines (review of part 2, 19 October 2026: F3, F4, F7,
598/// F8). Version 1: the blocked, "Let every call through", a second call, the
599/// frame's row for everyone. Nothing to decide from: it rings.
600pub fn decide(table: Option<&Table>, call: &Incoming) -> Decision {
601    let ring = |why: &'static str| Decision { refuse: false, why, who: String::new(), column: String::new(), called: false };
602    if call.outgoing {
603        return ring("outgoing");
604    }
605    let Some(table) = table else { return ring("no-table") };
606    let key = if call.hidden { String::new() } else { incoming_key(&call.number, table.region.as_ref(), &table.trunk_zero) };
607    // 1. What always rings.
608    if !call.hidden && (call.system_emergency || table.floors.emergency.contains(&key)) {
609        return ring("emergency");
610    }
611    if call.emergency_at > 0 && call.now >= call.emergency_at && call.now - call.emergency_at < table.emergency_hours * 3_600_000 {
612        return ring("after-emergency");
613    }
614    if !call.hidden && table.floors.people.contains(&key) {
615        return ring("people");
616    }
617    // Who: the number's own, a prefix of your lists, a contact of the phone's
618    // only; from version 2, a number you called lately; else a stranger.
619    let screening = table.screening.as_ref();
620    let listed = if call.hidden { None } else { table.numbers.get(&key).cloned().filter(|w| !w.is_empty()).or_else(|| table.by_prefix(&key).map(str::to_string)) };
621    let lately = screening.is_some_and(|s| s.called_days > 0 && call.called > 0 && call.now >= call.called && call.now - call.called < s.called_days * DAY_MS);
622    let (who, called) = match listed {
623        _ if call.hidden => ("hidden".to_string(), false),
624        Some(who) => (who, false),
625        None if call.phone_contact => (table.phone_contacts.clone(), false),
626        None if lately => (table.phone_contacts.clone(), true),
627        None => ("stranger".to_string(), false),
628    };
629    let decided = |refuse: bool, why: &'static str, column: &str| Decision { refuse, why, who: who.clone(), column: column.to_string(), called };
630    // 2. Blocked ("blocked entirely": every call let through but theirs).
631    if who == "blocked" {
632        return decided(true, "blocked", "");
633    }
634    let repeat = !call.hidden && call.last_call > 0 && call.now - call.last_call <= table.repeat_minutes * 60_000;
635    let frame = table.frame_at(call.now);
636    // A person's own row, by this number, before their list's (from version 2, decision 16).
637    let own = if call.hidden || called { None } else { table.own.get(&key) };
638    let by_frame = |frame: Option<&Frame>| match frame {
639        None => decided(false, "no-frame", ""),
640        // Decided by their own row: "own", for the Porch's sentence (review of part 6, F7).
641        Some(frame) => match own.and_then(|row| frame.ring.get(row).map(|r| (r, "own"))).or_else(|| frame.ring.get(&who).map(|r| (r, "matrix"))) {
642            Some((rings, why)) => decided(!rings, why, &frame.column),
643            None => decided(false, "no-row", &frame.column),
644        },
645    };
646    let Some(screening) = screening else {
647        // Version 1: Let every call through, the later press of this phone's and the table's; a second call; the frame's row.
648        if later(call.local, table.through).is_some_and(|t| t.holds(call.now)) {
649            return decided(false, "through", "");
650        }
651        if repeat {
652            return decided(false, "repeat", "");
653        }
654        return by_frame(frame);
655    };
656    let column = frame.map_or("", |f| f.column.as_str());
657    // 3. What the screening keeps out of a number nobody knows (no list, no
658    // address book, not called lately) whatever else, while it screens and the
659    // table is current: a number never shown as a caller's (089), a failed
660    // verification, an automated caller, each while it goes to voicemail. No
661    // second call, no hours and no rule let them ring; Expecting a call neither.
662    if screening.on && frame.is_some() && who == "stranger" {
663        if let Some(why) = spam(screening, &key, &call.verified) {
664            return decided(true, why, column);
665        }
666    }
667    // 4. A second call within 15 minutes rings, the blocked's and that aside; and a
668    // number called lately whose verification failed, a borrowed caller ID of
669    // it calling twice (the re-check of part 2, R1).
670    let borrowed = called && screening.on && screening.unverified == Spam::Voicemail && call.verified == "failed";
671    if repeat && !borrowed {
672        return decided(false, "repeat", "");
673    }
674    // Expecting a call: the later press of this phone's and the table's (all devices').
675    let expecting = later(call.local, table.expecting).is_some_and(|t| t.holds(call.now));
676    // 5. The people you know: the matrix, the time and its layers. A number
677    // you called lately counts as your contacts do: declined by their row, only
678    // Expecting a call lets it ring, never when the network could not verify it
679    // (a borrowed caller ID of a number you called).
680    if who != "stranger" && who != "hidden" {
681        let known = by_frame(frame);
682        if !(called && known.refuse && expecting) {
683            return known;
684        }
685        if screening.on && screening.unverified == Spam::Voicemail && call.verified == "failed" {
686            return decided(true, "unverified", column);
687        }
688        return decided(false, "expecting", column);
689    }
690    // 6. Everyone else, by the screening, which no state of yours switches
691    // off; past the frames, it rings. Expecting a call opens to them.
692    let Some(frame) = frame else { return decided(false, "no-frame", "") };
693    if expecting {
694        return decided(false, "expecting", &frame.column);
695    }
696    // What the screening lets ring by itself: off, resting in your work and admin hours, its rule.
697    let opened = if !screening.on {
698        Some("screening-off")
699    } else if screening.hours == screening::Hours::OutsideWork && frame.hours {
700        Some("work-hours")
701    } else {
702        screening.screening().rings(call.hidden, frame.hours).then_some("screening")
703    };
704    match opened {
705        None => decided(true, "screening", &frame.column),
706        // Never beyond your contacts: where their row declines (do-not-disturb, the night, a pause), so does it.
707        Some(_) if frame.ring.get(&table.phone_contacts) == Some(&false) => decided(true, "matrix", &frame.column),
708        Some(why) => decided(false, why, &frame.column),
709    }
710}
711
712/// What the screening keeps out of a number nobody knows whatever else
713/// (`decide`, its step 3): a number never shown as a caller's, a failed
714/// verification, an automated caller, each while set to voicemail.
715fn spam(screening: &screening::TablePart, key: &str, verified: &str) -> Option<&'static str> {
716    let national = screening::national(key);
717    let among = |prefixes: &[String]| national.as_deref().is_some_and(|n| screening::starts_with_any(n, prefixes));
718    if screening.impossible == Spam::Voicemail && among(&screening.impossible_prefixes) {
719        Some("impossible")
720    } else if screening.unverified == Spam::Voicemail && verified == "failed" {
721        Some("unverified")
722    } else if screening.automated == Spam::Voicemail && among(&screening.automated_prefixes) {
723        Some("automated")
724    } else {
725        None
726    }
727}
728
729/// Java's numbers called lately (`CALLED`) pruned to the days that count
730/// (`days`, the table's `called_days`): never more than
731/// `screening::CALLED_DAYS`, and none while the screening counts none, the
732/// file then gone; gone too when nothing is left or it does not read (review
733/// of part 2, 19 October 2026, F5). Java prunes it at each call; this, at each
734/// table made, so that nothing stays its days out while no call comes.
735pub fn prune_called(path: &Path, now_ms: i64, days: i64) -> std::io::Result<()> {
736    let gone = || match std::fs::remove_file(path) {
737        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
738        other => other,
739    };
740    let days = days.clamp(0, screening::CALLED_DAYS);
741    if days == 0 {
742        return gone();
743    }
744    let text = match std::fs::read_to_string(path) {
745        Ok(text) => text,
746        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
747        Err(e) => return Err(e),
748    };
749    let Ok(serde_json::Value::Object(called)) = serde_json::from_str::<serde_json::Value>(&text) else { return gone() };
750    let all = called.len();
751    let kept: serde_json::Map<String, serde_json::Value> = called.into_iter().filter(|(_, at)| at.as_i64().is_some_and(|at| at <= now_ms && now_ms - at < days * DAY_MS)).collect();
752    if kept.is_empty() {
753        return gone();
754    }
755    if kept.len() == all {
756        return Ok(());
757    }
758    let fresh = path.with_extension(format!("json.{}", std::process::id()));
759    std::fs::write(&fresh, serde_json::Value::Object(kept).to_string())?;
760    std::fs::rename(&fresh, path)
761}
762
763/// The later of this phone's own press and your devices' latest (by when each was pressed).
764fn later(local: Option<Through>, shared: Option<Through>) -> Option<Through> {
765    match (local, shared) {
766        (Some(local), Some(shared)) => Some(if local.pressed > shared.pressed { local } else { shared }),
767        (local, shared) => local.or(shared),
768    }
769}
770
771// ---------------------------------------------------------------- numbers as Java keys them
772
773/// A caller ID as Java keys it (Calls.key, the same steps): `phones::key`
774/// for what a caller ID holds (no extension, no "(0)"). The tests check that
775/// the two agree.
776pub fn incoming_key(raw: &str, region: Option<&RegionRules>, trunk_zero: &[String]) -> String {
777    let mut value = raw.trim_matches(|c: char| c.is_whitespace() || spacing(c));
778    if value.len() >= 4 && value.is_char_boundary(4) && value[..4].eq_ignore_ascii_case("tel:") {
779        value = value[4..].trim();
780    }
781    let as_written = || value.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase();
782    let mut digits = String::new();
783    for (i, c) in value.chars().enumerate() {
784        match c {
785            '+' if i == 0 => digits.push('+'),
786            '0'..='9' => digits.push(c),
787            '(' | ')' => {}
788            c if spacing(c) => {}
789            _ => return as_written(),
790        }
791    }
792    if digits.is_empty() || digits == "+" {
793        return as_written();
794    }
795    let drop_trunk = |digits: &str| -> String {
796        for length in 1..=3usize.min(digits.len().saturating_sub(1)) {
797            let (calling, rest) = digits.split_at(length);
798            if rest.starts_with('0') && trunk_zero.iter().any(|t| t == calling) {
799                return format!("{calling}{}", &rest[1..]);
800            }
801        }
802        digits.to_string()
803    };
804    if let Some(international) = digits.strip_prefix('+') {
805        return format!("+{}", drop_trunk(international));
806    }
807    let Some(region) = region else { return digits };
808    for prefix in [region.international.as_str(), "00"] {
809        if prefix.is_empty() {
810            continue;
811        }
812        if let Some(rest) = digits.strip_prefix(prefix).filter(|rest| rest.len() >= 6 && !rest.starts_with('0')) {
813            return format!("+{}", drop_trunk(rest));
814        }
815    }
816    let length = digits.len();
817    if length < region.digits[0] || length > region.digits[1] {
818        return digits;
819    }
820    match region.trunk.as_str() {
821        "" => format!("+{}{digits}", region.calling),
822        "1" => match length {
823            10 if !digits.starts_with(['0', '1']) => format!("+1{digits}"),
824            11 if digits.starts_with('1') => format!("+{digits}"),
825            _ => digits,
826        },
827        trunk => match digits.strip_prefix(trunk) {
828            Some(national) if !national.is_empty() && !national.starts_with('0') => {
829                let calling = if region.french { national.get(..3).and_then(|p| region.overseas.get(p)).cloned().unwrap_or_else(|| region.calling.clone()) } else { region.calling.clone() };
830                format!("+{calling}{national}")
831            }
832            _ => digits,
833        },
834    }
835}
836
837/// The characters that only space a number out (as `phones` reads them).
838fn spacing(c: char) -> bool {
839    matches!(c, ' ' | '\t' | '.' | '-' | '/' | '\u{a0}' | '\u{202f}' | '\u{2009}' | '\u{2007}' | '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}')
840        || matches!(c, '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{feff}')
841}
842
843/// A key as shown: "01 99 00 12 34" for a number of your own country's
844/// ten-digit plan, else its international form.
845pub fn shown_number(key: &str, region: Option<&Region>) -> String {
846    let Some(region) = region else { return key.to_string() };
847    if region.french {
848        // France and its overseas departments share one ten-digit plan.
849        for calling in ["33", "262", "590", "594", "596"] {
850            if let Some(national) = key.strip_prefix('+').and_then(|k| k.strip_prefix(calling))
851                && national.len() == 9
852                && national.chars().all(|c| c.is_ascii_digit())
853                && (calling == "33" || FRENCH_OVERSEAS.iter().any(|(p, c)| *c == calling && national.starts_with(p)))
854            {
855                let digits = format!("0{national}");
856                return digits.as_bytes().chunks(2).map(|pair| String::from_utf8_lossy(pair).to_string()).collect::<Vec<_>>().join(" ");
857            }
858        }
859        return key.to_string();
860    }
861    match key.strip_prefix('+').and_then(|k| k.strip_prefix(region.calling)) {
862        Some(national) if !region.trunk.is_empty() && region.trunk != "1" && national.chars().all(|c| c.is_ascii_digit()) => format!("{}{national}", region.trunk),
863        _ => key.to_string(),
864    }
865}
866
867// ---------------------------------------------------------------- Expecting a call
868
869/// The calls' part of one device's table in the switch's file.
870#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
871pub struct DevicePart {
872    /// This device's last press (ms, hybrid clock); 0 never.
873    #[serde(default)]
874    pub pressed: i64,
875    #[serde(default)]
876    pub on: bool,
877    /// Its end (ms); 0 until turned off.
878    #[serde(default)]
879    pub until: i64,
880    /// The last press of the phone's notification carried here (ms, Java's stamp).
881    #[serde(default)]
882    pub local: i64,
883    /// This device screens calls (a phone holding the role).
884    #[serde(default)]
885    pub screens: bool,
886}
887
888/// A device's calls part; nothing there: none pressed.
889pub fn part_of(device: &Device) -> DevicePart {
890    device.other.get(DEVICE_PART).and_then(|v| v.clone().try_into().ok()).unwrap_or_default()
891}
892
893/// A device's calls part written; a part with nothing to say is taken out.
894pub fn set_part(device: &mut Device, part: DevicePart) {
895    if part == DevicePart::default() {
896        device.other.remove(DEVICE_PART);
897        return;
898    }
899    if let Ok(value) = toml::Value::try_from(part) {
900        device.other.insert(DEVICE_PART.to_string(), value);
901    }
902}
903
904/// Expecting a call as your devices say it: the latest press of any
905/// (an "on" wins a tie); none when none pressed.
906pub fn through_of(switch: &Switch) -> Option<Through> {
907    switch
908        .device
909        .iter()
910        .map(|(id, d)| (id, part_of(d)))
911        .filter(|(_, p)| p.pressed > 0)
912        .max_by_key(|(id, p)| (p.pressed, p.on, (*id).clone()))
913        .map(|(_, p)| Through { pressed: p.pressed, on: p.on, until: if p.on { p.until } else { 0 } })
914}
915
916/// Whether some device screens calls: the switch's tables say.
917pub fn screens_anywhere(switch: &Switch) -> bool {
918    switch.device.values().any(|d| part_of(d).screens)
919}
920
921/// A press here (ms): stamped after every press known, so that the latest
922/// wins everywhere whatever the clocks. Returns its stamp.
923pub fn press(switch: &mut Switch, here: &str, on: bool, until: i64, now: i64) -> i64 {
924    let latest = switch.device.values().map(|d| part_of(d).pressed).max().unwrap_or(0);
925    let stamp = now.max(latest + 1);
926    let own = switch.device.entry(here.to_string()).or_default();
927    let mut part = part_of(own);
928    part.pressed = stamp;
929    part.on = on;
930    part.until = if on { until.max(0) } else { 0 };
931    set_part(own, part);
932    stamp
933}
934
935/// The phone's own press, from its notification (`through-here.json`),
936/// carried into its table once: with Java's stamp, which came after every
937/// press the phone knew then. Whether anything changed.
938pub fn merge_local(switch: &mut Switch, here: &str, local: &Through) -> bool {
939    let own = switch.device.entry(here.to_string()).or_default();
940    let mut part = part_of(own);
941    if local.pressed <= part.local {
942        return false;
943    }
944    part.local = local.pressed;
945    if local.pressed > part.pressed {
946        part.pressed = local.pressed;
947        part.on = local.on;
948        part.until = if local.on { local.until.max(0) } else { 0 };
949    }
950    set_part(own, part);
951    true
952}
953
954/// This device screens calls, or no longer: written when it changed.
955pub fn set_screens(switch: &mut Switch, here: &str, screens: bool) -> bool {
956    let own = switch.device.entry(here.to_string()).or_default();
957    let mut part = part_of(own);
958    if part.screens == screens {
959        return false;
960    }
961    part.screens = screens;
962    set_part(own, part);
963    true
964}
965
966/// A small JSON file of Java's; none when it is not there or does not read.
967pub fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Option<T> {
968    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
969}
970
971// ---------------------------------------------------------------- the calls screened
972
973/// A call screened, as Java wrote it (Calls.held) and as a phone's log keeps
974/// it: declined (held, sent to voicemail) or let ring (`rang`). The log's line
975/// leaves out what stays on the phone (the number as the network gave it, its
976/// presentation) and every empty field (`logged`).
977#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
978pub struct Held {
979    /// When (ms).
980    pub at: i64,
981    /// The number as Sioul keys it (`phones::key`); "" for a hidden number.
982    #[serde(default, skip_serializing_if = "String::is_empty")]
983    pub key: String,
984    /// As the network gave it; "" hidden. In Java's file only.
985    #[serde(default, skip_serializing_if = "String::is_empty")]
986    pub number: String,
987    #[serde(default, skip_serializing_if = "is_false")]
988    pub hidden: bool,
989    /// Android's presentation of the number. In Java's file only.
990    #[serde(default, skip_serializing_if = "is_zero")]
991    pub presentation: i32,
992    /// The row it was judged by: "safe", "neutral", "restricted", "stranger",
993    /// "hidden", "blocked", or "always-" and its own ("always-neutral"); ""
994    /// when it rang before anyone was judged (an emergency number).
995    #[serde(default, skip_serializing_if = "String::is_empty")]
996    pub who: String,
997    /// The phone's contacts' name for it, when Sioul's address books did not know it.
998    #[serde(default, skip_serializing_if = "String::is_empty")]
999    pub name: String,
1000    /// The time of day: a column of the matrix ("work", "sleep"…) or a layer
1001    /// ("slot", "dnd"); "" when no frame held (no table).
1002    #[serde(default, skip_serializing_if = "String::is_empty")]
1003    pub column: String,
1004    /// Why it rang or not, as Java decided (`Decision::why`).
1005    #[serde(default, skip_serializing_if = "String::is_empty")]
1006    pub why: String,
1007    /// Let ring; false, declined and sent to voicemail (a line of Java's from
1008    /// before calls that rang were logged too was declined).
1009    #[serde(default)]
1010    pub rang: bool,
1011    /// The network's verification of the number: "passed", "failed"; "" not said.
1012    #[serde(default, skip_serializing_if = "String::is_empty")]
1013    pub verified: String,
1014    /// Judged as your contacts are because you had called this number in
1015    /// the last `screening::CALLED_DAYS` days.
1016    #[serde(default, skip_serializing_if = "is_false")]
1017    pub called: bool,
1018    /// The phone that screened it: its name in the sharing, its log's file's
1019    /// name; "" as read from Java's file. Never written in a line.
1020    #[serde(skip)]
1021    pub device: String,
1022}
1023
1024fn is_false(value: &bool) -> bool {
1025    !*value
1026}
1027
1028fn is_zero(value: &i32) -> bool {
1029    *value == 0
1030}
1031
1032impl Held {
1033    /// Its name on the list: when, and who.
1034    pub fn id(&self) -> String {
1035        format!("{}-{}", self.at, if self.hidden { "hidden" } else { self.key.as_str() })
1036    }
1037
1038    /// The line a phone's log keeps of it: without the number as the network
1039    /// gave it, nor its presentation; keyed as `region` writes numbers when
1040    /// Java could not key it (it had no table then).
1041    pub fn logged(&self, region: Option<&Region>) -> Held {
1042        let key = if self.hidden || !self.key.is_empty() { self.key.clone() } else { phones::key(&self.number, region) };
1043        Held { key, number: String::new(), presentation: 0, device: String::new(), ..self.clone() }
1044    }
1045}
1046
1047/// The calls of one file, oldest first; a line that does not read (half written) is passed over.
1048pub fn read_held(path: &Path) -> Vec<Held> {
1049    let mut held: Vec<Held> = read_lines::<Held>(path).into_iter().filter(|h| h.at > 0).collect();
1050    held.sort_by_key(|h| h.at);
1051    held
1052}
1053
1054/// Every line of a file of JSON lines that reads; a line cut by a crash, or
1055/// written by a later Sioul in a shape this one does not know, is passed over.
1056pub(crate) fn read_lines<T: serde::de::DeserializeOwned>(path: &Path) -> Vec<T> {
1057    std::fs::read_to_string(path).unwrap_or_default().lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str(l).ok()).collect()
1058}
1059
1060/// A device's own file in one of the calls' shared folders (`LOG`,
1061/// `SEEN_LOG`) under `root`, the calls' folder: `<folder>/<device>.jsonl`,
1062/// its name in the sharing (a UUID made once).
1063pub fn own_file(root: &Path, folder: &str, device: &str) -> PathBuf {
1064    root.join(folder).join(format!("{}.jsonl", file_safe(device)))
1065}
1066
1067/// A device's name as a file's: letters, digits, `-` and `_`.
1068pub(crate) fn file_safe(device: &str) -> String {
1069    let safe: String = device.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
1070    if safe.is_empty() { "this-device".to_string() } else { safe }
1071}
1072
1073/// Every device's file of a shared folder (`*.jsonl`), each with its device's name, by name.
1074pub(crate) fn device_files(folder: &Path) -> Vec<(String, PathBuf)> {
1075    let mut files: Vec<(String, PathBuf)> = std::fs::read_dir(folder)
1076        .into_iter()
1077        .flatten()
1078        .filter_map(Result::ok)
1079        .map(|e| e.path())
1080        .filter(|p| p.is_file() && p.extension().is_some_and(|e| e == "jsonl") && !p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')))
1081        .filter_map(|p| Some((p.file_stem()?.to_string_lossy().to_string(), p)))
1082        .collect();
1083    files.sort();
1084    files
1085}
1086
1087/// Every phone's log under `root` (the calls' folder), each call with its
1088/// phone (`device`), oldest first: never a blocked caller's (their calls never
1089/// leave their phone), never one kept past `KEPT_DAYS` before `now_ms` (a
1090/// phone that stopped sharing never takes its own out).
1091pub fn read_logs(root: &Path, now_ms: i64) -> Vec<Held> {
1092    let mut calls: Vec<Held> = device_files(&root.join(LOG))
1093        .into_iter()
1094        .flat_map(|(device, path)| read_held(&path).into_iter().map(move |h| Held { device: device.clone(), ..h }))
1095        .filter(|h| h.who != "blocked" && now_ms - h.at <= KEPT_DAYS * DAY_MS)
1096        .collect();
1097    calls.sort_by(|a, b| (a.at, &a.device).cmp(&(b.at, &b.device)));
1098    calls
1099}
1100
1101/// Java's new calls copied into this phone's own log (`own`), under its lock,
1102/// read again there: those whose line is not in it yet (by its id), never a
1103/// blocked caller's, nor one past `KEPT_DAYS` (taken out already, it would
1104/// come back). Two processes copying at once never write a line twice.
1105/// Returns how many lines were written.
1106pub fn carry_into(own: &Path, java: &[Held], region: Option<&Region>, now_ms: i64) -> Result<usize, String> {
1107    let fresh: Vec<Held> = java.iter().filter(|h| h.at > 0 && h.who != "blocked" && now_ms - h.at <= KEPT_DAYS * DAY_MS).map(|h| h.logged(region)).collect();
1108    if fresh.is_empty() {
1109        return Ok(0);
1110    }
1111    crate::filelock::with_lock(own, || {
1112        let mut known: BTreeSet<String> = read_held(own).iter().map(Held::id).collect();
1113        let mut text = String::new();
1114        let mut written = 0;
1115        for line in fresh {
1116            if known.insert(line.id()) {
1117                text.push_str(&serde_json::to_string(&line).map_err(|e| e.to_string())?);
1118                text.push('\n');
1119                written += 1;
1120            }
1121        }
1122        if written > 0 {
1123            append_private(own, &text)?;
1124        }
1125        Ok(written)
1126    })
1127}
1128
1129/// Adds lines at the end of a file of this device's own, made yours alone
1130/// (0600, in a 0700 folder, on Unix). The caller holds its lock.
1131pub(crate) fn append_private(path: &Path, text: &str) -> Result<(), String> {
1132    use std::io::Write;
1133    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
1134    if let Some(folder) = path.parent() {
1135        let mut builder = std::fs::DirBuilder::new();
1136        builder.recursive(true);
1137        #[cfg(unix)]
1138        std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
1139        builder.create(folder).map_err(fail)?;
1140    }
1141    let mut options = std::fs::OpenOptions::new();
1142    options.create(true).append(true);
1143    #[cfg(unix)]
1144    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
1145    options.open(path).and_then(|mut file| file.write_all(text.as_bytes())).map_err(fail)
1146}
1147
1148/// A file of the calls' kept but for the lines `drop` names: written beside
1149/// under a hidden name (yours alone), then put in place, under its lock.
1150/// Returns how many lines went.
1151pub(crate) fn rewrite_lines(path: &Path, drop: impl Fn(&str) -> bool) -> Result<usize, String> {
1152    crate::filelock::with_lock(path, || {
1153        let Ok(text) = std::fs::read_to_string(path) else { return Ok(0) };
1154        let (kept, gone): (Vec<&str>, Vec<&str>) = text.lines().filter(|l| !l.trim().is_empty()).partition(|l| !drop(l));
1155        if gone.is_empty() {
1156            return Ok(0);
1157        }
1158        let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
1159        let fresh = path.with_file_name(format!(".{name}.{}.new", std::process::id()));
1160        let _ = std::fs::remove_file(&fresh);
1161        let body: String = kept.iter().map(|l| format!("{l}\n")).collect();
1162        append_private(&fresh, &body).and_then(|()| {
1163            std::fs::rename(&fresh, path).map_err(|e| {
1164                let _ = std::fs::remove_file(&fresh);
1165                format!("{}: {e}", path.display())
1166            })
1167        })?;
1168        Ok(gone.len())
1169    })
1170}
1171
1172/// This device's own lines kept past `KEPT_DAYS` taken out of its log and of
1173/// its seen log under `root` (the calls' folder): the sharing then takes them
1174/// out on every device. A line that does not read stays. Returns how many went.
1175pub fn trim_own(root: &Path, device: &str, now_ms: i64) -> Result<usize, String> {
1176    let old = older_than(KEPT_DAYS, now_ms);
1177    Ok(rewrite_lines(&own_file(root, LOG, device), &old)? + rewrite_lines(&own_file(root, SEEN_LOG, device), &old)?)
1178}
1179
1180/// Another device's lines kept `LATE_DAYS` past their month taken out here
1181/// (`here`: this device): a phone gone for good never takes its own out, and
1182/// its calls must not stay on your other devices for ever. While it shares,
1183/// it took them out long before (`trim_own`), and nothing differs. Returns
1184/// how many went.
1185pub fn trim_others(root: &Path, here: &str, now_ms: i64) -> Result<usize, String> {
1186    let old = older_than(KEPT_DAYS + LATE_DAYS, now_ms);
1187    let mut gone = 0;
1188    for folder in [LOG, SEEN_LOG] {
1189        for (device, path) in device_files(&root.join(folder)) {
1190            if device != file_safe(here) {
1191                gone += rewrite_lines(&path, &old)?;
1192            }
1193        }
1194    }
1195    Ok(gone)
1196}
1197
1198/// A line older than `days` before `now_ms`, by its `at`; a line that does not read is kept.
1199fn older_than(days: i64, now_ms: i64) -> impl Fn(&str) -> bool {
1200    move |line: &str| serde_json::from_str::<serde_json::Value>(line).ok().and_then(|v| v["at"].as_i64()).is_some_and(|at| now_ms - at > days * DAY_MS)
1201}
1202
1203/// One press of Seen on a device: when (ms), and the calls of its line.
1204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1205pub struct SeenLine {
1206    pub at: i64,
1207    #[serde(default)]
1208    pub ids: Vec<String>,
1209}
1210
1211/// The calls seen, by id.
1212#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1213pub struct Seen {
1214    #[serde(default)]
1215    pub seen: BTreeSet<String>,
1216}
1217
1218impl Seen {
1219    /// This phone's file from before Seen was shared (`SEEN`).
1220    pub fn load(path: &Path) -> Seen {
1221        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
1222    }
1223
1224    /// Those calls marked seen; ids older than the list forgotten (`now` in ms).
1225    pub fn add(&mut self, ids: &[String], now: i64) {
1226        self.seen.extend(ids.iter().cloned());
1227        let oldest = now - (LISTED_DAYS + 1) * DAY_MS;
1228        self.seen.retain(|id| id.split('-').next().and_then(|at| at.parse::<i64>().ok()).is_some_and(|at| at >= oldest));
1229    }
1230}
1231
1232/// The calls seen on any device: every device's seen log under `root` (the
1233/// calls' folder), read together. Seen on one device, gone from the Porch of
1234/// every device.
1235pub fn read_seen(root: &Path) -> Seen {
1236    let mut seen = Seen::default();
1237    for (_, path) in device_files(&root.join(SEEN_LOG)) {
1238        for line in read_lines::<SeenLine>(&path) {
1239            seen.seen.extend(line.ids);
1240        }
1241    }
1242    seen
1243}
1244
1245/// Calls marked seen on this device: one line in its own seen log under
1246/// `root`, which the sharing carries to the others. Returns the line's time,
1247/// to undo it (`unmark_seen`).
1248pub fn mark_seen(root: &Path, device: &str, ids: &[String], now_ms: i64) -> Result<i64, String> {
1249    let path = own_file(root, SEEN_LOG, device);
1250    let text = serde_json::to_string(&SeenLine { at: now_ms, ids: ids.to_vec() }).map_err(|e| e.to_string())? + "\n";
1251    crate::filelock::with_lock(&path, || append_private(&path, &text))?;
1252    Ok(now_ms)
1253}
1254
1255/// A press of Seen undone: this device's own line written at `at` taken out
1256/// of its seen log, and so out of every device's once the sharing carries it.
1257/// Another device's Seen of the same calls stays. Whether a line went.
1258pub fn unmark_seen(root: &Path, device: &str, at: i64) -> Result<bool, String> {
1259    Ok(rewrite_lines(&own_file(root, SEEN_LOG, device), |line| serde_json::from_str::<SeenLine>(line).is_ok_and(|s| s.at == at))? > 0)
1260}
1261
1262/// This phone's calls seen before Seen was shared (`SEEN`): one line of its
1263/// own seen log, then the older file goes. Nothing to do once it is gone.
1264pub fn adopt_older_seen(root: &Path, device: &str, now_ms: i64) -> Result<(), String> {
1265    let older = root.join(SEEN);
1266    if !older.exists() {
1267        return Ok(());
1268    }
1269    let ids: Vec<String> = Seen::load(&older).seen.into_iter().collect();
1270    if !ids.is_empty() {
1271        mark_seen(root, device, &ids, now_ms)?;
1272    }
1273    std::fs::remove_file(&older).map_err(|e| format!("{}: {e}", older.display()))
1274}
1275
1276/// A voicemail left, as the list says it (`voicemail::Voicemail`, linked).
1277#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1278pub struct Message {
1279    /// "They left a message (0:42)."
1280    pub text: String,
1281    /// The mail that carries it, and its sound's place among the attachments; none in a notice without it.
1282    pub path: String,
1283    pub sound: Option<u32>,
1284}
1285
1286/// One line of the list: a caller, in one context, one day, on one phone.
1287#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1288pub struct Line {
1289    /// The calls it says, to mark them seen.
1290    pub ids: Vec<String>,
1291    /// "While you slept, a number not in your contacts called at 09:30."; on
1292    /// another device, "…called your phone at 09:30."
1293    pub text: String,
1294    /// "01 99 00 12 34"; "" for a hidden number.
1295    pub number: String,
1296    /// What Call back and Text back dial ("+33199001234"); "" hidden.
1297    pub dial: String,
1298    /// In your address books or the phone's: no "Add to contacts".
1299    pub known: bool,
1300    pub hidden: bool,
1301    /// "They may have left a message.", when no voicemail is linked.
1302    pub doubt: String,
1303    pub message: Option<Message>,
1304    /// Why it went to voicemail, on request: "Numbers not in your contacts go to voicemail in your leisure time."
1305    pub why: String,
1306    /// "The same number called again at 06:48, and that call rang.": a call
1307    /// that rang from the same number after the line's last, the same day; "" none.
1308    pub again: String,
1309}
1310
1311/// What the list needs besides the calls.
1312pub struct Lister<'a> {
1313    pub now: &'a Zoned,
1314    pub tr: &'a Translator,
1315    pub region: Option<&'static Region>,
1316    /// The name your address books give a number's key; none: not in them.
1317    pub name_of: &'a dyn Fn(&str) -> Option<String>,
1318    /// Whether this call's caller may reach you now (by phone or in writing).
1319    pub shows: &'a dyn Fn(&Held) -> bool,
1320    /// The phone a call came to, as this device says it (`Held::device`): ""
1321    /// for this device's own ("called at"), else "your phone", "your other
1322    /// phone", "your phone (GS290)" (`phone_words`).
1323    pub phone: &'a dyn Fn(&str) -> String,
1324}
1325
1326/// The list, oldest first: the declined calls not seen yet on any device,
1327/// of the last two weeks, whose callers may reach you now; never the blocked;
1328/// one line per phone, caller, context and day. A call that rang is never a
1329/// line: it is said under the line of the declined calls it followed.
1330/// `calls` are every phone's (`read_logs`), oldest first; `messages` maps a
1331/// call's id to its voicemail.
1332pub fn lines(calls: &[Held], seen: &Seen, messages: &BTreeMap<String, Message>, l: &Lister) -> Vec<Line> {
1333    let now_ms = l.now.timestamp().as_millisecond();
1334    let mut shown: Vec<&Held> = calls.iter().filter(|h| !h.rang && h.who != "blocked" && !seen.seen.contains(&h.id()) && now_ms - h.at <= LISTED_DAYS * DAY_MS && h.at <= now_ms + 60_000 && (l.shows)(h)).collect();
1335    shown.sort_by_key(|h| h.at);
1336    // One line per phone, caller, context and day, in the order of their first call.
1337    let mut groups: Vec<(&str, String, &str, jiff::civil::Date, Vec<&Held>)> = Vec::new();
1338    for h in shown {
1339        let date = local(h.at, l.now).date();
1340        let caller = if h.hidden { "hidden".to_string() } else { h.key.clone() };
1341        match groups.iter_mut().find(|(d, c, col, day, _)| *d == h.device && *c == caller && *col == h.column && *day == date) {
1342            Some((_, _, _, _, group)) => group.push(h),
1343            None => groups.push((h.device.as_str(), caller, h.column.as_str(), date, vec![h])),
1344        }
1345    }
1346    groups.into_iter().map(|(device, _, column, date, group)| line(&group, calls, device, column, date, messages, l)).collect()
1347}
1348
1349pub(crate) fn local(at_ms: i64, now: &Zoned) -> Zoned {
1350    jiff::Timestamp::from_millisecond(at_ms).map_or_else(|_| now.clone(), |t| t.to_zoned(now.time_zone().clone()))
1351}
1352
1353/// The first call that rang from the same number after the last of `group`,
1354/// the same day, on any phone: none for a hidden number (two hidden calls
1355/// cannot be told apart).
1356fn rang_after<'a>(calls: &'a [Held], group: &[&Held], date: jiff::civil::Date, l: &Lister) -> Option<&'a Held> {
1357    let last = group.last()?;
1358    if last.hidden {
1359        return None;
1360    }
1361    let now_ms = l.now.timestamp().as_millisecond();
1362    calls.iter().filter(|h| h.rang && !h.hidden && h.key == last.key && h.at > last.at && h.at <= now_ms + 60_000).min_by_key(|h| h.at).filter(|h| local(h.at, l.now).date() == date)
1363}
1364
1365/// "Monday 5 October", "yesterday", "today": a day, as the lines say it before
1366/// a capital is put to the sentence.
1367pub(crate) fn day_words(tr: &Translator, date: jiff::civil::Date, today: jiff::civil::Date) -> String {
1368    if date == today {
1369        tr.text("calls-day-today", None)
1370    } else if today.yesterday().is_ok_and(|y| y == date) {
1371        tr.text("calls-day-yesterday", None)
1372    } else {
1373        format!("{} {}", tr.text(&format!("weekday-{}", date.weekday().to_monday_one_offset()), None), tr.day_month(date))
1374    }
1375}
1376
1377fn line(group: &[&Held], calls: &[Held], device: &str, column: &str, date: jiff::civil::Date, messages: &BTreeMap<String, Message>, l: &Lister) -> Line {
1378    let tr = l.tr;
1379    let first = group[0];
1380    let name = if first.hidden { None } else { (l.name_of)(&first.key).or_else(|| group.iter().map(|h| h.name.trim()).find(|n| !n.is_empty()).map(str::to_string)) };
1381    // A number nobody knows, as the screening judged it: an automated caller, a number that cannot be a caller's, one the network could not verify.
1382    let unknown = match first.why.as_str() {
1383        "automated" | "impossible" | "unverified" => format!("calls-who-{}", first.why),
1384        _ => "calls-who-stranger".to_string(),
1385    };
1386    let who = if first.hidden {
1387        tr.text("calls-who-hidden", None)
1388    } else {
1389        name.clone().unwrap_or_else(|| tr.text(&unknown, None))
1390    };
1391    let context_words = context(tr, column);
1392    let today = l.now.date();
1393    let context_said = if date == today {
1394        context_words.clone()
1395    } else {
1396        let mut args = crate::i18n::args();
1397        args.set("day", day_words(tr, date, today));
1398        args.set("context", context_words.clone());
1399        tr.text("calls-context-day", Some(&args))
1400    };
1401    let time = |h: &Held| local(h.at, l.now).strftime("%H:%M").to_string();
1402    // This phone's own calls say "called at"; another phone's, which one it was.
1403    let phone = (l.phone)(device);
1404    let on = if phone.is_empty() { "" } else { "-on" };
1405    let mut args = crate::i18n::args();
1406    args.set("context", context_said);
1407    args.set("who", who);
1408    args.set("phone", phone.clone());
1409    let text = match group.len() {
1410        1 => {
1411            args.set("time", time(first));
1412            tr.text(&format!("calls-line-once{on}"), Some(&args))
1413        }
1414        2 => {
1415            args.set("first", time(first));
1416            args.set("second", time(group[1]));
1417            tr.text(&format!("calls-line-twice{on}"), Some(&args))
1418        }
1419        n => {
1420            args.set("count", tr.count(n, false, false));
1421            args.set("time", time(group[n - 1]));
1422            tr.text(&format!("calls-line-more{on}"), Some(&args))
1423        }
1424    };
1425    let rang = rang_after(calls, group, date, l);
1426    let again = rang
1427        .map(|h| {
1428            let mut args = crate::i18n::args();
1429            args.set("time", time(h));
1430            tr.text("calls-again-rang", Some(&args))
1431        })
1432        .unwrap_or_default();
1433    let ids: Vec<String> = group.iter().map(|h| h.id()).collect();
1434    // A message left after the declined calls, or after the call that rang next.
1435    let message = ids.iter().chain(rang.map(Held::id).iter()).find_map(|id| messages.get(id)).cloned();
1436    // Someone Always through, declined: their own row held them ("always-neutral": as the neutral).
1437    let row = if first.hidden { "hidden" } else if name.is_none() { "stranger" } else { first.who.trim_start_matches("always-") };
1438    let mut why_args = crate::i18n::args();
1439    why_args.set("context", context_words);
1440    let why = match (first.why.as_str(), row) {
1441        // A number you called lately, declined as your contacts were then (the screening declining it too).
1442        ("matrix", _) if first.called => tr.text("calls-why-called", Some(&why_args)),
1443        // Their own times held them, never their list (review of part 6, F7).
1444        ("own", _) => tr.text("calls-why-own", Some(&why_args)),
1445        // The screening's own (a table of version 2): what it is, never the time.
1446        ("automated" | "impossible" | "unverified", _) => tr.text(&format!("calls-why-{}", first.why), None),
1447        ("screening", "hidden") => tr.text("calls-why-screening-hidden", None),
1448        ("screening", _) => tr.text("calls-why-screening-stranger", None),
1449        (_, "safe" | "neutral" | "restricted" | "stranger" | "hidden") => tr.text(&format!("calls-why-{row}"), Some(&why_args)),
1450        _ => String::new(),
1451    };
1452    // A number nobody knows from a block ARCEP had given to no operator, while
1453    // that list counts: said, deciding nothing (it may be a new line; review
1454    // of part 2, F1).
1455    let unattributed = (!first.hidden && name.is_none() && !first.called && matches!(first.why.as_str(), "screening" | "matrix")).then(|| screening::shipped().unattributed_since(&first.key, today)).flatten();
1456    let why = match unattributed {
1457        Some(read) if !why.is_empty() => {
1458            let mut args = crate::i18n::args();
1459            args.set("date", format!("{} {}", tr.day_month(read), read.year()));
1460            format!("{why} {}", tr.text("calls-why-unattributed", Some(&args)))
1461        }
1462        _ => why,
1463    };
1464    Line {
1465        ids,
1466        text: capitalized(&text),
1467        number: if first.hidden { String::new() } else { shown_number(&first.key, l.region) },
1468        dial: if first.hidden { String::new() } else { first.key.clone() },
1469        known: name.is_some(),
1470        hidden: first.hidden,
1471        doubt: if message.is_none() { tr.text("calls-may-have-left", None) } else { String::new() },
1472        message,
1473        why,
1474        again,
1475    }
1476}
1477
1478/// "while you slept", "during work": what the time was, in a few words.
1479pub(crate) fn context(tr: &Translator, column: &str) -> String {
1480    match column {
1481        "work" | "admin" | "leisure" | "meals" | "sleep" | "pause" | "free" | "slot" | "dnd" | "reachable" => tr.text(&format!("calls-context-{column}"), None),
1482        _ => tr.text("calls-context-any", None),
1483    }
1484}
1485
1486/// The phone a call came to, said on another device: "your phone" when one
1487/// phone shares its calls with this device, "your other phone" on a phone
1488/// when one other does, else "your phone (GS290)" by its name among your
1489/// devices (`phones`: each phone's id with its name; "" for this device's own).
1490pub fn phone_words(tr: &Translator, device: &str, here: &str, phones: &[(String, String)], this_is_a_phone: bool) -> String {
1491    if device == here {
1492        return String::new();
1493    }
1494    let others: Vec<&(String, String)> = phones.iter().filter(|(id, _)| id != here).collect();
1495    let name = others.iter().find(|(id, _)| id == device).map(|(_, name)| name.trim()).unwrap_or("");
1496    if others.len() > 1 && !name.is_empty() {
1497        let mut args = crate::i18n::args();
1498        args.set("name", name.to_string());
1499        return tr.text("calls-phone-named", Some(&args));
1500    }
1501    tr.text(if this_is_a_phone { "calls-phone-other" } else { "calls-phone-yours" }, None)
1502}
1503
1504/// A person's calls of the last month (`keys`, their numbers as Sioul keys
1505/// them), newest first, from every phone, rang or declined: one sentence each,
1506/// for their sheet, never a count. Each says the phone only when several
1507/// phones share their calls. A blocked caller's never come: their calls never
1508/// leave their phone.
1509pub fn history(calls: &[Held], keys: &BTreeSet<String>, l: &Lister) -> Vec<String> {
1510    let now_ms = l.now.timestamp().as_millisecond();
1511    let several = calls.iter().map(|h| h.device.as_str()).collect::<BTreeSet<_>>().len() > 1;
1512    calls.iter().rev().filter(|h| !h.hidden && keys.contains(&h.key) && h.who != "blocked" && now_ms - h.at <= KEPT_DAYS * DAY_MS && h.at <= now_ms + 60_000).map(|h| history_line(h, several, l)).collect()
1513}
1514
1515/// "Tuesday 6 October at 23:10, while you slept: declined, sent to
1516/// voicemail.", "Today at 06:48, while you slept: it rang. A second call
1517/// within 15 minutes rings."
1518fn history_line(h: &Held, several: bool, l: &Lister) -> String {
1519    let tr = l.tr;
1520    let at = local(h.at, l.now);
1521    let mut args = crate::i18n::args();
1522    args.set("day", day_words(tr, at.date(), l.now.date()));
1523    args.set("time", at.strftime("%H:%M").to_string());
1524    let when = tr.text("calls-history-when", Some(&args));
1525    let head = if h.column.is_empty() {
1526        when
1527    } else {
1528        let mut args = crate::i18n::args();
1529        args.set("when", when);
1530        args.set("context", context(tr, &h.column));
1531        tr.text("calls-history-head", Some(&args))
1532    };
1533    let phone = if several {
1534        let said = (l.phone)(&h.device);
1535        if said.is_empty() { tr.text("calls-phone-this", None) } else { said }
1536    } else {
1537        String::new()
1538    };
1539    let outcome = if h.rang { "calls-history-rang" } else { "calls-history-declined" };
1540    let mut args = crate::i18n::args();
1541    args.set("phone", phone.clone());
1542    let outcome = tr.text(&format!("{outcome}{}", if phone.is_empty() { "" } else { "-on" }), Some(&args));
1543    let mut args = crate::i18n::args();
1544    args.set("head", head);
1545    args.set("outcome", outcome);
1546    let mut text = capitalized(&tr.text("calls-history-line", Some(&args)));
1547    let mut said: Vec<String> = Vec::new();
1548    said.extend(h.rang.then(|| rang_reason(tr, &h.why)).flatten());
1549    // A number you called lately, decided as your contacts are (else the
1550    // screening's reason says it); one the network could not verify rang all
1551    // the same when you know them (decided before the screening).
1552    if h.called && h.why == "matrix" {
1553        said.push(tr.text("calls-history-called", None));
1554    }
1555    if h.verified == "failed" && h.why != "unverified" {
1556        said.push(tr.text("calls-history-unverified", None));
1557    }
1558    for reason in said {
1559        text.push(' ');
1560        text.push_str(&reason);
1561    }
1562    text
1563}
1564
1565/// Why a call rang when its row alone would not say it: a second call,
1566/// Expecting a call (once Let every call through), an emergency, the
1567/// screening off, resting in your work hours or letting such numbers ring,
1568/// nothing to decide from.
1569fn rang_reason(tr: &Translator, why: &str) -> Option<String> {
1570    let key = match why {
1571        "repeat" => "calls-rang-repeat",
1572        "through" => "calls-rang-through",
1573        "expecting" => "calls-rang-expecting",
1574        "screening-off" => "calls-rang-screening-off",
1575        "work-hours" => "calls-rang-work-hours",
1576        "screening" => "calls-rang-screening",
1577        "emergency" => "calls-rang-emergency",
1578        "after-emergency" => "calls-rang-after-emergency",
1579        "no-table" | "no-frame" | "no-row" | "error" => "calls-rang-undecided",
1580        _ => return None,
1581    };
1582    Some(tr.text(key, None))
1583}
1584
1585/// The first letter in capital: a sentence that starts with a context's words.
1586pub fn capitalized(text: &str) -> String {
1587    let mut chars = text.chars();
1588    match chars.next() {
1589        Some(first) => first.to_uppercase().chain(chars).collect(),
1590        None => String::new(),
1591    }
1592}
1593
1594/// "0:42": a voicemail's length.
1595pub fn length(seconds: u32) -> String {
1596    format!("{}:{:02}", seconds / 60, seconds % 60)
1597}
1598
1599/// What the list says of a voicemail linked to a call.
1600pub fn message_words(tr: &Translator, seconds: Option<u32>) -> String {
1601    match seconds.filter(|s| *s > 0) {
1602        Some(seconds) => {
1603            let mut args = crate::i18n::args();
1604            args.set("length", length(seconds));
1605            tr.text("calls-left-message", Some(&args))
1606        }
1607        None => tr.text("calls-left-message-plain", None),
1608    }
1609}
1610
1611// ---------------------------------------------------------------- what is said of it
1612
1613/// Expecting a call in words: "Expecting a call until 15:30: numbers nobody
1614/// knows ring.", "… until you turn it off.", or after an emergency call,
1615/// "Every call rings until tomorrow at 09:31: you called an emergency
1616/// number."; "" otherwise. `through` and `emergency_at` in ms; `when` words
1617/// an instant (ms).
1618pub fn through_line(tr: &Translator, through: Option<&Through>, emergency_at: i64, now: i64, when: &dyn Fn(i64) -> String) -> String {
1619    let after = emergency_at + AFTER_EMERGENCY_HOURS * 3_600_000;
1620    if emergency_at > 0 && emergency_at <= now && now < after {
1621        let mut args = crate::i18n::args();
1622        args.set("until", when(after));
1623        return tr.text("calls-through-emergency", Some(&args));
1624    }
1625    match through.filter(|t| t.holds(now)) {
1626        Some(t) if t.until > 0 => {
1627            let mut args = crate::i18n::args();
1628            args.set("until", when(t.until));
1629            tr.text("calls-expecting-until", Some(&args))
1630        }
1631        Some(_) => tr.text("calls-expecting-on", None),
1632        None => String::new(),
1633    }
1634}
1635
1636#[cfg(test)]
1637mod tests {
1638    use super::*;
1639    use crate::everywhere::Person;
1640
1641    fn fr() -> Option<&'static Region> {
1642        phones::region_named("FR")
1643    }
1644
1645    #[test]
1646    fn java_keys_numbers_as_phones_does() {
1647        let zero = trunk_zero();
1648        // Numbers lent to fiction only: ARCEP's (01 99 00, 06 39 98), Ofcom's
1649        // drama ones (020 7946 0xxx, 07700 900xxx), North America's 555-01xx.
1650        // No country without a trunk prefix (Spain, Italy) lends any: a
1651        // country code nobody has (999, spare in E.164) reads them as they do,
1652        // the leading 0 kept as Italy's.
1653        let spare = Region { code: "XX", calling: "999", trunk: "", international: "00", digits: (9, 10), french: false };
1654        for (region, numbers) in [
1655            (phones::region_named("FR"), &["0199001234", "01 99 00 12 34", "+33199001234", "0033199001234", "+330199001234", "0639981234", "0692123456", "0590123456", "0696123456", "0594123456", "112", "15", "3114", "0800112112", "08 00 11 21 12", "+44 20 7946 0018", "0044 20 7946 0018", "+262692123456", "+262 6 39 98 12 34", "tel:+262639981234", "*#06#", "1-555-SIOUL", "639981234", "\u{202a}+33 1 99 00 12 34\u{202c}", "01\u{a0}99\u{202f}00 12 34"][..]),
1656            (phones::region_named("GB"), &["07700 900123", "020 7946 0018", "+44 20 7946 0018", "0044 7700 900123", "999"][..]),
1657            (phones::region_named("US"), &["(212) 555-0100", "1 212 555 0100", "011 33 6 39 98 12 34", "911"][..]),
1658            (Some(&spare), &["639 98 12 34", "0639 98 12 34", "+999 0639 98 12 34", "112"][..]),
1659        ] {
1660            let rules = region.map(RegionRules::of);
1661            let country = region.map_or("none", |r| r.code);
1662            for number in numbers {
1663                assert_eq!(incoming_key(number, rules.as_ref(), &zero), phones::key(number, region), "{country}: {number}");
1664            }
1665        }
1666        // The keys the branches give: a trunk 0 dropped, none to drop (the 0 kept), letters as written.
1667        assert_eq!(phones::key("07700 900123", phones::region_named("GB")), "+447700900123");
1668        assert_eq!((phones::key("639 98 12 34", Some(&spare)), phones::key("0639 98 12 34", Some(&spare))), ("+999639981234".to_string(), "+9990639981234".to_string()));
1669        assert_eq!(incoming_key("1-555-SIOUL", None, &zero), "1-555-sioul");
1670        // No country known: as written, as phones does.
1671        assert_eq!(incoming_key("06 39 98 12 34", None, &zero), phones::key("06 39 98 12 34", None));
1672        assert_eq!(incoming_key("+33 05 36 49 12 34", None, &zero), "+33536491234");
1673        // Every overseas prefix agrees with phones' own reading (Mayotte's 0639 98 is fiction's).
1674        let rules = RegionRules::of(fr().unwrap());
1675        for (prefix, calling) in FRENCH_OVERSEAS {
1676            let number = format!("0{prefix}981234");
1677            assert_eq!(phones::key(&number, fr()), format!("+{calling}{prefix}981234"), "{number}");
1678            assert_eq!(incoming_key(&number, Some(&rules), &zero), phones::key(&number, fr()));
1679        }
1680    }
1681
1682    /// A table as Java reads it: Monday 5 October 2026, work 09:00–17:00, then
1683    /// leisure, the night from 22:00; the usual Calls matrix. The carer and
1684    /// a blocked number are on the Always through list.
1685    fn decision_table() -> Table {
1686        let mut people = People::default();
1687        people.add(Person { name: "Carer".into(), phones: vec!["01 99 00 00 09".into(), "01 99 00 00 04".into()], ..Person::default() }, fr());
1688        let blocked = |key: &str| if key == "+33199000004" { Who::Blocked } else { Who::Stranger };
1689        let work = (ms("2026-10-05T09:00[Europe/Paris]"), ms("2026-10-05T17:00[Europe/Paris]"));
1690        let leisure = (work.1, ms("2026-10-05T22:00[Europe/Paris]"));
1691        let sleep = (leisure.1, ms("2026-10-06T07:00[Europe/Paris]"));
1692        table(Made {
1693            made: 0,
1694            region: fr(),
1695            numbers: vec![
1696                ("+33199000001".into(), "safe".into()),
1697                ("+33199000002".into(), "neutral".into()),
1698                ("+33199000003".into(), "restricted".into()),
1699                ("+33199000004".into(), "blocked".into()),
1700            ],
1701            prefixes: vec![("+3346571*".into(), "blocked".into())],
1702            always: always_numbers(&people, fr(), &blocked),
1703            own: Vec::new(),
1704            expecting: None,
1705            screening: None,
1706            frames: vec![
1707                frame(ms("2026-10-05T00:00[Europe/Paris]"), work.0, "sleep", &["always-stranger"]),
1708                frame(work.0, work.1, "work", &["safe", "neutral", "restricted", "hidden", "always-stranger"]),
1709                frame(leisure.0, leisure.1, "leisure", &["safe", "always-stranger"]),
1710                frame(sleep.0, sleep.1, "sleep", &["always-stranger"]),
1711            ],
1712        })
1713    }
1714
1715    fn call(number: &str, at: &str) -> Incoming {
1716        Incoming { number: number.into(), hidden: number.is_empty(), now: ms(&format!("2026-10-05T{at}[Europe/Paris]")), ..Incoming::default() }
1717    }
1718
1719    /// A table of version 1, as an older Sioul wrote it (until this one
1720    /// writes its own): still read, in its own order, strangers and hidden
1721    /// numbers by their frame's rows, Let every call through before a second call.
1722    #[test]
1723    fn who_rings_when_and_what_always_rings() {
1724        let t = decision_table();
1725        assert_eq!((t.v, t.screening.is_none()), (1, true));
1726        let said = |c: &Incoming| {
1727            let d = decide(Some(&t), c);
1728            (d.refuse, d.why, d.who)
1729        };
1730        // Who × time: the frame's row, as the matrix said it ahead.
1731        for (number, at, refused, who) in [
1732            ("01 99 00 00 01", "10:00", false, "safe"),
1733            ("01 99 00 00 01", "18:00", false, "safe"),
1734            ("01 99 00 00 01", "23:00", true, "safe"),
1735            ("01 99 00 00 02", "10:00", false, "neutral"),
1736            ("01 99 00 00 02", "18:00", true, "neutral"),
1737            ("+33 1 99 00 00 03", "10:00", false, "restricted"),
1738            ("0199000003", "18:00", true, "restricted"),
1739            ("01 99 00 55 55", "10:00", true, "stranger"),
1740            ("", "10:00", false, "hidden"),
1741            ("", "18:00", true, "hidden"),
1742        ] {
1743            let (refuse, why, w) = said(&call(number, at));
1744            assert_eq!((refuse, why, w.as_str()), (refused, "matrix", who), "{number} at {at}");
1745        }
1746        // Blocked: by its number, or a prefix on a list, at every time, a second call too.
1747        assert_eq!(said(&call("01 99 00 00 04", "10:00")), (true, "blocked", "blocked".to_string()));
1748        assert_eq!(said(&call("04 65 71 12 34", "10:00")), (true, "blocked", "blocked".to_string()));
1749        let again = Incoming { last_call: ms("2026-10-05T09:55[Europe/Paris]"), ..call("01 99 00 00 04", "10:00") };
1750        assert_eq!(said(&again).1, "blocked");
1751        // A contact of the phone's only: neutral.
1752        assert_eq!(said(&Incoming { phone_contact: true, ..call("01 99 00 55 55", "10:00") }), (false, "matrix", "neutral".to_string()));
1753        assert_eq!(said(&Incoming { phone_contact: true, ..call("01 99 00 55 55", "18:00") }), (true, "matrix", "neutral".to_string()));
1754        // A second call within 15 minutes rings; after 15, the matrix again; hidden numbers never count.
1755        let first = ms("2026-10-05T18:00[Europe/Paris]");
1756        assert_eq!(said(&Incoming { last_call: first, ..call("01 99 00 55 55", "18:10") }).1, "repeat");
1757        assert_eq!(said(&Incoming { last_call: first, ..call("01 99 00 55 55", "18:16") }), (true, "matrix", "stranger".to_string()));
1758        assert_eq!(said(&Incoming { last_call: first, ..call("", "18:10") }), (true, "matrix", "hidden".to_string()));
1759        // The floors: emergency numbers and callbacks, as the table or Android says; the do-not-disturb list.
1760        for number in ["112", "15", "0 800 112 112", "+33800112112"] {
1761            assert_eq!(said(&call(number, "23:00")).1, "emergency", "{number}");
1762        }
1763        assert_eq!(said(&Incoming { system_emergency: true, ..call("17", "23:00") }).1, "emergency");
1764        assert_eq!(said(&call("17", "23:00")).0, true, "a short code Android does not call an emergency number: a stranger");
1765        // Always through: their own row, at night too; blocked beats it (before: the list's floor rang it).
1766        assert_eq!(said(&call("01 99 00 00 09", "23:00")), (false, "matrix", "always-stranger".to_string()));
1767        assert_eq!(said(&call("01 99 00 00 04", "23:00")).1, "blocked");
1768        assert!(t.floors.people.is_empty(), "no floor before the blocked any more");
1769        // A day after you call an emergency number, every call rings, the blocked too; not after.
1770        let called = ms("2026-10-05T03:00[Europe/Paris]");
1771        assert_eq!(said(&Incoming { emergency_at: called, ..call("01 99 00 00 04", "23:00") }).1, "after-emergency");
1772        assert_eq!(said(&Incoming { emergency_at: called - 86_400_000, ..call("01 99 00 55 55", "23:00") }).1, "matrix");
1773        // Let every call through: the later press of this phone's and the table's.
1774        let on = Through { pressed: 10, on: true, until: 0 };
1775        let mut through = t.clone();
1776        through.through = Some(on);
1777        assert_eq!(decide(Some(&through), &call("01 99 00 55 55", "23:00")).why, "through");
1778        assert_eq!(decide(Some(&through), &call("01 99 00 00 04", "23:00")).why, "blocked", "blocked entirely: never let through");
1779        let off_here = Through { pressed: 11, on: false, until: 0 };
1780        assert!(decide(Some(&through), &Incoming { local: Some(off_here), ..call("01 99 00 55 55", "23:00") }).refuse, "turned off on the phone after");
1781        let ended = Through { pressed: 12, on: true, until: ms("2026-10-05T22:30[Europe/Paris]") };
1782        assert!(decide(Some(&t), &Incoming { local: Some(ended), ..call("01 99 00 55 55", "23:00") }).refuse, "an hour that ended");
1783        // Nothing to decide from: it rings.
1784        assert_eq!(decide(None, &call("01 99 00 55 55", "23:00")).why, "no-table");
1785        assert_eq!(said(&Incoming { now: ms("2026-10-06T08:00[Europe/Paris]"), ..call("01 99 00 55 55", "23:00") }), (false, "no-frame", "stranger".to_string()));
1786        assert_eq!(said(&Incoming { outgoing: true, ..call("112", "23:00") }).1, "outgoing");
1787    }
1788
1789    /// The table of version 2 these tests decide from: `decision_table`'s
1790    /// people and frames, the screening as usual (numbers nobody knows to
1791    /// voicemail at all hours, hidden numbers ringing in work and admin hours),
1792    /// a fiction number for ARCEP's roots for machines, 02 61 91, and 089,
1793    /// which the plan never lets be a caller ID.
1794    fn screening_table() -> Table {
1795        let base = decision_table();
1796        let part = screening::TablePart { automated_prefixes: vec!["026191".into()], impossible_prefixes: vec!["089".into()], ..screening::TablePart::of(&screening::Screening::default(), &screening::Numbers::default(), "2026-10-05".parse().unwrap()) };
1797        Table { v: VERSION, through: None, screening: Some(part), ..base }
1798    }
1799
1800    /// Every case of the screening apart (the overrides' part 2, and its
1801    /// review): what always rings, the blocked, what the screening keeps out
1802    /// whatever else, the people you know by the matrix, then everyone else by
1803    /// the screening, never opened by do-not-disturb or any frame of the people
1804    /// you know, and never ringing where your contacts' row declines but while
1805    /// you expect a call; a missing or outdated table rings.
1806    #[test]
1807    fn the_screening_decides_numbers_nobody_knows_apart() {
1808        let t = screening_table();
1809        let decided = |table: &Table, c: &Incoming| {
1810            let d = decide(Some(table), c);
1811            (d.refuse, d.why, d.who)
1812        };
1813        let said = |c: &Incoming| decided(&t, c);
1814        let stranger = "01 99 00 55 55";
1815        let (automated, impossible, other) = ("02 61 91 12 34", "08 99 12 34 56", "05 36 49 12 34");
1816        // What always rings: an emergency number, its callback, the day after you call one (the blocked too).
1817        for number in ["112", "15", "0 800 112 112"] {
1818            assert_eq!(said(&call(number, "23:00")).1, "emergency", "{number}");
1819        }
1820        let called_112 = ms("2026-10-05T03:00[Europe/Paris]");
1821        for number in ["01 99 00 00 04", stranger, automated, impossible, ""] {
1822            assert_eq!(said(&Incoming { emergency_at: called_112, ..call(number, "23:00") }).1, "after-emergency", "{number}");
1823        }
1824        // The blocked: refused, a second call too, Expecting a call too.
1825        let blocked = call("01 99 00 00 04", "10:00");
1826        assert_eq!(said(&blocked), (true, "blocked", "blocked".to_string()));
1827        assert_eq!(said(&Incoming { last_call: ms("2026-10-05T09:55[Europe/Paris]"), ..blocked.clone() }).1, "blocked");
1828        let expecting = Table { expecting: Some(Through { pressed: 10, on: true, until: 0 }), ..t.clone() };
1829        assert_eq!(decided(&expecting, &blocked).1, "blocked");
1830        // A second call within 15 minutes rings, whoever it is but the blocked and what the screening keeps out
1831        // whatever else (an automated caller here); never a hidden number.
1832        let first = ms("2026-10-05T18:00[Europe/Paris]");
1833        assert_eq!(said(&Incoming { last_call: first, ..call(stranger, "18:10") }), (false, "repeat", "stranger".to_string()));
1834        assert_eq!(said(&Incoming { last_call: first, ..call(automated, "18:10") }), (true, "automated", "stranger".to_string()));
1835        assert_eq!(said(&Incoming { last_call: first, ..call("", "18:10") }), (true, "screening", "hidden".to_string()));
1836        // The people you know: by the matrix's frame, the screening never asked. Always through rings at night.
1837        assert_eq!(said(&call("01 99 00 00 09", "23:00")), (false, "matrix", "always-stranger".to_string()));
1838        assert_eq!(said(&call("01 99 00 00 01", "10:00")), (false, "matrix", "safe".to_string()));
1839        assert_eq!(said(&call("01 99 00 00 01", "23:00")), (true, "matrix", "safe".to_string()));
1840        assert_eq!(said(&call("01 99 00 00 02", "18:00")), (true, "matrix", "neutral".to_string()));
1841        // A contact of the phone's only: your contacts' row.
1842        assert_eq!(said(&Incoming { phone_contact: true, ..call(stranger, "10:00") }), (false, "matrix", "neutral".to_string()));
1843        assert_eq!(said(&Incoming { phone_contact: true, ..call(stranger, "18:00") }), (true, "matrix", "neutral".to_string()));
1844        // A number you called in the last 14 days counts as your contacts do (at night too as they do); past 14 days, nobody.
1845        let lately = Incoming { called: ms("2026-10-02T12:00[Europe/Paris]"), ..call(stranger, "10:00") };
1846        let d = decide(Some(&t), &lately);
1847        assert_eq!((d.refuse, d.why, d.who.as_str(), d.called), (false, "matrix", "neutral", true));
1848        assert_eq!(said(&Incoming { now: ms("2026-10-05T23:00[Europe/Paris]"), ..lately.clone() }), (true, "matrix", "neutral".to_string()));
1849        assert_eq!(said(&Incoming { called: ms("2026-09-20T12:00[Europe/Paris]"), ..lately.clone() }).1, "screening");
1850        let no_called = Table { screening: t.screening.clone().map(|s| screening::TablePart { called_days: 0, ..s }), ..t.clone() };
1851        assert_eq!(decided(&no_called, &lately).1, "screening", "switched off: nobody");
1852        // Declined by your contacts' row, only Expecting a call lets it ring (review of part 2, F3): neither the
1853        // screening off, nor its rule letting them ring, nor its hours resting under do-not-disturb at work.
1854        let night = Incoming { now: ms("2026-10-05T23:00[Europe/Paris]"), ..lately.clone() };
1855        let with = |part: &dyn Fn(screening::TablePart) -> screening::TablePart| Table { screening: t.screening.clone().map(part), ..t.clone() };
1856        assert_eq!(decided(&expecting, &night), (false, "expecting", "neutral".to_string()));
1857        assert_eq!(decided(&with(&|s| screening::TablePart { on: false, ..s }), &night), (true, "matrix", "neutral".to_string()));
1858        assert_eq!(decided(&with(&|s| screening::TablePart { unknown: screening::Rule::Ring, ..s }), &night), (true, "matrix", "neutral".to_string()));
1859        let mut held_at_work = with(&|s| screening::TablePart { hours: screening::Hours::OutsideWork, ..s });
1860        for f in held_at_work.frames.iter_mut().filter(|f| f.column == "work") {
1861            f.column = "dnd".into();
1862            f.ring.insert("neutral".into(), false);
1863        }
1864        assert_eq!(decided(&held_at_work, &lately), (true, "matrix", "neutral".to_string()), "as your contacts, no further");
1865        assert_eq!(decided(&held_at_work, &Incoming { phone_contact: true, ..call(stranger, "10:00") }), (true, "matrix", "neutral".to_string()), "a contact: their row alone");
1866        assert_eq!(decided(&held_at_work, &call(stranger, "10:00")), (true, "matrix", "stranger".to_string()), "nobody known: no further either");
1867        let machine = Incoming { called: lately.called, ..call(automated, "10:00") };
1868        assert_eq!(said(&machine), (false, "matrix", "neutral".to_string()));
1869        assert_eq!(said(&Incoming { now: night.now, ..machine.clone() }), (true, "matrix", "neutral".to_string()));
1870        assert_eq!(said(&Incoming { verified: "failed".into(), ..lately.clone() }).1, "matrix");
1871        // Numbers nobody knows: to voicemail at all hours, as usual.
1872        for at in ["03:00", "10:00", "18:00", "23:00"] {
1873            assert_eq!(said(&call(stranger, at)), (true, "screening", "stranger".to_string()), "{at}");
1874        }
1875        // An automated caller, a number that cannot be a caller's, a failed verification: voicemail, whatever else.
1876        assert_eq!(said(&call(automated, "10:00")).1, "automated");
1877        assert_eq!(said(&call(impossible, "10:00")).1, "impossible", "089 is never a caller ID");
1878        let failed = Incoming { verified: "failed".into(), ..call(other, "10:00") };
1879        assert_eq!(said(&failed), (true, "unverified", "stranger".to_string()));
1880        // 089 stays out whatever the network says: the plan never lets it be a caller ID.
1881        assert_eq!(said(&Incoming { verified: "passed".into(), ..call(impossible, "10:00") }).1, "impossible");
1882        // A failed verification on someone you know: they ring as the matrix says; the line says the network could not verify.
1883        let known_failed = decide(Some(&t), &Incoming { verified: "failed".into(), ..call("01 99 00 00 01", "10:00") });
1884        assert_eq!((known_failed.refuse, known_failed.why), (false, "matrix"));
1885        // Each kind may go as any number nobody knows instead: ringing where your contacts may.
1886        let lenient = Table { screening: t.screening.clone().map(|s| screening::TablePart { automated: Spam::Unknown, impossible: Spam::Unknown, unverified: Spam::Unknown, unknown: screening::Rule::Ring, ..s }), ..t.clone() };
1887        for c in [call(automated, "10:00"), call(impossible, "10:00"), failed.clone()] {
1888            assert_eq!(decided(&lenient, &c), (false, "screening", "stranger".to_string()));
1889        }
1890        assert_eq!(decided(&lenient, &call(automated, "23:00")), (true, "matrix", "stranger".to_string()));
1891        // A hidden number at each hour: ringing in work and admin hours as usual, else voicemail; as you set it.
1892        for (at, rings) in [("03:00", false), ("10:00", true), ("18:00", false), ("23:00", false)] {
1893            assert_eq!(said(&call("", at)), (!rings, "screening", "hidden".to_string()), "{at}");
1894        }
1895        let held = Table { screening: t.screening.clone().map(|s| screening::TablePart { hidden: screening::Rule::Voicemail, ..s }), ..t.clone() };
1896        assert!(decided(&held, &call("", "10:00")).0, "voicemail at all hours, as the owner has it");
1897        let open = Table { screening: t.screening.clone().map(|s| screening::TablePart { hidden: screening::Rule::Ring, ..s }), ..t.clone() };
1898        assert!(!decided(&open, &call("", "10:00")).0);
1899        assert_eq!(decided(&open, &call("", "23:00")), (true, "matrix", "hidden".to_string()), "never where your contacts wait");
1900        // Expecting a call: numbers nobody knows and hidden numbers ring; never the blocked, an automated caller,
1901        // a number that cannot be a caller's or a failed verification; and never the people you know beyond their row.
1902        for c in [call(stranger, "23:00"), call("", "23:00")] {
1903            assert_eq!(decided(&expecting, &c).1, "expecting");
1904        }
1905        for (c, why) in [(call(automated, "23:00"), "automated"), (call(impossible, "23:00"), "impossible"), (Incoming { now: ms("2026-10-05T23:00[Europe/Paris]"), ..failed.clone() }, "unverified")] {
1906            assert_eq!(decided(&expecting, &c), (true, why, "stranger".to_string()));
1907        }
1908        assert_eq!(decided(&expecting, &call("01 99 00 00 02", "23:00")), (true, "matrix", "neutral".to_string()), "Expecting a call is for numbers nobody knows");
1909        // This phone's own press, later than your devices', turns it off; one that ended, too.
1910        assert!(decide(Some(&expecting), &Incoming { local: Some(Through { pressed: 11, on: false, until: 0 }), ..call(stranger, "23:00") }).refuse);
1911        assert!(decide(Some(&t), &Incoming { local: Some(Through { pressed: 12, on: true, until: ms("2026-10-05T22:30[Europe/Paris]") }), ..call(stranger, "23:00") }).refuse);
1912        // The screening off: every number nobody knows rings where your contacts may, machines too; the blocked never.
1913        let off = Table { screening: t.screening.clone().map(|s| screening::TablePart { on: false, ..s }), ..t.clone() };
1914        for c in [call(stranger, "10:00"), call(automated, "10:00"), call("", "10:00")] {
1915            assert_eq!(decided(&off, &c).1, "screening-off");
1916        }
1917        for c in [call(stranger, "23:00"), call(automated, "23:00"), call("", "18:00")] {
1918            assert_eq!(decided(&off, &c).1, "matrix", "where your contacts wait");
1919        }
1920        assert_eq!(decided(&off, &blocked).1, "blocked");
1921        // Only outside your work and admin hours: in them, numbers nobody knows and hidden numbers ring; what it
1922        // keeps out whatever else stays out (review of part 2, F4).
1923        let outside = Table { screening: t.screening.clone().map(|s| screening::TablePart { hours: screening::Hours::OutsideWork, ..s }), ..t.clone() };
1924        assert_eq!(decided(&outside, &call(stranger, "10:00")).1, "work-hours");
1925        assert_eq!(decided(&outside, &call(automated, "10:00")).1, "automated");
1926        assert_eq!(decided(&outside, &call(stranger, "18:00")), (true, "screening", "stranger".to_string()));
1927        // Do-not-disturb, or any frame of the people you know (Reachable to come), never opens the screening: a
1928        // frame where everyone you know rings changes nothing; one where nobody you know rings (do-not-disturb)
1929        // lets no number nobody knows ring.
1930        for ringing in [&[][..], &ROWS[..]] {
1931            let mut moved = t.clone();
1932            for f in &mut moved.frames {
1933                f.column = "dnd".into();
1934                for row in ROWS {
1935                    f.ring.insert(row.to_string(), ringing.contains(&row));
1936                }
1937            }
1938            for (number, at) in [(stranger, "10:00"), (stranger, "23:00"), ("", "10:00"), ("", "18:00"), (automated, "10:00"), (impossible, "23:00")] {
1939                let expected = if ringing.is_empty() { true } else { said(&call(number, at)).0 };
1940                assert_eq!(decided(&moved, &call(number, at)).0, expected, "{number} at {at}, known ringing: {}", !ringing.is_empty());
1941            }
1942        }
1943        // Nothing to decide from: it rings. No table; past the frames, everyone but the blocked.
1944        assert_eq!(decide(None, &call(automated, "23:00")).why, "no-table");
1945        let tomorrow = |number: &str| Incoming { now: ms("2026-10-06T08:00[Europe/Paris]"), ..call(number, "00:00") };
1946        for number in [stranger, automated, "01 99 00 00 01", ""] {
1947            assert_eq!(said(&tomorrow(number)).1, "no-frame", "{number}");
1948        }
1949        assert_eq!(said(&tomorrow("01 99 00 00 04")).1, "blocked");
1950        assert_eq!(said(&Incoming { outgoing: true, ..call(automated, "23:00") }).1, "outgoing");
1951    }
1952
1953    /// Decided on `table`: refused or not, why, the row.
1954    fn decided_on(table: &Table, c: &Incoming) -> (bool, &'static str, String) {
1955        let d = decide(Some(table), c);
1956        (d.refuse, d.why, d.who)
1957    }
1958
1959    /// `screening_table` with its screening changed.
1960    fn screening_with(change: &dyn Fn(screening::TablePart) -> screening::TablePart) -> Table {
1961        let t = screening_table();
1962        Table { screening: t.screening.clone().map(change), ..t }
1963    }
1964
1965    /// The work frame of `table` under do-not-disturb (a focus session): the
1966    /// people you know held, Always through ringing, work's hours kept.
1967    fn focus_at_work(mut table: Table) -> Table {
1968        for f in table.frames.iter_mut().filter(|f| f.column == "work") {
1969            f.column = "dnd".into();
1970            for row in ROWS {
1971                f.ring.insert(row.to_string(), row.starts_with("always-"));
1972            }
1973        }
1974        table
1975    }
1976
1977    /// The review of part 2 (19 October 2026), F3: a number nobody knows, a
1978    /// hidden number or a number called lately never rings by the screening's
1979    /// own openings (off, resting in work hours, a rule letting it ring) where
1980    /// your contacts' row declines: under do-not-disturb, in leisure here, at
1981    /// night. Expecting a call is the one exception.
1982    #[test]
1983    fn nobody_unknown_rings_where_your_contacts_wait() {
1984        let stranger = "01 99 00 55 55";
1985        // As usual, hidden numbers ring in work hours; in a focus session at work, where your contacts wait, not.
1986        let usual = screening_table();
1987        assert_eq!(decided_on(&usual, &call("", "10:00")), (false, "screening", "hidden".to_string()));
1988        assert_eq!(decided_on(&focus_at_work(usual.clone()), &call("", "10:00")), (true, "matrix", "hidden".to_string()));
1989        // Resting in work hours: never under do-not-disturb.
1990        let resting = screening_with(&|s| screening::TablePart { hours: screening::Hours::OutsideWork, ..s });
1991        assert_eq!(decided_on(&resting, &call(stranger, "10:00")), (false, "work-hours", "stranger".to_string()));
1992        assert_eq!(decided_on(&focus_at_work(resting.clone()), &call(stranger, "10:00")), (true, "matrix", "stranger".to_string()));
1993        // Off: where your contacts ring only.
1994        let off = screening_with(&|s| screening::TablePart { on: false, ..s });
1995        assert_eq!(decided_on(&off, &call(stranger, "10:00")), (false, "screening-off", "stranger".to_string()));
1996        for at in ["18:00", "23:00", "03:00"] {
1997            assert_eq!(decided_on(&off, &call(stranger, at)), (true, "matrix", "stranger".to_string()), "{at}");
1998        }
1999        // Letting such numbers ring: where your contacts ring only.
2000        let ring = screening_with(&|s| screening::TablePart { unknown: screening::Rule::Ring, hidden: screening::Rule::Ring, ..s });
2001        for number in [stranger, ""] {
2002            assert!(!decided_on(&ring, &call(number, "10:00")).0, "{number:?}");
2003            for at in ["18:00", "23:00"] {
2004                assert_eq!(decided_on(&ring, &call(number, at)).1, "matrix", "{number:?} at {at}");
2005            }
2006        }
2007        // A number called lately: as your contacts, no further.
2008        let lately = Incoming { called: ms("2026-10-02T12:00[Europe/Paris]"), ..call(stranger, "10:00") };
2009        assert_eq!(decided_on(&focus_at_work(resting.clone()), &lately), (true, "matrix", "neutral".to_string()));
2010        assert_eq!(decided_on(&off, &Incoming { now: ms("2026-10-05T23:00[Europe/Paris]"), ..lately.clone() }), (true, "matrix", "neutral".to_string()));
2011        // Expecting a call opens beyond them: numbers nobody knows, hidden numbers, numbers called lately.
2012        let expecting = Table { expecting: Some(Through { pressed: 10, on: true, until: 0 }), ..focus_at_work(screening_table()) };
2013        for c in [call(stranger, "10:00"), call("", "10:00"), lately.clone(), call(stranger, "23:00"), call("", "18:00")] {
2014            assert_eq!(decided_on(&expecting, &c).1, "expecting", "{c:?}");
2015        }
2016    }
2017
2018    /// F8: a number called lately keeps the failed-verification check where
2019    /// only Expecting a call would let it ring: a spoofed caller ID of a number
2020    /// you called stays out, as the same spoof from a number nobody knows does.
2021    /// Where your contacts ring, it rings whatever its verification; a machine's
2022    /// root you called exists, you called it.
2023    #[test]
2024    fn a_number_called_lately_keeps_the_failed_verification_check() {
2025        let expecting = Table { expecting: Some(Through { pressed: 10, on: true, until: 0 }), ..screening_table() };
2026        let lately = |number: &str, at: &str, verified: &str| Incoming { called: ms("2026-10-03T12:00[Europe/Paris]"), verified: verified.into(), ..call(number, at) };
2027        assert_eq!(decided_on(&expecting, &lately("01 99 00 55 55", "23:00", "failed")), (true, "unverified", "neutral".to_string()));
2028        assert_eq!(decided_on(&expecting, &Incoming { verified: "failed".into(), ..call("01 99 00 55 55", "23:00") }), (true, "unverified", "stranger".to_string()));
2029        assert_eq!(decided_on(&expecting, &lately("01 99 00 55 55", "23:00", "")), (false, "expecting", "neutral".to_string()));
2030        assert_eq!(decided_on(&expecting, &lately("01 99 00 55 55", "10:00", "failed")), (false, "matrix", "neutral".to_string()));
2031        assert_eq!(decided_on(&screening_table(), &lately("01 99 00 55 55", "23:00", "failed")), (true, "matrix", "neutral".to_string()));
2032        assert_eq!(decided_on(&expecting, &lately("02 61 91 12 34", "23:00", "")), (false, "expecting", "neutral".to_string()));
2033    }
2034
2035    /// F4 (the owner's decision after the review): "only outside work and
2036    /// admin hours" rests for numbers nobody knows and hidden numbers only;
2037    /// automated callers, numbers never shown as a caller's (089) and failed
2038    /// verifications stay screened at all hours, unless set to go as other
2039    /// numbers nobody knows.
2040    #[test]
2041    fn the_hours_rest_for_numbers_nobody_knows_only() {
2042        let resting = screening_with(&|s| screening::TablePart { hours: screening::Hours::OutsideWork, ..s });
2043        let said = |table: &Table, c: &Incoming| decided_on(table, c).1;
2044        assert_eq!(said(&resting, &call("01 99 00 55 55", "10:00")), "work-hours");
2045        assert_eq!(said(&resting, &call("", "10:00")), "work-hours");
2046        assert_eq!(decided_on(&resting, &call("02 61 91 12 34", "10:00")), (true, "automated", "stranger".to_string()));
2047        assert_eq!(decided_on(&resting, &call("08 99 12 34 56", "10:00")), (true, "impossible", "stranger".to_string()));
2048        assert_eq!(decided_on(&resting, &Incoming { verified: "failed".into(), ..call("05 36 49 12 34", "10:00") }), (true, "unverified", "stranger".to_string()));
2049        let lenient = screening_with(&|s| screening::TablePart { hours: screening::Hours::OutsideWork, automated: Spam::Unknown, ..s });
2050        assert_eq!(decided_on(&lenient, &call("02 61 91 12 34", "10:00")), (false, "work-hours", "stranger".to_string()));
2051    }
2052
2053    /// F7 (the owner's decision after the review): a second call within 15
2054    /// minutes rings for the people you know and for numbers nobody knows,
2055    /// never for an automated caller, a failed verification or 089 while their
2056    /// kind goes to voicemail.
2057    #[test]
2058    fn a_second_call_is_no_floor_for_spam() {
2059        let t = screening_table();
2060        let again = |number: &str, verified: &str| Incoming { last_call: ms("2026-10-05T02:55[Europe/Paris]"), verified: verified.into(), ..call(number, "03:00") };
2061        assert_eq!(decided_on(&t, &again("02 61 91 12 34", "")), (true, "automated", "stranger".to_string()));
2062        assert_eq!(decided_on(&t, &again("08 99 12 34 56", "")), (true, "impossible", "stranger".to_string()));
2063        assert_eq!(decided_on(&t, &again("05 36 49 12 34", "failed")), (true, "unverified", "stranger".to_string()));
2064        assert_eq!(decided_on(&t, &again("01 99 00 55 55", "")), (false, "repeat", "stranger".to_string()));
2065        assert_eq!(decided_on(&t, &again("01 99 00 00 02", "failed")), (false, "repeat", "neutral".to_string()), "someone you know");
2066        let lenient = screening_with(&|s| screening::TablePart { automated: Spam::Unknown, ..s });
2067        assert_eq!(decided_on(&lenient, &again("02 61 91 12 34", "")).1, "repeat");
2068        let off = screening_with(&|s| screening::TablePart { on: false, ..s });
2069        assert_eq!(decided_on(&off, &again("02 61 91 12 34", "")).1, "repeat");
2070    }
2071
2072    /// R1 (the re-check of part 2): a number called lately whose
2073    /// verification failed calling a second time is no second call that
2074    /// rings: as your contacts' row says, and while you expect a call kept
2075    /// out; with its verification unsaid, a second call as before.
2076    #[test]
2077    fn a_borrowed_number_called_lately_gets_no_second_call() {
2078        let lately = |at: &str, verified: &str| Incoming { called: ms("2026-10-03T12:00[Europe/Paris]"), last_call: ms(&format!("2026-10-05T{at}[Europe/Paris]")) - 5 * 60_000, verified: verified.into(), ..call("01 99 00 55 55", at) };
2079        let t = screening_table();
2080        assert_eq!(decided_on(&t, &lately("23:00", "failed")), (true, "matrix", "neutral".to_string()));
2081        let expecting = Table { expecting: Some(Through { pressed: 10, on: true, until: 0 }), ..t.clone() };
2082        assert_eq!(decided_on(&expecting, &lately("23:00", "failed")), (true, "unverified", "neutral".to_string()));
2083        assert_eq!(decided_on(&t, &lately("10:00", "failed")), (false, "matrix", "neutral".to_string()), "where your contacts ring");
2084        assert_eq!(decided_on(&t, &lately("23:00", "")), (false, "repeat", "neutral".to_string()));
2085        // A phone contact with a failed verification keeps the second call, as the people you know do.
2086        let contact = Incoming { phone_contact: true, called: 0, ..lately("23:00", "failed") };
2087        assert_eq!(decided_on(&t, &contact).1, "repeat");
2088    }
2089
2090    /// F1: the snapshot of blocks ARCEP had attributed to nobody (a block
2091    /// attributed since may hold a new line) never declines more than a number
2092    /// nobody knows: it rings while you expect a call and when such numbers
2093    /// ring. 089, which the plan never lets be a caller ID, stays out.
2094    #[test]
2095    fn the_snapshot_never_declines_more_than_nobody_knows() {
2096        let snapshot = "03 53 01 12 34";
2097        let numbers = screening::Numbers::built_in();
2098        assert!(numbers.group.iter().any(|g| g.kind == "unattributed" && g.prefixes.iter().any(|p| "0353011234".starts_with(p.as_str()))), "a fiction block attributed to nobody in the snapshot");
2099        let part = |s: screening::Screening| screening::TablePart::of(&s, &numbers, "2026-10-20".parse().unwrap());
2100        let t = Table { screening: Some(part(screening::Screening::default())), ..screening_table() };
2101        assert_eq!(decided_on(&t, &call(snapshot, "10:00")), (true, "screening", "stranger".to_string()));
2102        let expecting = Table { expecting: Some(Through { pressed: 10, on: true, until: 0 }), ..t.clone() };
2103        assert_eq!(decided_on(&expecting, &call(snapshot, "10:00")), (false, "expecting", "stranger".to_string()));
2104        assert_eq!(decided_on(&expecting, &call("08 99 12 34 56", "10:00")), (true, "impossible", "stranger".to_string()));
2105        let ring = Table { screening: Some(part(screening::Screening { unknown: screening::Rule::Ring, ..screening::Screening::default() })), ..screening_table() };
2106        assert_eq!(decided_on(&ring, &call(snapshot, "10:00")), (false, "screening", "stranger".to_string()));
2107    }
2108
2109    #[test]
2110    fn frames_follow_the_day_and_its_change_of_hour() {
2111        use crate::needs::{Days, Needs};
2112        let config: crate::config::Config = toml::from_str(
2113            &["monday", "tuesday", "wednesday", "thursday", "friday"].iter().map(|d| format!("[[window]]\nday = \"{d}\"\nstart = \"09:00\"\nend = \"17:00\"\n\n")).collect::<String>(),
2114        )
2115        .unwrap();
2116        let needs = Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() };
2117        let clock = Clock::new(&config, crate::quiet::Overrides::default(), needs, Days::default(), Vec::new());
2118        let attention = Attention::usual();
2119        let none = Layers::default();
2120        let from = at("2026-10-05T00:00[Europe/Paris]");
2121        let until = at("2026-10-06T00:00[Europe/Paris]");
2122        let frames = frames(&attention, &clock, &none, &from, &until);
2123        // From midnight to midnight, end to end.
2124        assert_eq!(frames.first().unwrap().from, from.timestamp().as_millisecond());
2125        assert_eq!(frames.last().unwrap().until, until.timestamp().as_millisecond());
2126        assert!(frames.windows(2).all(|w| w[0].until == w[1].from), "{frames:?}");
2127        let table = Table { frames, ..table(Made { made: 0, region: fr(), numbers: Vec::new(), prefixes: Vec::new(), always: Vec::new(), own: Vec::new(), expecting: None, screening: Some(screening::TablePart::of(&screening::Screening::default(), &screening::Numbers::default(), from.date())), frames: Vec::new() }) };
2128        let at_ = |time: &str| table.frame_at(ms(&format!("2026-10-05T{time}[Europe/Paris]"))).unwrap();
2129        // The usual Calls matrix: safe all but sleep and the pause; neutral work, admin, leisure and meals; restricted
2130        // work. Numbers nobody knows and hidden numbers are the screening's: no row of theirs in a frame. Work's hours said.
2131        for (time, column, hours, ringing) in [
2132            ("03:00", "sleep", false, vec![]),
2133            ("08:00", "meals", false, vec!["safe", "neutral"]),
2134            ("10:00", "work", true, vec!["safe", "neutral", "restricted"]),
2135            ("12:30", "meals", false, vec!["safe", "neutral"]),
2136            ("14:10", "sleep", false, vec![]),
2137            ("18:00", "leisure", false, vec!["safe", "neutral"]),
2138            ("22:30", "sleep", false, vec![]),
2139        ] {
2140            let f = at_(time);
2141            assert_eq!((f.column.as_str(), f.hours), (column, hours), "{time}");
2142            let rings: Vec<&str> = ["safe", "neutral", "restricted"].into_iter().filter(|r| f.ring[*r]).collect();
2143            assert_eq!(rings, ringing, "{time}");
2144            assert!(f.ring["always-stranger"], "Always through rings at any time: {time}");
2145            assert!(!f.ring.contains_key("stranger") && !f.ring.contains_key("hidden"), "the screening's, apart: {time}");
2146        }
2147        // The layers (Q11): do-not-disturb's switch from 18:00 to 19:00 sends the safe to voicemail, Always through rings;
2148        // a slot of time for you holds no call.
2149        let (dnd_from, dnd_until) = (at("2026-10-05T18:00[Europe/Paris]").timestamp().as_second(), at("2026-10-05T19:00[Europe/Paris]").timestamp().as_second());
2150        let layers = Layers { slots: vec![(dnd_until + 600, dnd_until + 1800)], dnd: Some((dnd_from, Some(dnd_until))), reachable: None };
2151        let table = Table { frames: super::frames(&attention, &clock, &layers, &from, &until), ..table.clone() };
2152        let at_ = |time: &str| table.frame_at(ms(&format!("2026-10-05T{time}[Europe/Paris]"))).unwrap();
2153        assert_eq!((at_("18:30").column.as_str(), at_("18:30").ring["safe"], at_("18:30").ring["always-safe"]), ("dnd", false, true));
2154        assert_eq!((at_("17:30").column.as_str(), at_("17:30").ring["safe"]), ("leisure", true));
2155        assert_eq!((at_("19:15").column.as_str(), at_("19:15").ring["safe"]), ("slot", true));
2156        // A layer over work keeps work's hours: the screening's hours are the time's, never do-not-disturb's.
2157        let morning = Layers { slots: Vec::new(), dnd: Some((at("2026-10-05T10:00[Europe/Paris]").timestamp().as_second(), None)), reachable: None };
2158        let table_dnd = Table { frames: super::frames(&attention, &clock, &morning, &from, &until), ..table.clone() };
2159        let f = table_dnd.frame_at(ms("2026-10-05T10:30[Europe/Paris]")).unwrap();
2160        assert_eq!((f.column.as_str(), f.hours, f.ring["safe"]), ("dnd", true, false));
2161        // Reachable (part 3) from 21:00 to waking: the people you know ring, at night too, a frame cut at its edges.
2162        let evening = Layers { reachable: Some((at("2026-10-05T21:00[Europe/Paris]").timestamp().as_second(), Some(at("2026-10-06T07:00[Europe/Paris]").timestamp().as_second()))), ..Layers::default() };
2163        let table_open = Table { frames: super::frames(&attention, &clock, &evening, &from, &until), ..table.clone() };
2164        let f = table_open.frame_at(ms("2026-10-05T23:30[Europe/Paris]")).unwrap();
2165        assert_eq!(f.column, "reachable");
2166        assert!(["safe", "neutral", "restricted"].iter().all(|row| f.ring[*row]), "{f:?}");
2167        assert_eq!(table_open.frame_at(ms("2026-10-05T20:30[Europe/Paris]")).unwrap().column, "leisure");
2168        // Free time as cells: the safe ring in it, the others not; "Nothing at all", only Always through.
2169        let free = crate::quiet::Overrides { free_since: Some(at("2026-10-05T10:00[Europe/Paris]").timestamp().as_second()), free_nothing: Some(true), ..crate::quiet::Overrides::default() };
2170        let clock_free = Clock::new(&config, free, Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() }, Days::default(), Vec::new());
2171        let table = Table { frames: super::frames(&attention, &clock_free, &none, &from, &until), ..table.clone() };
2172        let f = table.frame_at(ms("2026-10-05T11:00[Europe/Paris]")).unwrap();
2173        assert_eq!((f.column.as_str(), f.ring["safe"], f.ring["always-neutral"]), ("free", false, true));
2174        // The night the clocks go back (25 October 2026): no gap, no overlap, 25 hours that day.
2175        let from = at("2026-10-25T00:00[Europe/Paris]");
2176        let until = at("2026-10-26T00:00[Europe/Paris]");
2177        let frames = super::frames(&attention, &clock, &none, &from, &until);
2178        assert!(frames.windows(2).all(|w| w[0].until == w[1].from));
2179        assert_eq!(frames.last().unwrap().until - frames.first().unwrap().from, 25 * 3_600_000);
2180        // In the pause: the pause's column to the end, nobody ringing.
2181        let paused = crate::quiet::Overrides { paused_since: Some(from.timestamp().as_second()), ..crate::quiet::Overrides::default() };
2182        let clock = Clock::new(&config, paused, Needs::default(), Days::default(), Vec::new());
2183        let frames = super::frames(&attention, &clock, &none, &from, &until);
2184        assert!(frames.iter().all(|f| f.column == "pause" && f.ring.iter().all(|(row, r)| *r == row.starts_with("always-"))), "{frames:?}");
2185        assert_eq!(frames.last().unwrap().until, until.timestamp().as_millisecond());
2186    }
2187
2188    #[test]
2189    fn numbers_shown_as_people_write_them() {
2190        assert_eq!(shown_number("+33199001234", fr()), "01 99 00 12 34");
2191        assert_eq!(shown_number("+262692123456", fr()), "06 92 12 34 56");
2192        assert_eq!(shown_number("+442079460018", fr()), "+442079460018");
2193        assert_eq!(shown_number("112", fr()), "112");
2194        assert_eq!(shown_number("+442079460018", phones::region_named("GB")), "02079460018");
2195        assert_eq!(shown_number("+33199001234", None), "+33199001234");
2196    }
2197
2198    #[test]
2199    fn the_floors_hold_emergency_numbers_and_the_list_s_people() {
2200        let keys = emergency_keys(fr());
2201        for number in ["112", "15", "114", "3114", "+33800112112", "999", "911", "988"] {
2202            assert!(keys.iter().any(|k| k == number), "{number} in {keys:?}");
2203        }
2204        let mut people = People::default();
2205        people.add(Person { name: "Alice".into(), phones: vec!["01 99 00 00 01".into(), "3114".into()], ..Person::default() }, fr());
2206        assert_eq!(people_keys(&people, fr()), vec!["+33199000001".to_string()], "a short code is nobody's");
2207        assert_eq!(always_numbers(&people, fr(), &|_| Who::Neutral), vec![("+33199000001".to_string(), "always-neutral".to_string())]);
2208        assert!(always_numbers(&people, fr(), &|_| Who::Blocked).is_empty(), "blocked beats Always through");
2209        // 0639 is Mayotte's plan: its own country code, as phones reads it.
2210        assert_eq!(phones::key("06 39 98 00 01", fr()), "+262639980001");
2211    }
2212
2213    /// The rows a frame of version 1 said, as an older Sioul wrote them.
2214    const ROWS_V1: [&str; 9] = ["safe", "neutral", "restricted", "stranger", "hidden", "always-safe", "always-neutral", "always-restricted", "always-stranger"];
2215
2216    /// A frame with version 1's rows (version 2 reads its known rows alone), work's hours by its column.
2217    fn frame(from: i64, until: i64, column: &str, ringing: &[&str]) -> Frame {
2218        Frame { from, until, column: column.into(), hours: matches!(column, "work" | "admin"), ring: ROWS_V1.iter().map(|r| (r.to_string(), ringing.contains(r))).collect() }
2219    }
2220
2221    /// Decision 16 of 18 October 2026: a person's own row decides their
2222    /// calls before their list's, by each of their numbers ("own-0"), never
2223    /// for a blocked number nor one on Always through; the frames say it as
2224    /// the matrix reads it (`frames_with`). No name in the table.
2225    #[test]
2226    fn a_person_s_own_row_rings_before_their_list() {
2227        use crate::needs::{Days, Needs};
2228        let config: crate::config::Config = toml::from_str(&["monday", "tuesday", "wednesday", "thursday", "friday"].iter().map(|d| format!("[[window]]\nday = \"{d}\"\nstart = \"09:00\"\nend = \"17:00\"\n\n")).collect::<String>()).unwrap();
2229        let clock = Clock::new(&config, crate::quiet::Overrides::default(), Needs { meals_on: true, sleep_on: true, ..Needs::default() }, Days::default(), Vec::new());
2230        let mut attention = Attention::usual();
2231        let id = attention::person_row("contact:card-2");
2232        attention.set_source(&id, attention::Column::Work, Level::Later).unwrap();
2233        let own = [OwnRow { row: "own-0".into(), ids: vec![id.clone()], who: Who::Neutral }];
2234        let frames = frames_with(&attention, &clock, &Layers::default(), &at("2026-10-05T00:00[Europe/Paris]"), &at("2026-10-06T00:00[Europe/Paris]"), &own);
2235        let work = frames.iter().find(|f| f.column == "work").unwrap();
2236        assert_eq!((work.ring["neutral"], work.ring["own-0"]), (true, false), "their row holds them at work, their list would ring");
2237        // Reachable over the work morning: they ring, their own row as their list (review of part 6, F1).
2238        let morning = (at("2026-10-05T09:00[Europe/Paris]").timestamp().as_second(), Some(at("2026-10-05T12:00[Europe/Paris]").timestamp().as_second()));
2239        let reachable = frames_with(&attention, &clock, &Layers { reachable: Some(morning), ..Layers::default() }, &at("2026-10-05T00:00[Europe/Paris]"), &at("2026-10-06T00:00[Europe/Paris]"), &own);
2240        let lifted = reachable.iter().find(|f| f.column == "reachable").unwrap();
2241        assert!(lifted.ring["own-0"] && lifted.ring["neutral"], "{lifted:?}");
2242        let mut people = People::default();
2243        people.add(Person { name: "Alice".into(), phones: vec!["01 99 00 00 01".into()], ..Person::default() }, fr());
2244        let table = table(Made {
2245            made: 0,
2246            region: fr(),
2247            numbers: vec![("+33199000002".into(), "neutral".into()), ("+33199000004".into(), "blocked".into()), ("+33199000001".into(), "safe".into())],
2248            prefixes: Vec::new(),
2249            always: always_numbers(&people, fr(), &|_| Who::Safe),
2250            own: vec![("+33199000002".into(), "own-0".into()), ("+33199000004".into(), "own-0".into()), ("+33199000001".into(), "own-0".into())],
2251            expecting: None,
2252            screening: Some(screening::TablePart::of(&screening::Screening::default(), &screening::Numbers::built_in(), "2026-10-05".parse().unwrap())),
2253            frames: frames.clone(),
2254        });
2255        assert_eq!(table.own.keys().collect::<Vec<_>>(), ["+33199000002"], "never a blocked number, nor one on Always through");
2256        let call = |number: &str| decide(Some(&table), &Incoming { number: number.into(), now: work.from + 1, ..Incoming::default() });
2257        let held = call("01 99 00 00 02");
2258        assert!(held.refuse && held.why == "own", "{held:?}");
2259        assert!(call("01 99 00 00 04").refuse && call("01 99 00 00 04").why == "blocked");
2260        // Their list's row in a frame that has no row of theirs (an older table).
2261        let older = Table { frames: frames.iter().map(|f| Frame { ring: f.ring.iter().filter(|(k, _)| !k.starts_with("own-")).map(|(k, v)| (k.clone(), *v)).collect(), ..f.clone() }).collect(), ..table.clone() };
2262        assert!(!decide(Some(&older), &Incoming { number: "01 99 00 00 02".into(), now: work.from + 1, ..Incoming::default() }).refuse);
2263        assert!(!serde_json::to_string(&table).unwrap().contains("card-2"), "no card, no name in the table");
2264    }
2265
2266    /// Review of part 6, F2 to F4: the table's own rows number by number, as
2267    /// a text from that number reads them: a card's numbers each judged by
2268    /// their own list; a blocked one dropping only itself; two cards, or a
2269    /// card and a number's row, on one number read in the listener's order;
2270    /// the same table whatever the order things were read in.
2271    #[test]
2272    fn the_own_rows_are_made_number_by_number() {
2273        use crate::porch::{Book, SenderList, Senders};
2274        let card = |uid: &str, phones: &[&str]| crate::contacts::Contact { uid: uid.into(), key: format!("/books/{uid}.vcf"), name: uid.into(), phones: phones.iter().map(|p| crate::contacts::Labeled { label: String::new(), value: p.to_string() }).collect(), ..Default::default() };
2275        let contacts = vec![card("a", &["01 99 00 00 21", "01 99 00 00 22", "01 99 00 00 23"]), card("b", &["01 99 00 00 23"]), card("c", &["01 99 00 00 24"])];
2276        let senders = Senders {
2277            safe: SenderList::parse("tel:+33199000021"),
2278            restricted: SenderList::parse("tel:+33199000022"),
2279            blocked: SenderList::parse("tel:+33199000024"),
2280            contacts: std::sync::Arc::new(Book::of(&contacts, fr())),
2281            ..Senders::default()
2282        };
2283        let mut attention = Attention::usual();
2284        for id in ["person.contact:a", "person.contact:b", "person.contact:c", "person.tel:+33199000023"] {
2285            attention.set_source(id, attention::Column::Work, Level::Later).unwrap();
2286        }
2287        let (numbers, rows) = own_rows(&attention, &senders);
2288        let row_of = |key: &str| numbers.iter().find(|(k, _)| k == key).map(|(_, r)| rows.iter().find(|o| &o.row == r).unwrap().clone());
2289        let safe = row_of("+33199000021").unwrap();
2290        let restricted = row_of("+33199000022").unwrap();
2291        assert_eq!((safe.who, restricted.who), (Who::Safe, Who::Restricted), "each number by its own list");
2292        assert_ne!(safe.row, restricted.row);
2293        assert_eq!(row_of("+33199000023").unwrap().ids, ["person.contact:a", "person.contact:b", "person.tel:+33199000023"], "the listener's order: the cards', then the number's");
2294        assert!(row_of("+33199000024").is_none(), "a blocked number drops itself alone");
2295        assert_eq!(own_rows(&attention, &senders), (numbers, rows), "the same table each time");
2296    }
2297
2298    #[test]
2299    fn the_table_says_what_java_needs_and_nothing_twice() {
2300        let mut people = People::default();
2301        people.add(Person { name: "Alice".into(), phones: vec!["01 99 00 00 01".into()], ..Person::default() }, fr());
2302        let made = |at: i64| Made {
2303            made: at,
2304            region: fr(),
2305            numbers: vec![("+262639980002".into(), "safe".into()), ("+33199001234".into(), "blocked".into()), ("+33199009999".into(), "stranger".into()), (String::new(), "safe".into())],
2306            prefixes: vec![("+33465711*".into(), "blocked".into()), ("+3346571*".into(), "safe".into()), ("+334657112*".into(), "neutral".into()), ("*".into(), "blocked".into())],
2307            always: always_numbers(&people, fr(), &|_| Who::Safe),
2308            own: Vec::new(),
2309            expecting: Some(Through { pressed: 5, on: true, until: 0 }),
2310            screening: Some(screening::TablePart::of(&screening::Screening::default(), &screening::Numbers::built_in(), "2026-10-20".parse().unwrap())),
2311            frames: vec![frame(0, 1_000, "work", &["safe", "neutral", "restricted", "hidden"]), frame(1_000, 2_000, "sleep", &[])],
2312        };
2313        let table = table(made(42));
2314        assert_eq!(table.v, VERSION);
2315        assert_eq!(table.numbers.len(), 3, "strangers and empty keys are not written: {:?}", table.numbers);
2316        assert_eq!((table.numbers["+33199001234"].as_str(), table.numbers["+33199000001"].as_str()), ("blocked", "always-safe"));
2317        // Prefixes without their star, the longest first; a bare star is nothing.
2318        assert_eq!(table.prefixes.iter().map(|p| (p.prefix.as_str(), p.who.as_str())).collect::<Vec<_>>(), [("+334657112", "neutral"), ("+33465711", "blocked"), ("+3346571", "safe")]);
2319        assert!(table.floors.people.is_empty());
2320        assert!(table.floors.emergency.contains(&"+33800112112".to_string()));
2321        assert_eq!(table.region.as_ref().map(|r| (r.calling.as_str(), r.trunk.as_str(), r.digits)), Some(("33", "0", [10, 10])));
2322        assert_eq!(table.region.as_ref().unwrap().overseas.get("692").map(String::as_str), Some("262"));
2323        assert!(table.trunk_zero.contains(&"33".to_string()) && !table.trunk_zero.contains(&"39".to_string()));
2324        assert_eq!((table.repeat_minutes, table.emergency_hours, table.phone_contacts.as_str()), (15, 24, "neutral"));
2325        // Made at another time but saying the same: the same content, not written again.
2326        assert_eq!(content(&table), content(&self::table(made(43))));
2327        // As Java reads it: the keys it looks for.
2328        let json: serde_json::Value = serde_json::from_str(&serde_json::to_string(&table).unwrap()).unwrap();
2329        for key in ["v", "made", "region", "trunk_zero", "numbers", "prefixes", "phone_contacts", "floors", "expecting", "screening", "frames", "repeat_minutes", "emergency_hours"] {
2330            assert!(json.get(key).is_some(), "{key}");
2331        }
2332        assert!(json.get("through").is_none(), "version 2 says Expecting a call");
2333        assert_eq!((json["frames"][0]["hours"].as_bool(), json["frames"][1]["hours"].as_bool()), (Some(true), Some(false)));
2334        assert_eq!(json["frames"][1]["column"], "sleep");
2335        assert_eq!(json["expecting"]["on"], true);
2336        let s = &json["screening"];
2337        assert_eq!((s["on"].as_bool(), s["hours"].as_str(), s["unknown"].as_str(), s["hidden"].as_str(), s["automated"].as_str(), s["called_days"].as_i64()), (Some(true), Some("always"), Some("voicemail"), Some("work"), Some("voicemail"), Some(14)));
2338        assert!(s["automated_prefixes"].as_array().unwrap().iter().any(|p| p == "0162") && s["impossible_prefixes"].as_array().unwrap().iter().any(|p| p == "089"));
2339        // An older Sioul's table: version 1, Let every call through.
2340        let older = self::table(Made { screening: None, ..made(42) });
2341        assert_eq!((older.v, older.through.is_some(), older.expecting.is_none()), (1, true, true));
2342        // Written whole, read back the same.
2343        let dir = std::env::temp_dir().join(format!("sioul-calls-table-{}", std::process::id()));
2344        let path = dir.join(TABLE);
2345        write(&path, &table).unwrap();
2346        let back: Table = read_json(&path).unwrap();
2347        assert_eq!(back, table);
2348        let _ = std::fs::remove_dir_all(&dir);
2349    }
2350
2351    #[test]
2352    fn the_latest_press_anywhere_lets_calls_through() {
2353        let mut switch = Switch::default();
2354        assert_eq!(through_of(&switch), None);
2355        // The computer: every call for an hour.
2356        let stamp = press(&mut switch, "desk", true, 1_000 + 3_600_000, 1_000);
2357        assert_eq!(stamp, 1_000);
2358        let t = through_of(&switch).unwrap();
2359        assert!(t.holds(2_000) && !t.holds(1_000 + 3_600_000));
2360        // The phone's clock is behind: its "off" still comes after the press it knew of.
2361        let stamp = press(&mut switch, "phone", false, 0, 500);
2362        assert_eq!(stamp, 1_001);
2363        assert!(!through_of(&switch).unwrap().holds(2_000));
2364        // Until turned off.
2365        press(&mut switch, "desk", true, 0, 9_000);
2366        assert!(through_of(&switch).unwrap().holds(i64::MAX - 1));
2367        // The DND part of the device's table is untouched; the calls part reads back.
2368        assert!(switch.device["desk"].pressed == 0 && !switch.device["desk"].on);
2369        let text = toml::to_string(&switch).unwrap();
2370        assert!(text.contains("[device.desk.calls]"), "{text}");
2371        let back: Switch = toml::from_str(&text).unwrap();
2372        assert_eq!(through_of(&back), through_of(&switch));
2373    }
2374
2375    #[test]
2376    fn the_phone_s_own_press_is_carried_once_with_its_own_time() {
2377        let mut switch = Switch::default();
2378        // The phone's notification pressed at 10:00; the computer turned it off at 10:30.
2379        let local = Through { pressed: 36_000_000, on: true, until: 36_000_000 + 3_600_000 };
2380        press(&mut switch, "desk", false, 0, 37_800_000);
2381        assert!(merge_local(&mut switch, "phone", &local));
2382        assert!(!merge_local(&mut switch, "phone", &local), "once");
2383        // Carried with its own time: the later "off" still holds.
2384        let held = through_of(&switch).unwrap();
2385        assert!(!held.on && held.pressed == 37_800_000);
2386        // A press after it, from the phone again: it holds.
2387        let later = Through { pressed: 38_000_000, on: true, until: 0 };
2388        assert!(merge_local(&mut switch, "phone", &later));
2389        assert!(through_of(&switch).unwrap().holds(40_000_000));
2390        // Which device screens: said in its part.
2391        assert!(!screens_anywhere(&switch));
2392        assert!(set_screens(&mut switch, "phone", true) && !set_screens(&mut switch, "phone", true));
2393        assert!(screens_anywhere(&switch));
2394    }
2395
2396    fn held(at: i64, key: &str, who: &str, column: &str) -> Held {
2397        Held { at, key: key.into(), number: key.into(), hidden: key.is_empty(), who: if key.is_empty() { "hidden".into() } else { who.into() }, column: column.into(), why: "matrix".into(), ..Held::default() }
2398    }
2399
2400    fn at(text: &str) -> Zoned {
2401        text.parse().unwrap()
2402    }
2403
2404    fn ms(text: &str) -> i64 {
2405        at(text).timestamp().as_millisecond()
2406    }
2407
2408    #[test]
2409    fn the_list_says_who_called_when_calmly() {
2410        let now = at("2026-10-07T10:00[Europe/Paris]");
2411        let calls = vec![
2412            held(ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep"),
2413            held(ms("2026-10-07T06:21[Europe/Paris]"), "+33199001234", "stranger", "sleep"),
2414            held(ms("2026-10-07T07:00[Europe/Paris]"), "", "hidden", "sleep"),
2415            held(ms("2026-10-06T23:10[Europe/Paris]"), "+262639980002", "neutral", "sleep"),
2416            held(ms("2026-10-07T09:30[Europe/Paris]"), "+33199005555", "blocked", "work"),
2417            held(ms("2026-10-05T12:30[Europe/Paris]"), "+33199007777", "stranger", "meals"),
2418            held(ms("2026-10-05T12:31[Europe/Paris]"), "+33199007777", "stranger", "meals"),
2419            held(ms("2026-10-05T12:40[Europe/Paris]"), "+33199007777", "stranger", "meals"),
2420        ];
2421        let names = |key: &str| (key == "+262639980002").then(|| "Dr Martin's office".to_string());
2422        let all = |_: &Held| true;
2423        let here = |_: &str| String::new();
2424        for (language, expected) in [
2425            (
2426                "en",
2427                [
2428                    "Monday 5 October, during a meal, a number not in your contacts called three times, the last at 12:40.",
2429                    "Yesterday, while you slept, Dr Martin's office called at 23:10.",
2430                    "While you slept, a number not in your contacts called twice, at 06:10 and 06:21.",
2431                    "While you slept, a hidden number called at 07:00.",
2432                ],
2433            ),
2434            (
2435                "fr",
2436                [
2437                    "Lundi 5 octobre, pendant un repas, un numéro absent de vos contacts a appelé trois fois, la dernière à 12:40.",
2438                    "Hier, pendant votre sommeil, Dr Martin's office a appelé à 23:10.",
2439                    "Pendant votre sommeil, un numéro absent de vos contacts a appelé deux fois, à 06:10 et à 06:21.",
2440                    "Pendant votre sommeil, un numéro masqué a appelé à 07:00.",
2441                ],
2442            ),
2443        ] {
2444            let tr = Translator::new(language);
2445            let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &names, shows: &all, phone: &here };
2446            let lines = lines(&calls, &Seen::default(), &BTreeMap::new(), &l);
2447            assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), expected, "{language}");
2448            // Never the blocked.
2449            assert!(lines.iter().all(|l| !l.dial.contains("5555")));
2450            // The doubt said, never "no message".
2451            assert!(lines.iter().all(|l| !l.doubt.is_empty()));
2452        }
2453        let tr = Translator::new("en");
2454        let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &names, shows: &all, phone: &here };
2455        let lines_en = lines(&calls, &Seen::default(), &BTreeMap::new(), &l);
2456        let stranger = &lines_en[2];
2457        assert_eq!((stranger.number.as_str(), stranger.dial.as_str(), stranger.known, stranger.hidden), ("01 99 00 12 34", "+33199001234", false, false));
2458        assert_eq!(stranger.doubt, "They may have left a message.");
2459        assert_eq!(stranger.why, "Numbers not in your contacts go to voicemail while you slept.");
2460        assert_eq!(stranger.ids.len(), 2);
2461        let hidden = &lines_en[3];
2462        assert!(hidden.hidden && hidden.number.is_empty() && hidden.dial.is_empty());
2463        assert!(lines_en[1].known);
2464        assert_eq!(lines_en[1].why, "Calls from your contacts go to voicemail while you slept.");
2465        // Someone Always through, declined (their row as their own, which held them then): said as their own row.
2466        let always = [held(ms("2026-10-07T06:40[Europe/Paris]"), "+262639980002", "always-neutral", "sleep")];
2467        assert_eq!(lines(&always, &Seen::default(), &BTreeMap::new(), &l)[0].why, "Calls from your contacts go to voicemail while you slept.");
2468        // Seen: gone; the others stay.
2469        let mut seen = Seen::default();
2470        seen.add(&stranger.ids, now.timestamp().as_millisecond());
2471        assert_eq!(lines(&calls, &seen, &BTreeMap::new(), &l).len(), 3);
2472        // Shown only when their callers may reach you now.
2473        let contacts_only = |h: &Held| h.who == "neutral";
2474        let l2 = Lister { shows: &contacts_only, ..l };
2475        assert_eq!(lines(&calls, &Seen::default(), &BTreeMap::new(), &l2).len(), 1);
2476        // A voicemail linked: said, and no doubt.
2477        let mut messages = BTreeMap::new();
2478        messages.insert(stranger.ids[1].clone(), Message { text: message_words(&tr, Some(42)), path: "/mail/1".into(), sound: Some(0) });
2479        let with = lines(&calls, &Seen::default(), &messages, &l);
2480        assert_eq!(with[2].message.as_ref().map(|m| m.text.as_str()), Some("They left a message (0:42)."));
2481        assert!(with[2].doubt.is_empty());
2482    }
2483
2484    #[test]
2485    fn old_calls_leave_the_list_and_seen_ids_are_forgotten_with_them() {
2486        let now = at("2026-10-30T10:00[Europe/Paris]");
2487        let calls = vec![held(ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep")];
2488        let tr = Translator::new("en");
2489        let all = |_: &Held| true;
2490        let none = |_: &str| None;
2491        let here = |_: &str| String::new();
2492        let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &here };
2493        assert!(lines(&calls, &Seen::default(), &BTreeMap::new(), &l).is_empty(), "past two weeks");
2494        let mut seen = Seen::default();
2495        seen.add(&["1000-+33199001234".to_string(), format!("{}-x", now.timestamp().as_millisecond())], now.timestamp().as_millisecond());
2496        assert_eq!(seen.seen.len(), 1);
2497    }
2498
2499    #[test]
2500    fn the_held_file_reads_whatever_java_left_in_it() {
2501        let dir = std::env::temp_dir().join(format!("sioul-calls-held-{}", std::process::id()));
2502        let _ = std::fs::remove_dir_all(&dir);
2503        std::fs::create_dir_all(&dir).unwrap();
2504        let path = dir.join(HELD);
2505        std::fs::write(&path, "{\"at\":2000,\"key\":\"+33199001234\",\"number\":\"0199001234\",\"hidden\":false,\"presentation\":1,\"who\":\"stranger\",\"name\":\"\",\"column\":\"sleep\",\"why\":\"matrix\",\"verified\":\"\",\"later\":1}\n{\"at\":1000,\"key\":\"\",\"hidden\":true,\"who\":\"hidden\"}\n{\"at\":30").unwrap();
2506        let held = read_held(&path);
2507        assert_eq!(held.iter().map(|h| h.at).collect::<Vec<_>>(), vec![1000, 2000], "oldest first, the half line passed over");
2508        assert_eq!(held[0].id(), "1000-hidden");
2509        assert_eq!(held[1].id(), "2000-+33199001234");
2510        let _ = std::fs::remove_dir_all(&dir);
2511    }
2512
2513    fn scratch(name: &str) -> PathBuf {
2514        let dir = std::env::temp_dir().join(format!("sioul-calls-{name}-{}", std::process::id()));
2515        let _ = std::fs::remove_dir_all(&dir);
2516        std::fs::create_dir_all(&dir).unwrap();
2517        dir
2518    }
2519
2520    /// A call that rang, or was declined, on a phone (`device`).
2521    fn call_on(device: &str, at: i64, key: &str, who: &str, column: &str, why: &str, rang: bool) -> Held {
2522        Held { device: device.into(), rang, why: why.into(), ..held(at, key, who, column) }
2523    }
2524
2525    /// The line a phone's log keeps: what Java wrote, less the number as the
2526    /// network gave it and its presentation, every empty field left out; a
2527    /// line of Java's from before calls that rang were logged was declined.
2528    #[test]
2529    fn a_logged_line_reads_back() {
2530        let java: Held = serde_json::from_str(r#"{"at":1791346200000,"key":"+33199001234","number":"01 99 00 12 34","hidden":false,"presentation":1,"who":"stranger","name":"","column":"sleep","why":"matrix","verified":""}"#).unwrap();
2531        assert!(!java.rang, "declined, as every line was before");
2532        let logged = java.logged(fr());
2533        assert_eq!(serde_json::to_string(&logged).unwrap(), r#"{"at":1791346200000,"key":"+33199001234","who":"stranger","column":"sleep","why":"matrix","rang":false}"#);
2534        assert_eq!(logged.id(), java.id());
2535        // Java had no table to key it with: keyed as the country writes numbers.
2536        let unkeyed = Held { key: String::new(), why: "no-table".into(), rang: true, ..java.clone() };
2537        assert_eq!(unkeyed.logged(fr()).key, "+33199001234");
2538        // A hidden number keeps no key; a name and a verification are kept.
2539        let hidden = Held { at: 5, hidden: true, who: "hidden".into(), why: "matrix".into(), ..Held::default() }.logged(fr());
2540        assert_eq!(serde_json::to_string(&hidden).unwrap(), r#"{"at":5,"hidden":true,"who":"hidden","why":"matrix","rang":false}"#);
2541        let named = Held { name: "Cabinet du Dr Martin".into(), verified: "passed".into(), ..java }.logged(fr());
2542        let back: Held = serde_json::from_str(&serde_json::to_string(&named).unwrap()).unwrap();
2543        assert_eq!((back.name.as_str(), back.verified.as_str(), back.number.as_str()), ("Cabinet du Dr Martin", "passed", ""));
2544    }
2545
2546    /// The lines Java writes (android/jvm-checks/LogCheck.java, where
2547    /// SIOUL_CALLS_SAMPLE says), read as Java's file is read here: declined; a second call that
2548    /// rang, its time of day said; hidden; no table, keyed here. Skipped
2549    /// without the sample.
2550    #[test]
2551    fn java_s_lines_read_here() {
2552        let Some(sample) = std::env::var_os("SIOUL_CALLS_SAMPLE") else { return };
2553        let calls = read_held(Path::new(&sample));
2554        assert_eq!(
2555            calls.iter().map(|h| (h.rang, h.why.as_str(), h.column.as_str(), h.hidden)).collect::<Vec<_>>(),
2556            [(false, "matrix", "sleep", false), (true, "repeat", "sleep", false), (false, "matrix", "sleep", true), (true, "no-table", "", false), (false, "screening", "work", false), (true, "matrix", "work", false)]
2557        );
2558        assert_eq!((calls[0].key.as_str(), calls[0].who.as_str()), ("+33199001234", "stranger"));
2559        // A table of version 2's: the screening's refusal, and a number called lately said so.
2560        assert_eq!((calls[4].who.as_str(), calls[4].called, calls[5].who.as_str(), calls[5].called), ("stranger", false, "neutral", true));
2561        assert_eq!(calls[3].logged(fr()).key, "+33465710042");
2562        assert_eq!(calls[0].logged(fr()).number, "", "the number as given stays on the phone");
2563    }
2564
2565    /// Every phone's log read together, each call with its phone, oldest
2566    /// first; never a blocked caller's, never one past the month.
2567    #[test]
2568    fn every_phone_s_log_is_read() {
2569        let root = scratch("logs");
2570        let now = ms("2026-10-07T10:00[Europe/Paris]");
2571        let write = |device: &str, lines: &[Held]| {
2572            let text: String = lines.iter().map(|h| serde_json::to_string(&h.logged(fr())).unwrap() + "\n").collect();
2573            std::fs::create_dir_all(root.join(LOG)).unwrap();
2574            std::fs::write(own_file(&root, LOG, device), text).unwrap();
2575        };
2576        write("phone-a", &[held(ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep"), held(ms("2026-10-07T09:00[Europe/Paris]"), "+33199005555", "blocked", "work")]);
2577        write("phone-b", &[held(ms("2026-10-07T06:00[Europe/Paris]"), "+33465710042", "safe", "sleep"), held(ms("2026-09-01T09:00[Europe/Paris]"), "+33465710042", "safe", "work")]);
2578        std::fs::write(root.join(LOG).join(".phone-c.jsonl.lock"), "").unwrap();
2579        let calls = read_logs(&root, now);
2580        assert_eq!(calls.iter().map(|h| (h.device.as_str(), h.key.as_str())).collect::<Vec<_>>(), [("phone-b", "+33465710042"), ("phone-a", "+33199001234")]);
2581        let _ = std::fs::remove_dir_all(&root);
2582    }
2583
2584    /// On the phone that screened, a line says "called at"; on another
2585    /// device, which phone: "your phone", "your other phone" on a phone, or
2586    /// its name when several phones share their calls.
2587    #[test]
2588    fn the_list_names_the_phone_elsewhere() {
2589        let now = at("2026-10-07T10:00[Europe/Paris]");
2590        let calls = vec![call_on("phone-a", ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep", "matrix", false)];
2591        let none = |_: &str| None;
2592        let all = |_: &Held| true;
2593        let one = [("phone-a".to_string(), "A12".to_string())];
2594        let two = [("phone-a".to_string(), "A12".to_string()), ("phone-b".to_string(), "B7".to_string())];
2595        for (language, own, computer, other, named) in [
2596            (
2597                "en",
2598                "While you slept, a number not in your contacts called at 06:10.",
2599                "While you slept, a number not in your contacts called your phone at 06:10.",
2600                "While you slept, a number not in your contacts called your other phone at 06:10.",
2601                "While you slept, a number not in your contacts called your phone (A12) at 06:10.",
2602            ),
2603            (
2604                "fr",
2605                "Pendant votre sommeil, un numéro absent de vos contacts a appelé à 06:10.",
2606                "Pendant votre sommeil, un numéro absent de vos contacts a appelé votre téléphone à 06:10.",
2607                "Pendant votre sommeil, un numéro absent de vos contacts a appelé votre autre téléphone à 06:10.",
2608                "Pendant votre sommeil, un numéro absent de vos contacts a appelé votre téléphone (A12) à 06:10.",
2609            ),
2610        ] {
2611            let tr = Translator::new(language);
2612            let said = |here: &str, phones: &[(String, String)], phone: bool| {
2613                let words = |device: &str| phone_words(&tr, device, here, phones, phone);
2614                let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &words };
2615                lines(&calls, &Seen::default(), &BTreeMap::new(), &l)[0].text.clone()
2616            };
2617            assert_eq!(said("phone-a", &one, true), own, "{language}: on the phone that screened");
2618            assert_eq!(said("desk", &one, false), computer, "{language}: on a computer");
2619            assert_eq!(said("desk", &[], false), computer, "{language}: without the devices' list");
2620            assert_eq!(said("phone-b", &two, true), other, "{language}: on the other phone");
2621            assert_eq!(said("desk", &two, false), named, "{language}: two phones, from a computer");
2622        }
2623    }
2624
2625    /// A call that rang is never a line of its own; after declined calls of
2626    /// the same number the same day, it is said under their line; never for
2627    /// a hidden number. A message left after it goes with the line.
2628    #[test]
2629    fn a_call_that_rang_is_never_listed_but_said() {
2630        let now = at("2026-10-07T10:00[Europe/Paris]");
2631        let calls = vec![
2632            call_on("phone-a", ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep", "matrix", false),
2633            call_on("phone-a", ms("2026-10-07T06:40[Europe/Paris]"), "+33199001234", "stranger", "sleep", "matrix", false),
2634            call_on("phone-a", ms("2026-10-07T06:48[Europe/Paris]"), "+33199001234", "stranger", "sleep", "repeat", true),
2635            call_on("phone-a", ms("2026-10-07T07:00[Europe/Paris]"), "", "hidden", "sleep", "matrix", false),
2636            call_on("phone-a", ms("2026-10-07T07:05[Europe/Paris]"), "", "hidden", "sleep", "repeat", true),
2637            call_on("phone-a", ms("2026-10-07T09:15[Europe/Paris]"), "+33465710042", "safe", "work", "matrix", true),
2638        ];
2639        let none = |_: &str| None;
2640        let all = |_: &Held| true;
2641        let here = |_: &str| String::new();
2642        for (language, first, again) in [
2643            ("en", "While you slept, a number not in your contacts called twice, at 06:10 and 06:40.", "The same number called again at 06:48, and that call rang."),
2644            ("fr", "Pendant votre sommeil, un numéro absent de vos contacts a appelé deux fois, à 06:10 et à 06:40.", "Le même numéro a rappelé à 06:48, et cet appel a sonné."),
2645        ] {
2646            let tr = Translator::new(language);
2647            let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &here };
2648            let said = lines(&calls, &Seen::default(), &BTreeMap::new(), &l);
2649            assert_eq!(said.len(), 2, "{language}: the stranger's and the hidden number's; never a call that rang");
2650            assert_eq!((said[0].text.as_str(), said[0].again.as_str()), (first, again), "{language}");
2651            assert_eq!(said[1].again, "", "{language}: two hidden calls cannot be told apart");
2652        }
2653        // The message left after the call that rang goes with the line.
2654        let tr = Translator::new("en");
2655        let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &here };
2656        let mut messages = BTreeMap::new();
2657        messages.insert(calls[2].id(), Message { text: message_words(&tr, Some(12)), path: "/mail/2".into(), sound: Some(0) });
2658        let said = lines(&calls, &Seen::default(), &messages, &l);
2659        assert_eq!((said[0].message.as_ref().map(|m| m.text.as_str()), said[0].doubt.as_str()), (Some("They left a message (0:12)."), ""));
2660        // The next day's call that rang is no longer said under that line.
2661        let later = [calls[0].clone(), call_on("phone-a", ms("2026-10-08T08:00[Europe/Paris]"), "+33199001234", "stranger", "work", "matrix", true)];
2662        let tomorrow = at("2026-10-08T10:00[Europe/Paris]");
2663        let l = Lister { now: &tomorrow, ..l };
2664        assert_eq!(lines(&later, &Seen::default(), &BTreeMap::new(), &l)[0].again, "");
2665    }
2666
2667    /// Seen on one device, seen on all: each device's own log, every
2668    /// device's read together; Undo takes out this device's own line only.
2669    /// The phone's file from before Seen was shared becomes a line of its own log.
2670    #[test]
2671    fn seen_anywhere_is_seen_everywhere() {
2672        let root = scratch("seen");
2673        let a = "1791346200000-+33199001234".to_string();
2674        let b = "1791349200000-hidden".to_string();
2675        let pressed = mark_seen(&root, "desk", std::slice::from_ref(&a), 1_000).unwrap();
2676        mark_seen(&root, "phone-a", &[a.clone(), b.clone()], 2_000).unwrap();
2677        assert_eq!(read_seen(&root).seen, [a.clone(), b.clone()].into());
2678        // Undo on the desk: the phone's Seen stays.
2679        assert!(unmark_seen(&root, "desk", pressed).unwrap());
2680        assert!(!unmark_seen(&root, "desk", pressed).unwrap(), "once");
2681        assert_eq!(read_seen(&root).seen, [a.clone(), b.clone()].into());
2682        assert!(unmark_seen(&root, "phone-a", 2_000).unwrap());
2683        assert!(read_seen(&root).seen.is_empty());
2684        // The older file, this phone's alone: a line of its own log, then gone.
2685        std::fs::write(root.join(SEEN), "seen = [\"1791346200000-+33199001234\"]\n").unwrap();
2686        adopt_older_seen(&root, "phone-a", 3_000).unwrap();
2687        assert!(!root.join(SEEN).exists());
2688        assert_eq!(read_seen(&root).seen, [a].into());
2689        adopt_older_seen(&root, "phone-a", 4_000).unwrap();
2690        assert_eq!(std::fs::read_to_string(own_file(&root, SEEN_LOG, "phone-a")).unwrap().lines().count(), 1, "nothing twice");
2691        #[cfg(unix)]
2692        {
2693            use std::os::unix::fs::PermissionsExt;
2694            assert_eq!(std::fs::metadata(own_file(&root, SEEN_LOG, "phone-a")).unwrap().permissions().mode() & 0o777, 0o600, "yours alone");
2695        }
2696        let _ = std::fs::remove_dir_all(&root);
2697    }
2698
2699    /// A person's calls of the month on their sheet: newest first, rang or
2700    /// declined, why a call rang when its row alone would not say it, the
2701    /// phone named only when several share their calls; never a blocked
2702    /// caller's, nothing past the month.
2703    #[test]
2704    fn a_person_s_calls_of_the_month() {
2705        let now = at("2026-10-07T10:30[Europe/Paris]");
2706        let calls = vec![
2707            call_on("phone-a", ms("2026-09-01T09:00[Europe/Paris]"), "+262639980002", "neutral", "work", "matrix", true),
2708            call_on("phone-a", ms("2026-10-06T23:10[Europe/Paris]"), "+262639980002", "neutral", "sleep", "matrix", false),
2709            call_on("phone-a", ms("2026-10-07T06:48[Europe/Paris]"), "+262639980002", "neutral", "sleep", "repeat", true),
2710            call_on("phone-a", ms("2026-10-07T08:00[Europe/Paris]"), "+33199005555", "blocked", "work", "blocked", false),
2711            call_on("phone-a", ms("2026-10-07T10:05[Europe/Paris]"), "+262639980002", "neutral", "work", "matrix", true),
2712        ];
2713        let keys: BTreeSet<String> = ["+262639980002".to_string(), "+33199005555".to_string()].into();
2714        let none = |_: &str| None;
2715        let all = |_: &Held| true;
2716        for (language, expected) in [
2717            ("en", ["Today at 10:05, during work: it rang.", "Today at 06:48, while you slept: it rang. A second call within 15 minutes rings.", "Yesterday at 23:10, while you slept: declined, sent to voicemail."]),
2718            ("fr", ["Aujourd’hui à 10:05, pendant le travail\u{202f}: l’appel a sonné.", "Aujourd’hui à 06:48, pendant votre sommeil\u{202f}: l’appel a sonné. Un deuxième appel en moins de 15 minutes sonne.", "Hier à 23:10, pendant votre sommeil\u{202f}: refusé, envoyé sur votre messagerie."]),
2719        ] {
2720            let tr = Translator::new(language);
2721            let words = |device: &str| phone_words(&tr, device, "desk", &[("phone-a".to_string(), "A12".to_string())], false);
2722            let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &words };
2723            assert_eq!(history(&calls, &keys, &l), expected, "{language}");
2724        }
2725        // Two phones: each line names its phone.
2726        let mut both = calls.clone();
2727        both.push(call_on("phone-b", ms("2026-10-07T09:00[Europe/Paris]"), "+262639980002", "neutral", "work", "matrix", false));
2728        both.sort_by_key(|h| h.at);
2729        let tr = Translator::new("en");
2730        let phones = [("phone-a".to_string(), "A12".to_string()), ("phone-b".to_string(), "B7".to_string())];
2731        let words = |device: &str| phone_words(&tr, device, "desk", &phones, false);
2732        let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &words };
2733        let said = history(&both, &keys, &l);
2734        assert_eq!(&said[..2], ["Today at 10:05, during work: it rang on your phone (A12).", "Today at 09:00, during work: declined on your phone (B7), sent to voicemail."]);
2735    }
2736
2737    /// Java's calls copied into this phone's own log once, whoever copies;
2738    /// never a blocked caller's, nor one past the month; this device's own
2739    /// lines taken out after the month, and never copied back.
2740    #[test]
2741    fn old_lines_leave_with_their_writer() {
2742        let root = scratch("carry");
2743        let own = own_file(&root, LOG, "phone-a");
2744        let now = ms("2026-10-07T10:00[Europe/Paris]");
2745        let java = vec![
2746            Held { number: "01 99 00 12 34".into(), ..held(ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep") },
2747            Held { key: String::new(), number: "0465710042".into(), rang: true, why: "no-table".into(), ..held(ms("2026-10-07T09:15[Europe/Paris]"), "x", "", "") },
2748            held(ms("2026-10-07T09:30[Europe/Paris]"), "+33199005555", "blocked", "work"),
2749            held(ms("2026-08-20T09:30[Europe/Paris]"), "+33199007777", "stranger", "work"),
2750        ];
2751        assert_eq!(carry_into(&own, &java, fr(), now).unwrap(), 2);
2752        assert_eq!(carry_into(&own, &java, fr(), now).unwrap(), 0, "once");
2753        let logged = read_held(&own);
2754        assert_eq!(logged.iter().map(|h| (h.key.as_str(), h.rang, h.number.as_str())).collect::<Vec<_>>(), [("+33199001234", false, ""), ("+33465710042", true, "")]);
2755        // A month and a few days later: taken out of this device's own files, a line that does not read kept.
2756        mark_seen(&root, "phone-a", &[logged[0].id()], now).unwrap();
2757        std::fs::write(&own, std::fs::read_to_string(&own).unwrap() + "{\"at\":\n").unwrap();
2758        let later = now + 33 * DAY_MS;
2759        assert_eq!(trim_own(&root, "phone-a", later).unwrap(), 3);
2760        assert_eq!(std::fs::read_to_string(&own).unwrap(), "{\"at\":\n");
2761        assert!(read_seen(&root).seen.is_empty());
2762        assert_eq!(carry_into(&own, &java, fr(), later).unwrap(), 0, "never copied back");
2763        // Nobody else's file is touched, until a line of theirs is long past its month (its phone gone for good).
2764        std::fs::write(own_file(&root, LOG, "phone-b"), serde_json::to_string(&java[0].logged(fr())).unwrap() + "\n").unwrap();
2765        trim_own(&root, "phone-a", later).unwrap();
2766        assert_eq!(trim_others(&root, "phone-a", later).unwrap(), 0);
2767        assert_eq!(read_held(&own_file(&root, LOG, "phone-b")).len(), 1);
2768        assert_eq!(trim_others(&root, "phone-a", now + (KEPT_DAYS + LATE_DAYS + 1) * DAY_MS).unwrap(), 1);
2769        assert!(read_held(&own_file(&root, LOG, "phone-b")).is_empty());
2770        assert_eq!(std::fs::read_to_string(&own).unwrap(), "{\"at\":\n", "its own left to trim_own");
2771        let _ = std::fs::remove_dir_all(&root);
2772    }
2773
2774    /// The calls the screening declined, said for what they are: an automated
2775    /// caller, a number that cannot be a caller's, one the network could not
2776    /// verify; why, on request, the screening's, never the time's. On a
2777    /// sheet: a number called lately, someone known the network could not
2778    /// verify, Expecting a call.
2779    #[test]
2780    fn the_screening_s_calls_are_said_for_what_they_are() {
2781        let now = at("2026-10-07T10:00[Europe/Paris]");
2782        let tr = Translator::new("en");
2783        let name_of = |_: &str| None;
2784        let shows = |_: &Held| true;
2785        let phone = |_: &str| String::new();
2786        let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &name_of, shows: &shows, phone: &phone };
2787        let declined = |time: &str, key: &str, why: &str| Held { why: why.into(), ..held(ms(&format!("2026-10-07T{time}[Europe/Paris]")), key, "stranger", "work") };
2788        let calls = vec![declined("09:00", "+33261911234", "automated"), declined("09:10", "+33353011234", "impossible"), declined("09:20", "+33536491234", "unverified"), declined("09:30", "+33199005555", "screening"), declined("09:40", "", "screening")];
2789        let listed = lines(&calls, &Seen::default(), &BTreeMap::new(), &l);
2790        let texts: Vec<&str> = listed.iter().map(|l| l.text.as_str()).collect();
2791        assert_eq!(
2792            texts,
2793            [
2794                "During work, an automated caller called at 09:00.",
2795                "During work, a number that cannot be a caller's called at 09:10.",
2796                "During work, a number your network could not verify called at 09:20.",
2797                "During work, a number not in your contacts called at 09:30.",
2798                "During work, a hidden number called at 09:40."
2799            ]
2800        );
2801        assert!(listed[0].why.starts_with("Automated callers go to your voicemail"), "{}", listed[0].why);
2802        assert!(listed[1].why.starts_with("This number cannot be a caller's") && listed[2].why.starts_with("Your network could not verify"));
2803        assert_eq!(listed[3].why, "Your call screening sends numbers not in your contacts to your voicemail.");
2804        assert_eq!(listed[4].why, "Your call screening sends hidden numbers to your voicemail.");
2805        // A number you called lately, declined as your contacts were then: said so, never as a stranger's.
2806        let lately = [Held { called: true, ..held(ms("2026-10-07T09:45[Europe/Paris]"), "+33199005556", "neutral", "sleep") }];
2807        assert_eq!(lines(&lately, &Seen::default(), &BTreeMap::new(), &l)[0].why, "You had called this number in the last two weeks, so it rings as your contacts do: their calls go to voicemail while you slept.");
2808        // On a sheet.
2809        let keys = BTreeSet::from(["+33199000001".to_string()]);
2810        let mut rang = Held { rang: true, called: true, ..held(ms("2026-10-07T09:50[Europe/Paris]"), "+33199000001", "neutral", "work") };
2811        assert!(history(std::slice::from_ref(&rang), &keys, &l)[0].ends_with("it rang. You had called this number in the last two weeks: it rings as your contacts do."), "{:?}", history(std::slice::from_ref(&rang), &keys, &l));
2812        rang.called = false;
2813        rang.verified = "failed".into();
2814        assert!(history(std::slice::from_ref(&rang), &keys, &l)[0].ends_with("it rang. Your network could not verify the number."));
2815        rang.verified = String::new();
2816        rang.why = "expecting".into();
2817        assert!(history(std::slice::from_ref(&rang), &keys, &l)[0].ends_with("it rang. You were expecting a call: numbers nobody knows rang."));
2818        // Called lately and let ring by the screening: its reason alone.
2819        rang.called = true;
2820        rang.why = "screening".into();
2821        assert!(history(std::slice::from_ref(&rang), &keys, &l)[0].ends_with("it rang. Your call screening lets such numbers ring then."), "{:?}", history(std::slice::from_ref(&rang), &keys, &l));
2822    }
2823
2824    /// The review of part 2: a number from a block ARCEP's list of 19 October
2825    /// 2026 gave to no operator is declined as any number nobody knows, its
2826    /// line saying what that list said, dated, while it counts (F1); 089 is
2827    /// said with the plan's date; in French, a machine is « un automate
2828    /// d'appel » (F12).
2829    #[test]
2830    fn the_snapshot_and_the_plan_are_said_with_their_dates() {
2831        let name_of = |_: &str| None;
2832        let shows = |_: &Held| true;
2833        let phone = |_: &str| String::new();
2834        let declined = |key: &str, why: &str| Held { why: why.into(), ..held(ms("2026-10-20T09:00[Europe/Paris]"), key, "stranger", "work") };
2835        let calls = vec![declined("+33353011234", "screening"), declined("+33899123456", "impossible"), declined("+33261911234", "automated")];
2836        let now = at("2026-10-20T10:00[Europe/Paris]");
2837        let tr = Translator::new("en");
2838        let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &name_of, shows: &shows, phone: &phone };
2839        let listed = lines(&calls, &Seen::default(), &BTreeMap::new(), &l);
2840        let why = |key: &str| listed.iter().find(|line| line.dial == key).map(|line| line.why.clone()).unwrap();
2841        assert_eq!(why("+33353011234"), "Your call screening sends numbers not in your contacts to your voicemail. ARCEP's list of 19 October 2026 gave this number's block to no operator: it may be a new line, or a borrowed number.");
2842        assert!(why("+33899123456").contains("version of 1 January 2026") && !why("+33899123456").contains("ARCEP had"), "{}", why("+33899123456"));
2843        // Past the list's days: nothing said of it.
2844        let later = at("2027-02-20T10:00[Europe/Paris]");
2845        let l = Lister { now: &later, ..l };
2846        let old = Held { at: ms("2027-02-20T09:00[Europe/Paris]"), ..calls[0].clone() };
2847        assert_eq!(lines(&[old], &Seen::default(), &BTreeMap::new(), &l)[0].why, "Your call screening sends numbers not in your contacts to your voicemail.");
2848        let fr_tr = Translator::new("fr");
2849        let l = Lister { now: &now, tr: &fr_tr, region: fr(), name_of: &name_of, shows: &shows, phone: &phone };
2850        let listed = lines(&calls[2..], &Seen::default(), &BTreeMap::new(), &l);
2851        assert!(listed[0].text.contains("un automate d’appel a appelé"), "{}", listed[0].text);
2852    }
2853
2854    /// F5 (review of part 2): the numbers called are kept only for the days
2855    /// they count, never more than 14, and not at all while the screening
2856    /// counts none: the file pruned, or gone.
2857    #[test]
2858    fn numbers_called_are_kept_their_days_only() {
2859        let dir = std::env::temp_dir().join(format!("sioul-called-{}", std::process::id()));
2860        let _ = std::fs::remove_dir_all(&dir);
2861        std::fs::create_dir_all(&dir).unwrap();
2862        let path = dir.join(CALLED);
2863        let now = ms("2026-10-20T10:00[Europe/Paris]");
2864        let day = 86_400_000;
2865        let write = || std::fs::write(&path, format!("{{\"+33199005555\":{},\"+33199005556\":{},\"+33199005557\":{}}}", now - day, now - 20 * day, now + day)).unwrap();
2866        write();
2867        prune_called(&path, now, 14).unwrap();
2868        let kept: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
2869        assert_eq!(kept.as_object().unwrap().keys().collect::<Vec<_>>(), ["+33199005555"], "a day ago kept; twenty days and the future out");
2870        write();
2871        prune_called(&path, now, 31).unwrap();
2872        assert_eq!(serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(&path).unwrap()).unwrap().as_object().unwrap().len(), 1, "14 at most");
2873        prune_called(&path, now, 0).unwrap();
2874        assert!(!path.exists(), "the setting off: gone");
2875        prune_called(&path, now, 0).unwrap();
2876        std::fs::write(&path, format!("{{\"+33199005556\":{}}}", now - 20 * day)).unwrap();
2877        prune_called(&path, now, 14).unwrap();
2878        assert!(!path.exists(), "nothing left: gone");
2879        std::fs::write(&path, "not json").unwrap();
2880        prune_called(&path, now, 14).unwrap();
2881        assert!(!path.exists(), "unreadable: gone");
2882        let _ = std::fs::remove_dir_all(&dir);
2883    }
2884
2885    #[test]
2886    fn letting_calls_through_is_said_with_its_end() {
2887        let now = 10_000_000;
2888        let when = |ms: i64| format!("<{ms}>");
2889        for (language, hour, open, emergency) in [
2890            ("en", "Expecting a call until <13600000>: numbers nobody knows ring.", "Expecting a call until you turn it off: numbers nobody knows ring.", "Every call rings until <92810000>: you called an emergency number."),
2891            ("fr", "En attente d’un appel jusqu’à <13600000>\u{202f}: les numéros que personne ne connaît sonnent.", "En attente d’un appel, jusqu’à ce que vous l’arrêtiez\u{202f}: les numéros que personne ne connaît sonnent.", "Tous les appels sonnent jusqu’à <92810000>\u{202f}: vous avez appelé un numéro d’urgence."),
2892        ] {
2893            let tr = Translator::new(language);
2894            let on = Through { pressed: 1, on: true, until: now + 3_600_000 };
2895            assert_eq!(through_line(&tr, Some(&on), 0, now, &when), hour);
2896            assert_eq!(through_line(&tr, Some(&Through { until: 0, ..on }), 0, now, &when), open);
2897            assert_eq!(through_line(&tr, Some(&Through { on: false, ..on }), 0, now, &when), "");
2898            assert_eq!(through_line(&tr, None, now - 3_590_000, now, &when), emergency);
2899            assert_eq!(through_line(&tr, None, now - 25 * 3_600_000, now, &when), "", "a day after");
2900        }
2901    }
2902
2903    #[test]
2904    fn every_word_has_both_languages() {
2905        for language in ["en", "fr"] {
2906            let tr = Translator::new(language);
2907            let mut keys: Vec<String> = [
2908                "calls-who-hidden", "calls-who-stranger", "calls-context-day", "calls-day-yesterday", "calls-day-today", "calls-line-once", "calls-line-twice", "calls-line-more", "calls-line-once-on", "calls-line-twice-on", "calls-line-more-on", "calls-may-have-left", "calls-left-message", "calls-left-message-plain", "calls-through-emergency", "calls-expecting-until", "calls-expecting-on", "calls-context-any", "calls-who-automated", "calls-who-impossible", "calls-who-unverified",
2909                "calls-phone-yours", "calls-phone-other", "calls-phone-named", "calls-phone-this", "calls-again-rang", "calls-history-title", "calls-history-when", "calls-history-head", "calls-history-line", "calls-history-declined", "calls-history-rang", "calls-history-declined-on", "calls-history-rang-on",
2910                "calls-rang-repeat", "calls-rang-through", "calls-rang-emergency", "calls-rang-after-emergency", "calls-rang-undecided",
2911                "calls-rang-expecting", "calls-rang-screening-off", "calls-rang-work-hours", "calls-rang-screening", "calls-history-called", "calls-history-unverified",
2912                "calls-why-automated", "calls-why-impossible", "calls-why-unverified", "calls-why-screening-stranger", "calls-why-screening-hidden", "calls-why-called", "calls-why-own",
2913            ]
2914            .iter()
2915            .map(|k| k.to_string())
2916            .collect();
2917            keys.extend(["work", "admin", "leisure", "meals", "sleep", "pause", "free", "slot", "dnd", "reachable"].iter().map(|c| format!("calls-context-{c}")));
2918            keys.extend(["safe", "neutral", "restricted", "stranger", "hidden"].iter().map(|r| format!("calls-why-{r}")));
2919            for key in keys {
2920                let mut args = crate::i18n::args();
2921                for name in ["context", "day", "who", "time", "first", "second", "count", "length", "until", "phone", "name", "when", "head", "outcome"] {
2922                    args.set(name, "X");
2923                }
2924                let words = tr.text(&key, Some(&args));
2925                assert!(!words.is_empty() && words != key && !words.contains('{'), "{language}: {key}: {words}");
2926                // French typography: a narrow no-break space before « : ; ? ! », the apostrophe ’.
2927                if language == "fr" {
2928                    assert!(![" :", " ;", " ?", " !", "'"].iter().any(|bad| words.contains(bad)), "{key}: {words}");
2929                }
2930            }
2931        }
2932    }
2933}