1use 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
67pub const FOLDER: &str = "calls";
69pub const TABLE: &str = "table.json";
71pub const HELD: &str = "held.jsonl";
74pub const LOG: &str = "log";
77pub const SEEN_LOG: &str = "seen";
80pub const THROUGH_HERE: &str = "through-here.json";
82pub const EMERGENCY: &str = "emergency.json";
84pub const CALLED: &str = "called.json";
88pub const SEEN: &str = "seen.toml";
91pub const VERSION: u32 = 2;
95pub const REPEAT_MINUTES: i64 = 15;
97pub const AFTER_EMERGENCY_HOURS: i64 = 24;
99pub const TABLE_DAYS: i64 = 4;
102pub const LISTED_DAYS: i64 = 14;
104pub const KEPT_DAYS: i64 = 31;
107pub const LATE_DAYS: i64 = 14;
110const DAY_MS: i64 = 86_400_000;
112const DEVICE_PART: &str = "calls";
114const 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
132pub fn folder() -> PathBuf {
134 crate::config::state_dir().join(FOLDER)
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141pub struct Table {
142 pub v: u32,
143 pub made: i64,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub region: Option<RegionRules>,
147 #[serde(default)]
149 pub trunk_zero: Vec<String>,
150 #[serde(default)]
152 pub numbers: BTreeMap<String, String>,
153 #[serde(default)]
157 pub prefixes: Vec<Prefix>,
158 pub phone_contacts: String,
160 pub floors: Floors,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub through: Option<Through>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub expecting: Option<Through>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub screening: Option<screening::TablePart>,
171 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct Prefix {
212 pub prefix: String,
214 pub who: String,
215}
216
217#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
219pub struct Floors {
220 pub emergency: Vec<String>,
222 pub people: Vec<String>,
225}
226
227#[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 pub fn holds(&self, now: i64) -> bool {
242 self.on && (self.until <= 0 || now < self.until)
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248pub struct Frame {
249 pub from: i64,
250 pub until: i64,
251 pub column: String,
254 #[serde(default)]
257 pub hours: bool,
258 pub ring: BTreeMap<String, bool>,
260}
261
262pub const ROWS: [&str; 7] = ["safe", "neutral", "restricted", "always-safe", "always-neutral", "always-restricted", "always-stranger"];
269
270fn 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
284pub struct Layers {
285 pub slots: Vec<(i64, i64)>,
286 pub dnd: Option<(i64, Option<i64>)>,
287 pub reachable: Option<(i64, Option<i64>)>,
290}
291
292fn 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
307pub 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#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct OwnRow {
321 pub row: String,
322 pub ids: Vec<String>,
323 pub who: Who,
324}
325
326pub 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
358pub 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
397pub 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
407pub struct Made {
409 pub made: i64,
411 pub region: Option<&'static Region>,
412 pub numbers: Vec<(String, String)>,
414 pub prefixes: Vec<(String, String)>,
416 pub always: Vec<(String, String)>,
418 pub own: Vec<(String, String)>,
420 pub expecting: Option<Through>,
422 pub screening: Option<screening::TablePart>,
425 pub frames: Vec<Frame>,
426}
427
428pub 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 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 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 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
467pub fn content(table: &Table) -> String {
469 serde_json::to_string(&Table { made: 0, ..table.clone() }).unwrap_or_default()
470}
471
472pub 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
480pub 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
499pub 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
505pub 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
529pub struct Incoming {
530 pub number: String,
532 pub hidden: bool,
534 pub outgoing: bool,
536 pub now: i64,
538 pub emergency_at: i64,
540 pub local: Option<Through>,
542 pub last_call: i64,
544 pub phone_contact: bool,
546 pub system_emergency: bool,
548 pub verified: String,
551 pub called: i64,
553}
554
555#[derive(Debug, Clone, PartialEq, Eq)]
557pub struct Decision {
558 pub refuse: bool,
559 pub why: &'static str,
565 pub who: String,
567 pub column: String,
568 pub called: bool,
571}
572
573impl Table {
574 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 pub fn frame_at(&self, now: i64) -> Option<&Frame> {
581 self.frames.iter().find(|f| f.from <= now && now < f.until)
582 }
583}
584
585pub 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 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 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 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 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 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 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 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 let borrowed = called && screening.on && screening.unverified == Spam::Voicemail && call.verified == "failed";
671 if repeat && !borrowed {
672 return decided(false, "repeat", "");
673 }
674 let expecting = later(call.local, table.expecting).is_some_and(|t| t.holds(call.now));
676 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 let Some(frame) = frame else { return decided(false, "no-frame", "") };
693 if expecting {
694 return decided(false, "expecting", &frame.column);
695 }
696 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 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
712fn 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
729pub 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
763fn 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
771pub 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
837fn 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
843pub fn shown_number(key: &str, region: Option<&Region>) -> String {
846 let Some(region) = region else { return key.to_string() };
847 if region.french {
848 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
871pub struct DevicePart {
872 #[serde(default)]
874 pub pressed: i64,
875 #[serde(default)]
876 pub on: bool,
877 #[serde(default)]
879 pub until: i64,
880 #[serde(default)]
882 pub local: i64,
883 #[serde(default)]
885 pub screens: bool,
886}
887
888pub 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
893pub 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
904pub 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
916pub fn screens_anywhere(switch: &Switch) -> bool {
918 switch.device.values().any(|d| part_of(d).screens)
919}
920
921pub 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
935pub 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
954pub 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
966pub 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
978pub struct Held {
979 pub at: i64,
981 #[serde(default, skip_serializing_if = "String::is_empty")]
983 pub key: String,
984 #[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 #[serde(default, skip_serializing_if = "is_zero")]
991 pub presentation: i32,
992 #[serde(default, skip_serializing_if = "String::is_empty")]
996 pub who: String,
997 #[serde(default, skip_serializing_if = "String::is_empty")]
999 pub name: String,
1000 #[serde(default, skip_serializing_if = "String::is_empty")]
1003 pub column: String,
1004 #[serde(default, skip_serializing_if = "String::is_empty")]
1006 pub why: String,
1007 #[serde(default)]
1010 pub rang: bool,
1011 #[serde(default, skip_serializing_if = "String::is_empty")]
1013 pub verified: String,
1014 #[serde(default, skip_serializing_if = "is_false")]
1017 pub called: bool,
1018 #[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 pub fn id(&self) -> String {
1035 format!("{}-{}", self.at, if self.hidden { "hidden" } else { self.key.as_str() })
1036 }
1037
1038 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
1047pub 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
1054pub(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
1060pub fn own_file(root: &Path, folder: &str, device: &str) -> PathBuf {
1064 root.join(folder).join(format!("{}.jsonl", file_safe(device)))
1065}
1066
1067pub(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
1073pub(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
1087pub 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
1101pub 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
1129pub(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
1148pub(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
1172pub 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
1180pub 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
1198fn 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#[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1213pub struct Seen {
1214 #[serde(default)]
1215 pub seen: BTreeSet<String>,
1216}
1217
1218impl Seen {
1219 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 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
1232pub 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
1245pub 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
1255pub 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
1262pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1278pub struct Message {
1279 pub text: String,
1281 pub path: String,
1283 pub sound: Option<u32>,
1284}
1285
1286#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1288pub struct Line {
1289 pub ids: Vec<String>,
1291 pub text: String,
1294 pub number: String,
1296 pub dial: String,
1298 pub known: bool,
1300 pub hidden: bool,
1301 pub doubt: String,
1303 pub message: Option<Message>,
1304 pub why: String,
1306 pub again: String,
1309}
1310
1311pub struct Lister<'a> {
1313 pub now: &'a Zoned,
1314 pub tr: &'a Translator,
1315 pub region: Option<&'static Region>,
1316 pub name_of: &'a dyn Fn(&str) -> Option<String>,
1318 pub shows: &'a dyn Fn(&Held) -> bool,
1320 pub phone: &'a dyn Fn(&str) -> String,
1324}
1325
1326pub 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 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
1353fn 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
1365pub(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 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 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 let message = ids.iter().chain(rang.map(Held::id).iter()).find_map(|id| messages.get(id)).cloned();
1436 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 ("matrix", _) if first.called => tr.text("calls-why-called", Some(&why_args)),
1443 ("own", _) => tr.text("calls-why-own", Some(&why_args)),
1445 ("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 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
1478pub(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
1486pub 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
1504pub 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
1515fn 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 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
1565fn 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
1585pub 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
1594pub fn length(seconds: u32) -> String {
1596 format!("{}:{:02}", seconds / 60, seconds % 60)
1597}
1598
1599pub 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
1611pub 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 assert_eq!(said(&Incoming { verified: "passed".into(), ..call(impossible, "10:00") }).1, "impossible");
1882 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
1983 fn nobody_unknown_rings_where_your_contacts_wait() {
1984 let stranger = "01 99 00 55 55";
1985 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 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 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 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 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 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 #[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 #[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 #[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 #[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 let contact = Incoming { phone_contact: true, called: 0, ..lately("23:00", "failed") };
2087 assert_eq!(decided_on(&t, &contact).1, "repeat");
2088 }
2089
2090 #[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 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 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 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 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 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 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 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 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 assert_eq!(phones::key("06 39 98 00 01", fr()), "+262639980001");
2211 }
2212
2213 const ROWS_V1: [&str; 9] = ["safe", "neutral", "restricted", "stranger", "hidden", "always-safe", "always-neutral", "always-restricted", "always-stranger"];
2215
2216 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 #[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 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 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 #[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 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 assert_eq!(content(&table), content(&self::table(made(43))));
2327 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 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 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 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 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 press(&mut switch, "desk", true, 0, 9_000);
2366 assert!(through_of(&switch).unwrap().holds(i64::MAX - 1));
2367 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 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 let held = through_of(&switch).unwrap();
2385 assert!(!held.on && held.pressed == 37_800_000);
2386 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 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 assert!(lines.iter().all(|l| !l.dial.contains("5555")));
2450 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 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 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 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 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 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 #[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 let unkeyed = Held { key: String::new(), why: "no-table".into(), rang: true, ..java.clone() };
2537 assert_eq!(unkeyed.logged(fr()).key, "+33199001234");
2538 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 #[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 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 #[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 #[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 #[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 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 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 #[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 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 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 #[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 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 #[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 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 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 #[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 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 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 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 #[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 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 #[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 if language == "fr" {
2928 assert!(![" :", " ;", " ?", " !", "'"].iter().any(|bad| words.contains(bad)), "{key}: {words}");
2929 }
2930 }
2931 }
2932 }
2933}