1use crate::attention::{Attention, Column, Level, Person, Row};
47use crate::config::{Config, SettingValue};
48use crate::reach::Channel;
49use serde::{Deserialize, Serialize};
50use std::collections::BTreeMap;
51use std::path::Path;
52
53pub const CALLED_DAYS: i64 = 14;
56
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum Hours {
61 #[default]
63 Always,
64 OutsideWork,
68}
69
70impl Hours {
71 pub const ALL: [Hours; 2] = [Hours::Always, Hours::OutsideWork];
72
73 pub fn id(self) -> &'static str {
74 match self {
75 Hours::Always => "always",
76 Hours::OutsideWork => "outside-work",
77 }
78 }
79
80 pub fn read(text: &str) -> Option<Hours> {
81 Hours::ALL.into_iter().find(|h| h.id() == text.trim())
82 }
83}
84
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum Rule {
89 #[default]
91 Voicemail,
92 Work,
95 Ring,
97}
98
99impl Rule {
100 pub const ALL: [Rule; 3] = [Rule::Voicemail, Rule::Work, Rule::Ring];
101
102 pub fn id(self) -> &'static str {
103 match self {
104 Rule::Voicemail => "voicemail",
105 Rule::Work => "work",
106 Rule::Ring => "ring",
107 }
108 }
109
110 pub fn read(text: &str) -> Option<Rule> {
111 Rule::ALL.into_iter().find(|r| r.id() == text.trim())
112 }
113}
114
115#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "kebab-case")]
119pub enum Spam {
120 #[default]
122 Voicemail,
123 Unknown,
125}
126
127impl Spam {
128 pub const ALL: [Spam; 2] = [Spam::Voicemail, Spam::Unknown];
129
130 pub fn id(self) -> &'static str {
131 match self {
132 Spam::Voicemail => "voicemail",
133 Spam::Unknown => "unknown",
134 }
135 }
136
137 pub fn read(text: &str) -> Option<Spam> {
138 Spam::ALL.into_iter().find(|s| s.id() == text.trim())
139 }
140}
141
142#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
146#[serde(transparent)]
147pub struct Settings(pub BTreeMap<String, toml::Value>);
148
149impl Settings {
150 fn word(&self, key: &str) -> Option<&str> {
151 self.0.get(key).and_then(toml::Value::as_str)
152 }
153
154 fn flag(&self, key: &str) -> Option<bool> {
155 self.0.get(key).and_then(toml::Value::as_bool)
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
161pub struct Screening {
162 pub on: bool,
164 pub hours: Hours,
165 pub unknown: Rule,
167 pub hidden: Rule,
169 pub automated: Spam,
171 pub impossible: Spam,
173 pub unverified: Spam,
175 pub called: bool,
178}
179
180impl Default for Screening {
181 fn default() -> Screening {
182 Screening::of_rows(&Attention::usual(), None)
183 }
184}
185
186impl Screening {
187 pub fn of(config: &Config) -> Screening {
190 Screening::of_rows(&Attention::of(config), config.screening.as_ref())
191 }
192
193 fn of_rows(attention: &Attention, set: Option<&Settings>) -> Screening {
194 let word = |key: &str| set.and_then(|s| s.word(key));
195 let flag = |key: &str| set.and_then(|s| s.flag(key));
196 Screening {
197 on: flag("on").unwrap_or(true),
198 hours: word("hours").and_then(Hours::read).unwrap_or_default(),
199 unknown: word("unknown").and_then(Rule::read).unwrap_or_else(|| first_rule(attention, Person::Stranger)),
200 hidden: word("hidden").and_then(Rule::read).unwrap_or_else(|| first_rule(attention, Person::Hidden)),
201 automated: word("automated").and_then(Spam::read).unwrap_or_default(),
202 impossible: word("impossible").and_then(Spam::read).unwrap_or_default(),
203 unverified: word("unverified").and_then(Spam::read).unwrap_or_default(),
204 called: flag("called").unwrap_or(true),
205 }
206 }
207
208 pub fn rings(&self, hidden: bool, hours: bool) -> bool {
214 if !self.on || (self.hours == Hours::OutsideWork && hours) {
215 return true;
216 }
217 match if hidden { self.hidden } else { self.unknown } {
218 Rule::Ring => true,
219 Rule::Work => hours,
220 Rule::Voicemail => false,
221 }
222 }
223}
224
225pub fn first_rule(attention: &Attention, person: Person) -> Rule {
233 let row = Row::People(Channel::Calls, person);
234 let rings = |column: Column| attention.cell(row, column) == Level::Now;
235 if Column::TIMES.into_iter().all(|column| rings(column)) {
236 Rule::Ring
237 } else if rings(Column::Work) || rings(Column::Admin) {
238 Rule::Work
239 } else {
240 Rule::Voicemail
241 }
242}
243
244pub const KEYS: [(&str, &[&str]); 8] = [
246 ("on", &["bool"]),
247 ("hours", &["always", "outside-work"]),
248 ("unknown", &["voicemail", "work", "ring"]),
249 ("hidden", &["voicemail", "work", "ring"]),
250 ("automated", &["voicemail", "unknown"]),
251 ("impossible", &["voicemail", "unknown"]),
252 ("unverified", &["voicemail", "unknown"]),
253 ("called", &["bool"]),
254];
255
256pub fn set(path: &Path, key: &str, value: &SettingValue) -> Result<(), String> {
261 let name = key.strip_prefix("screening.").unwrap_or(key);
262 let Some((_, words)) = KEYS.iter().find(|(k, _)| *k == name) else { return Err(format!("{key}: no such setting of the call screening")) };
263 let boolean = words.len() == 1 && words[0] == "bool";
264 let checked = match value {
265 SettingValue::Bool(on) if boolean => SettingValue::Bool(*on),
266 SettingValue::Text(word) if !boolean && words.contains(&word.trim()) => SettingValue::Text(word.trim().to_string()),
267 _ if boolean => return Err(format!("{key}: on or off")),
268 _ => return Err(format!("{key}: {}", words.join(", "))),
269 };
270 if path.exists() {
273 let config = Config::load(path)?;
274 let now = Screening::of(&config);
275 for (unsaid, rule) in [("unknown", now.unknown), ("hidden", now.hidden)] {
276 if unsaid != name && config.screening.as_ref().and_then(|s| s.word(unsaid)).and_then(Rule::read).is_none() {
277 crate::config::set_value(path, &format!("screening.{unsaid}"), &SettingValue::Text(rule.id().to_string()))?;
278 }
279 }
280 }
281 crate::config::set_value(path, &format!("screening.{name}"), &checked)
282}
283
284const NUMBERS: &str = include_str!("../data/screening-numbers.toml");
287
288#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
290pub struct Numbers {
291 #[serde(default)]
292 pub group: Vec<Group>,
293}
294
295#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
297pub struct Group {
298 pub kind: String,
300 #[serde(default)]
301 pub territory: String,
302 #[serde(default)]
304 pub prefixes: Vec<String>,
305 #[serde(default)]
307 pub about: String,
308 pub source: String,
309 pub checked: String,
311 #[serde(default)]
313 pub valid_days: Option<i64>,
314}
315
316impl Group {
317 pub fn counts(&self, today: jiff::civil::Date) -> bool {
319 let Some(days) = self.valid_days else { return true };
320 let Ok(read) = self.checked.parse::<jiff::civil::Date>() else { return false };
321 read.checked_add(jiff::Span::new().days(days)).is_ok_and(|end| today < end) && today >= read
322 }
323}
324
325impl Numbers {
326 pub fn built_in() -> Numbers {
328 toml::from_str(NUMBERS).unwrap_or_default()
329 }
330
331 fn of(&self, kinds: &[&str], today: jiff::civil::Date) -> Vec<String> {
332 let mut out: Vec<String> = self.group.iter().filter(|g| kinds.contains(&g.kind.as_str()) && g.counts(today)).flat_map(|g| g.prefixes.iter().map(|p| p.trim().to_string())).filter(|p| p.len() >= 2 && p.starts_with('0') && p.bytes().all(|b| b.is_ascii_digit())).collect();
333 out.sort();
334 out.dedup();
335 out
336 }
337
338 pub fn automated(&self, today: jiff::civil::Date) -> Vec<String> {
340 self.of(&["automated"], today)
341 }
342
343 pub fn never_shown(&self, today: jiff::civil::Date) -> Vec<String> {
348 self.of(&["never-shown"], today)
349 }
350
351 pub fn unattributed_since(&self, key: &str, today: jiff::civil::Date) -> Option<jiff::civil::Date> {
355 let national = national(key)?;
356 self.group.iter().filter(|g| g.kind == "unattributed" && g.counts(today)).find(|g| starts_with_any(&national, &g.prefixes)).and_then(|g| g.checked.parse().ok())
357 }
358}
359
360pub fn shipped() -> &'static Numbers {
362 static SHIPPED: std::sync::OnceLock<Numbers> = std::sync::OnceLock::new();
363 SHIPPED.get_or_init(Numbers::built_in)
364}
365
366pub fn national(key: &str) -> Option<String> {
370 ["+33", "+262", "+590", "+594", "+596"].iter().find_map(|code| key.strip_prefix(code).filter(|rest| rest.len() == 9 && rest.bytes().all(|b| b.is_ascii_digit())).map(|rest| format!("0{rest}")))
371}
372
373pub fn starts_with_any(national: &str, prefixes: &[String]) -> bool {
375 prefixes.iter().any(|p| !p.is_empty() && national.starts_with(p.as_str()))
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383pub struct TablePart {
384 pub on: bool,
385 pub hours: Hours,
386 pub unknown: Rule,
387 pub hidden: Rule,
388 pub automated: Spam,
389 pub impossible: Spam,
390 pub unverified: Spam,
391 pub called_days: i64,
393 #[serde(default)]
395 pub automated_prefixes: Vec<String>,
396 #[serde(default)]
399 pub impossible_prefixes: Vec<String>,
400}
401
402impl TablePart {
403 pub fn of(screening: &Screening, numbers: &Numbers, today: jiff::civil::Date) -> TablePart {
405 TablePart {
406 on: screening.on,
407 hours: screening.hours,
408 unknown: screening.unknown,
409 hidden: screening.hidden,
410 automated: screening.automated,
411 impossible: screening.impossible,
412 unverified: screening.unverified,
413 called_days: if screening.called { CALLED_DAYS } else { 0 },
414 automated_prefixes: numbers.automated(today),
415 impossible_prefixes: numbers.never_shown(today),
416 }
417 }
418
419 pub fn screening(&self) -> Screening {
421 Screening { on: self.on, hours: self.hours, unknown: self.unknown, hidden: self.hidden, automated: self.automated, impossible: self.impossible, unverified: self.unverified, called: self.called_days > 0 }
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use crate::attention::Column as C;
429
430 fn day(text: &str) -> jiff::civil::Date {
431 text.parse().unwrap()
432 }
433
434 #[test]
439 fn screening_numbers_are_well_formed_and_sourced() {
440 let numbers: Numbers = toml::from_str(NUMBERS).expect("data/screening-numbers.toml reads");
441 assert!(numbers.group.len() >= 7, "{} groups", numbers.group.len());
442 for g in &numbers.group {
443 assert!(["automated", "never-shown", "unattributed"].contains(&g.kind.as_str()), "{}", g.kind);
444 assert!(g.source.contains("ARCEP") && g.source.contains("https://"), "a source with its address: {}", g.source);
445 assert!(g.checked.parse::<jiff::civil::Date>().is_ok(), "{}: the day read", g.checked);
446 assert!(!g.territory.is_empty() && !g.prefixes.is_empty());
447 for p in &g.prefixes {
448 assert!(p.len() >= 3 && p.len() <= 6 && p.starts_with('0') && p.bytes().all(|b| b.is_ascii_digit()), "{p}");
449 }
450 }
451 let today = day("2026-10-20");
452 let automated = numbers.automated(today);
453 for root in ["0162", "0163", "0270", "0271", "0377", "0378", "0424", "0425", "0568", "0569", "0948", "0949", "05987", "09475", "05988", "09476", "05989", "09477", "02688", "02689", "09478", "09479"] {
454 assert!(automated.contains(&root.to_string()), "{root}");
455 }
456 assert_eq!(automated.len(), 22, "nothing else: the public-interest roots stay out");
457 assert_eq!(numbers.never_shown(today), vec!["089".to_string()]);
459 let unattributed = numbers.group.iter().find(|g| g.kind == "unattributed").unwrap();
460 for root in ["01510", "02810", "03410", "04410", "05410", "09410", "059850", "059864", "094700", "094709", "02687", "050860"] {
463 assert!(!unattributed.prefixes.iter().chain(&automated).any(|p| p.starts_with(root) || root.starts_with(p.as_str())), "{root}");
464 }
465 assert!(unattributed.prefixes.len() > 1_000, "the blocks attributed to nobody on the day read");
466 assert!(!automated.iter().any(|a| a.starts_with("089")));
468 assert_eq!(unattributed.valid_days, Some(90));
470 let read = day(&unattributed.checked);
471 assert!(unattributed.counts(read) && unattributed.counts(read.checked_add(jiff::Span::new().days(89)).unwrap()));
472 assert!(!unattributed.counts(read.checked_add(jiff::Span::new().days(90)).unwrap()), "past its days");
473 assert!(!unattributed.counts(read.yesterday().unwrap()), "a clock before the day read: not counted");
474 assert_eq!(numbers.unattributed_since("+33353011234", read), Some(read), "a fiction block attributed to nobody that day");
475 assert_eq!(numbers.unattributed_since("+33353011234", read.checked_add(jiff::Span::new().days(90)).unwrap()), None);
476 assert_eq!(numbers.unattributed_since("+441632960000", read), None, "another country's");
477 assert_eq!(Numbers::built_in(), numbers);
478 }
479
480 #[test]
481 fn numbers_in_national_form() {
482 assert_eq!(national("+33199001234").as_deref(), Some("0199001234"));
483 assert_eq!(national("+262262123456").as_deref(), Some("0262123456"), "La Réunion's fixed numbers");
484 assert_eq!(national("+590590123456").as_deref(), Some("0590123456"));
485 assert_eq!(national("+3319900123"), None, "a digit short");
486 assert_eq!(national("+441632960000"), None, "another country's");
487 assert_eq!(national("0199001234"), None, "not a key");
488 assert_eq!(national(""), None);
489 let prefixes = vec!["0465".to_string(), "089".to_string()];
490 assert!(starts_with_any("0465711234", &prefixes) && starts_with_any("0899123456", &prefixes) && !starts_with_any("0199001234", &prefixes));
491 assert!(!starts_with_any("0199001234", &[String::new()]), "an empty prefix holds nothing");
492 }
493
494 #[test]
498 fn first_values_from_the_calls_rows_and_each_key_once_written() {
499 let usual = Screening::default();
500 assert_eq!(usual, Screening { on: true, hours: Hours::Always, unknown: Rule::Voicemail, hidden: Rule::Work, automated: Spam::Voicemail, impossible: Spam::Voicemail, unverified: Spam::Voicemail, called: true });
501 let config: Config = toml::from_str("[attention]\n\"calls.hidden\" = [\"work:later\", \"admin:later\", \"leisure:later\", \"meals:later\", \"sleep:later\", \"pause:later\", \"free:later\", \"slot:later\", \"dnd:later\"]\n").unwrap();
503 assert_eq!(Screening::of(&config).hidden, Rule::Voicemail);
504 let mut attention = Attention::usual();
506 attention.set(Row::People(Channel::Calls, Person::Stranger), C::Work, Level::Now).unwrap();
507 attention.set(Row::People(Channel::Calls, Person::Stranger), C::Admin, Level::Now).unwrap();
508 assert_eq!(first_rule(&attention, Person::Stranger), Rule::Work);
509 attention.set(Row::People(Channel::Calls, Person::Stranger), C::Leisure, Level::Now).unwrap();
510 assert_eq!(first_rule(&attention, Person::Stranger), Rule::Work, "never ringing where the row declined");
511 assert_eq!(attention.cell(Row::People(Channel::Calls, Person::Stranger), C::Slot), Level::Now);
513 assert_eq!(first_rule(&Attention::usual(), Person::Stranger), Rule::Voicemail);
514 let config: Config = toml::from_str("[screening]\non = false\nhours = \"outside-work\"\nunknown = \"ring\"\nautomated = \"unknown\"\nimpossible = \"later-word\"\ncalled = \"yes\"\n").unwrap();
516 let read = Screening::of(&config);
517 assert_eq!((read.on, read.hours, read.unknown, read.hidden, read.automated, read.impossible, read.called), (false, Hours::OutsideWork, Rule::Ring, Rule::Work, Spam::Unknown, Spam::Voicemail, true));
518 }
519
520 const OWNER_HIDDEN: &str = "[attention]\n\"calls.hidden\" = [\"work:later\", \"admin:later\", \"leisure:later\", \"meals:later\", \"sleep:later\", \"pause:later\", \"free:later\", \"slot:later\", \"dnd:later\"]\n";
523
524 fn scratch(name: &str, text: &str) -> (std::path::PathBuf, std::path::PathBuf) {
526 let dir = std::env::temp_dir().join(format!("sioul-screening-{name}-{}", std::process::id()));
527 let _ = std::fs::remove_dir_all(&dir);
528 std::fs::create_dir_all(&dir).unwrap();
529 let path = dir.join("config.toml");
530 std::fs::write(&path, text).unwrap();
531 (dir, path)
532 }
533
534 #[test]
538 fn the_presets_never_move_the_screening() {
539 for preset in crate::attention::Preset::ALL {
540 let (dir, path) = scratch(preset.id(), OWNER_HIDDEN);
541 let before = Screening::of(&Config::load(&path).unwrap());
542 crate::attention::apply_preset(&path, &Config::load(&path).unwrap(), preset).unwrap();
543 let after = Screening::of(&Config::load(&path).unwrap());
544 assert_eq!((after, after.hidden), (before, Rule::Voicemail), "{preset:?}");
545 assert!(std::fs::read_to_string(&path).unwrap().contains("\"calls.hidden\" = [\"work:later\""), "{preset:?}: the row as it was");
546 let _ = std::fs::remove_dir_all(&dir);
547 }
548 }
549
550 #[test]
554 fn the_first_key_written_fixes_unknown_and_hidden() {
555 let (dir, path) = scratch("first", OWNER_HIDDEN);
556 set(&path, "screening.on", &SettingValue::Bool(true)).unwrap();
557 let written = Config::load(&path).unwrap().screening.unwrap();
558 assert_eq!((written.word("unknown"), written.word("hidden")), (Some("voicemail"), Some("voicemail")));
559 let text = std::fs::read_to_string(&path).unwrap().replace("\"calls.hidden\" = [\"work:later\", \"admin:later\"", "\"calls.hidden\" = [\"work\", \"admin\"");
560 std::fs::write(&path, text).unwrap();
561 assert_eq!(Screening::of(&Config::load(&path).unwrap()).hidden, Rule::Voicemail);
562 let _ = std::fs::remove_dir_all(&dir);
563 }
564
565 #[test]
569 fn a_row_that_rang_out_of_work_never_rings_at_all_hours() {
570 let stranger = Row::People(Channel::Calls, Person::Stranger);
571 let mut leisure = Attention::usual();
572 leisure.set(stranger, C::Leisure, Level::Now).unwrap();
573 assert_eq!(first_rule(&leisure, Person::Stranger), Rule::Voicemail);
574 let mut both = leisure.clone();
575 both.set(stranger, C::Work, Level::Now).unwrap();
576 assert_eq!(first_rule(&both, Person::Stranger), Rule::Work);
577 let mut every = Attention::usual();
578 for column in C::TIMES {
579 every.set(stranger, column, Level::Now).unwrap();
580 }
581 assert_eq!(first_rule(&every, Person::Stranger), Rule::Ring);
582 }
583
584 #[test]
587 fn what_rings_by_the_screening_alone() {
588 let usual = Screening::default();
589 assert!(!usual.rings(false, true) && !usual.rings(false, false), "unknown numbers: voicemail at all hours");
590 assert!(usual.rings(true, true) && !usual.rings(true, false), "hidden numbers: in work and admin hours");
591 let off = Screening { on: false, ..usual };
592 assert!(off.rings(false, false) && off.rings(true, false));
593 let outside = Screening { hours: Hours::OutsideWork, hidden: Rule::Voicemail, ..usual };
594 assert!(outside.rings(false, true) && outside.rings(true, true) && !outside.rings(false, false) && !outside.rings(true, false));
595 let ring = Screening { unknown: Rule::Ring, ..usual };
596 assert!(ring.rings(false, false));
597 }
598
599 #[test]
602 fn settings_written_a_key_at_a_time() {
603 let dir = std::env::temp_dir().join(format!("sioul-screening-{}", std::process::id()));
604 let _ = std::fs::remove_dir_all(&dir);
605 std::fs::create_dir_all(&dir).unwrap();
606 let path = dir.join("config.toml");
607 std::fs::write(&path, "# mine\n[dnd]\nfocus = true\n\n[screening]\nlater = \"a later version's\"\n").unwrap();
608 set(&path, "screening.hidden", &SettingValue::Text("voicemail".into())).unwrap();
609 set(&path, "screening.on", &SettingValue::Bool(true)).unwrap();
610 assert!(set(&path, "screening.hidden", &SettingValue::Text("sometimes".into())).unwrap_err().contains("voicemail, work, ring"));
611 assert!(set(&path, "screening.on", &SettingValue::Text("yes".into())).is_err());
612 assert!(set(&path, "screening.color", &SettingValue::Text("red".into())).is_err());
613 let text = std::fs::read_to_string(&path).unwrap();
614 assert!(text.contains("# mine") && text.contains("focus = true") && text.contains("later = \"a later version's\""), "{text}");
615 let config = Config::load(&path).unwrap();
616 let read = Screening::of(&config);
617 assert_eq!((read.on, read.hidden, read.unknown), (true, Rule::Voicemail, Rule::Voicemail));
618 let written = &config.screening.as_ref().unwrap().0;
620 assert_eq!(written.get("unknown").and_then(toml::Value::as_str), Some("voicemail"));
621 assert!(!written.contains_key("automated") && !written.contains_key("hours"), "only the keys changed, and the two fixed");
622 let _ = std::fs::remove_dir_all(&dir);
623 }
624}