1use crate::attention::{self, Attention, Level, Person};
47use crate::everywhere::{Device, People, Switch};
48use crate::i18n::Translator;
49use crate::phones::{self, Region};
50use crate::reach::{Channel, Clock, Who};
51use jiff::Zoned;
52use serde::{Deserialize, Serialize};
53use std::collections::{BTreeMap, BTreeSet};
54use std::path::{Path, PathBuf};
55
56pub const FOLDER: &str = "calls";
58pub const TABLE: &str = "table.json";
60pub const HELD: &str = "held.jsonl";
63pub const LOG: &str = "log";
66pub const SEEN_LOG: &str = "seen";
69pub const THROUGH_HERE: &str = "through-here.json";
71pub const EMERGENCY: &str = "emergency.json";
73pub const SEEN: &str = "seen.toml";
76pub const VERSION: u32 = 1;
78pub const REPEAT_MINUTES: i64 = 15;
80pub const AFTER_EMERGENCY_HOURS: i64 = 24;
82pub const TABLE_DAYS: i64 = 4;
85pub const LISTED_DAYS: i64 = 14;
87pub const KEPT_DAYS: i64 = 31;
90pub const LATE_DAYS: i64 = 14;
93const DAY_MS: i64 = 86_400_000;
95const DEVICE_PART: &str = "calls";
97const FRENCH_OVERSEAS: [(&str, &str); 14] = [
99 ("262", "262"),
100 ("263", "262"),
101 ("269", "262"),
102 ("639", "262"),
103 ("692", "262"),
104 ("693", "262"),
105 ("590", "590"),
106 ("690", "590"),
107 ("691", "590"),
108 ("594", "594"),
109 ("694", "594"),
110 ("596", "596"),
111 ("696", "596"),
112 ("697", "596"),
113];
114
115pub fn folder() -> PathBuf {
117 crate::config::state_dir().join(FOLDER)
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct Table {
125 pub v: u32,
126 pub made: i64,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub region: Option<RegionRules>,
130 #[serde(default)]
132 pub trunk_zero: Vec<String>,
133 #[serde(default)]
135 pub numbers: BTreeMap<String, String>,
136 #[serde(default)]
140 pub prefixes: Vec<Prefix>,
141 pub phone_contacts: String,
143 pub floors: Floors,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub through: Option<Through>,
147 pub frames: Vec<Frame>,
148 pub repeat_minutes: i64,
149 pub emergency_hours: i64,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct RegionRules {
155 pub code: String,
156 pub calling: String,
157 pub trunk: String,
158 pub international: String,
159 pub digits: [usize; 2],
160 #[serde(default)]
161 pub french: bool,
162 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
163 pub overseas: BTreeMap<String, String>,
164}
165
166impl RegionRules {
167 pub fn of(region: &Region) -> RegionRules {
168 RegionRules {
169 code: region.code.to_string(),
170 calling: region.calling.to_string(),
171 trunk: region.trunk.to_string(),
172 international: region.international.to_string(),
173 digits: [region.digits.0, region.digits.1],
174 french: region.french,
175 overseas: if region.french { FRENCH_OVERSEAS.iter().map(|(p, c)| (p.to_string(), c.to_string())).collect() } else { BTreeMap::new() },
176 }
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct Prefix {
183 pub prefix: String,
185 pub who: String,
186}
187
188#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
190pub struct Floors {
191 pub emergency: Vec<String>,
193 pub people: Vec<String>,
196}
197
198#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
201pub struct Through {
202 #[serde(default)]
203 pub pressed: i64,
204 #[serde(default)]
205 pub on: bool,
206 #[serde(default)]
207 pub until: i64,
208}
209
210impl Through {
211 pub fn holds(&self, now: i64) -> bool {
213 self.on && (self.until <= 0 || now < self.until)
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219pub struct Frame {
220 pub from: i64,
221 pub until: i64,
222 pub column: String,
225 pub ring: BTreeMap<String, bool>,
227}
228
229pub const ROWS: [&str; 9] = ["safe", "neutral", "restricted", "stranger", "hidden", "always-safe", "always-neutral", "always-restricted", "always-stranger"];
233
234fn person_of(row: &str) -> (Person, bool) {
236 let (always, own) = match row.strip_prefix("always-") {
237 Some(own) => (true, own),
238 None => (false, row),
239 };
240 (Person::read(own).unwrap_or(Person::Stranger), always)
241}
242
243#[derive(Debug, Clone, Default, PartialEq, Eq)]
248pub struct Layers {
249 pub slots: Vec<(i64, i64)>,
250 pub dnd: Option<(i64, Option<i64>)>,
251}
252
253fn column_of(now: &attention::Now) -> &'static str {
257 if now.dnd {
258 "dnd"
259 } else if now.slot {
260 "slot"
261 } else {
262 now.times.first().map_or("", |c| c.id())
263 }
264}
265
266pub fn frames(attention: &Attention, clock: &Clock, layers: &Layers, from: &Zoned, until: &Zoned) -> Vec<Frame> {
270 let mut out: Vec<Frame> = Vec::new();
271 for f in clock.frames(from, until) {
272 let mut cuts = vec![f.start, f.end];
273 let edges = layers.slots.iter().flat_map(|&(a, b)| [a, b]).chain(layers.dnd.iter().flat_map(|&(a, b)| [Some(a), b]).flatten());
274 cuts.extend(edges.filter(|t| *t > f.start && *t < f.end));
275 cuts.sort_unstable();
276 cuts.dedup();
277 for pair in cuts.windows(2) {
278 let (start, end) = (pair[0], pair[1]);
279 let slot = layers.slots.iter().any(|&(a, b)| a <= start && start < b);
280 let dnd = layers.dnd.is_some_and(|(a, b)| a <= start && b.is_none_or(|b| start < b));
281 let now = attention::Now::of_moment(&f.moment).layers(slot, dnd);
282 let ring: BTreeMap<String, bool> = ROWS
283 .iter()
284 .map(|row| {
285 let (person, always) = person_of(row);
286 (row.to_string(), attention.person(Channel::Calls, person, always, &now) == Level::Now)
287 })
288 .collect();
289 let column = column_of(&now).to_string();
290 let (from_ms, until_ms) = (start.saturating_mul(1000), end.saturating_mul(1000));
291 match out.last_mut() {
292 Some(last) if last.until == from_ms && last.column == column && last.ring == ring => last.until = until_ms,
293 _ => out.push(Frame { from: from_ms, until: until_ms, column, ring }),
294 }
295 }
296 }
297 out
298}
299
300pub fn always_numbers(people: &People, region: Option<&Region>, judge: &dyn Fn(&str) -> Who) -> Vec<(String, String)> {
304 people_keys(people, region).into_iter().filter_map(|key| match judge(&key) {
305 Who::Blocked => None,
306 who => Some((key, format!("always-{}", who.id()))),
307 }).collect()
308}
309
310pub struct Made {
312 pub made: i64,
314 pub region: Option<&'static Region>,
315 pub numbers: Vec<(String, String)>,
317 pub prefixes: Vec<(String, String)>,
319 pub always: Vec<(String, String)>,
321 pub through: Option<Through>,
322 pub frames: Vec<Frame>,
323}
324
325pub fn table(made: Made) -> Table {
327 let mut numbers = BTreeMap::new();
328 for (key, who) in made.numbers {
329 if !key.is_empty() && !who.is_empty() && who != "stranger" {
330 numbers.insert(key, who);
331 }
332 }
333 for (key, row) in made.always {
335 if !key.is_empty() && numbers.get(&key).is_none_or(|who| who != "blocked") {
336 numbers.insert(key, row);
337 }
338 }
339 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();
341 prefixes.sort_by_key(|p| std::cmp::Reverse(p.prefix.len()));
342 Table {
343 v: VERSION,
344 made: made.made,
345 region: made.region.map(RegionRules::of),
346 trunk_zero: trunk_zero(),
347 numbers,
348 prefixes,
349 phone_contacts: "neutral".to_string(),
350 floors: Floors { emergency: emergency_keys(made.region), people: Vec::new() },
351 through: made.through,
352 frames: made.frames,
353 repeat_minutes: REPEAT_MINUTES,
354 emergency_hours: AFTER_EMERGENCY_HOURS,
355 }
356}
357
358pub fn content(table: &Table) -> String {
360 serde_json::to_string(&Table { made: 0, ..table.clone() }).unwrap_or_default()
361}
362
363pub fn trunk_zero() -> Vec<String> {
365 let mut codes: Vec<String> = phones::REGIONS.iter().filter(|r| r.trunk == "0").map(|r| r.calling.to_string()).collect();
366 codes.sort();
367 codes.dedup();
368 codes
369}
370
371pub fn emergency_keys(region: Option<&Region>) -> Vec<String> {
375 let lines = crate::pause::CrisisLines::built_in();
376 let mut keys = BTreeSet::new();
377 for country in &lines.country {
378 let own = phones::region_named(&country.code);
379 for line in &country.line {
380 for key in [phones::key(&line.number, own.or(region)), phones::key(&line.number, region), line.number.chars().filter(char::is_ascii_digit).collect()] {
381 if !key.is_empty() {
382 keys.insert(key);
383 }
384 }
385 }
386 }
387 keys.into_iter().collect()
388}
389
390pub fn people_keys(people: &People, region: Option<&Region>) -> Vec<String> {
392 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();
393 keys.into_iter().collect()
394}
395
396pub fn write(path: &Path, table: &Table) -> Result<(), String> {
398 let text = serde_json::to_string(table).map_err(|e| e.to_string())?;
399 write_whole(path, &text)
400}
401
402fn write_whole(path: &Path, text: &str) -> Result<(), String> {
403 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
404 if let Some(parent) = path.parent() {
405 std::fs::create_dir_all(parent).map_err(fail)?;
406 }
407 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
408 let fresh = path.with_file_name(format!(".{name}.{}.new", std::process::id()));
409 std::fs::write(&fresh, text).map_err(fail)?;
410 std::fs::rename(&fresh, path).map_err(|e| {
411 let _ = std::fs::remove_file(&fresh);
412 fail(e)
413 })
414}
415
416#[derive(Debug, Clone, Default, PartialEq, Eq)]
420pub struct Incoming {
421 pub number: String,
423 pub hidden: bool,
425 pub outgoing: bool,
427 pub now: i64,
429 pub emergency_at: i64,
431 pub local: Option<Through>,
433 pub last_call: i64,
435 pub phone_contact: bool,
437 pub system_emergency: bool,
439}
440
441#[derive(Debug, Clone, PartialEq, Eq)]
443pub struct Decision {
444 pub refuse: bool,
445 pub why: &'static str,
447 pub who: String,
449 pub column: String,
450}
451
452impl Table {
453 pub fn by_prefix(&self, key: &str) -> Option<&str> {
455 self.prefixes.iter().find(|p| key.starts_with(&p.prefix)).map(|p| p.who.as_str())
456 }
457
458 pub fn frame_at(&self, now: i64) -> Option<&Frame> {
460 self.frames.iter().find(|f| f.from <= now && now < f.until)
461 }
462}
463
464pub fn decide(table: Option<&Table>, call: &Incoming) -> Decision {
471 let ring = |why: &'static str, who: &str, column: &str| Decision { refuse: false, why, who: who.to_string(), column: column.to_string() };
472 if call.outgoing {
473 return ring("outgoing", "", "");
474 }
475 let Some(table) = table else { return ring("no-table", "", "") };
476 let key = if call.hidden { String::new() } else { incoming_key(&call.number, table.region.as_ref(), &table.trunk_zero) };
477 if !call.hidden && (call.system_emergency || table.floors.emergency.contains(&key)) {
479 return ring("emergency", "", "");
480 }
481 if call.emergency_at > 0 && call.now >= call.emergency_at && call.now - call.emergency_at < table.emergency_hours * 3_600_000 {
482 return ring("after-emergency", "", "");
483 }
484 if !call.hidden && table.floors.people.contains(&key) {
485 return ring("people", "", "");
486 }
487 let who = if call.hidden {
489 "hidden".to_string()
490 } else {
491 table.numbers.get(&key).cloned().filter(|w| !w.is_empty()).or_else(|| table.by_prefix(&key).map(str::to_string)).unwrap_or_else(|| if call.phone_contact { table.phone_contacts.clone() } else { "stranger".to_string() })
492 };
493 if who == "blocked" {
495 return Decision { refuse: true, why: "blocked", who, column: String::new() };
496 }
497 let through = match (call.local, table.through) {
499 (Some(local), Some(shared)) => Some(if local.pressed > shared.pressed { local } else { shared }),
500 (local, shared) => local.or(shared),
501 };
502 if through.is_some_and(|t| t.holds(call.now)) {
503 return ring("through", &who, "");
504 }
505 if !call.hidden && call.last_call > 0 && call.now - call.last_call <= table.repeat_minutes * 60_000 {
507 return ring("repeat", &who, "");
508 }
509 let Some(frame) = table.frame_at(call.now) else { return ring("no-frame", &who, "") };
511 match frame.ring.get(&who) {
512 Some(rings) => Decision { refuse: !rings, why: "matrix", who, column: frame.column.clone() },
513 None => ring("no-row", &who, &frame.column),
514 }
515}
516
517pub fn incoming_key(raw: &str, region: Option<&RegionRules>, trunk_zero: &[String]) -> String {
523 let mut value = raw.trim_matches(|c: char| c.is_whitespace() || spacing(c));
524 if value.len() >= 4 && value.is_char_boundary(4) && value[..4].eq_ignore_ascii_case("tel:") {
525 value = value[4..].trim();
526 }
527 let as_written = || value.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase();
528 let mut digits = String::new();
529 for (i, c) in value.chars().enumerate() {
530 match c {
531 '+' if i == 0 => digits.push('+'),
532 '0'..='9' => digits.push(c),
533 '(' | ')' => {}
534 c if spacing(c) => {}
535 _ => return as_written(),
536 }
537 }
538 if digits.is_empty() || digits == "+" {
539 return as_written();
540 }
541 let drop_trunk = |digits: &str| -> String {
542 for length in 1..=3usize.min(digits.len().saturating_sub(1)) {
543 let (calling, rest) = digits.split_at(length);
544 if rest.starts_with('0') && trunk_zero.iter().any(|t| t == calling) {
545 return format!("{calling}{}", &rest[1..]);
546 }
547 }
548 digits.to_string()
549 };
550 if let Some(international) = digits.strip_prefix('+') {
551 return format!("+{}", drop_trunk(international));
552 }
553 let Some(region) = region else { return digits };
554 for prefix in [region.international.as_str(), "00"] {
555 if prefix.is_empty() {
556 continue;
557 }
558 if let Some(rest) = digits.strip_prefix(prefix).filter(|rest| rest.len() >= 6 && !rest.starts_with('0')) {
559 return format!("+{}", drop_trunk(rest));
560 }
561 }
562 let length = digits.len();
563 if length < region.digits[0] || length > region.digits[1] {
564 return digits;
565 }
566 match region.trunk.as_str() {
567 "" => format!("+{}{digits}", region.calling),
568 "1" => match length {
569 10 if !digits.starts_with(['0', '1']) => format!("+1{digits}"),
570 11 if digits.starts_with('1') => format!("+{digits}"),
571 _ => digits,
572 },
573 trunk => match digits.strip_prefix(trunk) {
574 Some(national) if !national.is_empty() && !national.starts_with('0') => {
575 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() };
576 format!("+{calling}{national}")
577 }
578 _ => digits,
579 },
580 }
581}
582
583fn spacing(c: char) -> bool {
585 matches!(c, ' ' | '\t' | '.' | '-' | '/' | '\u{a0}' | '\u{202f}' | '\u{2009}' | '\u{2007}' | '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}')
586 || matches!(c, '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{feff}')
587}
588
589pub fn shown_number(key: &str, region: Option<&Region>) -> String {
592 let Some(region) = region else { return key.to_string() };
593 if region.french {
594 for calling in ["33", "262", "590", "594", "596"] {
596 if let Some(national) = key.strip_prefix('+').and_then(|k| k.strip_prefix(calling))
597 && national.len() == 9
598 && national.chars().all(|c| c.is_ascii_digit())
599 && (calling == "33" || FRENCH_OVERSEAS.iter().any(|(p, c)| *c == calling && national.starts_with(p)))
600 {
601 let digits = format!("0{national}");
602 return digits.as_bytes().chunks(2).map(|pair| String::from_utf8_lossy(pair).to_string()).collect::<Vec<_>>().join(" ");
603 }
604 }
605 return key.to_string();
606 }
607 match key.strip_prefix('+').and_then(|k| k.strip_prefix(region.calling)) {
608 Some(national) if !region.trunk.is_empty() && region.trunk != "1" && national.chars().all(|c| c.is_ascii_digit()) => format!("{}{national}", region.trunk),
609 _ => key.to_string(),
610 }
611}
612
613#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
617pub struct DevicePart {
618 #[serde(default)]
620 pub pressed: i64,
621 #[serde(default)]
622 pub on: bool,
623 #[serde(default)]
625 pub until: i64,
626 #[serde(default)]
628 pub local: i64,
629 #[serde(default)]
631 pub screens: bool,
632}
633
634pub fn part_of(device: &Device) -> DevicePart {
636 device.other.get(DEVICE_PART).and_then(|v| v.clone().try_into().ok()).unwrap_or_default()
637}
638
639pub fn set_part(device: &mut Device, part: DevicePart) {
641 if part == DevicePart::default() {
642 device.other.remove(DEVICE_PART);
643 return;
644 }
645 if let Ok(value) = toml::Value::try_from(part) {
646 device.other.insert(DEVICE_PART.to_string(), value);
647 }
648}
649
650pub fn through_of(switch: &Switch) -> Option<Through> {
653 switch
654 .device
655 .iter()
656 .map(|(id, d)| (id, part_of(d)))
657 .filter(|(_, p)| p.pressed > 0)
658 .max_by_key(|(id, p)| (p.pressed, p.on, (*id).clone()))
659 .map(|(_, p)| Through { pressed: p.pressed, on: p.on, until: if p.on { p.until } else { 0 } })
660}
661
662pub fn screens_anywhere(switch: &Switch) -> bool {
664 switch.device.values().any(|d| part_of(d).screens)
665}
666
667pub fn press(switch: &mut Switch, here: &str, on: bool, until: i64, now: i64) -> i64 {
670 let latest = switch.device.values().map(|d| part_of(d).pressed).max().unwrap_or(0);
671 let stamp = now.max(latest + 1);
672 let own = switch.device.entry(here.to_string()).or_default();
673 let mut part = part_of(own);
674 part.pressed = stamp;
675 part.on = on;
676 part.until = if on { until.max(0) } else { 0 };
677 set_part(own, part);
678 stamp
679}
680
681pub fn merge_local(switch: &mut Switch, here: &str, local: &Through) -> bool {
685 let own = switch.device.entry(here.to_string()).or_default();
686 let mut part = part_of(own);
687 if local.pressed <= part.local {
688 return false;
689 }
690 part.local = local.pressed;
691 if local.pressed > part.pressed {
692 part.pressed = local.pressed;
693 part.on = local.on;
694 part.until = if local.on { local.until.max(0) } else { 0 };
695 }
696 set_part(own, part);
697 true
698}
699
700pub fn set_screens(switch: &mut Switch, here: &str, screens: bool) -> bool {
702 let own = switch.device.entry(here.to_string()).or_default();
703 let mut part = part_of(own);
704 if part.screens == screens {
705 return false;
706 }
707 part.screens = screens;
708 set_part(own, part);
709 true
710}
711
712pub fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Option<T> {
714 serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
715}
716
717#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
724pub struct Held {
725 pub at: i64,
727 #[serde(default, skip_serializing_if = "String::is_empty")]
729 pub key: String,
730 #[serde(default, skip_serializing_if = "String::is_empty")]
732 pub number: String,
733 #[serde(default, skip_serializing_if = "is_false")]
734 pub hidden: bool,
735 #[serde(default, skip_serializing_if = "is_zero")]
737 pub presentation: i32,
738 #[serde(default, skip_serializing_if = "String::is_empty")]
742 pub who: String,
743 #[serde(default, skip_serializing_if = "String::is_empty")]
745 pub name: String,
746 #[serde(default, skip_serializing_if = "String::is_empty")]
749 pub column: String,
750 #[serde(default, skip_serializing_if = "String::is_empty")]
752 pub why: String,
753 #[serde(default)]
756 pub rang: bool,
757 #[serde(default, skip_serializing_if = "String::is_empty")]
759 pub verified: String,
760 #[serde(skip)]
763 pub device: String,
764}
765
766fn is_false(value: &bool) -> bool {
767 !*value
768}
769
770fn is_zero(value: &i32) -> bool {
771 *value == 0
772}
773
774impl Held {
775 pub fn id(&self) -> String {
777 format!("{}-{}", self.at, if self.hidden { "hidden" } else { self.key.as_str() })
778 }
779
780 pub fn logged(&self, region: Option<&Region>) -> Held {
784 let key = if self.hidden || !self.key.is_empty() { self.key.clone() } else { phones::key(&self.number, region) };
785 Held { key, number: String::new(), presentation: 0, device: String::new(), ..self.clone() }
786 }
787}
788
789pub fn read_held(path: &Path) -> Vec<Held> {
791 let mut held: Vec<Held> = read_lines::<Held>(path).into_iter().filter(|h| h.at > 0).collect();
792 held.sort_by_key(|h| h.at);
793 held
794}
795
796pub(crate) fn read_lines<T: serde::de::DeserializeOwned>(path: &Path) -> Vec<T> {
799 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()
800}
801
802pub fn own_file(root: &Path, folder: &str, device: &str) -> PathBuf {
806 root.join(folder).join(format!("{}.jsonl", file_safe(device)))
807}
808
809pub(crate) fn file_safe(device: &str) -> String {
811 let safe: String = device.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
812 if safe.is_empty() { "this-device".to_string() } else { safe }
813}
814
815pub(crate) fn device_files(folder: &Path) -> Vec<(String, PathBuf)> {
817 let mut files: Vec<(String, PathBuf)> = std::fs::read_dir(folder)
818 .into_iter()
819 .flatten()
820 .filter_map(Result::ok)
821 .map(|e| e.path())
822 .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('.')))
823 .filter_map(|p| Some((p.file_stem()?.to_string_lossy().to_string(), p)))
824 .collect();
825 files.sort();
826 files
827}
828
829pub fn read_logs(root: &Path, now_ms: i64) -> Vec<Held> {
834 let mut calls: Vec<Held> = device_files(&root.join(LOG))
835 .into_iter()
836 .flat_map(|(device, path)| read_held(&path).into_iter().map(move |h| Held { device: device.clone(), ..h }))
837 .filter(|h| h.who != "blocked" && now_ms - h.at <= KEPT_DAYS * DAY_MS)
838 .collect();
839 calls.sort_by(|a, b| (a.at, &a.device).cmp(&(b.at, &b.device)));
840 calls
841}
842
843pub fn carry_into(own: &Path, java: &[Held], region: Option<&Region>, now_ms: i64) -> Result<usize, String> {
849 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();
850 if fresh.is_empty() {
851 return Ok(0);
852 }
853 crate::filelock::with_lock(own, || {
854 let mut known: BTreeSet<String> = read_held(own).iter().map(Held::id).collect();
855 let mut text = String::new();
856 let mut written = 0;
857 for line in fresh {
858 if known.insert(line.id()) {
859 text.push_str(&serde_json::to_string(&line).map_err(|e| e.to_string())?);
860 text.push('\n');
861 written += 1;
862 }
863 }
864 if written > 0 {
865 append_private(own, &text)?;
866 }
867 Ok(written)
868 })
869}
870
871pub(crate) fn append_private(path: &Path, text: &str) -> Result<(), String> {
874 use std::io::Write;
875 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
876 if let Some(folder) = path.parent() {
877 let mut builder = std::fs::DirBuilder::new();
878 builder.recursive(true);
879 #[cfg(unix)]
880 std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
881 builder.create(folder).map_err(fail)?;
882 }
883 let mut options = std::fs::OpenOptions::new();
884 options.create(true).append(true);
885 #[cfg(unix)]
886 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
887 options.open(path).and_then(|mut file| file.write_all(text.as_bytes())).map_err(fail)
888}
889
890pub(crate) fn rewrite_lines(path: &Path, drop: impl Fn(&str) -> bool) -> Result<usize, String> {
894 crate::filelock::with_lock(path, || {
895 let Ok(text) = std::fs::read_to_string(path) else { return Ok(0) };
896 let (kept, gone): (Vec<&str>, Vec<&str>) = text.lines().filter(|l| !l.trim().is_empty()).partition(|l| !drop(l));
897 if gone.is_empty() {
898 return Ok(0);
899 }
900 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
901 let fresh = path.with_file_name(format!(".{name}.{}.new", std::process::id()));
902 let _ = std::fs::remove_file(&fresh);
903 let body: String = kept.iter().map(|l| format!("{l}\n")).collect();
904 append_private(&fresh, &body).and_then(|()| {
905 std::fs::rename(&fresh, path).map_err(|e| {
906 let _ = std::fs::remove_file(&fresh);
907 format!("{}: {e}", path.display())
908 })
909 })?;
910 Ok(gone.len())
911 })
912}
913
914pub fn trim_own(root: &Path, device: &str, now_ms: i64) -> Result<usize, String> {
918 let old = older_than(KEPT_DAYS, now_ms);
919 Ok(rewrite_lines(&own_file(root, LOG, device), &old)? + rewrite_lines(&own_file(root, SEEN_LOG, device), &old)?)
920}
921
922pub fn trim_others(root: &Path, here: &str, now_ms: i64) -> Result<usize, String> {
928 let old = older_than(KEPT_DAYS + LATE_DAYS, now_ms);
929 let mut gone = 0;
930 for folder in [LOG, SEEN_LOG] {
931 for (device, path) in device_files(&root.join(folder)) {
932 if device != file_safe(here) {
933 gone += rewrite_lines(&path, &old)?;
934 }
935 }
936 }
937 Ok(gone)
938}
939
940fn older_than(days: i64, now_ms: i64) -> impl Fn(&str) -> bool {
942 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)
943}
944
945#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
947pub struct SeenLine {
948 pub at: i64,
949 #[serde(default)]
950 pub ids: Vec<String>,
951}
952
953#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
955pub struct Seen {
956 #[serde(default)]
957 pub seen: BTreeSet<String>,
958}
959
960impl Seen {
961 pub fn load(path: &Path) -> Seen {
963 std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
964 }
965
966 pub fn add(&mut self, ids: &[String], now: i64) {
968 self.seen.extend(ids.iter().cloned());
969 let oldest = now - (LISTED_DAYS + 1) * DAY_MS;
970 self.seen.retain(|id| id.split('-').next().and_then(|at| at.parse::<i64>().ok()).is_some_and(|at| at >= oldest));
971 }
972}
973
974pub fn read_seen(root: &Path) -> Seen {
978 let mut seen = Seen::default();
979 for (_, path) in device_files(&root.join(SEEN_LOG)) {
980 for line in read_lines::<SeenLine>(&path) {
981 seen.seen.extend(line.ids);
982 }
983 }
984 seen
985}
986
987pub fn mark_seen(root: &Path, device: &str, ids: &[String], now_ms: i64) -> Result<i64, String> {
991 let path = own_file(root, SEEN_LOG, device);
992 let text = serde_json::to_string(&SeenLine { at: now_ms, ids: ids.to_vec() }).map_err(|e| e.to_string())? + "\n";
993 crate::filelock::with_lock(&path, || append_private(&path, &text))?;
994 Ok(now_ms)
995}
996
997pub fn unmark_seen(root: &Path, device: &str, at: i64) -> Result<bool, String> {
1001 Ok(rewrite_lines(&own_file(root, SEEN_LOG, device), |line| serde_json::from_str::<SeenLine>(line).is_ok_and(|s| s.at == at))? > 0)
1002}
1003
1004pub fn adopt_older_seen(root: &Path, device: &str, now_ms: i64) -> Result<(), String> {
1007 let older = root.join(SEEN);
1008 if !older.exists() {
1009 return Ok(());
1010 }
1011 let ids: Vec<String> = Seen::load(&older).seen.into_iter().collect();
1012 if !ids.is_empty() {
1013 mark_seen(root, device, &ids, now_ms)?;
1014 }
1015 std::fs::remove_file(&older).map_err(|e| format!("{}: {e}", older.display()))
1016}
1017
1018#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1020pub struct Message {
1021 pub text: String,
1023 pub path: String,
1025 pub sound: Option<u32>,
1026}
1027
1028#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1030pub struct Line {
1031 pub ids: Vec<String>,
1033 pub text: String,
1036 pub number: String,
1038 pub dial: String,
1040 pub known: bool,
1042 pub hidden: bool,
1043 pub doubt: String,
1045 pub message: Option<Message>,
1046 pub why: String,
1048 pub again: String,
1051}
1052
1053pub struct Lister<'a> {
1055 pub now: &'a Zoned,
1056 pub tr: &'a Translator,
1057 pub region: Option<&'static Region>,
1058 pub name_of: &'a dyn Fn(&str) -> Option<String>,
1060 pub shows: &'a dyn Fn(&Held) -> bool,
1062 pub phone: &'a dyn Fn(&str) -> String,
1066}
1067
1068pub fn lines(calls: &[Held], seen: &Seen, messages: &BTreeMap<String, Message>, l: &Lister) -> Vec<Line> {
1075 let now_ms = l.now.timestamp().as_millisecond();
1076 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();
1077 shown.sort_by_key(|h| h.at);
1078 let mut groups: Vec<(&str, String, &str, jiff::civil::Date, Vec<&Held>)> = Vec::new();
1080 for h in shown {
1081 let date = local(h.at, l.now).date();
1082 let caller = if h.hidden { "hidden".to_string() } else { h.key.clone() };
1083 match groups.iter_mut().find(|(d, c, col, day, _)| *d == h.device && *c == caller && *col == h.column && *day == date) {
1084 Some((_, _, _, _, group)) => group.push(h),
1085 None => groups.push((h.device.as_str(), caller, h.column.as_str(), date, vec![h])),
1086 }
1087 }
1088 groups.into_iter().map(|(device, _, column, date, group)| line(&group, calls, device, column, date, messages, l)).collect()
1089}
1090
1091pub(crate) fn local(at_ms: i64, now: &Zoned) -> Zoned {
1092 jiff::Timestamp::from_millisecond(at_ms).map_or_else(|_| now.clone(), |t| t.to_zoned(now.time_zone().clone()))
1093}
1094
1095fn rang_after<'a>(calls: &'a [Held], group: &[&Held], date: jiff::civil::Date, l: &Lister) -> Option<&'a Held> {
1099 let last = group.last()?;
1100 if last.hidden {
1101 return None;
1102 }
1103 let now_ms = l.now.timestamp().as_millisecond();
1104 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)
1105}
1106
1107pub(crate) fn day_words(tr: &Translator, date: jiff::civil::Date, today: jiff::civil::Date) -> String {
1110 if date == today {
1111 tr.text("calls-day-today", None)
1112 } else if today.yesterday().is_ok_and(|y| y == date) {
1113 tr.text("calls-day-yesterday", None)
1114 } else {
1115 format!("{} {}", tr.text(&format!("weekday-{}", date.weekday().to_monday_one_offset()), None), tr.day_month(date))
1116 }
1117}
1118
1119fn line(group: &[&Held], calls: &[Held], device: &str, column: &str, date: jiff::civil::Date, messages: &BTreeMap<String, Message>, l: &Lister) -> Line {
1120 let tr = l.tr;
1121 let first = group[0];
1122 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)) };
1123 let who = if first.hidden {
1124 tr.text("calls-who-hidden", None)
1125 } else {
1126 name.clone().unwrap_or_else(|| tr.text("calls-who-stranger", None))
1127 };
1128 let context_words = context(tr, column);
1129 let today = l.now.date();
1130 let context_said = if date == today {
1131 context_words.clone()
1132 } else {
1133 let mut args = crate::i18n::args();
1134 args.set("day", day_words(tr, date, today));
1135 args.set("context", context_words.clone());
1136 tr.text("calls-context-day", Some(&args))
1137 };
1138 let time = |h: &Held| local(h.at, l.now).strftime("%H:%M").to_string();
1139 let phone = (l.phone)(device);
1141 let on = if phone.is_empty() { "" } else { "-on" };
1142 let mut args = crate::i18n::args();
1143 args.set("context", context_said);
1144 args.set("who", who);
1145 args.set("phone", phone.clone());
1146 let text = match group.len() {
1147 1 => {
1148 args.set("time", time(first));
1149 tr.text(&format!("calls-line-once{on}"), Some(&args))
1150 }
1151 2 => {
1152 args.set("first", time(first));
1153 args.set("second", time(group[1]));
1154 tr.text(&format!("calls-line-twice{on}"), Some(&args))
1155 }
1156 n => {
1157 args.set("count", tr.count(n, false, false));
1158 args.set("time", time(group[n - 1]));
1159 tr.text(&format!("calls-line-more{on}"), Some(&args))
1160 }
1161 };
1162 let rang = rang_after(calls, group, date, l);
1163 let again = rang
1164 .map(|h| {
1165 let mut args = crate::i18n::args();
1166 args.set("time", time(h));
1167 tr.text("calls-again-rang", Some(&args))
1168 })
1169 .unwrap_or_default();
1170 let ids: Vec<String> = group.iter().map(|h| h.id()).collect();
1171 let message = ids.iter().chain(rang.map(Held::id).iter()).find_map(|id| messages.get(id)).cloned();
1173 let row = if first.hidden { "hidden" } else if name.is_none() { "stranger" } else { first.who.trim_start_matches("always-") };
1175 let mut why_args = crate::i18n::args();
1176 why_args.set("context", context_words);
1177 let why = match row {
1178 "safe" | "neutral" | "restricted" | "stranger" | "hidden" => tr.text(&format!("calls-why-{row}"), Some(&why_args)),
1179 _ => String::new(),
1180 };
1181 Line {
1182 ids,
1183 text: capitalized(&text),
1184 number: if first.hidden { String::new() } else { shown_number(&first.key, l.region) },
1185 dial: if first.hidden { String::new() } else { first.key.clone() },
1186 known: name.is_some(),
1187 hidden: first.hidden,
1188 doubt: if message.is_none() { tr.text("calls-may-have-left", None) } else { String::new() },
1189 message,
1190 why,
1191 again,
1192 }
1193}
1194
1195pub(crate) fn context(tr: &Translator, column: &str) -> String {
1197 match column {
1198 "work" | "admin" | "leisure" | "meals" | "sleep" | "pause" | "free" | "slot" | "dnd" => tr.text(&format!("calls-context-{column}"), None),
1199 _ => tr.text("calls-context-any", None),
1200 }
1201}
1202
1203pub fn phone_words(tr: &Translator, device: &str, here: &str, phones: &[(String, String)], this_is_a_phone: bool) -> String {
1208 if device == here {
1209 return String::new();
1210 }
1211 let others: Vec<&(String, String)> = phones.iter().filter(|(id, _)| id != here).collect();
1212 let name = others.iter().find(|(id, _)| id == device).map(|(_, name)| name.trim()).unwrap_or("");
1213 if others.len() > 1 && !name.is_empty() {
1214 let mut args = crate::i18n::args();
1215 args.set("name", name.to_string());
1216 return tr.text("calls-phone-named", Some(&args));
1217 }
1218 tr.text(if this_is_a_phone { "calls-phone-other" } else { "calls-phone-yours" }, None)
1219}
1220
1221pub fn history(calls: &[Held], keys: &BTreeSet<String>, l: &Lister) -> Vec<String> {
1227 let now_ms = l.now.timestamp().as_millisecond();
1228 let several = calls.iter().map(|h| h.device.as_str()).collect::<BTreeSet<_>>().len() > 1;
1229 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()
1230}
1231
1232fn history_line(h: &Held, several: bool, l: &Lister) -> String {
1236 let tr = l.tr;
1237 let at = local(h.at, l.now);
1238 let mut args = crate::i18n::args();
1239 args.set("day", day_words(tr, at.date(), l.now.date()));
1240 args.set("time", at.strftime("%H:%M").to_string());
1241 let when = tr.text("calls-history-when", Some(&args));
1242 let head = if h.column.is_empty() {
1243 when
1244 } else {
1245 let mut args = crate::i18n::args();
1246 args.set("when", when);
1247 args.set("context", context(tr, &h.column));
1248 tr.text("calls-history-head", Some(&args))
1249 };
1250 let phone = if several {
1251 let said = (l.phone)(&h.device);
1252 if said.is_empty() { tr.text("calls-phone-this", None) } else { said }
1253 } else {
1254 String::new()
1255 };
1256 let outcome = if h.rang { "calls-history-rang" } else { "calls-history-declined" };
1257 let mut args = crate::i18n::args();
1258 args.set("phone", phone.clone());
1259 let outcome = tr.text(&format!("{outcome}{}", if phone.is_empty() { "" } else { "-on" }), Some(&args));
1260 let mut args = crate::i18n::args();
1261 args.set("head", head);
1262 args.set("outcome", outcome);
1263 let mut text = capitalized(&tr.text("calls-history-line", Some(&args)));
1264 if let Some(reason) = h.rang.then(|| rang_reason(tr, &h.why)).flatten() {
1265 text.push(' ');
1266 text.push_str(&reason);
1267 }
1268 text
1269}
1270
1271fn rang_reason(tr: &Translator, why: &str) -> Option<String> {
1274 let key = match why {
1275 "repeat" => "calls-rang-repeat",
1276 "through" => "calls-rang-through",
1277 "emergency" => "calls-rang-emergency",
1278 "after-emergency" => "calls-rang-after-emergency",
1279 "no-table" | "no-frame" | "no-row" | "error" => "calls-rang-undecided",
1280 _ => return None,
1281 };
1282 Some(tr.text(key, None))
1283}
1284
1285pub fn capitalized(text: &str) -> String {
1287 let mut chars = text.chars();
1288 match chars.next() {
1289 Some(first) => first.to_uppercase().chain(chars).collect(),
1290 None => String::new(),
1291 }
1292}
1293
1294pub fn length(seconds: u32) -> String {
1296 format!("{}:{:02}", seconds / 60, seconds % 60)
1297}
1298
1299pub fn message_words(tr: &Translator, seconds: Option<u32>) -> String {
1301 match seconds.filter(|s| *s > 0) {
1302 Some(seconds) => {
1303 let mut args = crate::i18n::args();
1304 args.set("length", length(seconds));
1305 tr.text("calls-left-message", Some(&args))
1306 }
1307 None => tr.text("calls-left-message-plain", None),
1308 }
1309}
1310
1311pub fn through_line(tr: &Translator, through: Option<&Through>, emergency_at: i64, now: i64, when: &dyn Fn(i64) -> String) -> String {
1318 let after = emergency_at + AFTER_EMERGENCY_HOURS * 3_600_000;
1319 if emergency_at > 0 && emergency_at <= now && now < after {
1320 let mut args = crate::i18n::args();
1321 args.set("until", when(after));
1322 return tr.text("calls-through-emergency", Some(&args));
1323 }
1324 match through.filter(|t| t.holds(now)) {
1325 Some(t) if t.until > 0 => {
1326 let mut args = crate::i18n::args();
1327 args.set("until", when(t.until));
1328 tr.text("calls-through-until", Some(&args))
1329 }
1330 Some(_) => tr.text("calls-through-on", None),
1331 None => String::new(),
1332 }
1333}
1334
1335#[cfg(test)]
1336mod tests {
1337 use super::*;
1338 use crate::everywhere::Person;
1339
1340 fn fr() -> Option<&'static Region> {
1341 phones::region_named("FR")
1342 }
1343
1344 #[test]
1345 fn java_keys_numbers_as_phones_does() {
1346 let zero = trunk_zero();
1347 let spare = Region { code: "XX", calling: "999", trunk: "", international: "00", digits: (9, 10), french: false };
1353 for (region, numbers) in [
1354 (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"][..]),
1355 (phones::region_named("GB"), &["07700 900123", "020 7946 0018", "+44 20 7946 0018", "0044 7700 900123", "999"][..]),
1356 (phones::region_named("US"), &["(212) 555-0100", "1 212 555 0100", "011 33 6 39 98 12 34", "911"][..]),
1357 (Some(&spare), &["639 98 12 34", "0639 98 12 34", "+999 0639 98 12 34", "112"][..]),
1358 ] {
1359 let rules = region.map(RegionRules::of);
1360 let country = region.map_or("none", |r| r.code);
1361 for number in numbers {
1362 assert_eq!(incoming_key(number, rules.as_ref(), &zero), phones::key(number, region), "{country}: {number}");
1363 }
1364 }
1365 assert_eq!(phones::key("07700 900123", phones::region_named("GB")), "+447700900123");
1367 assert_eq!((phones::key("639 98 12 34", Some(&spare)), phones::key("0639 98 12 34", Some(&spare))), ("+999639981234".to_string(), "+9990639981234".to_string()));
1368 assert_eq!(incoming_key("1-555-SIOUL", None, &zero), "1-555-sioul");
1369 assert_eq!(incoming_key("06 39 98 12 34", None, &zero), phones::key("06 39 98 12 34", None));
1371 assert_eq!(incoming_key("+33 05 36 49 12 34", None, &zero), "+33536491234");
1372 let rules = RegionRules::of(fr().unwrap());
1374 for (prefix, calling) in FRENCH_OVERSEAS {
1375 let number = format!("0{prefix}981234");
1376 assert_eq!(phones::key(&number, fr()), format!("+{calling}{prefix}981234"), "{number}");
1377 assert_eq!(incoming_key(&number, Some(&rules), &zero), phones::key(&number, fr()));
1378 }
1379 }
1380
1381 fn decision_table() -> Table {
1385 let mut people = People::default();
1386 people.add(Person { name: "Carer".into(), phones: vec!["01 99 00 00 09".into(), "01 99 00 00 04".into()], ..Person::default() }, fr());
1387 let blocked = |key: &str| if key == "+33199000004" { Who::Blocked } else { Who::Stranger };
1388 let work = (ms("2026-10-05T09:00[Europe/Paris]"), ms("2026-10-05T17:00[Europe/Paris]"));
1389 let leisure = (work.1, ms("2026-10-05T22:00[Europe/Paris]"));
1390 let sleep = (leisure.1, ms("2026-10-06T07:00[Europe/Paris]"));
1391 table(Made {
1392 made: 0,
1393 region: fr(),
1394 numbers: vec![
1395 ("+33199000001".into(), "safe".into()),
1396 ("+33199000002".into(), "neutral".into()),
1397 ("+33199000003".into(), "restricted".into()),
1398 ("+33199000004".into(), "blocked".into()),
1399 ],
1400 prefixes: vec![("+3346571*".into(), "blocked".into())],
1401 always: always_numbers(&people, fr(), &blocked),
1402 through: None,
1403 frames: vec![
1404 frame(ms("2026-10-05T00:00[Europe/Paris]"), work.0, "sleep", &["always-stranger"]),
1405 frame(work.0, work.1, "work", &["safe", "neutral", "restricted", "hidden", "always-stranger"]),
1406 frame(leisure.0, leisure.1, "leisure", &["safe", "always-stranger"]),
1407 frame(sleep.0, sleep.1, "sleep", &["always-stranger"]),
1408 ],
1409 })
1410 }
1411
1412 fn call(number: &str, at: &str) -> Incoming {
1413 Incoming { number: number.into(), hidden: number.is_empty(), now: ms(&format!("2026-10-05T{at}[Europe/Paris]")), ..Incoming::default() }
1414 }
1415
1416 #[test]
1417 fn who_rings_when_and_what_always_rings() {
1418 let t = decision_table();
1419 let said = |c: &Incoming| {
1420 let d = decide(Some(&t), c);
1421 (d.refuse, d.why, d.who)
1422 };
1423 for (number, at, refused, who) in [
1425 ("01 99 00 00 01", "10:00", false, "safe"),
1426 ("01 99 00 00 01", "18:00", false, "safe"),
1427 ("01 99 00 00 01", "23:00", true, "safe"),
1428 ("01 99 00 00 02", "10:00", false, "neutral"),
1429 ("01 99 00 00 02", "18:00", true, "neutral"),
1430 ("+33 1 99 00 00 03", "10:00", false, "restricted"),
1431 ("0199000003", "18:00", true, "restricted"),
1432 ("01 99 00 55 55", "10:00", true, "stranger"),
1433 ("", "10:00", false, "hidden"),
1434 ("", "18:00", true, "hidden"),
1435 ] {
1436 let (refuse, why, w) = said(&call(number, at));
1437 assert_eq!((refuse, why, w.as_str()), (refused, "matrix", who), "{number} at {at}");
1438 }
1439 assert_eq!(said(&call("01 99 00 00 04", "10:00")), (true, "blocked", "blocked".to_string()));
1441 assert_eq!(said(&call("04 65 71 12 34", "10:00")), (true, "blocked", "blocked".to_string()));
1442 let again = Incoming { last_call: ms("2026-10-05T09:55[Europe/Paris]"), ..call("01 99 00 00 04", "10:00") };
1443 assert_eq!(said(&again).1, "blocked");
1444 assert_eq!(said(&Incoming { phone_contact: true, ..call("01 99 00 55 55", "10:00") }), (false, "matrix", "neutral".to_string()));
1446 assert_eq!(said(&Incoming { phone_contact: true, ..call("01 99 00 55 55", "18:00") }), (true, "matrix", "neutral".to_string()));
1447 let first = ms("2026-10-05T18:00[Europe/Paris]");
1449 assert_eq!(said(&Incoming { last_call: first, ..call("01 99 00 55 55", "18:10") }).1, "repeat");
1450 assert_eq!(said(&Incoming { last_call: first, ..call("01 99 00 55 55", "18:16") }), (true, "matrix", "stranger".to_string()));
1451 assert_eq!(said(&Incoming { last_call: first, ..call("", "18:10") }), (true, "matrix", "hidden".to_string()));
1452 for number in ["112", "15", "0 800 112 112", "+33800112112"] {
1454 assert_eq!(said(&call(number, "23:00")).1, "emergency", "{number}");
1455 }
1456 assert_eq!(said(&Incoming { system_emergency: true, ..call("17", "23:00") }).1, "emergency");
1457 assert_eq!(said(&call("17", "23:00")).0, true, "a short code Android does not call an emergency number: a stranger");
1458 assert_eq!(said(&call("01 99 00 00 09", "23:00")), (false, "matrix", "always-stranger".to_string()));
1460 assert_eq!(said(&call("01 99 00 00 04", "23:00")).1, "blocked");
1461 assert!(t.floors.people.is_empty(), "no floor before the blocked any more");
1462 let called = ms("2026-10-05T03:00[Europe/Paris]");
1464 assert_eq!(said(&Incoming { emergency_at: called, ..call("01 99 00 00 04", "23:00") }).1, "after-emergency");
1465 assert_eq!(said(&Incoming { emergency_at: called - 86_400_000, ..call("01 99 00 55 55", "23:00") }).1, "matrix");
1466 let on = Through { pressed: 10, on: true, until: 0 };
1468 let mut through = t.clone();
1469 through.through = Some(on);
1470 assert_eq!(decide(Some(&through), &call("01 99 00 55 55", "23:00")).why, "through");
1471 assert_eq!(decide(Some(&through), &call("01 99 00 00 04", "23:00")).why, "blocked", "blocked entirely: never let through");
1472 let off_here = Through { pressed: 11, on: false, until: 0 };
1473 assert!(decide(Some(&through), &Incoming { local: Some(off_here), ..call("01 99 00 55 55", "23:00") }).refuse, "turned off on the phone after");
1474 let ended = Through { pressed: 12, on: true, until: ms("2026-10-05T22:30[Europe/Paris]") };
1475 assert!(decide(Some(&t), &Incoming { local: Some(ended), ..call("01 99 00 55 55", "23:00") }).refuse, "an hour that ended");
1476 assert_eq!(decide(None, &call("01 99 00 55 55", "23:00")).why, "no-table");
1478 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()));
1479 assert_eq!(said(&Incoming { outgoing: true, ..call("112", "23:00") }).1, "outgoing");
1480 }
1481
1482 #[test]
1483 fn frames_follow_the_day_and_its_change_of_hour() {
1484 use crate::needs::{Days, Needs};
1485 let config: crate::config::Config = toml::from_str(
1486 &["monday", "tuesday", "wednesday", "thursday", "friday"].iter().map(|d| format!("[[window]]\nday = \"{d}\"\nstart = \"09:00\"\nend = \"17:00\"\n\n")).collect::<String>(),
1487 )
1488 .unwrap();
1489 let needs = Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() };
1490 let clock = Clock::new(&config, crate::quiet::Overrides::default(), needs, Days::default(), Vec::new());
1491 let attention = Attention::usual();
1492 let none = Layers::default();
1493 let from = at("2026-10-05T00:00[Europe/Paris]");
1494 let until = at("2026-10-06T00:00[Europe/Paris]");
1495 let frames = frames(&attention, &clock, &none, &from, &until);
1496 assert_eq!(frames.first().unwrap().from, from.timestamp().as_millisecond());
1498 assert_eq!(frames.last().unwrap().until, until.timestamp().as_millisecond());
1499 assert!(frames.windows(2).all(|w| w[0].until == w[1].from), "{frames:?}");
1500 let table = Table { frames, ..table(Made { made: 0, region: fr(), numbers: Vec::new(), prefixes: Vec::new(), always: Vec::new(), through: None, frames: Vec::new() }) };
1501 let at_ = |time: &str| table.frame_at(ms(&format!("2026-10-05T{time}[Europe/Paris]"))).unwrap();
1502 for (time, column, ringing) in [
1504 ("03:00", "sleep", vec![]),
1505 ("08:00", "meals", vec!["safe", "neutral"]),
1506 ("10:00", "work", vec!["safe", "neutral", "restricted", "hidden"]),
1507 ("12:30", "meals", vec!["safe", "neutral"]),
1508 ("14:10", "sleep", vec![]),
1509 ("18:00", "leisure", vec!["safe", "neutral"]),
1510 ("22:30", "sleep", vec![]),
1511 ] {
1512 let f = at_(time);
1513 assert_eq!(f.column, column, "{time}");
1514 let rings: Vec<&str> = ["safe", "neutral", "restricted", "stranger", "hidden"].into_iter().filter(|r| f.ring[*r]).collect();
1515 assert_eq!(rings, ringing, "{time}");
1516 assert!(f.ring["always-stranger"], "Always through rings at any time: {time}");
1517 }
1518 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());
1521 let layers = Layers { slots: vec![(dnd_until + 600, dnd_until + 1800)], dnd: Some((dnd_from, Some(dnd_until))) };
1522 let table = Table { frames: super::frames(&attention, &clock, &layers, &from, &until), ..table.clone() };
1523 let at_ = |time: &str| table.frame_at(ms(&format!("2026-10-05T{time}[Europe/Paris]"))).unwrap();
1524 assert_eq!((at_("18:30").column.as_str(), at_("18:30").ring["safe"], at_("18:30").ring["always-safe"]), ("dnd", false, true));
1525 assert_eq!((at_("17:30").column.as_str(), at_("17:30").ring["safe"]), ("leisure", true));
1526 assert_eq!((at_("19:15").column.as_str(), at_("19:15").ring["safe"]), ("slot", true));
1527 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() };
1529 let clock_free = Clock::new(&config, free, Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() }, Days::default(), Vec::new());
1530 let table = Table { frames: super::frames(&attention, &clock_free, &none, &from, &until), ..table.clone() };
1531 let f = table.frame_at(ms("2026-10-05T11:00[Europe/Paris]")).unwrap();
1532 assert_eq!((f.column.as_str(), f.ring["safe"], f.ring["always-neutral"]), ("free", false, true));
1533 let from = at("2026-10-25T00:00[Europe/Paris]");
1535 let until = at("2026-10-26T00:00[Europe/Paris]");
1536 let frames = super::frames(&attention, &clock, &none, &from, &until);
1537 assert!(frames.windows(2).all(|w| w[0].until == w[1].from));
1538 assert_eq!(frames.last().unwrap().until - frames.first().unwrap().from, 25 * 3_600_000);
1539 let paused = crate::quiet::Overrides { paused_since: Some(from.timestamp().as_second()), ..crate::quiet::Overrides::default() };
1541 let clock = Clock::new(&config, paused, Needs::default(), Days::default(), Vec::new());
1542 let frames = super::frames(&attention, &clock, &none, &from, &until);
1543 assert!(frames.iter().all(|f| f.column == "pause" && f.ring.iter().all(|(row, r)| *r == row.starts_with("always-"))), "{frames:?}");
1544 assert_eq!(frames.last().unwrap().until, until.timestamp().as_millisecond());
1545 }
1546
1547 #[test]
1548 fn numbers_shown_as_people_write_them() {
1549 assert_eq!(shown_number("+33199001234", fr()), "01 99 00 12 34");
1550 assert_eq!(shown_number("+262692123456", fr()), "06 92 12 34 56");
1551 assert_eq!(shown_number("+442079460018", fr()), "+442079460018");
1552 assert_eq!(shown_number("112", fr()), "112");
1553 assert_eq!(shown_number("+442079460018", phones::region_named("GB")), "02079460018");
1554 assert_eq!(shown_number("+33199001234", None), "+33199001234");
1555 }
1556
1557 #[test]
1558 fn the_floors_hold_emergency_numbers_and_the_list_s_people() {
1559 let keys = emergency_keys(fr());
1560 for number in ["112", "15", "114", "3114", "+33800112112", "999", "911", "988"] {
1561 assert!(keys.iter().any(|k| k == number), "{number} in {keys:?}");
1562 }
1563 let mut people = People::default();
1564 people.add(Person { name: "Alice".into(), phones: vec!["01 99 00 00 01".into(), "3114".into()], ..Person::default() }, fr());
1565 assert_eq!(people_keys(&people, fr()), vec!["+33199000001".to_string()], "a short code is nobody's");
1566 assert_eq!(always_numbers(&people, fr(), &|_| Who::Neutral), vec![("+33199000001".to_string(), "always-neutral".to_string())]);
1567 assert!(always_numbers(&people, fr(), &|_| Who::Blocked).is_empty(), "blocked beats Always through");
1568 assert_eq!(phones::key("06 39 98 00 01", fr()), "+262639980001");
1570 }
1571
1572 fn frame(from: i64, until: i64, column: &str, ringing: &[&str]) -> Frame {
1573 Frame { from, until, column: column.into(), ring: ROWS.iter().map(|r| (r.to_string(), ringing.contains(r))).collect() }
1574 }
1575
1576 #[test]
1577 fn the_table_says_what_java_needs_and_nothing_twice() {
1578 let mut people = People::default();
1579 people.add(Person { name: "Alice".into(), phones: vec!["01 99 00 00 01".into()], ..Person::default() }, fr());
1580 let made = |at: i64| Made {
1581 made: at,
1582 region: fr(),
1583 numbers: vec![("+262639980002".into(), "safe".into()), ("+33199001234".into(), "blocked".into()), ("+33199009999".into(), "stranger".into()), (String::new(), "safe".into())],
1584 prefixes: vec![("+33465711*".into(), "blocked".into()), ("+3346571*".into(), "safe".into()), ("+334657112*".into(), "neutral".into()), ("*".into(), "blocked".into())],
1585 always: always_numbers(&people, fr(), &|_| Who::Safe),
1586 through: Some(Through { pressed: 5, on: true, until: 0 }),
1587 frames: vec![frame(0, 1_000, "work", &["safe", "neutral", "restricted", "hidden"]), frame(1_000, 2_000, "sleep", &[])],
1588 };
1589 let table = table(made(42));
1590 assert_eq!(table.v, VERSION);
1591 assert_eq!(table.numbers.len(), 3, "strangers and empty keys are not written: {:?}", table.numbers);
1592 assert_eq!((table.numbers["+33199001234"].as_str(), table.numbers["+33199000001"].as_str()), ("blocked", "always-safe"));
1593 assert_eq!(table.prefixes.iter().map(|p| (p.prefix.as_str(), p.who.as_str())).collect::<Vec<_>>(), [("+334657112", "neutral"), ("+33465711", "blocked"), ("+3346571", "safe")]);
1595 assert!(table.floors.people.is_empty());
1596 assert!(table.floors.emergency.contains(&"+33800112112".to_string()));
1597 assert_eq!(table.region.as_ref().map(|r| (r.calling.as_str(), r.trunk.as_str(), r.digits)), Some(("33", "0", [10, 10])));
1598 assert_eq!(table.region.as_ref().unwrap().overseas.get("692").map(String::as_str), Some("262"));
1599 assert!(table.trunk_zero.contains(&"33".to_string()) && !table.trunk_zero.contains(&"39".to_string()));
1600 assert_eq!((table.repeat_minutes, table.emergency_hours, table.phone_contacts.as_str()), (15, 24, "neutral"));
1601 assert_eq!(content(&table), content(&self::table(made(43))));
1603 let json: serde_json::Value = serde_json::from_str(&serde_json::to_string(&table).unwrap()).unwrap();
1605 for key in ["v", "made", "region", "trunk_zero", "numbers", "prefixes", "phone_contacts", "floors", "through", "frames", "repeat_minutes", "emergency_hours"] {
1606 assert!(json.get(key).is_some(), "{key}");
1607 }
1608 assert_eq!(json["frames"][0]["ring"]["hidden"], true);
1609 assert_eq!(json["frames"][1]["column"], "sleep");
1610 assert_eq!(json["through"]["on"], true);
1611 let dir = std::env::temp_dir().join(format!("sioul-calls-table-{}", std::process::id()));
1613 let path = dir.join(TABLE);
1614 write(&path, &table).unwrap();
1615 let back: Table = read_json(&path).unwrap();
1616 assert_eq!(back, table);
1617 let _ = std::fs::remove_dir_all(&dir);
1618 }
1619
1620 #[test]
1621 fn the_latest_press_anywhere_lets_calls_through() {
1622 let mut switch = Switch::default();
1623 assert_eq!(through_of(&switch), None);
1624 let stamp = press(&mut switch, "desk", true, 1_000 + 3_600_000, 1_000);
1626 assert_eq!(stamp, 1_000);
1627 let t = through_of(&switch).unwrap();
1628 assert!(t.holds(2_000) && !t.holds(1_000 + 3_600_000));
1629 let stamp = press(&mut switch, "phone", false, 0, 500);
1631 assert_eq!(stamp, 1_001);
1632 assert!(!through_of(&switch).unwrap().holds(2_000));
1633 press(&mut switch, "desk", true, 0, 9_000);
1635 assert!(through_of(&switch).unwrap().holds(i64::MAX - 1));
1636 assert!(switch.device["desk"].pressed == 0 && !switch.device["desk"].on);
1638 let text = toml::to_string(&switch).unwrap();
1639 assert!(text.contains("[device.desk.calls]"), "{text}");
1640 let back: Switch = toml::from_str(&text).unwrap();
1641 assert_eq!(through_of(&back), through_of(&switch));
1642 }
1643
1644 #[test]
1645 fn the_phone_s_own_press_is_carried_once_with_its_own_time() {
1646 let mut switch = Switch::default();
1647 let local = Through { pressed: 36_000_000, on: true, until: 36_000_000 + 3_600_000 };
1649 press(&mut switch, "desk", false, 0, 37_800_000);
1650 assert!(merge_local(&mut switch, "phone", &local));
1651 assert!(!merge_local(&mut switch, "phone", &local), "once");
1652 let held = through_of(&switch).unwrap();
1654 assert!(!held.on && held.pressed == 37_800_000);
1655 let later = Through { pressed: 38_000_000, on: true, until: 0 };
1657 assert!(merge_local(&mut switch, "phone", &later));
1658 assert!(through_of(&switch).unwrap().holds(40_000_000));
1659 assert!(!screens_anywhere(&switch));
1661 assert!(set_screens(&mut switch, "phone", true) && !set_screens(&mut switch, "phone", true));
1662 assert!(screens_anywhere(&switch));
1663 }
1664
1665 fn held(at: i64, key: &str, who: &str, column: &str) -> Held {
1666 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() }
1667 }
1668
1669 fn at(text: &str) -> Zoned {
1670 text.parse().unwrap()
1671 }
1672
1673 fn ms(text: &str) -> i64 {
1674 at(text).timestamp().as_millisecond()
1675 }
1676
1677 #[test]
1678 fn the_list_says_who_called_when_calmly() {
1679 let now = at("2026-10-07T10:00[Europe/Paris]");
1680 let calls = vec![
1681 held(ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep"),
1682 held(ms("2026-10-07T06:21[Europe/Paris]"), "+33199001234", "stranger", "sleep"),
1683 held(ms("2026-10-07T07:00[Europe/Paris]"), "", "hidden", "sleep"),
1684 held(ms("2026-10-06T23:10[Europe/Paris]"), "+262639980002", "neutral", "sleep"),
1685 held(ms("2026-10-07T09:30[Europe/Paris]"), "+33199005555", "blocked", "work"),
1686 held(ms("2026-10-05T12:30[Europe/Paris]"), "+33199007777", "stranger", "meals"),
1687 held(ms("2026-10-05T12:31[Europe/Paris]"), "+33199007777", "stranger", "meals"),
1688 held(ms("2026-10-05T12:40[Europe/Paris]"), "+33199007777", "stranger", "meals"),
1689 ];
1690 let names = |key: &str| (key == "+262639980002").then(|| "Dr Martin's office".to_string());
1691 let all = |_: &Held| true;
1692 let here = |_: &str| String::new();
1693 for (language, expected) in [
1694 (
1695 "en",
1696 [
1697 "Monday 5 October, during a meal, a number not in your contacts called three times, the last at 12:40.",
1698 "Yesterday, while you slept, Dr Martin's office called at 23:10.",
1699 "While you slept, a number not in your contacts called twice, at 06:10 and 06:21.",
1700 "While you slept, a hidden number called at 07:00.",
1701 ],
1702 ),
1703 (
1704 "fr",
1705 [
1706 "Lundi 5 octobre, pendant un repas, un numéro absent de vos contacts a appelé trois fois, la dernière à 12:40.",
1707 "Hier, pendant votre sommeil, Dr Martin's office a appelé à 23:10.",
1708 "Pendant votre sommeil, un numéro absent de vos contacts a appelé deux fois, à 06:10 et à 06:21.",
1709 "Pendant votre sommeil, un numéro masqué a appelé à 07:00.",
1710 ],
1711 ),
1712 ] {
1713 let tr = Translator::new(language);
1714 let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &names, shows: &all, phone: &here };
1715 let lines = lines(&calls, &Seen::default(), &BTreeMap::new(), &l);
1716 assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), expected, "{language}");
1717 assert!(lines.iter().all(|l| !l.dial.contains("5555")));
1719 assert!(lines.iter().all(|l| !l.doubt.is_empty()));
1721 }
1722 let tr = Translator::new("en");
1723 let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &names, shows: &all, phone: &here };
1724 let lines_en = lines(&calls, &Seen::default(), &BTreeMap::new(), &l);
1725 let stranger = &lines_en[2];
1726 assert_eq!((stranger.number.as_str(), stranger.dial.as_str(), stranger.known, stranger.hidden), ("01 99 00 12 34", "+33199001234", false, false));
1727 assert_eq!(stranger.doubt, "They may have left a message.");
1728 assert_eq!(stranger.why, "Numbers not in your contacts go to voicemail while you slept.");
1729 assert_eq!(stranger.ids.len(), 2);
1730 let hidden = &lines_en[3];
1731 assert!(hidden.hidden && hidden.number.is_empty() && hidden.dial.is_empty());
1732 assert!(lines_en[1].known);
1733 assert_eq!(lines_en[1].why, "Calls from your neutral contacts go to voicemail while you slept.");
1734 let always = [held(ms("2026-10-07T06:40[Europe/Paris]"), "+262639980002", "always-neutral", "sleep")];
1736 assert_eq!(lines(&always, &Seen::default(), &BTreeMap::new(), &l)[0].why, "Calls from your neutral contacts go to voicemail while you slept.");
1737 let mut seen = Seen::default();
1739 seen.add(&stranger.ids, now.timestamp().as_millisecond());
1740 assert_eq!(lines(&calls, &seen, &BTreeMap::new(), &l).len(), 3);
1741 let contacts_only = |h: &Held| h.who == "neutral";
1743 let l2 = Lister { shows: &contacts_only, ..l };
1744 assert_eq!(lines(&calls, &Seen::default(), &BTreeMap::new(), &l2).len(), 1);
1745 let mut messages = BTreeMap::new();
1747 messages.insert(stranger.ids[1].clone(), Message { text: message_words(&tr, Some(42)), path: "/mail/1".into(), sound: Some(0) });
1748 let with = lines(&calls, &Seen::default(), &messages, &l);
1749 assert_eq!(with[2].message.as_ref().map(|m| m.text.as_str()), Some("They left a message (0:42)."));
1750 assert!(with[2].doubt.is_empty());
1751 }
1752
1753 #[test]
1754 fn old_calls_leave_the_list_and_seen_ids_are_forgotten_with_them() {
1755 let now = at("2026-10-30T10:00[Europe/Paris]");
1756 let calls = vec![held(ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep")];
1757 let tr = Translator::new("en");
1758 let all = |_: &Held| true;
1759 let none = |_: &str| None;
1760 let here = |_: &str| String::new();
1761 let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &here };
1762 assert!(lines(&calls, &Seen::default(), &BTreeMap::new(), &l).is_empty(), "past two weeks");
1763 let mut seen = Seen::default();
1764 seen.add(&["1000-+33199001234".to_string(), format!("{}-x", now.timestamp().as_millisecond())], now.timestamp().as_millisecond());
1765 assert_eq!(seen.seen.len(), 1);
1766 }
1767
1768 #[test]
1769 fn the_held_file_reads_whatever_java_left_in_it() {
1770 let dir = std::env::temp_dir().join(format!("sioul-calls-held-{}", std::process::id()));
1771 let _ = std::fs::remove_dir_all(&dir);
1772 std::fs::create_dir_all(&dir).unwrap();
1773 let path = dir.join(HELD);
1774 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();
1775 let held = read_held(&path);
1776 assert_eq!(held.iter().map(|h| h.at).collect::<Vec<_>>(), vec![1000, 2000], "oldest first, the half line passed over");
1777 assert_eq!(held[0].id(), "1000-hidden");
1778 assert_eq!(held[1].id(), "2000-+33199001234");
1779 let _ = std::fs::remove_dir_all(&dir);
1780 }
1781
1782 fn scratch(name: &str) -> PathBuf {
1783 let dir = std::env::temp_dir().join(format!("sioul-calls-{name}-{}", std::process::id()));
1784 let _ = std::fs::remove_dir_all(&dir);
1785 std::fs::create_dir_all(&dir).unwrap();
1786 dir
1787 }
1788
1789 fn call_on(device: &str, at: i64, key: &str, who: &str, column: &str, why: &str, rang: bool) -> Held {
1791 Held { device: device.into(), rang, why: why.into(), ..held(at, key, who, column) }
1792 }
1793
1794 #[test]
1798 fn a_logged_line_reads_back() {
1799 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();
1800 assert!(!java.rang, "declined, as every line was before");
1801 let logged = java.logged(fr());
1802 assert_eq!(serde_json::to_string(&logged).unwrap(), r#"{"at":1791346200000,"key":"+33199001234","who":"stranger","column":"sleep","why":"matrix","rang":false}"#);
1803 assert_eq!(logged.id(), java.id());
1804 let unkeyed = Held { key: String::new(), why: "no-table".into(), rang: true, ..java.clone() };
1806 assert_eq!(unkeyed.logged(fr()).key, "+33199001234");
1807 let hidden = Held { at: 5, hidden: true, who: "hidden".into(), why: "matrix".into(), ..Held::default() }.logged(fr());
1809 assert_eq!(serde_json::to_string(&hidden).unwrap(), r#"{"at":5,"hidden":true,"who":"hidden","why":"matrix","rang":false}"#);
1810 let named = Held { name: "Cabinet du Dr Martin".into(), verified: "passed".into(), ..java }.logged(fr());
1811 let back: Held = serde_json::from_str(&serde_json::to_string(&named).unwrap()).unwrap();
1812 assert_eq!((back.name.as_str(), back.verified.as_str(), back.number.as_str()), ("Cabinet du Dr Martin", "passed", ""));
1813 }
1814
1815 #[test]
1820 fn java_s_lines_read_here() {
1821 let Some(sample) = std::env::var_os("SIOUL_CALLS_SAMPLE") else { return };
1822 let calls = read_held(Path::new(&sample));
1823 assert_eq!(calls.iter().map(|h| (h.rang, h.why.as_str(), h.column.as_str(), h.hidden)).collect::<Vec<_>>(), [(false, "matrix", "sleep", false), (true, "repeat", "sleep", false), (false, "matrix", "sleep", true), (true, "no-table", "", false)]);
1824 assert_eq!((calls[0].key.as_str(), calls[0].who.as_str()), ("+33199001234", "stranger"));
1825 assert_eq!(calls[3].logged(fr()).key, "+33465710042");
1826 assert_eq!(calls[0].logged(fr()).number, "", "the number as given stays on the phone");
1827 }
1828
1829 #[test]
1832 fn every_phone_s_log_is_read() {
1833 let root = scratch("logs");
1834 let now = ms("2026-10-07T10:00[Europe/Paris]");
1835 let write = |device: &str, lines: &[Held]| {
1836 let text: String = lines.iter().map(|h| serde_json::to_string(&h.logged(fr())).unwrap() + "\n").collect();
1837 std::fs::create_dir_all(root.join(LOG)).unwrap();
1838 std::fs::write(own_file(&root, LOG, device), text).unwrap();
1839 };
1840 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")]);
1841 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")]);
1842 std::fs::write(root.join(LOG).join(".phone-c.jsonl.lock"), "").unwrap();
1843 let calls = read_logs(&root, now);
1844 assert_eq!(calls.iter().map(|h| (h.device.as_str(), h.key.as_str())).collect::<Vec<_>>(), [("phone-b", "+33465710042"), ("phone-a", "+33199001234")]);
1845 let _ = std::fs::remove_dir_all(&root);
1846 }
1847
1848 #[test]
1852 fn the_list_names_the_phone_elsewhere() {
1853 let now = at("2026-10-07T10:00[Europe/Paris]");
1854 let calls = vec![call_on("phone-a", ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep", "matrix", false)];
1855 let none = |_: &str| None;
1856 let all = |_: &Held| true;
1857 let one = [("phone-a".to_string(), "A12".to_string())];
1858 let two = [("phone-a".to_string(), "A12".to_string()), ("phone-b".to_string(), "B7".to_string())];
1859 for (language, own, computer, other, named) in [
1860 (
1861 "en",
1862 "While you slept, a number not in your contacts called at 06:10.",
1863 "While you slept, a number not in your contacts called your phone at 06:10.",
1864 "While you slept, a number not in your contacts called your other phone at 06:10.",
1865 "While you slept, a number not in your contacts called your phone (A12) at 06:10.",
1866 ),
1867 (
1868 "fr",
1869 "Pendant votre sommeil, un numéro absent de vos contacts a appelé à 06:10.",
1870 "Pendant votre sommeil, un numéro absent de vos contacts a appelé votre téléphone à 06:10.",
1871 "Pendant votre sommeil, un numéro absent de vos contacts a appelé votre autre téléphone à 06:10.",
1872 "Pendant votre sommeil, un numéro absent de vos contacts a appelé votre téléphone (A12) à 06:10.",
1873 ),
1874 ] {
1875 let tr = Translator::new(language);
1876 let said = |here: &str, phones: &[(String, String)], phone: bool| {
1877 let words = |device: &str| phone_words(&tr, device, here, phones, phone);
1878 let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &words };
1879 lines(&calls, &Seen::default(), &BTreeMap::new(), &l)[0].text.clone()
1880 };
1881 assert_eq!(said("phone-a", &one, true), own, "{language}: on the phone that screened");
1882 assert_eq!(said("desk", &one, false), computer, "{language}: on a computer");
1883 assert_eq!(said("desk", &[], false), computer, "{language}: without the devices' list");
1884 assert_eq!(said("phone-b", &two, true), other, "{language}: on the other phone");
1885 assert_eq!(said("desk", &two, false), named, "{language}: two phones, from a computer");
1886 }
1887 }
1888
1889 #[test]
1893 fn a_call_that_rang_is_never_listed_but_said() {
1894 let now = at("2026-10-07T10:00[Europe/Paris]");
1895 let calls = vec![
1896 call_on("phone-a", ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep", "matrix", false),
1897 call_on("phone-a", ms("2026-10-07T06:40[Europe/Paris]"), "+33199001234", "stranger", "sleep", "matrix", false),
1898 call_on("phone-a", ms("2026-10-07T06:48[Europe/Paris]"), "+33199001234", "stranger", "sleep", "repeat", true),
1899 call_on("phone-a", ms("2026-10-07T07:00[Europe/Paris]"), "", "hidden", "sleep", "matrix", false),
1900 call_on("phone-a", ms("2026-10-07T07:05[Europe/Paris]"), "", "hidden", "sleep", "repeat", true),
1901 call_on("phone-a", ms("2026-10-07T09:15[Europe/Paris]"), "+33465710042", "safe", "work", "matrix", true),
1902 ];
1903 let none = |_: &str| None;
1904 let all = |_: &Held| true;
1905 let here = |_: &str| String::new();
1906 for (language, first, again) in [
1907 ("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."),
1908 ("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é."),
1909 ] {
1910 let tr = Translator::new(language);
1911 let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &here };
1912 let said = lines(&calls, &Seen::default(), &BTreeMap::new(), &l);
1913 assert_eq!(said.len(), 2, "{language}: the stranger's and the hidden number's; never a call that rang");
1914 assert_eq!((said[0].text.as_str(), said[0].again.as_str()), (first, again), "{language}");
1915 assert_eq!(said[1].again, "", "{language}: two hidden calls cannot be told apart");
1916 }
1917 let tr = Translator::new("en");
1919 let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &here };
1920 let mut messages = BTreeMap::new();
1921 messages.insert(calls[2].id(), Message { text: message_words(&tr, Some(12)), path: "/mail/2".into(), sound: Some(0) });
1922 let said = lines(&calls, &Seen::default(), &messages, &l);
1923 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)."), ""));
1924 let later = [calls[0].clone(), call_on("phone-a", ms("2026-10-08T08:00[Europe/Paris]"), "+33199001234", "stranger", "work", "matrix", true)];
1926 let tomorrow = at("2026-10-08T10:00[Europe/Paris]");
1927 let l = Lister { now: &tomorrow, ..l };
1928 assert_eq!(lines(&later, &Seen::default(), &BTreeMap::new(), &l)[0].again, "");
1929 }
1930
1931 #[test]
1935 fn seen_anywhere_is_seen_everywhere() {
1936 let root = scratch("seen");
1937 let a = "1791346200000-+33199001234".to_string();
1938 let b = "1791349200000-hidden".to_string();
1939 let pressed = mark_seen(&root, "desk", std::slice::from_ref(&a), 1_000).unwrap();
1940 mark_seen(&root, "phone-a", &[a.clone(), b.clone()], 2_000).unwrap();
1941 assert_eq!(read_seen(&root).seen, [a.clone(), b.clone()].into());
1942 assert!(unmark_seen(&root, "desk", pressed).unwrap());
1944 assert!(!unmark_seen(&root, "desk", pressed).unwrap(), "once");
1945 assert_eq!(read_seen(&root).seen, [a.clone(), b.clone()].into());
1946 assert!(unmark_seen(&root, "phone-a", 2_000).unwrap());
1947 assert!(read_seen(&root).seen.is_empty());
1948 std::fs::write(root.join(SEEN), "seen = [\"1791346200000-+33199001234\"]\n").unwrap();
1950 adopt_older_seen(&root, "phone-a", 3_000).unwrap();
1951 assert!(!root.join(SEEN).exists());
1952 assert_eq!(read_seen(&root).seen, [a].into());
1953 adopt_older_seen(&root, "phone-a", 4_000).unwrap();
1954 assert_eq!(std::fs::read_to_string(own_file(&root, SEEN_LOG, "phone-a")).unwrap().lines().count(), 1, "nothing twice");
1955 #[cfg(unix)]
1956 {
1957 use std::os::unix::fs::PermissionsExt;
1958 assert_eq!(std::fs::metadata(own_file(&root, SEEN_LOG, "phone-a")).unwrap().permissions().mode() & 0o777, 0o600, "yours alone");
1959 }
1960 let _ = std::fs::remove_dir_all(&root);
1961 }
1962
1963 #[test]
1968 fn a_person_s_calls_of_the_month() {
1969 let now = at("2026-10-07T10:30[Europe/Paris]");
1970 let calls = vec![
1971 call_on("phone-a", ms("2026-09-01T09:00[Europe/Paris]"), "+262639980002", "neutral", "work", "matrix", true),
1972 call_on("phone-a", ms("2026-10-06T23:10[Europe/Paris]"), "+262639980002", "neutral", "sleep", "matrix", false),
1973 call_on("phone-a", ms("2026-10-07T06:48[Europe/Paris]"), "+262639980002", "neutral", "sleep", "repeat", true),
1974 call_on("phone-a", ms("2026-10-07T08:00[Europe/Paris]"), "+33199005555", "blocked", "work", "blocked", false),
1975 call_on("phone-a", ms("2026-10-07T10:05[Europe/Paris]"), "+262639980002", "neutral", "work", "matrix", true),
1976 ];
1977 let keys: BTreeSet<String> = ["+262639980002".to_string(), "+33199005555".to_string()].into();
1978 let none = |_: &str| None;
1979 let all = |_: &Held| true;
1980 for (language, expected) in [
1981 ("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."]),
1982 ("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."]),
1983 ] {
1984 let tr = Translator::new(language);
1985 let words = |device: &str| phone_words(&tr, device, "desk", &[("phone-a".to_string(), "A12".to_string())], false);
1986 let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &words };
1987 assert_eq!(history(&calls, &keys, &l), expected, "{language}");
1988 }
1989 let mut both = calls.clone();
1991 both.push(call_on("phone-b", ms("2026-10-07T09:00[Europe/Paris]"), "+262639980002", "neutral", "work", "matrix", false));
1992 both.sort_by_key(|h| h.at);
1993 let tr = Translator::new("en");
1994 let phones = [("phone-a".to_string(), "A12".to_string()), ("phone-b".to_string(), "B7".to_string())];
1995 let words = |device: &str| phone_words(&tr, device, "desk", &phones, false);
1996 let l = Lister { now: &now, tr: &tr, region: fr(), name_of: &none, shows: &all, phone: &words };
1997 let said = history(&both, &keys, &l);
1998 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."]);
1999 }
2000
2001 #[test]
2005 fn old_lines_leave_with_their_writer() {
2006 let root = scratch("carry");
2007 let own = own_file(&root, LOG, "phone-a");
2008 let now = ms("2026-10-07T10:00[Europe/Paris]");
2009 let java = vec![
2010 Held { number: "01 99 00 12 34".into(), ..held(ms("2026-10-07T06:10[Europe/Paris]"), "+33199001234", "stranger", "sleep") },
2011 Held { key: String::new(), number: "0465710042".into(), rang: true, why: "no-table".into(), ..held(ms("2026-10-07T09:15[Europe/Paris]"), "x", "", "") },
2012 held(ms("2026-10-07T09:30[Europe/Paris]"), "+33199005555", "blocked", "work"),
2013 held(ms("2026-08-20T09:30[Europe/Paris]"), "+33199007777", "stranger", "work"),
2014 ];
2015 assert_eq!(carry_into(&own, &java, fr(), now).unwrap(), 2);
2016 assert_eq!(carry_into(&own, &java, fr(), now).unwrap(), 0, "once");
2017 let logged = read_held(&own);
2018 assert_eq!(logged.iter().map(|h| (h.key.as_str(), h.rang, h.number.as_str())).collect::<Vec<_>>(), [("+33199001234", false, ""), ("+33465710042", true, "")]);
2019 mark_seen(&root, "phone-a", &[logged[0].id()], now).unwrap();
2021 std::fs::write(&own, std::fs::read_to_string(&own).unwrap() + "{\"at\":\n").unwrap();
2022 let later = now + 33 * DAY_MS;
2023 assert_eq!(trim_own(&root, "phone-a", later).unwrap(), 3);
2024 assert_eq!(std::fs::read_to_string(&own).unwrap(), "{\"at\":\n");
2025 assert!(read_seen(&root).seen.is_empty());
2026 assert_eq!(carry_into(&own, &java, fr(), later).unwrap(), 0, "never copied back");
2027 std::fs::write(own_file(&root, LOG, "phone-b"), serde_json::to_string(&java[0].logged(fr())).unwrap() + "\n").unwrap();
2029 trim_own(&root, "phone-a", later).unwrap();
2030 assert_eq!(trim_others(&root, "phone-a", later).unwrap(), 0);
2031 assert_eq!(read_held(&own_file(&root, LOG, "phone-b")).len(), 1);
2032 assert_eq!(trim_others(&root, "phone-a", now + (KEPT_DAYS + LATE_DAYS + 1) * DAY_MS).unwrap(), 1);
2033 assert!(read_held(&own_file(&root, LOG, "phone-b")).is_empty());
2034 assert_eq!(std::fs::read_to_string(&own).unwrap(), "{\"at\":\n", "its own left to trim_own");
2035 let _ = std::fs::remove_dir_all(&root);
2036 }
2037
2038 #[test]
2039 fn letting_calls_through_is_said_with_its_end() {
2040 let now = 10_000_000;
2041 let when = |ms: i64| format!("<{ms}>");
2042 for (language, hour, open, emergency) in [
2043 ("en", "Every call rings until <13600000>.", "Every call rings until you turn it off.", "Every call rings until <92810000>: you called an emergency number."),
2044 ("fr", "Tous les appels sonnent jusqu’à <13600000>.", "Tous les appels sonnent jusqu’à ce que vous l’arrêtiez.", "Tous les appels sonnent jusqu’à <92810000>\u{202f}: vous avez appelé un numéro d’urgence."),
2045 ] {
2046 let tr = Translator::new(language);
2047 let on = Through { pressed: 1, on: true, until: now + 3_600_000 };
2048 assert_eq!(through_line(&tr, Some(&on), 0, now, &when), hour);
2049 assert_eq!(through_line(&tr, Some(&Through { until: 0, ..on }), 0, now, &when), open);
2050 assert_eq!(through_line(&tr, Some(&Through { on: false, ..on }), 0, now, &when), "");
2051 assert_eq!(through_line(&tr, None, now - 3_590_000, now, &when), emergency);
2052 assert_eq!(through_line(&tr, None, now - 25 * 3_600_000, now, &when), "", "a day after");
2053 }
2054 }
2055
2056 #[test]
2057 fn every_word_has_both_languages() {
2058 for language in ["en", "fr"] {
2059 let tr = Translator::new(language);
2060 let mut keys: Vec<String> = [
2061 "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-through-until", "calls-through-on", "calls-context-any",
2062 "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",
2063 "calls-rang-repeat", "calls-rang-through", "calls-rang-emergency", "calls-rang-after-emergency", "calls-rang-undecided",
2064 ]
2065 .iter()
2066 .map(|k| k.to_string())
2067 .collect();
2068 keys.extend(["work", "admin", "leisure", "meals", "sleep", "pause", "free", "slot", "dnd"].iter().map(|c| format!("calls-context-{c}")));
2069 keys.extend(["safe", "neutral", "restricted", "stranger", "hidden"].iter().map(|r| format!("calls-why-{r}")));
2070 for key in keys {
2071 let mut args = crate::i18n::args();
2072 for name in ["context", "day", "who", "time", "first", "second", "count", "length", "until", "phone", "name", "when", "head", "outcome"] {
2073 args.set(name, "X");
2074 }
2075 let words = tr.text(&key, Some(&args));
2076 assert!(!words.is_empty() && words != key && !words.contains('{'), "{language}: {key}: {words}");
2077 if language == "fr" {
2079 assert!(![" :", " ;", " ?", " !", "'"].iter().any(|bad| words.contains(bad)), "{key}: {words}");
2080 }
2081 }
2082 }
2083 }
2084}