1use crate::areas::{Area, Time, Week, in_view};
47use crate::config::{Config, SettingValue};
48use crate::i18n::Translator;
49use crate::quiet::Mode;
50use crate::reach::{Channel, Who};
51use serde::{Deserialize, Serialize};
52use std::collections::BTreeMap;
53use std::path::{Path, PathBuf};
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum Person {
61 Always,
64 Safe,
65 Neutral,
66 Restricted,
67 Stranger,
69 Hidden,
71 Groups,
73 Blocked,
75}
76
77impl Person {
78 pub const ALL: [Person; 8] = [Person::Always, Person::Safe, Person::Neutral, Person::Restricted, Person::Stranger, Person::Hidden, Person::Groups, Person::Blocked];
79
80 pub fn id(self) -> &'static str {
81 match self {
82 Person::Always => "always",
83 Person::Safe => "safe",
84 Person::Neutral => "neutral",
85 Person::Restricted => "restricted",
86 Person::Stranger => "stranger",
87 Person::Hidden => "hidden",
88 Person::Groups => "groups",
89 Person::Blocked => "blocked",
90 }
91 }
92
93 pub fn read(text: &str) -> Option<Person> {
94 let text = text.trim().to_ascii_lowercase();
95 match text.as_str() {
96 "strangers" => Some(Person::Stranger),
97 "group" => Some(Person::Groups),
98 _ => Person::ALL.into_iter().find(|p| p.id() == text),
99 }
100 }
101
102 pub fn of(who: Who) -> Person {
104 match who {
105 Who::Safe => Person::Safe,
106 Who::Neutral => Person::Neutral,
107 Who::Restricted => Person::Restricted,
108 Who::Stranger => Person::Stranger,
109 Who::Blocked => Person::Blocked,
110 }
111 }
112
113 pub fn state(self) -> bool {
115 !matches!(self, Person::Always | Person::Blocked)
116 }
117}
118
119pub fn persons(channel: Channel) -> &'static [Person] {
121 use Person::*;
122 match channel {
123 Channel::Mail => &[Always, Safe, Neutral, Restricted, Stranger, Blocked],
124 Channel::Calls => &[Always, Safe, Neutral, Restricted, Stranger, Hidden, Blocked],
125 Channel::Messages => &[Always, Safe, Neutral, Restricted, Stranger, Groups, Blocked],
126 }
127}
128
129pub fn screened(row: Row) -> bool {
136 matches!(row, Row::People(Channel::Calls, Person::Stranger | Person::Hidden))
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
141#[serde(rename_all = "kebab-case")]
142pub enum Kind {
143 Codes,
145 Doses,
147 Wake,
149 Alarms,
151 Before,
153 DayBefore,
155 Dates,
157 Needs,
159 Move,
161 WorkOver,
163 Time,
165 Sites,
167 SitesLive,
169 SiteCalls,
171 AppAutomatons,
173 AppAtOnce,
175}
176
177impl Kind {
178 pub const ALL: [Kind; 16] = [
179 Kind::Codes,
180 Kind::Doses,
181 Kind::Wake,
182 Kind::Alarms,
183 Kind::Before,
184 Kind::DayBefore,
185 Kind::Dates,
186 Kind::Needs,
187 Kind::Move,
188 Kind::WorkOver,
189 Kind::Time,
190 Kind::Sites,
191 Kind::SitesLive,
192 Kind::SiteCalls,
193 Kind::AppAutomatons,
194 Kind::AppAtOnce,
195 ];
196
197 pub fn id(self) -> &'static str {
198 match self {
199 Kind::Codes => "codes",
200 Kind::Doses => "doses",
201 Kind::Wake => "wake",
202 Kind::Alarms => "alarms",
203 Kind::Before => "before",
204 Kind::DayBefore => "day-before",
205 Kind::Dates => "dates",
206 Kind::Needs => "needs",
207 Kind::Move => "move",
208 Kind::WorkOver => "work-over",
209 Kind::Time => "time",
210 Kind::Sites => "sites",
211 Kind::SitesLive => "sites-live",
212 Kind::SiteCalls => "site-calls",
213 Kind::AppAutomatons => "app-automatons",
214 Kind::AppAtOnce => "app-at-once",
215 }
216 }
217
218 pub fn read(text: &str) -> Option<Kind> {
219 let text = text.trim().to_ascii_lowercase().replace('_', "-");
220 Kind::ALL.into_iter().find(|k| k.id() == text)
221 }
222
223 pub fn group(self) -> &'static str {
226 match self {
227 Kind::Codes | Kind::Doses | Kind::Wake | Kind::Alarms => "asked",
228 Kind::Before | Kind::DayBefore | Kind::Dates => "reminders",
229 Kind::Needs | Kind::Move | Kind::WorkOver | Kind::Time => "day",
230 Kind::Sites | Kind::SitesLive | Kind::SiteCalls => "sites",
231 Kind::AppAutomatons | Kind::AppAtOnce => "apps",
232 }
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
238pub enum Row {
239 People(Channel, Person),
240 Own(Kind),
241}
242
243impl Row {
244 pub const ALL: [Row; 36] = [
246 Row::People(Channel::Mail, Person::Always),
247 Row::People(Channel::Mail, Person::Safe),
248 Row::People(Channel::Mail, Person::Neutral),
249 Row::People(Channel::Mail, Person::Restricted),
250 Row::People(Channel::Mail, Person::Stranger),
251 Row::People(Channel::Mail, Person::Blocked),
252 Row::People(Channel::Calls, Person::Always),
253 Row::People(Channel::Calls, Person::Safe),
254 Row::People(Channel::Calls, Person::Neutral),
255 Row::People(Channel::Calls, Person::Restricted),
256 Row::People(Channel::Calls, Person::Stranger),
257 Row::People(Channel::Calls, Person::Hidden),
258 Row::People(Channel::Calls, Person::Blocked),
259 Row::People(Channel::Messages, Person::Always),
260 Row::People(Channel::Messages, Person::Safe),
261 Row::People(Channel::Messages, Person::Neutral),
262 Row::People(Channel::Messages, Person::Restricted),
263 Row::People(Channel::Messages, Person::Stranger),
264 Row::People(Channel::Messages, Person::Groups),
265 Row::People(Channel::Messages, Person::Blocked),
266 Row::Own(Kind::Codes),
267 Row::Own(Kind::Doses),
268 Row::Own(Kind::Wake),
269 Row::Own(Kind::Alarms),
270 Row::Own(Kind::Before),
271 Row::Own(Kind::DayBefore),
272 Row::Own(Kind::Dates),
273 Row::Own(Kind::Needs),
274 Row::Own(Kind::Move),
275 Row::Own(Kind::WorkOver),
276 Row::Own(Kind::Time),
277 Row::Own(Kind::Sites),
278 Row::Own(Kind::SitesLive),
279 Row::Own(Kind::SiteCalls),
280 Row::Own(Kind::AppAutomatons),
281 Row::Own(Kind::AppAtOnce),
282 ];
283
284 pub fn id(self) -> String {
286 match self {
287 Row::People(channel, person) => format!("{}.{}", channel.id(), person.id()),
288 Row::Own(kind) => kind.id().to_string(),
289 }
290 }
291
292 pub fn read(text: &str) -> Option<Row> {
295 let text = text.trim();
296 if let Some((channel, person)) = text.split_once('.') {
297 let (channel, person) = (Channel::read(channel)?, Person::read(person)?);
298 return persons(channel).contains(&person).then_some(Row::People(channel, person));
299 }
300 Kind::read(text).map(Row::Own)
301 }
302
303 fn index(self) -> usize {
304 Row::ALL.iter().position(|r| *r == self).unwrap_or(0)
305 }
306
307 pub fn group(self) -> &'static str {
309 match self {
310 Row::People(channel, _) => channel.id(),
311 Row::Own(kind) => kind.group(),
312 }
313 }
314
315 pub fn always(self) -> bool {
317 matches!(self, Row::People(_, Person::Always))
318 }
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
323#[serde(rename_all = "lowercase")]
324pub enum Column {
325 Work,
326 Admin,
327 Leisure,
328 Meals,
329 Sleep,
331 Pause,
333 Free,
335 Slot,
337 Dnd,
339}
340
341impl Column {
342 pub const ALL: [Column; 9] = [Column::Work, Column::Admin, Column::Leisure, Column::Meals, Column::Sleep, Column::Pause, Column::Free, Column::Slot, Column::Dnd];
343 pub const TIMES: [Column; 7] = [Column::Work, Column::Admin, Column::Leisure, Column::Meals, Column::Sleep, Column::Pause, Column::Free];
345
346 pub fn id(self) -> &'static str {
347 match self {
348 Column::Work => "work",
349 Column::Admin => "admin",
350 Column::Leisure => "leisure",
351 Column::Meals => "meals",
352 Column::Sleep => "sleep",
353 Column::Pause => "pause",
354 Column::Free => "free",
355 Column::Slot => "slot",
356 Column::Dnd => "dnd",
357 }
358 }
359
360 pub fn read(word: &str) -> Option<Column> {
363 Some(match word.trim().to_lowercase().as_str() {
364 "work" | "travail" => Column::Work,
365 "admin" | "démarches" | "demarches" => Column::Admin,
366 "leisure" | "loisirs" | "rest" | "personal" | "perso" => Column::Leisure,
367 "meals" | "meal" | "repas" => Column::Meals,
368 "sleep" | "sommeil" | "night" | "nuit" => Column::Sleep,
369 "pause" | "paused" | "en pause" => Column::Pause,
370 "free" | "free-time" | "free time" | "temps libre" => Column::Free,
371 "slot" | "for-you" | "time-for-you" | "time for you" | "du temps pour vous" => Column::Slot,
372 "dnd" | "do-not-disturb" | "do not disturb" | "ne pas déranger" => Column::Dnd,
373 _ => return None,
374 })
375 }
376
377 pub fn is_time(self) -> bool {
379 !matches!(self, Column::Slot | Column::Dnd)
380 }
381
382 pub fn ends(self) -> bool {
385 !matches!(self, Column::Pause | Column::Slot | Column::Dnd)
386 }
387
388 fn index(self) -> usize {
389 Column::ALL.iter().position(|c| *c == self).unwrap_or(0)
390 }
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
396#[serde(rename_all = "kebab-case")]
397pub enum Level {
398 Now,
400 Quiet,
403 Event,
405 Gathered,
407 Later,
409 Never,
411 As,
413 Through,
415}
416
417impl Level {
418 pub const ALL: [Level; 8] = [Level::Now, Level::Quiet, Level::Event, Level::Gathered, Level::Later, Level::Never, Level::As, Level::Through];
419
420 pub fn id(self) -> &'static str {
421 match self {
422 Level::Now => "now",
423 Level::Quiet => "quiet",
424 Level::Event => "event",
425 Level::Gathered => "gathered",
426 Level::Later => "later",
427 Level::Never => "never",
428 Level::As => "as",
429 Level::Through => "through",
430 }
431 }
432
433 pub fn read(text: &str) -> Option<Level> {
434 let text = text.trim().to_ascii_lowercase();
435 match text.as_str() {
436 "shown" => Some(Level::Quiet),
437 _ => Level::ALL.into_iter().find(|l| l.id() == text),
438 }
439 }
440
441 pub fn mark(self, always: bool) -> &'static str {
443 match self {
444 Level::Now if always => "★",
445 Level::Now => "●",
446 Level::Quiet => "◑",
447 Level::Event => "◐",
448 Level::Gathered => "◎",
449 Level::Later => "○",
450 Level::Never => "–",
451 Level::As => "=",
452 Level::Through => "☆",
453 }
454 }
455}
456
457pub fn usual(row: Row, column: Column) -> Level {
465 use Column::*;
466 use Level::*;
467 let hours = matches!(column, Work | Admin | Leisure | Meals);
468 let rests = matches!(column, Sleep | Pause | Free);
469 match row {
470 Row::People(_, Person::Blocked) => Never,
471 Row::People(Channel::Mail, Person::Always) => if matches!(column, Sleep | Pause) { Quiet } else { Now },
472 Row::People(_, Person::Always) => Now,
473 Row::People(Channel::Mail, Person::Safe) => if hours { Now } else { Quiet },
474 Row::People(Channel::Mail, Person::Neutral | Person::Stranger) => match column {
475 Work | Admin => Now,
476 Slot | Dnd => Quiet,
477 _ => Later,
478 },
479 Row::People(Channel::Mail, Person::Restricted) => match column {
480 Work => Now,
481 Slot | Dnd => Quiet,
482 _ => Later,
483 },
484 Row::People(Channel::Messages, Person::Safe | Person::Neutral | Person::Groups) if column == Dnd => Quiet,
488 Row::People(Channel::Calls | Channel::Messages, Person::Safe) => if matches!(column, Sleep | Pause | Dnd) { Later } else { Now },
489 Row::People(Channel::Calls | Channel::Messages, Person::Neutral) | Row::People(Channel::Messages, Person::Groups) => if matches!(column, Work | Admin | Leisure | Meals | Slot) { Now } else { Later },
494 Row::People(Channel::Calls, Person::Hidden) => if matches!(column, Work | Admin | Slot) { Now } else { Later },
495 Row::People(Channel::Messages, Person::Stranger) => if matches!(column, Work | Admin | Slot) { Now } else { Later },
496 Row::People(Channel::Calls | Channel::Messages, Person::Restricted) => if matches!(column, Work | Slot) { Now } else { Later },
497 Row::People(Channel::Calls, Person::Stranger) => if column == Slot { Now } else { Later },
498 Row::People(_, _) => Later,
499 Row::Own(kind) => match kind {
500 Kind::Codes | Kind::Doses | Kind::Wake | Kind::Time => Now,
501 Kind::Alarms | Kind::Before => if matches!(column, Sleep | Free) { Event } else { Now },
502 Kind::DayBefore | Kind::Dates => if rests { Later } else { Now },
503 Kind::Needs => if rests { Never } else { Now },
504 Kind::Move => if hours { Now } else { Never },
505 Kind::WorkOver => if column == Work || rests { Never } else { Now },
506 Kind::Sites | Kind::AppAutomatons => if hours { Gathered } else { Later },
507 Kind::SitesLive | Kind::AppAtOnce => if hours { Now } else { Later },
508 Kind::SiteCalls => if hours || column == Slot { Now } else { Later },
509 },
510 }
511}
512
513pub fn choices(row: Row, column: Column) -> &'static [Level] {
515 use Level::*;
516 let layer = !column.is_time();
517 match row {
518 Row::People(_, Person::Blocked) => &[Never],
519 Row::People(Channel::Mail, Person::Always) if layer => &[Now, Quiet, Through, As],
520 Row::People(Channel::Mail, Person::Always) => &[Now, Quiet, As],
521 Row::People(_, Person::Always) if layer => &[Now, Through, As],
522 Row::People(_, Person::Always) => &[Now, As],
523 Row::People(Channel::Mail, _) => &[Now, Quiet, Later, Never],
524 Row::People(Channel::Messages, _) if column == Column::Dnd => &[Now, Quiet, Later],
526 Row::People(_, _) => &[Now, Later],
527 Row::Own(kind) => match kind {
528 Kind::Codes => &[Now, Never],
529 Kind::Doses => &[Now, Later],
530 Kind::Wake => &[Now],
531 Kind::Alarms | Kind::Before if column.ends() => &[Now, Event, Later],
532 Kind::Alarms | Kind::Before => &[Now, Later],
533 Kind::DayBefore | Kind::Dates | Kind::SitesLive | Kind::SiteCalls | Kind::AppAtOnce => &[Now, Later],
534 Kind::Needs | Kind::Move | Kind::WorkOver | Kind::Time => &[Now, Never],
535 Kind::Sites => &[Gathered, Later],
536 Kind::AppAutomatons => &[Now, Gathered, Later],
537 },
538 }
539}
540
541pub fn lock(row: Row, column: Column) -> Option<&'static str> {
547 match row {
548 Row::People(_, Person::Blocked) => Some("attention-lock-blocked"),
549 Row::Own(Kind::Doses) if !matches!(column, Column::Sleep | Column::Pause) => Some("attention-lock-doses"),
550 Row::Own(Kind::Codes) if column != Column::Sleep => Some("attention-lock-codes"),
551 Row::Own(Kind::Alarms) if matches!(column, Column::Pause | Column::Dnd) => Some("attention-lock-alarms"),
552 Row::Own(Kind::Wake) => Some("attention-lock-wake"),
553 _ => None,
554 }
555}
556
557pub const APP_ROW: &str = "app.";
569pub const CONVERSATION_ROW: &str = "conversation.";
570pub const PERSON_ROW: &str = "person.";
579pub const NAME: &str = "name=";
582
583pub const SOURCE_CHOICES: [Level; 4] = [Level::Now, Level::Gathered, Level::Later, Level::As];
586
587pub fn person_choices(column: Column) -> &'static [Level] {
590 if column == Column::Dnd { &[Level::Now, Level::Quiet, Level::Later, Level::As] } else { &[Level::Now, Level::Later, Level::As] }
591}
592
593pub fn is_source(id: &str) -> bool {
595 [APP_ROW, CONVERSATION_ROW, PERSON_ROW].iter().any(|prefix| id.strip_prefix(prefix).is_some_and(|rest| !rest.is_empty() && !rest.contains(char::is_whitespace)))
596}
597
598pub fn is_person(id: &str) -> bool {
600 id.starts_with(PERSON_ROW) && is_source(id)
601}
602
603pub fn person_row(who: &str) -> String {
605 format!("{PERSON_ROW}{}", who.trim())
606}
607
608pub fn source_rows(package: &str, conversation: &str) -> Vec<String> {
611 source_rows_with(package, conversation, &[])
612}
613
614pub fn source_rows_with(package: &str, conversation: &str, persons: &[String]) -> Vec<String> {
618 let (package, conversation) = (package.trim(), conversation.trim());
619 let mut rows = Vec::new();
620 if !conversation.is_empty() {
621 rows.push(format!("{CONVERSATION_ROW}{conversation}"));
622 }
623 rows.extend(persons.iter().filter(|id| is_person(id)).cloned());
624 if !package.is_empty() {
625 rows.push(format!("{APP_ROW}{package}"));
626 }
627 rows
628}
629
630#[derive(Debug, Clone, Copy, PartialEq, Eq)]
633enum Base {
634 Row(Row),
635 Person(Channel, Person, bool),
636}
637
638#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
643pub struct Holds {
644 pub porch_rests: bool,
646 pub meeting: bool,
648 pub chats: bool,
650}
651
652#[derive(Debug, Clone, PartialEq, Eq)]
654pub struct Now {
655 pub times: Vec<Column>,
658 pub time: Time,
661 pub week: Week,
662 pub slot: bool,
664 pub dnd: bool,
666 pub nothing: bool,
668 pub span: (i64, i64),
670 pub gathering: bool,
672 pub realtime: bool,
674 pub through: bool,
677 pub reachable: bool,
683 pub silenced: bool,
689 pub holds: Holds,
690}
691
692impl Now {
693 pub fn of(mode: &Mode) -> Now {
696 let times = if mode.paused() {
697 vec![Column::Pause]
698 } else if mode.sleeps() {
699 vec![Column::Sleep]
700 } else if mode.free() {
701 vec![Column::Free]
702 } else {
703 match mode.time {
704 Time::Work => vec![Column::Work],
705 Time::Admin => vec![Column::Admin],
706 Time::Leisure => vec![Column::Leisure],
707 Time::Meals => vec![Column::Meals],
708 Time::Sleep => vec![Column::Sleep],
709 Time::Several(open) if open.work != open.admin => vec![if open.work { Column::Work } else { Column::Admin }],
710 Time::Any | Time::Several(_) => vec![Column::Work, Column::Admin],
711 }
712 };
713 let end = if mode.paused() { i64::MAX } else { mode.until.as_ref().map_or(i64::MAX, |u| u.timestamp().as_second()) };
714 Now { times, time: mode.time, week: mode.week, slot: false, dnd: false, nothing: false, span: (i64::MIN, end), gathering: false, realtime: false, through: false, reachable: false, silenced: false, holds: Holds::default() }
715 }
716
717 pub fn of_moment(moment: &crate::reach::Moment) -> Now {
720 let times = if moment.paused {
721 vec![Column::Pause]
722 } else if moment.time == Time::Sleep {
723 vec![Column::Sleep]
724 } else if moment.free {
725 vec![Column::Free]
726 } else {
727 match moment.time {
728 Time::Work => vec![Column::Work],
729 Time::Admin => vec![Column::Admin],
730 Time::Meals => vec![Column::Meals],
731 Time::Several(open) if open.work != open.admin => vec![if open.work { Column::Work } else { Column::Admin }],
732 Time::Any | Time::Several(_) => vec![Column::Work, Column::Admin],
733 _ => vec![Column::Leisure],
734 }
735 };
736 Now { times, time: moment.time, week: moment.week, slot: false, dnd: false, nothing: moment.free && moment.nothing, span: (i64::MIN, i64::MAX), gathering: false, realtime: false, through: false, reachable: false, silenced: false, holds: Holds::default() }
737 }
738
739 pub fn time(column: Column) -> Now {
741 let time = match column {
742 Column::Work => Time::Work,
743 Column::Admin => Time::Admin,
744 Column::Meals => Time::Meals,
745 Column::Sleep | Column::Pause => Time::Sleep,
746 _ => Time::Leisure,
747 };
748 let week = Week { work_hours: true, admin_hours: true, meals: true, sleep: true };
749 Now { times: vec![column], time, week, slot: false, dnd: false, nothing: false, span: (i64::MIN, i64::MAX), gathering: false, realtime: false, through: false, reachable: false, silenced: false, holds: Holds::default() }
750 }
751
752 pub fn layers(mut self, slot: bool, dnd: bool) -> Now {
754 self.slot = slot;
755 self.dnd = dnd;
756 self
757 }
758
759 pub fn is(&self, column: Column) -> bool {
761 self.times.contains(&column)
762 }
763}
764
765#[derive(Debug, Clone, PartialEq, Eq)]
771pub struct Attention {
772 cells: [[Level; 9]; 36],
773 sources: BTreeMap<String, [Level; 9]>,
774 names: BTreeMap<String, String>,
775}
776
777impl Default for Attention {
778 fn default() -> Attention {
779 Attention::usual()
780 }
781}
782
783impl Attention {
784 pub fn usual() -> Attention {
786 let mut cells = [[Level::Now; 9]; 36];
787 for row in Row::ALL {
788 for column in Column::ALL {
789 cells[row.index()][column.index()] = usual(row, column);
790 }
791 }
792 Attention { cells, sources: BTreeMap::new(), names: BTreeMap::new() }
793 }
794
795 pub fn of(config: &Config) -> Attention {
800 let Some(settings) = config.attention.as_ref() else { return seeded(config) };
801 let mut attention = Attention::usual();
802 for row in Row::ALL {
803 for (column, level) in settings.words(&row.id()).unwrap_or_default().iter().filter_map(|w| word(w)) {
804 let _ = attention.set(row, column, level);
805 }
806 }
807 for (id, words) in settings.sources() {
809 for text in &words {
810 match text.strip_prefix(NAME) {
811 Some(name) => attention.name_source(&id, name),
812 None => {
813 if let Some((column, level)) = word(text) {
814 let _ = attention.set_source(&id, column, level);
815 }
816 }
817 }
818 }
819 }
820 let sources = &attention.sources;
821 attention.names.retain(|id, _| sources.contains_key(id));
822 attention
823 }
824
825 pub fn cell(&self, row: Row, column: Column) -> Level {
826 self.cells[row.index()][column.index()]
827 }
828
829 pub fn set(&mut self, row: Row, column: Column, level: Level) -> Result<(), String> {
832 if self.cell(row, column) == level {
833 return Ok(());
834 }
835 if lock(row, column).is_some() {
836 return Err(format!("attention.{}: {} is fixed", row.id(), column.id()));
837 }
838 if !choices(row, column).contains(&level) {
839 let offered: Vec<&str> = choices(row, column).iter().map(|l| l.id()).collect();
840 return Err(format!("attention.{}: {}: {}", row.id(), column.id(), offered.join(", ")));
841 }
842 self.cells[row.index()][column.index()] = level;
843 Ok(())
844 }
845
846 pub fn words(&self, row: Row) -> Vec<String> {
849 Column::ALL
850 .iter()
851 .map(|column| match self.cell(row, *column) {
852 Level::Now => column.id().to_string(),
853 other => format!("{}:{}", column.id(), other.id()),
854 })
855 .collect()
856 }
857
858 pub fn changes_from(&self, other: &Attention) -> usize {
862 Row::ALL.iter().filter(|r| !screened(**r)).flat_map(|r| Column::ALL.iter().map(move |c| (*r, *c))).filter(|(r, c)| self.cell(*r, *c) != other.cell(*r, *c)).count()
863 }
864
865 pub fn source(&self, id: &str) -> Option<[Level; 9]> {
868 self.sources.get(id).copied()
869 }
870
871 pub fn sources(&self) -> &BTreeMap<String, [Level; 9]> {
873 &self.sources
874 }
875
876 pub fn source_name(&self, id: &str) -> &str {
879 self.names.get(id).map_or("", String::as_str)
880 }
881
882 pub fn set_source(&mut self, id: &str, column: Column, level: Level) -> Result<(), String> {
886 if !is_source(id) {
887 return Err(format!("attention.{id}: no such row"));
888 }
889 let offered: &[Level] = if is_person(id) { person_choices(column) } else { &SOURCE_CHOICES };
890 if !offered.contains(&level) {
891 let offered: Vec<&str> = offered.iter().map(|l| l.id()).collect();
892 return Err(format!("attention.{id}: {}: {}", column.id(), offered.join(", ")));
893 }
894 let mut cells = self.source(id).unwrap_or([Level::As; 9]);
895 cells[column.index()] = level;
896 if cells.iter().all(|l| *l == Level::As) {
897 self.sources.remove(id);
898 } else {
899 self.sources.insert(id.to_string(), cells);
900 }
901 Ok(())
902 }
903
904 pub fn name_source(&mut self, id: &str, name: &str) {
907 let name = name.trim();
908 if name.is_empty() || is_person(id) {
909 self.names.remove(id);
910 } else {
911 self.names.insert(id.to_string(), name.to_string());
912 }
913 }
914
915 pub fn source_words(&self, id: &str) -> Vec<String> {
919 let Some(cells) = self.source(id) else { return Vec::new() };
920 let mut words: Vec<String> = Column::ALL
921 .iter()
922 .filter(|c| cells[c.index()] != Level::As)
923 .map(|c| match cells[c.index()] {
924 Level::Now => c.id().to_string(),
925 other => format!("{}:{}", c.id(), other.id()),
926 })
927 .collect();
928 if let Some(name) = self.names.get(id).filter(|n| !n.is_empty()) {
929 words.push(format!("{NAME}{name}"));
930 }
931 words
932 }
933
934 pub fn has_sources(&self, ids: &[String]) -> bool {
936 ids.iter().any(|id| self.sources.contains_key(id))
937 }
938
939 fn source_cells(&self, ids: &[String]) -> Vec<[Level; 9]> {
941 ids.iter().filter_map(|id| self.source(id)).collect()
942 }
943
944 fn base_at(&self, base: Base, column: Column, now: &Now) -> Level {
948 let free_nothing = column == Column::Free && now.nothing;
949 match base {
950 Base::Row(row) => {
951 if free_nothing && matches!(row, Row::People(_, person) if person.state()) {
952 return Level::Later;
953 }
954 match self.lent(row, column, now.week) {
955 Level::As => Level::Later,
956 Level::Through => Level::Now,
957 other => other,
958 }
959 }
960 Base::Person(channel, person, always) => {
961 if person == Person::Blocked {
962 return Level::Never;
963 }
964 let own = Row::People(channel, if person == Person::Always { Person::Safe } else { person });
965 if !always && person != Person::Always {
966 return self.base_at(Base::Row(own), column, now);
967 }
968 let always_row = Row::People(channel, Person::Always);
969 let level = match self.lent(always_row, column, now.week) {
970 Level::As => self.lent(own, column, now.week),
971 Level::Through => Level::Now,
972 level => level,
973 };
974 if free_nothing && self.cell(always_row, Column::Free) == Level::As { level.max(Level::Later) } else { level }
975 }
976 }
977 }
978
979 fn over(&self, base: Base, sources: &[[Level; 9]], now: &Now) -> (Level, bool) {
986 let at = |column: Column| match sources.iter().map(|cells| cells[column.index()]).find(|l| *l != Level::As) {
987 Some(level) => (level, true),
988 None => (self.base_at(base, column, now), false),
989 };
990 let times: Vec<(Level, bool)> = now.times.iter().map(|c| at(*c)).collect();
991 let mut level = times.iter().map(|(l, _)| *l).min().unwrap_or(Level::Now);
992 let mut chosen = times.iter().any(|(_, set)| *set);
993 for (holds, column) in [(now.slot, Column::Slot), (now.dnd, Column::Dnd)] {
994 if holds {
995 let (layer, set) = at(column);
996 level = level.max(layer);
997 chosen |= set;
998 }
999 }
1000 (level, chosen)
1001 }
1002
1003 fn lent(&self, row: Row, column: Column, week: Week) -> Level {
1007 let own = self.cell(row, column);
1008 if !matches!(row, Row::People(..)) {
1009 return own;
1010 }
1011 match column {
1012 Column::Work if !week.admin_hours => own.min(self.cell(row, Column::Admin)),
1013 Column::Admin if !week.work_hours => own.min(self.cell(row, Column::Work)),
1014 _ => own,
1015 }
1016 }
1017
1018 pub fn level(&self, row: Row, now: &Now) -> Level {
1024 if let Row::People(_, person) = row
1026 && person.state()
1027 && now.reachable
1028 && !screened(row)
1029 {
1030 return Level::Now;
1031 }
1032 if let Row::People(_, person) = row
1033 && person.state()
1034 && now.nothing
1035 && now.is(Column::Free)
1036 {
1037 return Level::Later;
1038 }
1039 let read = |level: Level| match level {
1040 Level::As => Level::Later,
1041 Level::Through => Level::Now,
1042 other => other,
1043 };
1044 let mut level = now.times.iter().map(|c| read(self.lent(row, *c, now.week))).min().unwrap_or(Level::Now);
1045 if now.slot {
1046 level = level.max(read(self.cell(row, Column::Slot)));
1047 }
1048 if now.dnd {
1049 level = level.max(read(self.cell(row, Column::Dnd)));
1050 }
1051 level
1052 }
1053
1054 pub fn person(&self, channel: Channel, person: Person, always: bool, now: &Now) -> Level {
1058 if person == Person::Blocked {
1059 return Level::Never;
1060 }
1061 let own = Row::People(channel, if person == Person::Always { Person::Safe } else { person });
1062 if !always && person != Person::Always {
1063 return self.level(own, now);
1064 }
1065 if now.reachable {
1066 return Level::Now;
1067 }
1068 let always_row = Row::People(channel, Person::Always);
1069 let at = |column: Column| match self.lent(always_row, column, now.week) {
1070 Level::As => self.lent(own, column, now.week),
1071 Level::Through => Level::Now,
1072 level => level,
1073 };
1074 let mut level = now.times.iter().map(|c| at(*c)).min().unwrap_or(Level::Now);
1075 if now.nothing && now.is(Column::Free) && self.cell(always_row, Column::Free) == Level::As {
1077 level = level.max(Level::Later);
1078 }
1079 for (holds, column) in [(now.slot, Column::Slot), (now.dnd, Column::Dnd)] {
1080 if holds {
1081 level = level.max(at(column));
1082 }
1083 }
1084 level
1085 }
1086
1087 pub fn listed(&self, person: Person, always: bool, now: &Now) -> bool {
1093 if person == Person::Blocked {
1094 return false;
1095 }
1096 if self.person(Channel::Calls, person, always, now) == Level::Now {
1097 return true;
1098 }
1099 let row = Row::People(Channel::Calls, if person == Person::Always { Person::Safe } else { person });
1100 let never_rings = !always && Column::TIMES.iter().all(|c| self.cell(row, *c) != Level::Now);
1101 never_rings && !now.dnd && now.times.iter().any(|c| matches!(c, Column::Work | Column::Admin))
1102 }
1103
1104 fn meets(&self, channel: Channel, person: Person, area: Area, week: Week) -> bool {
1107 Time::STATES.into_iter().filter(|t| week.has(*t)).any(|t| {
1108 let column = match t {
1109 Time::Work => Column::Work,
1110 Time::Admin => Column::Admin,
1111 Time::Leisure => Column::Leisure,
1112 Time::Meals => Column::Meals,
1113 _ => Column::Sleep,
1114 };
1115 matches!(self.lent(Row::People(channel, person), column, week), Level::Now | Level::Quiet) && in_view(area, t, week)
1116 })
1117 }
1118}
1119
1120pub fn word(text: &str) -> Option<(Column, Level)> {
1123 match text.rsplit_once(':') {
1124 Some((column, value)) => Some((Column::read(column)?, Level::read(value)?)),
1125 None => Some((Column::read(text)?, Level::Now)),
1126 }
1127}
1128
1129#[derive(Debug, Clone, PartialEq, Eq)]
1133pub struct Event {
1134 pub source: Source,
1135 pub area: Option<Area>,
1138 pub starts: Option<i64>,
1140 pub sources: Vec<String>,
1143}
1144
1145impl Event {
1146 pub fn own(kind: Kind) -> Event {
1147 Event { source: Source::Own(kind), area: None, starts: None, sources: Vec::new() }
1148 }
1149
1150 pub fn of(source: Source) -> Event {
1151 Event { source, area: None, starts: None, sources: Vec::new() }
1152 }
1153
1154 pub fn from_sources(mut self, sources: Vec<String>) -> Event {
1156 self.sources = sources;
1157 self
1158 }
1159
1160 pub fn for_area(mut self, area: Option<Area>) -> Event {
1162 self.area = area;
1163 self
1164 }
1165
1166 pub fn starting(mut self, at: Option<i64>) -> Event {
1168 self.starts = at;
1169 self
1170 }
1171}
1172
1173#[derive(Debug, Clone, PartialEq, Eq)]
1175pub enum Source {
1176 Own(Kind),
1178 Mail { who: Who, always: bool, lane: Lane },
1182 Call { who: Option<Who>, always: bool, emergency: bool, repeat: bool },
1186 Message { via: Channel, who: Who, group: bool, always: bool },
1189 Site { call: bool, live: bool, named: Option<Who> },
1192}
1193
1194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1196pub enum Lane {
1197 Usual,
1199 Code,
1201 Own,
1203 Untold,
1206}
1207
1208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1210#[serde(rename_all = "kebab-case")]
1211pub enum Step {
1212 Emergency,
1214 Blocked,
1216 Content,
1218 Floor,
1220 Matrix,
1222 Chosen,
1224 Area,
1226 Hold,
1228}
1229
1230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1232pub struct Output {
1233 pub level: Level,
1235 pub shown: bool,
1237 pub told: bool,
1239 pub pierce: bool,
1243 pub step: Step,
1244}
1245
1246impl Output {
1247 fn at(level: Level, step: Step) -> Output {
1248 Output { level, shown: level != Level::Later, told: level == Level::Now, pierce: false, step }
1249 }
1250}
1251
1252impl Attention {
1253 pub fn decide(&self, event: &Event, now: &Now) -> Output {
1258 match &event.source {
1259 Source::Own(kind) => self.own(*kind, event, now),
1260 Source::Mail { who, always, lane } => self.letter(*who, *always, *lane, event, now),
1261 Source::Call { who, always, emergency, repeat } => self.call(*who, *always, *emergency, *repeat, event, now),
1262 Source::Message { via, who, group, always } => self.message(*via, *who, *group, *always, event, now),
1263 Source::Site { call, live, named } => self.site(*call, *live, *named, event, now),
1264 }
1265 }
1266
1267 pub fn holding(&self, event: &Event, now: &Now) -> Vec<Column> {
1275 let event = Event { area: None, ..event.clone() };
1276 let holds = |moment: &Now| self.decide(&event, moment).level >= Level::Later;
1277 let mut columns = Vec::new();
1278 if holds(&Now { slot: false, dnd: false, ..now.clone() }) {
1279 columns.extend(now.times.iter().copied());
1280 }
1281 for (on, layer) in [(now.slot, Column::Slot), (now.dnd, Column::Dnd)] {
1282 if on && holds(&Now { times: Vec::new(), slot: layer == Column::Slot, dnd: layer == Column::Dnd, nothing: false, ..now.clone() }) {
1283 columns.push(layer);
1284 }
1285 }
1286 columns
1287 }
1288
1289 pub fn chosen_source<'a>(&self, ids: &'a [String], now: &Now) -> Option<&'a str> {
1293 let mut read: Vec<Column> = now.times.clone();
1294 read.extend([(now.slot, Column::Slot), (now.dnd, Column::Dnd)].into_iter().filter(|(on, _)| *on).map(|(_, c)| c));
1295 ids.iter().find(|id| self.source(id).is_some_and(|cells| read.iter().any(|c| cells[c.index()] != Level::As))).map(String::as_str)
1296 }
1297
1298 pub fn mail(&self, t: &crate::porch::Triaged, senders: &crate::porch::Senders, always: &crate::everywhere::People, area: Area, now: &Now) -> Output {
1304 use crate::porch::{Lane as PorchLane, Reason};
1305 let unproven = t.lane == PorchLane::SetAside || t.reasons.contains(&Reason::NotAuthenticated);
1306 let who = if unproven { Who::Stranger } else { senders.who_of(&t.card) };
1307 let always = !unproven && t.card.from_address.as_deref().is_some_and(|address| always.admits_address(address));
1308 let lane = if t.lane == PorchLane::RightNow {
1309 Lane::Code
1310 } else if t.reasons.contains(&Reason::FromYourself) {
1311 Lane::Own
1312 } else {
1313 Lane::Usual
1314 };
1315 self.decide(&Event::of(Source::Mail { who, always, lane }).for_area(Some(area)), now)
1316 }
1317
1318 fn own(&self, kind: Kind, event: &Event, now: &Now) -> Output {
1319 let sources = self.source_cells(&event.sources);
1321 let (mut level, chosen) = if sources.is_empty() { (self.level(Row::Own(kind), now), false) } else { self.over(Base::Row(Row::Own(kind)), &sources, now) };
1322 if level == Level::Event {
1323 level = if event.starts.is_some_and(|s| now.span.0 <= s && s < now.span.1) { Level::Now } else { Level::Later };
1324 }
1325 let mut step = if chosen { Step::Chosen } else { Step::Matrix };
1326 let hours = !now.times.iter().any(|c| matches!(c, Column::Sleep | Column::Pause | Column::Free));
1330 if !chosen && hours && matches!(level, Level::Now | Level::Gathered) && event.area.is_some_and(|area| !in_view(area, now.time, now.week)) {
1331 (level, step) = (Level::Later, Step::Area);
1332 }
1333 if now.holds.meeting && matches!(kind, Kind::Needs | Kind::WorkOver) && level == Level::Now {
1335 (level, step) = (Level::Never, Step::Hold);
1336 }
1337 let told = level == Level::Now || (level == Level::Gathered && now.gathering);
1338 Output { level, shown: true, told, pierce: told && matches!(kind, Kind::Codes | Kind::Doses | Kind::Wake | Kind::Alarms), step }
1339 }
1340
1341 fn letter(&self, who: Who, always: bool, lane: Lane, event: &Event, now: &Now) -> Output {
1342 match lane {
1344 Lane::Code => {
1345 let told = self.level(Row::Own(Kind::Codes), now) == Level::Now;
1346 return Output { level: if told { Level::Now } else { Level::Quiet }, shown: true, told, pierce: told, step: Step::Content };
1347 }
1348 Lane::Own => return Output { level: Level::Quiet, shown: true, told: false, pierce: false, step: Step::Content },
1349 _ => {}
1350 }
1351 let person = Person::of(who);
1352 if person == Person::Blocked {
1353 return Output { level: Level::Never, shown: false, told: false, pierce: false, step: Step::Blocked };
1354 }
1355 let mut level = self.person(Channel::Mail, person, always, now);
1356 let mut step = Step::Matrix;
1357 if let Some(area) = event.area
1361 && !now.reachable
1362 && matches!(level, Level::Now | Level::Quiet)
1363 && person != Person::Safe
1364 && !always
1365 && !in_view(area, now.time, now.week)
1366 && self.meets(Channel::Mail, person, area, now.week)
1367 {
1368 (level, step) = (Level::Later, Step::Area);
1369 }
1370 let mut out = Output::at(level, step);
1371 out.told = level == Level::Now && lane == Lane::Usual;
1372 if out.told && now.holds.porch_rests && !now.reachable {
1374 (out.told, out.step) = (false, Step::Hold);
1375 }
1376 out.pierce = out.told && always;
1377 out
1378 }
1379
1380 fn call(&self, who: Option<Who>, always: bool, emergency: bool, repeat: bool, event: &Event, now: &Now) -> Output {
1381 let rings = |step: Step| Output { level: Level::Now, shown: true, told: true, pierce: true, step };
1382 if emergency {
1383 return rings(Step::Emergency);
1384 }
1385 let person = who.map_or(Person::Hidden, Person::of);
1386 if person == Person::Blocked {
1387 return Output { level: Level::Never, shown: false, told: false, pierce: false, step: Step::Blocked };
1388 }
1389 if (now.through && matches!(person, Person::Stranger | Person::Hidden)) || (repeat && who.is_some()) {
1391 return rings(Step::Floor);
1392 }
1393 let own = if always || now.reachable || matches!(person, Person::Stranger | Person::Hidden) {
1397 Vec::new()
1398 } else {
1399 self.source_cells(&event.sources.iter().filter(|id| is_person(id)).cloned().collect::<Vec<_>>())
1400 };
1401 let (level, chosen) = if own.is_empty() { (self.person(Channel::Calls, person, always && who.is_some(), now), false) } else { self.over(Base::Person(Channel::Calls, person, false), &own, now) };
1402 let level = if level == Level::Quiet { Level::Later } else { level };
1404 let mut out = Output::at(level, if chosen { Step::Chosen } else { Step::Matrix });
1405 out.pierce = out.told && always;
1406 out
1407 }
1408
1409 fn message(&self, via: Channel, who: Who, group: bool, always: bool, event: &Event, now: &Now) -> Output {
1410 let person = if who == Who::Blocked { Person::Blocked } else if group && via == Channel::Messages { Person::Groups } else { Person::of(who) };
1411 if person == Person::Blocked {
1412 return Output { level: Level::Never, shown: false, told: false, pierce: false, step: Step::Blocked };
1413 }
1414 let sources = if always { self.source_cells(&event.sources.iter().filter(|id| id.starts_with(CONVERSATION_ROW)).cloned().collect::<Vec<_>>()) } else { self.source_cells(&event.sources) };
1419 let (mut level, chosen) = if !sources.is_empty() {
1421 self.over(Base::Person(via, person, always), &sources, now)
1422 } else if via == Channel::Calls {
1423 (if self.listed(person, always, now) { Level::Now } else { Level::Later }, false)
1424 } else {
1425 (self.person(via, person, always, now), false)
1426 };
1427 if now.reachable && level != Level::Never && !(via == Channel::Calls && screened(Row::People(Channel::Calls, person))) {
1430 level = Level::Now;
1431 }
1432 if level == Level::Quiet {
1438 level = if now.silenced && via == Channel::Messages { Level::Now } else { Level::Later };
1439 }
1440 let mut step = if chosen { Step::Chosen } else { Step::Matrix };
1441 if !chosen && !now.reachable && level == Level::Now && person != Person::Safe && !always && event.area.is_some_and(|area| !in_view(area, now.time, now.week)) {
1442 (level, step) = (Level::Later, Step::Area);
1443 }
1444 let mut out = Output::at(level, step);
1445 out.pierce = out.told && always;
1446 out
1447 }
1448
1449 fn site(&self, call: bool, live: bool, named: Option<Who>, event: &Event, now: &Now) -> Output {
1450 let kind = if call {
1451 Kind::SiteCalls
1452 } else if live || now.realtime {
1453 Kind::SitesLive
1454 } else {
1455 Kind::Sites
1456 };
1457 let mut level = self.level(Row::Own(kind), now);
1458 let mut step = Step::Matrix;
1459 if let Some(who) = named {
1461 let theirs = self.person(Channel::Messages, Person::of(who), false, now);
1462 if theirs > level {
1463 level = if theirs == Level::Never { Level::Never } else { Level::Later };
1464 step = if who == Who::Blocked { Step::Blocked } else { Step::Matrix };
1465 }
1466 }
1467 if matches!(level, Level::Now | Level::Gathered) && event.area.is_some_and(|area| !in_view(area, now.time, now.week)) {
1468 (level, step) = (Level::Later, Step::Area);
1469 }
1470 if matches!(level, Level::Now | Level::Gathered) && now.holds.chats {
1471 (level, step) = (Level::Later, Step::Hold);
1472 }
1473 let told = level == Level::Now || (level == Level::Gathered && now.gathering);
1474 Output { level, shown: level != Level::Never, told, pierce: false, step }
1475 }
1476}
1477
1478#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1483#[serde(rename_all = "lowercase")]
1484pub enum Senders {
1485 None,
1486 Starred,
1487 Contacts,
1488 Anyone,
1489}
1490
1491#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1495pub struct Silence {
1496 pub calls: Senders,
1497 pub messages: Senders,
1498 pub repeat: bool,
1500 pub conversations: bool,
1502 pub alarms: bool,
1504 pub doses: bool,
1506 pub events: bool,
1508}
1509
1510#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1512pub struct Phone {
1513 pub screens: bool,
1516 pub unknown: bool,
1522}
1523
1524impl Attention {
1525 pub fn silence_at(&self, now: &Now, phone: Phone) -> Silence {
1540 let passes = |channel: Channel, person: Person| self.person(channel, person, false, now) == Level::Now;
1541 let always = |channel: Channel| self.person(channel, Person::Always, true, now) == Level::Now;
1542 let senders = |channel: Channel, own: bool| {
1543 let strangers = if channel == Channel::Calls && own { phone.unknown && passes(channel, Person::Neutral) } else { [Person::Stranger, Person::Hidden, Person::Groups].iter().any(|p| persons(channel).contains(p) && passes(channel, *p)) };
1546 let known = [Person::Safe, Person::Neutral, Person::Restricted].iter().any(|p| passes(channel, *p));
1547 match (own, strangers, known || always(channel)) {
1548 (true, true, _) => Senders::Anyone,
1549 (true, false, true) => Senders::Contacts,
1550 (false, _, true) | (false, true, _) => Senders::Starred,
1551 _ => Senders::None,
1552 }
1553 };
1554 let calls = if now.through { Senders::Anyone } else { senders(Channel::Calls, phone.screens) };
1555 Silence {
1556 calls,
1557 messages: senders(Channel::Messages, false),
1558 repeat: calls != Senders::None,
1559 conversations: always(Channel::Messages),
1560 alarms: true,
1561 doses: self.level(Row::Own(Kind::Doses), now) == Level::Now,
1562 events: matches!(self.level(Row::Own(Kind::Alarms), now), Level::Now | Level::Event),
1563 }
1564 }
1565
1566 pub fn silence(&self, columns: &[Column], nothing: bool, phone: Phone) -> Silence {
1571 let mut now = Now::time(Column::Leisure);
1572 now.times = columns.iter().copied().filter(|c| c.is_time()).collect();
1573 now.slot = columns.contains(&Column::Slot);
1574 now.dnd = columns.contains(&Column::Dnd);
1575 now.nothing = nothing;
1576 self.silence_at(&now, phone)
1577 }
1578}
1579
1580#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1585#[serde(rename_all = "kebab-case")]
1586pub enum Preset {
1587 Usual,
1588 Quieter,
1589 Reachable,
1590}
1591
1592impl Preset {
1593 pub const ALL: [Preset; 3] = [Preset::Usual, Preset::Quieter, Preset::Reachable];
1594
1595 pub fn id(self) -> &'static str {
1596 match self {
1597 Preset::Usual => "usual",
1598 Preset::Quieter => "quieter",
1599 Preset::Reachable => "reachable",
1600 }
1601 }
1602
1603 pub fn read(text: &str) -> Option<Preset> {
1604 Preset::ALL.into_iter().find(|p| p.id() == text.trim())
1605 }
1606
1607 pub fn matrix(self) -> Attention {
1609 use Column::*;
1610 use Level::*;
1611 let mut matrix = Attention::usual();
1612 let changes: &[(Row, &[Column], Level)] = match self {
1613 Preset::Usual => &[],
1614 Preset::Quieter => &[
1615 (Row::People(Channel::Mail, Person::Safe), &[Leisure, Meals], Quiet),
1616 (Row::People(Channel::Calls, Person::Neutral), &[Admin, Leisure, Meals], Later),
1617 (Row::People(Channel::Messages, Person::Neutral), &[Leisure, Meals], Later),
1618 (Row::People(Channel::Messages, Person::Groups), &[Leisure, Meals], Later),
1619 (Row::People(Channel::Messages, Person::Safe), &[Free], Later),
1620 (Row::People(Channel::Messages, Person::Safe), &[Dnd], Later),
1622 (Row::People(Channel::Messages, Person::Neutral), &[Dnd], Later),
1623 (Row::People(Channel::Messages, Person::Groups), &[Dnd], Later),
1624 (Row::Own(Kind::Dates), &[Leisure, Meals], Later),
1625 (Row::Own(Kind::SitesLive), &[Leisure, Meals], Later),
1626 ],
1627 Preset::Reachable => &[
1628 (Row::People(Channel::Mail, Person::Neutral), &[Leisure, Meals], Now),
1629 (Row::People(Channel::Mail, Person::Stranger), &[Leisure], Quiet),
1630 (Row::People(Channel::Calls, Person::Neutral), &[Leisure, Meals], Now),
1631 (Row::People(Channel::Messages, Person::Neutral), &[Leisure, Meals], Now),
1632 (Row::Own(Kind::AppAutomatons), &[Work, Admin], Now),
1633 ],
1634 };
1635 for (row, columns, level) in changes {
1636 for column in *columns {
1637 let _ = matrix.set(*row, *column, *level);
1638 }
1639 }
1640 matrix
1641 }
1642}
1643
1644#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
1649#[serde(transparent)]
1650pub struct Settings(pub BTreeMap<String, toml::Value>);
1651
1652impl Settings {
1653 pub fn words(&self, row: &str) -> Option<Vec<String>> {
1656 let value = self.0.iter().find(|(key, _)| Row::read(key).is_some_and(|r| r.id() == row)).map(|(_, v)| v)?;
1657 Some(match value {
1658 toml::Value::Array(list) => list.iter().filter_map(|w| w.as_str().map(str::to_string)).collect(),
1659 toml::Value::String(text) => text.split(',').map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect(),
1660 _ => Vec::new(),
1661 })
1662 }
1663
1664 pub fn sources(&self) -> Vec<(String, Vec<String>)> {
1667 self.0
1668 .iter()
1669 .filter(|(key, _)| is_source(key.trim()))
1670 .map(|(key, value)| {
1671 let words: Vec<String> = match value {
1672 toml::Value::Array(list) => list.iter().filter_map(|w| w.as_str().map(str::to_string)).collect(),
1673 toml::Value::String(text) => text.split(',').map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect(),
1674 _ => Vec::new(),
1675 };
1676 let words = if is_person(key.trim()) { words.into_iter().filter(|w| !w.starts_with(NAME)).collect() } else { words };
1678 (key.trim().to_string(), words)
1679 })
1680 .collect()
1681 }
1682}
1683
1684#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
1688#[serde(transparent)]
1689pub struct NotifySettings(pub BTreeMap<String, toml::Value>);
1690
1691impl NotifySettings {
1692 pub fn words(&self, row: &str) -> Option<Vec<String>> {
1695 let value = self.0.iter().find(|(key, _)| key.trim().to_ascii_lowercase().replace('_', "-") == row).map(|(_, v)| v)?;
1696 Some(match value {
1697 toml::Value::Array(list) => list.iter().filter_map(|w| w.as_str().map(str::to_string)).collect(),
1698 toml::Value::String(text) => text.split(',').map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect(),
1699 _ => Vec::new(),
1700 })
1701 }
1702}
1703
1704pub const OLDER: &[&str] = &["reach", "notify", "reminders.doses_in_sleep", "pause.doses", "pause.people", "dnd.people"];
1706
1707pub fn seeded(config: &Config) -> Attention {
1712 let (then, then_usual) = (older(config), older(&Config::default()));
1713 let mut attention = Attention::usual();
1714 for row in Row::ALL {
1715 for column in Column::ALL {
1716 let (was, usual_then) = (then[row.index()][column.index()], then_usual[row.index()][column.index()]);
1717 if was != usual_then {
1718 let _ = attention.set(row, column, was);
1719 }
1720 }
1721 }
1722 let always = |channel: Channel| Row::People(channel, Person::Always);
1726 if !config.dnd.people {
1727 for channel in Channel::ALL {
1728 for column in Column::ALL {
1729 let _ = attention.set(always(channel), column, Level::As);
1730 }
1731 }
1732 } else if !config.pause.people {
1733 for channel in [Channel::Calls, Channel::Messages] {
1734 let _ = attention.set(always(channel), Column::Pause, Level::As);
1735 }
1736 }
1737 attention
1738}
1739
1740fn older(config: &Config) -> [[Level; 9]; 36] {
1742 use Column::*;
1743 let mut cells = [[Level::Now; 9]; 36];
1744 let notify = |row: &str, column: Column| -> &'static str {
1746 let usual = older_notify(row, column);
1747 let mut value = usual;
1748 for text in config.notify.words(row).unwrap_or_default() {
1749 let (col, val) = match text.rsplit_once(':') {
1750 Some((c, v)) => (Column::read(c), v.trim().to_ascii_lowercase()),
1751 None => (Column::read(&text), "now".to_string()),
1752 };
1753 if col == Some(column)
1754 && let Some(known) = ["now", "list-any", "list", "event", "gathered", "later", "never"].into_iter().find(|k| *k == val.replace('_', "-"))
1755 {
1756 value = known;
1757 }
1758 }
1759 value
1760 };
1761 let doses = |column: Column| match column {
1762 Sleep if config.notify.words("doses").is_none_or(|w| !w.iter().any(|w| w.starts_with("sleep"))) => if config.reminders.doses_in_sleep { "now" } else { "later" },
1763 Pause if config.notify.words("doses").is_none_or(|w| !w.iter().any(|w| w.starts_with("pause"))) => if config.pause.doses { "now" } else { "later" },
1764 _ => notify("doses", column),
1765 };
1766 let level = |value: &str| match value {
1767 "now" | "list-any" => Level::Now,
1768 "event" => Level::Event,
1769 "gathered" => Level::Gathered,
1770 "never" => Level::Never,
1771 _ => Level::Later,
1772 };
1773 for kind in Kind::ALL {
1774 for column in Column::ALL {
1775 let value = if kind == Kind::Doses { doses(column) } else { notify(kind.id(), column) };
1776 cells[Row::Own(kind).index()][column.index()] = level(value);
1777 }
1778 }
1779 let reach = &config.reach;
1781 let before = reach.stranger.is_none();
1782 let ticks = |words: &[String]| -> [bool; 6] {
1783 let mut on = [false; 6];
1784 for column in words.iter().filter_map(|w| Column::read(w)) {
1785 if let Some(at) = [Work, Admin, Leisure, Meals, Sleep, Pause].iter().position(|c| *c == column) {
1786 on[at] = true;
1787 }
1788 }
1789 on
1790 };
1791 let mail_usual = |person: Person| -> [bool; 6] {
1792 match person {
1793 Person::Safe => [true; 6],
1794 Person::Neutral | Person::Stranger => [true, true, false, false, false, false],
1795 _ => [true, false, false, false, false, false],
1796 }
1797 };
1798 let mail_row = |set: &Option<Vec<String>>, person: Person| -> [bool; 6] {
1799 match set {
1800 None => mail_usual(person),
1801 Some(words) => {
1802 let mut on = ticks(words);
1803 if before {
1805 on[5] = on[4];
1806 }
1807 on
1808 }
1809 }
1810 };
1811 let neutral = mail_row(&reach.neutral, Person::Neutral);
1812 let mail: BTreeMap<Person, [bool; 6]> = [
1813 (Person::Safe, mail_row(&reach.safe, Person::Safe)),
1814 (Person::Neutral, neutral),
1815 (Person::Restricted, mail_row(&reach.restricted, Person::Restricted)),
1816 (Person::Stranger, if before { neutral } else { mail_row(&reach.stranger, Person::Stranger) }),
1817 ]
1818 .into_iter()
1819 .collect();
1820 let calls_usual = |person: Person| -> [bool; 6] {
1821 match person {
1822 Person::Safe => [true, true, true, true, false, false],
1823 Person::Neutral | Person::Hidden => [true, true, false, false, false, false],
1824 Person::Restricted => [true, false, false, false, false, false],
1825 _ => [false; 6],
1826 }
1827 };
1828 let row_of = |set: &crate::config::RowSettings, person: Person| match person {
1829 Person::Safe => set.safe.clone(),
1830 Person::Neutral => set.neutral.clone(),
1831 Person::Restricted => set.restricted.clone(),
1832 Person::Stranger => set.stranger.clone(),
1833 Person::Hidden => set.hidden.clone(),
1834 _ => None,
1835 };
1836 let times6 = [Work, Admin, Leisure, Meals, Sleep, Pause];
1837 for person in [Person::Safe, Person::Neutral, Person::Restricted, Person::Stranger] {
1838 let on = mail[&person];
1840 let row = Row::People(Channel::Mail, person).index();
1841 let told = |value: &str| match value {
1842 "now" => Level::Now,
1843 "never" => Level::Never,
1844 _ => Level::Quiet,
1845 };
1846 for (at, column) in times6.iter().enumerate() {
1847 cells[row][column.index()] = if on[at] { told(notify("mail", *column)) } else { Level::Later };
1848 }
1849 cells[row][Free.index()] = if person == Person::Safe && on[2] { told(notify("mail", Free)) } else { Level::Later };
1850 cells[row][Slot.index()] = told(notify("mail", Slot));
1851 cells[row][Dnd.index()] = match notify("mail", Dnd) {
1852 "now" => Level::Now,
1853 "never" => Level::Never,
1854 _ => Level::Quiet,
1855 };
1856 let on = row_of(&reach.messages, person).map_or(on, |w| ticks(&w));
1858 let row = Row::People(Channel::Messages, person).index();
1859 let now_or_later = |value: &str| if value == "now" { Level::Now } else { Level::Later };
1860 for (at, column) in times6.iter().enumerate() {
1861 cells[row][column.index()] = if on[at] { now_or_later(notify("app-people", *column)) } else { Level::Later };
1862 }
1863 cells[row][Free.index()] = if person == Person::Safe && on[2] { now_or_later(notify("app-people", Free)) } else { Level::Later };
1864 cells[row][Slot.index()] = now_or_later(notify("app-people", Slot));
1865 cells[row][Dnd.index()] = now_or_later(notify("app-people", Dnd));
1866 if person == Person::Stranger {
1867 cells[Row::People(Channel::Messages, Person::Groups).index()] = cells[row];
1868 }
1869 }
1870 for person in [Person::Safe, Person::Neutral, Person::Restricted, Person::Stranger, Person::Hidden] {
1872 let on = row_of(&reach.calls, person).map_or(calls_usual(person), |w| ticks(&w));
1873 let row = Row::People(Channel::Calls, person).index();
1874 for (at, column) in times6.iter().enumerate() {
1875 cells[row][column.index()] = if on[at] { Level::Now } else { Level::Later };
1876 }
1877 cells[row][Free.index()] = if person == Person::Safe && on[2] { Level::Now } else { Level::Later };
1878 cells[row][Slot.index()] = Level::Now;
1879 cells[row][Dnd.index()] = Level::Now;
1880 }
1881 for channel in Channel::ALL {
1884 let row = Row::People(channel, Person::Always).index();
1885 cells[row] = [Level::As; 9];
1886 if !config.dnd.people {
1887 continue;
1888 }
1889 match channel {
1890 Channel::Calls => cells[row] = [Level::Now; 9],
1891 Channel::Mail | Channel::Messages => {
1892 cells[row][Dnd.index()] = match notify(if channel == Channel::Mail { "mail" } else { "app-people" }, Dnd) {
1893 "list" => Level::Through,
1894 "list-any" => Level::Now,
1895 _ => Level::As,
1896 };
1897 }
1898 }
1899 if !config.pause.people && channel != Channel::Mail {
1901 cells[row][Pause.index()] = Level::As;
1902 }
1903 }
1904 for channel in Channel::ALL {
1905 cells[Row::People(channel, Person::Blocked).index()] = [Level::Never; 9];
1906 }
1907 cells
1908}
1909
1910fn older_notify(row: &str, column: Column) -> &'static str {
1912 use Column::*;
1913 let hours = matches!(column, Work | Admin | Leisure | Meals);
1914 let held = matches!(column, Sleep | Pause | Free);
1915 match row {
1916 "codes" => if matches!(column, Sleep | Pause) { "never" } else { "now" },
1917 "doses" | "wake" | "time" => "now",
1918 "alarms" | "before" => if matches!(column, Sleep | Free) { "event" } else { "now" },
1919 "day-before" | "dates" => if held { "later" } else { "now" },
1920 "needs" => if held { "never" } else { "now" },
1921 "move" => if hours { "now" } else { "never" },
1922 "work-over" => if column == Work || held { "never" } else { "now" },
1923 "mail" => match column {
1924 Dnd => "list",
1925 _ if hours => "now",
1926 _ => "later",
1927 },
1928 "sites" | "app-automatons" => if hours { "gathered" } else { "later" },
1929 "sites-live" | "app-at-once" => if hours { "now" } else { "later" },
1930 "site-calls" => if hours || column == Slot { "now" } else { "later" },
1931 "app-people" => if column == Dnd { "list" } else { "now" },
1932 _ => "now",
1933 }
1934}
1935
1936pub fn apply(path: &Path, config: &Config, key: &str, value: &SettingValue) -> Result<(), String> {
1947 crate::filelock::with_lock(path, || {
1948 let fresh = if path.exists() { Config::load(path)? } else { config.clone() };
1949 apply_locked(path, &fresh, key, value)
1950 })
1951}
1952
1953pub fn apply_change(path: &Path, key: &str, shown: Option<&SettingValue>, value: &SettingValue) -> Result<(), String> {
1958 let shown: Option<&[String]> = match shown {
1959 Some(SettingValue::Texts(words)) => Some(words),
1960 _ => None,
1961 };
1962 let value = match (value, shown) {
1963 (SettingValue::Texts(words), Some(shown)) if !key.starts_with("reach.") => SettingValue::Texts(words.iter().filter(|w| !shown.contains(w)).cloned().collect()),
1964 _ => value.clone(),
1965 };
1966 apply(path, &Config::default(), key, &value)
1967}
1968
1969fn apply_locked(path: &Path, config: &Config, key: &str, value: &SettingValue) -> Result<(), String> {
1970 let words: &[String] = match value {
1971 SettingValue::Texts(words) => words,
1972 SettingValue::Ints(none) if none.is_empty() => &[],
1973 _ => return Err(format!("{key}: a list of columns expected")),
1974 };
1975 let mut attention = Attention::of(config);
1976 if let Some(rest) = key.strip_prefix("reach.") {
1977 let (channel, person) = rest.split_once('.').unwrap_or(("mail", rest));
1979 let row = Row::read(&format!("{channel}.{person}")).filter(|r| matches!(r, Row::People(_, p) if p.state())).ok_or_else(|| format!("{key}: no such row"))?;
1980 if screened(row) {
1981 return Err(screened_refusal(key));
1982 }
1983 let ticked: Vec<Column> = words.iter().filter_map(|w| Column::read(w)).collect();
1984 for column in [Column::Work, Column::Admin, Column::Leisure, Column::Meals, Column::Sleep, Column::Pause] {
1985 let comes = matches!(attention.cell(row, column), Level::Now | Level::Quiet);
1986 let level = match (ticked.contains(&column), comes) {
1987 (true, true) => continue,
1988 (true, false) if matches!(row, Row::People(Channel::Mail, _)) && matches!(column, Column::Sleep | Column::Pause) => Level::Quiet,
1989 (true, false) => Level::Now,
1990 (false, _) => Level::Later,
1991 };
1992 attention.set(row, column, level)?;
1993 }
1994 return write(path, config, &attention, &[row]);
1995 }
1996 if let Some(id) = key.strip_prefix("attention.").filter(|id| is_source(id)) {
1998 for text in words {
1999 if let Some(name) = text.strip_prefix(NAME) {
2000 attention.name_source(id, name);
2001 continue;
2002 }
2003 let (column, level) = word(text).ok_or_else(|| format!("{key}: {text}: not understood"))?;
2004 attention.set_source(id, column, level)?;
2005 }
2006 return write_with(path, config, &attention, &[], &[id.to_string()]);
2007 }
2008 let rest = key.strip_prefix("attention.").or_else(|| key.strip_prefix("notify.")).ok_or_else(|| format!("{key}: no such row"))?;
2009 let row = Row::read(rest).ok_or_else(|| format!("{key}: no such row"))?;
2010 if screened(row) {
2011 return Err(screened_refusal(key));
2012 }
2013 for text in words {
2014 let (column, level) = word(text).ok_or_else(|| format!("{key}: {text}: not understood"))?;
2015 attention.set(row, column, level)?;
2016 }
2017 write(path, config, &attention, &[row])
2018}
2019
2020fn screened_refusal(key: &str) -> String {
2022 format!("{key}: the call screening's own (Settings ▸ This phone ▸ Calls ▸ Call screening: screening.unknown, screening.hidden)")
2023}
2024
2025pub fn apply_preset(path: &Path, config: &Config, preset: Preset) -> Result<(), String> {
2029 crate::filelock::with_lock(path, || {
2030 let fresh = if path.exists() { Config::load(path)? } else { config.clone() };
2031 let matrix = preset.matrix();
2032 write(path, &fresh, &matrix, &Row::ALL)
2033 })
2034}
2035
2036fn write(path: &Path, config: &Config, attention: &Attention, rows: &[Row]) -> Result<(), String> {
2039 write_with(path, config, attention, rows, &[])
2040}
2041
2042fn write_with(path: &Path, config: &Config, attention: &Attention, rows: &[Row], sources: &[String]) -> Result<(), String> {
2048 let usual = Attention::usual();
2049 let first = config.attention.is_none();
2050 let now = Attention::of(config);
2051 let rows: Vec<Row> = if first { Row::ALL.to_vec() } else { rows.iter().copied().filter(|r| !screened(*r)).collect() };
2052 let mut said: Vec<(String, Option<Vec<String>>)> = rows
2053 .iter()
2054 .filter(|r| !matches!(r, Row::People(_, Person::Blocked)))
2055 .map(|row| {
2056 let attention = if screened(*row) { &now } else { attention };
2057 let same = Column::ALL.iter().all(|c| attention.cell(*row, *c) == usual.cell(*row, *c));
2058 (row.id(), (!same).then(|| attention.words(*row)))
2059 })
2060 .collect();
2061 for id in sources {
2062 let words = attention.source_words(id);
2063 said.push((id.clone(), (!words.is_empty()).then_some(words)));
2064 }
2065 crate::config::set_table(path, "attention", &said, if first { OLDER } else { &[] })
2066}
2067
2068pub const SLOTS_FILE: &str = "slots.toml";
2072
2073#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2077pub struct Slots {
2078 #[serde(default, skip_serializing_if = "Option::is_none")]
2080 pub day: Option<jiff::civil::Date>,
2081 #[serde(default)]
2083 pub slots: Vec<(i64, i64)>,
2084}
2085
2086impl Slots {
2087 pub fn default_path() -> PathBuf {
2088 crate::config::state_dir().join(SLOTS_FILE)
2089 }
2090
2091 pub fn load(path: &Path) -> Slots {
2093 std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
2094 }
2095
2096 pub fn save(&self, path: &Path) -> Result<bool, String> {
2098 if Slots::load(path) == *self && path.exists() {
2099 return Ok(false);
2100 }
2101 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
2102 if let Some(parent) = path.parent() {
2103 std::fs::create_dir_all(parent).map_err(fail)?;
2104 }
2105 let fresh = path.with_extension(format!("toml.{}", std::process::id()));
2106 std::fs::write(&fresh, toml::to_string(self).map_err(|e| e.to_string())?).map_err(fail)?;
2107 std::fs::rename(&fresh, path).map_err(fail)?;
2108 Ok(true)
2109 }
2110
2111 pub fn at(&self, at: &jiff::Zoned) -> bool {
2113 self.day == Some(at.date()) && self.holds(at.timestamp().as_second())
2114 }
2115
2116 pub fn holds(&self, stamp: i64) -> bool {
2118 self.slots.iter().any(|&(from, to)| from <= stamp && stamp < to)
2119 }
2120
2121 pub fn edges(&self) -> Vec<i64> {
2123 let mut edges: Vec<i64> = self.slots.iter().flat_map(|&(from, to)| [from, to]).collect();
2124 edges.sort_unstable();
2125 edges.dedup();
2126 edges
2127 }
2128}
2129
2130pub fn dnd_from_files(config: &Config, now: i64) -> bool {
2138 use crate::everywhere;
2139 let zoned = jiff::Timestamp::from_second(now).map_or_else(|_| jiff::Zoned::now(), |t| t.to_zoned(jiff::tz::TimeZone::system()));
2140 let sources = everywhere::sources_from_files(&zoned);
2141 everywhere::now(&config.dnd, &sources, everywhere::read_switch("").latest().as_ref(), now).gates()
2142}
2143
2144#[derive(Debug, Clone, PartialEq, Serialize)]
2148pub struct Named {
2149 pub id: String,
2150 pub label: String,
2151}
2152
2153#[derive(Debug, Clone, PartialEq, Serialize)]
2155pub struct Mark {
2156 pub id: String,
2157 pub mark: String,
2158 pub label: String,
2159}
2160
2161#[derive(Debug, Clone, PartialEq, Serialize)]
2165pub struct GridRow {
2166 pub id: String,
2167 pub label: String,
2168 pub group: String,
2169 pub group_id: String,
2171 pub help: String,
2172 pub choices: Vec<Mark>,
2173 pub cells: Vec<GridCell>,
2174 pub changed: bool,
2176}
2177
2178#[derive(Debug, Clone, PartialEq, Serialize)]
2181pub struct GridCell {
2182 pub column: String,
2183 pub value: String,
2184 pub choices: Vec<String>,
2185 #[serde(skip_serializing_if = "String::is_empty")]
2186 pub locked: String,
2187 #[serde(skip_serializing_if = "std::ops::Not::not")]
2188 pub changed: bool,
2189 pub said: String,
2190}
2191
2192#[derive(Debug, Clone, PartialEq, Serialize)]
2194pub struct PresetView {
2195 pub id: String,
2196 pub label: String,
2197 pub current: bool,
2199 pub changes: usize,
2201}
2202
2203#[derive(Debug, Clone, PartialEq, Serialize)]
2207pub struct Grid {
2208 pub columns: Vec<Named>,
2209 pub layers: Vec<String>,
2211 pub marks: Vec<Mark>,
2212 pub rows: Vec<GridRow>,
2213 pub presets: Vec<PresetView>,
2214 pub changed: String,
2216}
2217
2218pub fn column_label(tr: &Translator, column: Column) -> String {
2220 tr.text(&format!("attention-column-{}", column.id()), None)
2221}
2222
2223pub fn row_label(tr: &Translator, row: Row) -> String {
2225 match row {
2226 Row::People(_, person) => tr.text(&format!("attention-person-{}", person.id()), None),
2227 Row::Own(kind) => tr.text(&format!("attention-row-{}", kind.id()), None),
2228 }
2229}
2230
2231pub fn level_label(tr: &Translator, row: Row, level: Level) -> String {
2234 let own = match (row, level) {
2235 (Row::Own(Kind::Codes), Level::Never) => Some("attention-level-never-codes"),
2236 (Row::People(_, Person::Always), Level::Now) => Some("attention-level-now-always"),
2237 (Row::People(Channel::Mail, _), Level::Never) => Some("attention-level-never-mail"),
2238 (Row::People(Channel::Calls, _), Level::Later) => Some("attention-level-later-calls"),
2239 (Row::People(Channel::Messages, _), Level::Quiet) => Some("attention-level-quiet-messages"),
2240 _ => None,
2241 };
2242 tr.text(own.unwrap_or(&format!("attention-level-{}", level.id())), None)
2243}
2244
2245pub fn grid(attention: &Attention, tr: &Translator, rows: &[Row], preset: Preset) -> Grid {
2249 let compared = preset.matrix();
2250 let columns = Column::ALL.iter().map(|c| Named { id: c.id().to_string(), label: column_label(tr, *c) }).collect();
2251 let marks = Level::ALL.iter().map(|l| Mark { id: l.id().to_string(), mark: l.mark(false).to_string(), label: tr.text(&format!("attention-level-{}", l.id()), None) }).collect();
2252 let rows: Vec<GridRow> = rows
2253 .iter()
2254 .map(|row| {
2255 let mut offered: Vec<Level> = Vec::new();
2256 for column in Column::ALL {
2257 for level in choices(*row, column) {
2258 if !offered.contains(level) {
2259 offered.push(*level);
2260 }
2261 }
2262 }
2263 offered.sort();
2264 let label = row_label(tr, *row);
2265 let group = tr.text(&format!("attention-group-{}", row.group()), None);
2266 let cells: Vec<GridCell> = Column::ALL
2267 .iter()
2268 .map(|column| {
2269 let value = attention.cell(*row, *column);
2270 let locked = lock(*row, *column).map(|id| tr.text(id, None)).unwrap_or_default();
2271 let mut args = crate::i18n::args();
2272 args.set("row", if matches!(row, Row::People(..)) { format!("{group}, {label}") } else { label.clone() });
2273 args.set("column", column_label(tr, *column));
2274 args.set("value", level_label(tr, *row, value));
2275 let said = tr.text("attention-cell", Some(&args));
2276 GridCell {
2277 column: column.id().to_string(),
2278 value: value.id().to_string(),
2279 choices: choices(*row, *column).iter().map(|l| l.id().to_string()).collect(),
2280 changed: value != compared.cell(*row, *column),
2281 said: if locked.is_empty() { said } else { format!("{said}. {locked}") },
2282 locked,
2283 }
2284 })
2285 .collect();
2286 let help = match row {
2287 Row::People(_, person) => tr.text(&format!("attention-person-{}-help", person.id()), None),
2288 Row::Own(kind) => tr.text(&format!("attention-row-{}-help", kind.id()), None),
2289 };
2290 GridRow {
2291 id: row.id(),
2292 group: group.clone(),
2293 group_id: row.group().to_string(),
2294 help,
2295 choices: offered.iter().map(|l| Mark { id: l.id().to_string(), mark: l.mark(row.always()).to_string(), label: level_label(tr, *row, *l) }).collect(),
2296 changed: cells.iter().any(|c| c.changed),
2297 label,
2298 cells,
2299 }
2300 })
2301 .collect();
2302 let presets: Vec<PresetView> = Preset::ALL
2303 .iter()
2304 .map(|p| {
2305 let changes = attention.changes_from(&p.matrix());
2306 PresetView { id: p.id().to_string(), label: tr.text(&format!("attention-preset-{}", p.id()), None), current: changes == 0, changes }
2307 })
2308 .collect();
2309 let from_usual = attention.changes_from(&Attention::usual());
2310 let changed = if from_usual == 0 { String::new() } else { tr.text("attention-changes", Some(&tr.counted(from_usual))) };
2311 Grid {
2312 columns,
2313 layers: vec![Column::Slot.id().to_string(), Column::Dnd.id().to_string()],
2314 marks,
2315 rows,
2316 presets,
2317 changed,
2318 }
2319}
2320
2321pub fn channel_rows(channel: Channel) -> Vec<Row> {
2323 persons(channel).iter().map(|p| Row::People(channel, *p)).collect()
2324}
2325
2326pub fn kind_rows() -> Vec<Row> {
2328 Kind::ALL.iter().map(|k| Row::Own(*k)).collect()
2329}
2330
2331#[derive(Debug, Clone, PartialEq, Eq)]
2335pub struct SourceLine {
2336 pub id: String,
2337 pub label: String,
2338 pub group: String,
2339 pub group_id: String,
2340}
2341
2342pub fn source_grid(attention: &Attention, tr: &Translator, lines: &[SourceLine]) -> Grid {
2347 let columns = Column::ALL.iter().map(|c| Named { id: c.id().to_string(), label: column_label(tr, *c) }).collect();
2348 let label = |level: Level| tr.text(&format!("attention-source-{}", level.id()), None);
2349 let choices: Vec<Mark> = SOURCE_CHOICES.iter().map(|l| Mark { id: l.id().to_string(), mark: l.mark(false).to_string(), label: label(*l) }).collect();
2350 let offered: Vec<String> = SOURCE_CHOICES.iter().map(|l| l.id().to_string()).collect();
2351 let rows = lines
2352 .iter()
2353 .map(|line| {
2354 let set = attention.source(&line.id).unwrap_or([Level::As; 9]);
2355 let cells: Vec<GridCell> = Column::ALL
2356 .iter()
2357 .map(|column| {
2358 let value = set[column.index()];
2359 let mut args = crate::i18n::args();
2360 args.set("row", line.label.clone());
2361 args.set("column", column_label(tr, *column));
2362 args.set("value", label(value));
2363 GridCell {
2364 column: column.id().to_string(),
2365 value: value.id().to_string(),
2366 choices: offered.clone(),
2367 locked: String::new(),
2368 changed: value != Level::As,
2369 said: tr.text("attention-cell", Some(&args)),
2370 }
2371 })
2372 .collect();
2373 GridRow {
2374 id: line.id.clone(),
2375 label: line.label.clone(),
2376 group: line.group.clone(),
2377 group_id: line.group_id.clone(),
2378 help: tr.text("attention-source-help", None),
2379 choices: choices.clone(),
2380 changed: cells.iter().any(|c| c.changed),
2381 cells,
2382 }
2383 })
2384 .collect();
2385 Grid {
2386 columns,
2387 layers: vec![Column::Slot.id().to_string(), Column::Dnd.id().to_string()],
2388 marks: choices,
2389 rows,
2390 presets: Vec::new(),
2391 changed: String::new(),
2392 }
2393}
2394
2395pub fn source_sentence(attention: &Attention, tr: &Translator, id: &str) -> String {
2400 let Some(cells) = attention.source(id) else { return tr.text("attention-source-usual", None) };
2401 let and = tr.text("word-and", None);
2402 let mut parts: Vec<String> = [Level::Now, Level::Gathered, Level::Later]
2403 .iter()
2404 .filter_map(|level| {
2405 let columns: Vec<String> = Column::ALL.iter().filter(|c| cells[c.index()] == *level).map(|c| column_label(tr, *c)).collect();
2406 let columns = match columns.as_slice() {
2407 [] => return None,
2408 [one] => one.clone(),
2409 [rest @ .., last] => format!("{} {and} {last}", rest.join(", ")),
2410 };
2411 let mut args = crate::i18n::args();
2412 args.set("columns", columns);
2413 Some(tr.text(&format!("attention-source-said-{}", level.id()), Some(&args)))
2414 })
2415 .collect();
2416 if cells.contains(&Level::As) {
2417 parts.push(tr.text("attention-source-said-as", None));
2418 }
2419 let mut args = crate::i18n::args();
2420 args.set("parts", parts.join(&tr.text("attention-source-said-join", None)));
2421 let said = tr.text("attention-source-said", Some(&args));
2422 let mut letters = said.chars();
2423 letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default()
2424}
2425
2426pub fn list_choice(tr: &Translator, who: Who, attention: &Attention, one: bool) -> String {
2430 let times: Vec<Column> = if who == Who::Blocked {
2431 Vec::new()
2432 } else {
2433 Column::TIMES.iter().copied().filter(|c| *c != Column::Free).filter(|c| matches!(attention.cell(Row::People(Channel::Mail, Person::of(who)), *c), Level::Now | Level::Quiet)).collect()
2434 };
2435 let words = if times.len() == 6 {
2436 tr.text("reach-any", None)
2437 } else if times.is_empty() {
2438 tr.text("reach-never", None)
2439 } else {
2440 times.iter().map(|c| tr.text(&format!("reach-word-{}", c.id()), None)).collect::<Vec<_>>().join(", ")
2441 };
2442 let mut args = crate::i18n::args();
2443 args.set("list", tr.text(&format!("sender-{}-{}", if one { "one" } else { "list" }, who.id()), None));
2444 args.set("times", words);
2445 tr.text("sender-list-times", Some(&args))
2446}
2447
2448#[cfg(test)]
2449mod tests {
2450 use super::*;
2451 use crate::areas::Area;
2452 use crate::quiet::Reason;
2453
2454 fn mode(time: Time, reason: Reason) -> Mode {
2455 Mode { quiet: !(time.works() || time == Time::Any), time, week: Week { work_hours: true, admin_hours: true, meals: true, sleep: true }, reason, until: None, back: None, label: String::new() }
2456 }
2457
2458 fn modes() -> Vec<Mode> {
2460 vec![
2461 mode(Time::Work, Reason::Working),
2462 mode(Time::Work, Reason::WorkingLate),
2463 mode(Time::Admin, Reason::AdminTime),
2464 mode(Time::Several(Area::MIXED), Reason::Working),
2465 mode(Time::Leisure, Reason::Evening),
2466 mode(Time::Leisure, Reason::DayOff),
2467 mode(Time::Leisure, Reason::DoneForTheDay),
2468 mode(Time::Meals, Reason::Meal),
2469 mode(Time::Any, Reason::NoHours),
2470 mode(Time::Sleep, Reason::Sleep),
2471 mode(Time::Sleep, Reason::WindingDown),
2472 mode(Time::Sleep, Reason::Nap),
2473 mode(Time::Leisure, Reason::FreeTime),
2474 mode(Time::Sleep, Reason::Paused),
2475 ]
2476 }
2477
2478 fn row(id: &str) -> Row {
2479 Row::read(id).unwrap_or_else(|| panic!("{id}"))
2480 }
2481
2482 #[test]
2486 fn the_usual_matrix_as_decided() {
2487 let expected = [
2488 ("mail.always", "●●●●◑◑●●●"),
2489 ("mail.safe", "●●●●◑◑◑◑◑"),
2490 ("mail.neutral", "●●○○○○○◑◑"),
2491 ("mail.restricted", "●○○○○○○◑◑"),
2492 ("mail.stranger", "●●○○○○○◑◑"),
2493 ("mail.blocked", "–––––––––"),
2494 ("calls.always", "●●●●●●●●●"),
2495 ("calls.safe", "●●●●○○●●○"),
2496 ("calls.neutral", "●●●●○○○●○"),
2497 ("calls.restricted", "●○○○○○○●○"),
2498 ("calls.stranger", "○○○○○○○●○"),
2499 ("calls.hidden", "●●○○○○○●○"),
2500 ("calls.blocked", "–––––––––"),
2501 ("messages.always", "●●●●●●●●●"),
2502 ("messages.safe", "●●●●○○●●◑"),
2503 ("messages.neutral", "●●●●○○○●◑"),
2504 ("messages.restricted", "●○○○○○○●○"),
2505 ("messages.stranger", "●●○○○○○●○"),
2506 ("messages.groups", "●●●●○○○●◑"),
2507 ("messages.blocked", "–––––––––"),
2508 ("codes", "●●●●●●●●●"),
2509 ("doses", "●●●●●●●●●"),
2510 ("wake", "●●●●●●●●●"),
2511 ("alarms", "●●●●◐●◐●●"),
2512 ("before", "●●●●◐●◐●●"),
2513 ("day-before", "●●●●○○○●●"),
2514 ("dates", "●●●●○○○●●"),
2515 ("needs", "●●●●–––●●"),
2516 ("move", "●●●●–––––"),
2517 ("work-over", "–●●●–––●●"),
2518 ("time", "●●●●●●●●●"),
2519 ("sites", "◎◎◎◎○○○○○"),
2520 ("sites-live", "●●●●○○○○○"),
2521 ("site-calls", "●●●●○○○●○"),
2522 ("app-automatons", "◎◎◎◎○○○○○"),
2523 ("app-at-once", "●●●●○○○○○"),
2524 ];
2525 let usual = Attention::usual();
2526 assert_eq!(expected.len(), Row::ALL.len());
2527 for (id, marks) in expected {
2528 let r = row(id);
2529 let said: String = Column::ALL.iter().map(|c| usual.cell(r, *c).mark(false)).collect();
2530 assert_eq!(said, marks, "{id}");
2531 for column in Column::ALL {
2532 assert!(choices(r, column).contains(&usual.cell(r, column)), "{id} {column:?}");
2534 }
2535 assert_eq!(r.id(), id, "ids both ways");
2536 }
2537 assert_eq!(Attention::of(&Config::default()), usual, "an empty configuration is the usual matrix");
2538 assert_eq!(Row::read("calls.groups"), None, "calls have no groups' row");
2539 assert_eq!(Row::read("Day_Before"), Some(Row::Own(Kind::DayBefore)));
2540 }
2541
2542 #[test]
2543 fn fixed_cells_and_choices() {
2544 let mut a = Attention::usual();
2545 assert!(a.set(row("doses"), Column::Dnd, Level::Later).is_err(), "a dose comes during do-not-disturb");
2546 assert!(a.set(row("doses"), Column::Sleep, Level::Never).is_err(), "a dose is never dropped");
2547 assert!(a.set(row("doses"), Column::Pause, Level::Later).is_ok());
2548 assert!(a.set(row("codes"), Column::Pause, Level::Never).is_err(), "a code in a pause comes, fixed");
2549 assert!(a.set(row("codes"), Column::Sleep, Level::Never).is_ok(), "On the Porch only stays a choice for sleep");
2550 assert!(a.set(row("alarms"), Column::Pause, Level::Later).is_err() && a.set(row("alarms"), Column::Dnd, Level::Later).is_err());
2551 assert!(a.set(row("mail.blocked"), Column::Work, Level::Now).is_err(), "the blocked never");
2552 assert!(a.set(row("mail.always"), Column::Work, Level::Through).is_err(), "☆ is a layer's");
2553 assert!(a.set(row("mail.always"), Column::Dnd, Level::Through).is_ok());
2554 assert!(a.set(row("calls.safe"), Column::Sleep, Level::Quiet).is_err(), "a phone cannot show a call it holds");
2555 assert!(a.set(row("mail.neutral"), Column::Leisure, Level::Quiet).is_ok());
2556 assert!(a.set(row("before"), Column::Pause, Level::Event).is_err(), "a pause has no known end");
2557 for r in Row::ALL {
2558 for column in Column::ALL {
2559 assert!(!choices(r, column).is_empty(), "{r:?} {column:?}");
2560 if lock(r, column).is_some() {
2561 assert_eq!(choices(r, column).first(), Some(&usual(r, column)), "{r:?} {column:?}");
2562 }
2563 }
2564 }
2565 }
2566
2567 #[test]
2568 fn rows_from_the_configuration_and_their_words() {
2569 let config: Config = toml::from_str(
2570 "[attention]\n\"mail.neutral\" = [\"leisure\", \"Repas:quiet\", \"sleep:never\", \"nonsense\", \"work:gathered\"]\n\
2571 codes = \"sleep:never, pause:never\"\n\
2572 \"calls.groups\" = [\"work\"]\n\
2573 \"messages.always\" = [\"dnd:through\", \"sleep:as\"]\n\
2574 day_before = [\"free\"]\n",
2575 )
2576 .unwrap();
2577 let a = Attention::of(&config);
2578 assert_eq!(a.words(row("mail.neutral")), ["work", "admin", "leisure", "meals:quiet", "sleep:never", "pause:later", "free:later", "slot:quiet", "dnd:quiet"]);
2580 assert_eq!((a.cell(row("codes"), Column::Sleep), a.cell(row("codes"), Column::Pause)), (Level::Never, Level::Now));
2581 assert_eq!((a.cell(row("messages.always"), Column::Dnd), a.cell(row("messages.always"), Column::Sleep)), (Level::Through, Level::As));
2582 assert_eq!(a.cell(row("day-before"), Column::Free), Level::Now);
2583 let odd: Config = toml::from_str("[attention]\n\"mail.safe\" = 3\n").unwrap();
2585 assert_eq!(Attention::of(&odd), Attention::usual());
2586 assert_eq!(word("Temps libre:quiet"), Some((Column::Free, Level::Quiet)));
2587 assert_eq!((word("sleep:soon"), word("night-shift")), (None, None));
2588 }
2589
2590 #[test]
2591 fn times_together_layers_and_lending() {
2592 let a = Attention::usual();
2593 let any = Now::of(&mode(Time::Any, Reason::NoHours));
2595 assert_eq!(any.times, vec![Column::Work, Column::Admin]);
2596 assert_eq!(a.level(row("mail.restricted"), &any), Level::Now);
2597 let work = Now::time(Column::Work).layers(true, false);
2599 assert_eq!((a.level(row("site-calls"), &work), a.level(row("sites-live"), &work)), (Level::Now, Level::Later));
2600 assert_eq!(a.level(row("site-calls"), &work.clone().layers(true, true)), Level::Later);
2601 let mut admin_only = Attention::usual();
2603 admin_only.set(row("mail.restricted"), Column::Admin, Level::Now).unwrap();
2604 admin_only.set(row("mail.restricted"), Column::Work, Level::Later).unwrap();
2605 let mut no_admin = Now::time(Column::Work);
2606 no_admin.week.admin_hours = false;
2607 assert_eq!((admin_only.level(row("mail.restricted"), &no_admin), admin_only.level(row("mail.restricted"), &Now::time(Column::Work))), (Level::Now, Level::Later));
2608 let mut kinds = Attention::usual();
2609 kinds.set(row("dates"), Column::Work, Level::Later).unwrap();
2610 assert_eq!(kinds.level(row("dates"), &no_admin), Level::Later, "kinds are never lent");
2611 assert_eq!(Now::of(&mode(Time::Sleep, Reason::Paused)).times, vec![Column::Pause]);
2613 assert_eq!(Now::of(&mode(Time::Leisure, Reason::FreeTime)).times, vec![Column::Free]);
2614 assert_eq!(Now::of(&mode(Time::Several(Area::ADMIN), Reason::AdminTime)).times, vec![Column::Admin]);
2615 }
2616
2617 #[test]
2618 fn always_through_reads_their_own_row_as_said() {
2619 let mut a = Attention::usual();
2620 let leisure = Now::time(Column::Leisure);
2621 assert_eq!((a.person(Channel::Mail, Person::Neutral, true, &leisure), a.person(Channel::Mail, Person::Neutral, false, &leisure)), (Level::Now, Level::Later));
2623 a.set(row("mail.always"), Column::Leisure, Level::As).unwrap();
2625 assert_eq!(a.person(Channel::Mail, Person::Neutral, true, &leisure), Level::Later);
2626 assert_eq!(a.person(Channel::Mail, Person::Safe, true, &leisure), Level::Now);
2627 a.set(row("mail.always"), Column::Dnd, Level::Through).unwrap();
2629 let dnd = Now::time(Column::Work).layers(false, true);
2630 assert_eq!((a.person(Channel::Mail, Person::Restricted, true, &dnd), a.person(Channel::Mail, Person::Restricted, false, &dnd)), (Level::Now, Level::Quiet));
2631 let dnd_leisure = leisure.clone().layers(false, true);
2632 assert_eq!(a.person(Channel::Mail, Person::Neutral, true, &dnd_leisure), Level::Later, "☆ at their own row's times");
2633 for channel in Channel::ALL {
2635 assert_eq!(a.person(channel, Person::Blocked, true, &leisure), Level::Never);
2636 }
2637 let mut free = Now::of(&mode(Time::Leisure, Reason::FreeTime));
2639 free.nothing = true;
2640 let usual = Attention::usual();
2641 assert_eq!((usual.person(Channel::Calls, Person::Safe, false, &free), usual.person(Channel::Calls, Person::Safe, true, &free)), (Level::Later, Level::Now));
2642 }
2643
2644 fn shown_before(who: Who, time: Time, paused: bool, free: bool) -> bool {
2648 if time == Time::Any && !paused && !free {
2649 return true;
2650 }
2651 let ticks: [bool; 6] = match who {
2652 Who::Safe => [true; 6],
2653 Who::Neutral | Who::Stranger => [true, true, false, false, false, false],
2654 Who::Restricted => [true, false, false, false, false, false],
2655 Who::Blocked => [false; 6],
2656 };
2657 if paused {
2658 return ticks[5];
2659 }
2660 if free {
2661 return who == Who::Safe && ticks[2];
2662 }
2663 match time {
2664 Time::Work => ticks[0],
2665 Time::Admin => ticks[1],
2666 Time::Leisure => ticks[2],
2667 Time::Meals => ticks[3],
2668 Time::Sleep => ticks[4],
2669 Time::Several(_) | Time::Any => ticks[0] || ticks[1],
2670 }
2671 }
2672
2673 fn told_before(who: Who, listed: bool, m: &Mode, slot: bool, dnd: bool) -> bool {
2675 let cell_now = !(m.sleeps() || m.paused() || m.free()) && !slot;
2676 let grid = shown_before(who, m.time, m.paused(), m.free());
2677 if !cell_now {
2678 return false;
2679 }
2680 if dnd { grid && listed } else { grid }
2681 }
2682
2683 #[test]
2684 fn the_usual_matrix_decides_as_the_code_did_but_where_decided() {
2685 let a = Attention::usual();
2686 for m in modes() {
2687 for (slot, dnd) in [(false, false), (true, false), (false, true), (true, true)] {
2688 let now = Now::of(&m).layers(slot, dnd);
2689 let said = format!("{:?} {:?} slot {slot} dnd {dnd}", m.time, m.reason);
2690 for who in [Who::Safe, Who::Neutral, Who::Restricted, Who::Stranger] {
2691 let mail = a.decide(&Event::of(Source::Mail { who, always: false, lane: Lane::Usual }), &now);
2693 assert_eq!(mail.shown, shown_before(who, m.time, m.paused(), m.free()), "shown {who:?} {said}");
2694 assert_eq!(mail.told, told_before(who, false, &m, slot, dnd), "told {who:?} {said}");
2695 let listed = a.decide(&Event::of(Source::Mail { who, always: true, lane: Lane::Usual }), &now);
2698 assert!(!told_before(who, true, &m, slot, dnd) || listed.told, "nothing told before is held now: {who:?} {said}");
2699 assert_eq!(listed.told, !(m.sleeps() || m.paused()), "listed {who:?} {said}");
2700 assert_eq!(listed.pierce, listed.told, "Always through mail passes the system's do-not-disturb {said}");
2701 }
2702 let comes = |kind: Kind| a.decide(&Event::own(kind), &now).told;
2704 assert!(comes(Kind::Codes), "codes {said}");
2705 assert!(comes(Kind::Doses) && comes(Kind::Wake) && comes(Kind::Time), "{said}");
2706 let quiet_ish = m.sleeps() || m.free() || m.paused();
2707 assert_eq!(comes(Kind::Move), !quiet_ish && !slot && !dnd, "move {said}");
2708 assert_eq!(comes(Kind::Needs), !quiet_ish, "needs {said}");
2709 assert_eq!(comes(Kind::SitesLive), !quiet_ish && !slot && !dnd, "sites live {said}");
2710 assert_eq!(comes(Kind::SiteCalls), !quiet_ish && !dnd, "site calls {said}");
2711 }
2712 }
2713 }
2714
2715 #[test]
2716 fn messages_calls_and_the_decided_changes() {
2717 let a = Attention::usual();
2718 let night = Now::of(&mode(Time::Sleep, Reason::Sleep));
2719 let paused = Now::of(&mode(Time::Sleep, Reason::Paused));
2720 let free = Now::of(&mode(Time::Leisure, Reason::FreeTime));
2721 let message = |who: Who, always: bool, now: &Now| a.decide(&Event::of(Source::Message { via: Channel::Messages, who, group: false, always }), now);
2722 assert!(!message(Who::Safe, false, &night).told && !message(Who::Safe, false, &paused).told);
2724 assert!(message(Who::Safe, false, &free).told);
2725 assert!(message(Who::Neutral, true, &night).told && message(Who::Neutral, true, &paused).told);
2727 assert_eq!(message(Who::Blocked, true, &night).step, Step::Blocked);
2729 let call = |who: Option<Who>, always: bool, now: &Now| a.decide(&Event::of(Source::Call { who, always, emergency: false, repeat: false }), now);
2730 assert!(call(Some(Who::Blocked), true, &Now::time(Column::Work)).level == Level::Never);
2731 assert!(call(Some(Who::Stranger), true, &night).told, "on the list, at any time");
2732 let dnd = Now::time(Column::Leisure).layers(false, true);
2734 assert!(!call(Some(Who::Safe), false, &dnd).told && call(Some(Who::Safe), true, &dnd).told);
2735 let mut through = night.clone();
2737 through.through = true;
2738 assert!(call(Some(Who::Stranger), false, &through).told && call(None, false, &through).told && !call(Some(Who::Blocked), false, &through).told);
2739 assert!(!call(Some(Who::Neutral), false, &through).told, "the people you know as their row says");
2740 let repeat = a.decide(&Event::of(Source::Call { who: None, always: false, emergency: false, repeat: true }), &night);
2741 assert!(!repeat.told, "hidden numbers cannot be told apart: no second call");
2742 assert!(a.decide(&Event::of(Source::Call { who: Some(Who::Blocked), always: false, emergency: true, repeat: false }), &night).told, "an emergency number rings, even blocked");
2743 let mail_app = a.decide(&Event::of(Source::Message { via: Channel::Mail, who: Who::Safe, group: false, always: false }), &night);
2745 assert_eq!((mail_app.level, mail_app.told), (Level::Later, false));
2746 let mut groups = Attention::usual();
2748 groups.set(row("messages.groups"), Column::Work, Level::Later).unwrap();
2749 let work = Now::time(Column::Work);
2750 let group = groups.decide(&Event::of(Source::Message { via: Channel::Messages, who: Who::Safe, group: true, always: false }), &work);
2751 let stranger = groups.decide(&Event::of(Source::Message { via: Channel::Messages, who: Who::Stranger, group: false, always: false }), &work);
2752 assert!(!group.told && stranger.told);
2753 let mut cells = Attention::usual();
2755 cells.set(row("mail.neutral"), Column::Free, Level::Now).unwrap();
2756 assert!(cells.decide(&Event::of(Source::Mail { who: Who::Neutral, always: false, lane: Lane::Usual }), &free).told);
2757 let mut held = Attention::usual();
2759 held.set(row("mail.neutral"), Column::Work, Level::Later).unwrap();
2760 held.set(row("mail.neutral"), Column::Admin, Level::Later).unwrap();
2761 assert!(!held.decide(&Event::of(Source::Mail { who: Who::Neutral, always: false, lane: Lane::Usual }), &Now::of(&mode(Time::Any, Reason::NoHours))).shown);
2762 let site = |named: Option<Who>, now: &Now| a.decide(&Event::of(Source::Site { call: false, live: true, named }), now);
2764 assert!(site(None, &Now::time(Column::Leisure)).told);
2765 assert!(site(Some(Who::Neutral), &Now::time(Column::Leisure)).told, "someone of your address books comes in your leisure");
2766 assert!(!site(Some(Who::Neutral), &Now::time(Column::Sleep)).told, "and waits while you sleep");
2767 assert!(site(Some(Who::Safe), &Now::time(Column::Leisure)).told);
2768 assert_eq!(site(Some(Who::Blocked), &Now::time(Column::Work)).level, Level::Never);
2769 }
2770
2771 fn message(from: &str, headers: &str, subject: &str, senders: &crate::porch::Senders) -> crate::porch::Triaged {
2773 let raw = format!("From: {from}\r\n{headers}Subject: {subject}\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\n\r\n{subject}.\r\n");
2774 let known = crate::porch::SenderList::default();
2775 let trusted = ["mx.example.net".to_string()];
2776 let own = ["me@example.net".to_string()];
2777 let ctx = crate::porch::Context { projects: None, known: &known, senders, trusted_ids: &trusted, now: None, priority: Default::default(), own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &own, spam: None };
2778 crate::porch::triage(crate::card::Card::from_bytes(raw.as_bytes()).unwrap(), &ctx)
2779 }
2780
2781 #[test]
2783 fn mail_shows_by_who_wrote_and_where_it_came() {
2784 use crate::porch::{SenderList, Senders};
2785 let usual = Attention::usual();
2786 let senders = Senders { safe: SenderList::parse("jane@example.org"), restricted: SenderList::parse("*@company.example"), ..Senders::default() };
2787 let set = Week { work_hours: true, admin_hours: true, meals: true, sleep: true };
2788 let nobody = crate::everywhere::People::default();
2789 let at = |time: Time, week: Week| Now::of(&Mode { quiet: false, time, week, reason: if time == Time::Sleep { Reason::Sleep } else { Reason::Working }, until: None, back: None, label: String::new() });
2790 let comes_in = |t: &crate::porch::Triaged, matrix: &Attention, account: Area, time: Time, week: Week| matrix.mail(t, &senders, &nobody, account, &at(time, week)).shown;
2791 let comes = |t: &crate::porch::Triaged, matrix: &Attention, account: Area, time: Time| comes_in(t, matrix, account, time, set);
2792 let at_times = |t: &crate::porch::Triaged, matrix: &Attention, account: Area| Time::STATES.into_iter().filter(|time| comes(t, matrix, account, *time)).map(Time::id).collect::<Vec<_>>();
2793 let jane = message("Jane <jane@example.org>", "", "Hello", &senders);
2795 assert!(Time::STATES.into_iter().all(|time| comes(&jane, &usual, Area::WORK, time)));
2796 let mut no_sleep = Attention::usual();
2797 no_sleep.set(row("mail.safe"), Column::Sleep, Level::Later).unwrap();
2798 assert!(!comes(&jane, &no_sleep, Area::WORK, Time::Sleep) && comes(&jane, &no_sleep, Area::WORK, Time::Meals));
2799 let stranger = message("Someone <someone@elsewhere.example>", "", "A question", &senders);
2801 assert_eq!(senders.who_of(&stranger.card), Who::Stranger);
2802 assert_eq!(at_times(&stranger, &usual, Area::WORK), ["work"]);
2803 assert_eq!(at_times(&stranger, &usual, Area::PERSONAL), ["admin"]);
2804 assert_eq!(at_times(&stranger, &usual, Area::LEISURE), ["work", "admin"]);
2806 let unproven = message("Jane <jane@example.org>", "Authentication-Results: mx.example.net; spf=fail smtp.mailfrom=example.org; dkim=fail header.d=example.org\r\n", "Hello", &senders);
2808 assert_eq!(at_times(&unproven, &usual, Area::WORK), ["work"]);
2809 let mut no_strangers = Attention::usual();
2811 for column in [Column::Work, Column::Admin] {
2812 no_strangers.set(row("mail.stranger"), column, Level::Later).unwrap();
2813 }
2814 assert_eq!(at_times(&stranger, &no_strangers, Area::WORK), Vec::<&str>::new());
2815 let boss = message("Boss <boss@company.example>", "", "Monday", &senders);
2816 assert_eq!(at_times(&boss, &usual, Area::PERSONAL), ["work"], "restricted to work, writing to a personal address");
2817 assert_eq!(at_times(&boss, &usual, Area::WORK), ["work"]);
2818 let mut admin_only = Attention::usual();
2820 admin_only.set(row("mail.stranger"), Column::Work, Level::Later).unwrap();
2821 assert!(comes_in(&stranger, &admin_only, Area::MIXED, Time::Work, Week { admin_hours: false, ..set }));
2822 assert!(!comes_in(&stranger, &admin_only, Area::MIXED, Time::Work, set));
2823 assert!(comes_in(&boss, &usual, Area::WORK, Time::Admin, Week { work_hours: false, ..set }));
2824 let code = message("Bank <codes@bank.example>", "", "Your verification code: 482913", &senders);
2826 assert_eq!(code.lane, crate::porch::Lane::RightNow);
2827 assert!(comes(&code, &no_strangers, Area::WORK, Time::Sleep));
2828 let mine = message("Me <me@example.net>", "Authentication-Results: mx.example.net; dmarc=pass header.from=example.net\r\n", "A file", &senders);
2829 assert!(mine.reasons.contains(&crate::porch::Reason::FromYourself), "{:?}", mine.reasons);
2830 assert!(comes(&mine, &no_strangers, Area::WORK, Time::Sleep));
2831 assert!(comes(&stranger, &usual, Area::LEISURE, Time::Any));
2832 let forged = message("Jane <jane@example.org>", "Authentication-Results: mx.example.net; dmarc=fail (p=reject) header.from=example.org\r\n", "Hello", &senders);
2834 assert_eq!(forged.lane, crate::porch::Lane::SetAside);
2835 assert!(!comes(&forged, &usual, Area::PERSONAL, Time::Leisure) && comes(&forged, &usual, Area::PERSONAL, Time::Admin));
2836 assert!(!comes(&forged, &no_strangers, Area::PERSONAL, Time::Admin), "on the strangers' row");
2837 let mut list = crate::everywhere::People::default();
2839 list.add(crate::everywhere::Person { name: "Someone".into(), emails: vec!["someone@elsewhere.example".into(), "jane@example.org".into()], ..Default::default() }, None);
2840 let leisure = at(Time::Leisure, set);
2841 let listed = usual.mail(&stranger, &senders, &list, Area::WORK, &leisure);
2842 assert!(listed.shown && listed.told && listed.pierce);
2843 assert!(!usual.mail(&forged, &senders, &list, Area::PERSONAL, &leisure).shown);
2844 }
2845
2846 #[test]
2848 fn declined_calls_are_listed_by_their_calls_row() {
2849 let a = Attention::usual();
2850 let (work, admin, leisure) = (Now::time(Column::Work), Now::time(Column::Admin), Now::time(Column::Leisure));
2851 assert!(a.listed(Person::Neutral, false, &work) && a.listed(Person::Neutral, false, &admin) && a.listed(Person::Neutral, false, &leisure));
2853 assert!(!a.listed(Person::Neutral, false, &Now::time(Column::Sleep)));
2854 assert!(a.listed(Person::Safe, false, &leisure) && a.listed(Person::Stranger, true, &Now::time(Column::Sleep)));
2856 assert!(!a.listed(Person::Blocked, true, &work));
2857 assert!(a.listed(Person::Stranger, false, &work) && a.listed(Person::Stranger, false, &admin) && !a.listed(Person::Stranger, false, &leisure));
2859 assert!(!a.listed(Person::Stranger, false, &work.clone().layers(false, true)));
2860 assert!(!a.listed(Person::Safe, false, &Now::time(Column::Sleep)));
2862 assert!(a.listed(Person::Restricted, false, &work) && !a.listed(Person::Restricted, false, &admin));
2863 let mut ringing = Attention::usual();
2865 ringing.set(row("calls.stranger"), Column::Leisure, Level::Now).unwrap();
2866 assert!(ringing.listed(Person::Stranger, false, &leisure) && !ringing.listed(Person::Stranger, false, &work));
2867 }
2868
2869 #[test]
2870 fn areas_and_holds() {
2871 let a = Attention::usual();
2872 let evening = Now::of(&mode(Time::Leisure, Reason::Evening));
2873 let work_mail = |who: Who| a.decide(&Event::of(Source::Mail { who, always: false, lane: Lane::Usual }).for_area(Some(Area::WORK)), &Now::of(&mode(Time::Admin, Reason::AdminTime)));
2874 assert_eq!((work_mail(Who::Stranger).step, work_mail(Who::Safe).told), (Step::Area, true));
2876 assert_eq!(a.decide(&Event::own(Kind::Dates).for_area(Some(Area::WORK)), &evening).step, Step::Area);
2878 assert!(a.decide(&Event::own(Kind::Dates), &evening).told);
2879 let mut night = Now::of(&mode(Time::Sleep, Reason::Sleep));
2881 night.span = (1_000, 2_000);
2882 assert!(a.decide(&Event::own(Kind::Alarms).starting(Some(1_500)), &night).told);
2883 assert!(!a.decide(&Event::own(Kind::Alarms).starting(Some(2_500)), &night).told);
2884 let mut rests = Now::time(Column::Work);
2886 rests.holds.porch_rests = true;
2887 let resting = a.decide(&Event::of(Source::Mail { who: Who::Safe, always: false, lane: Lane::Usual }), &rests);
2888 assert_eq!((resting.shown, resting.told, resting.step), (true, false, Step::Hold));
2889 let mut meeting = Now::time(Column::Work);
2891 meeting.holds.meeting = true;
2892 assert_eq!(a.decide(&Event::own(Kind::Needs), &meeting).step, Step::Hold);
2893 let code = a.decide(&Event::of(Source::Mail { who: Who::Stranger, always: false, lane: Lane::Code }), &Now::of(&mode(Time::Sleep, Reason::Sleep)));
2895 assert!(code.shown && code.told && code.pierce);
2896 let own = a.decide(&Event::of(Source::Mail { who: Who::Safe, always: false, lane: Lane::Own }), &Now::time(Column::Work));
2897 assert!(own.shown && !own.told);
2898 }
2899
2900 #[test]
2906 fn reachable_lets_everyone_not_blocked_through_at_every_time() {
2907 let a = Attention::usual();
2908 for column in Column::ALL {
2909 for (slot, dnd) in [(false, false), (true, false), (false, true)] {
2910 let mut now = Now::time(column).layers(slot, dnd);
2911 now.reachable = true;
2912 now.holds.porch_rests = true;
2913 for (channel, persons) in [(Channel::Mail, &[Person::Safe, Person::Neutral, Person::Restricted, Person::Stranger][..]), (Channel::Messages, &[Person::Safe, Person::Neutral, Person::Restricted, Person::Stranger, Person::Groups][..]), (Channel::Calls, &[Person::Safe, Person::Neutral, Person::Restricted][..])] {
2914 for person in persons {
2915 assert_eq!(a.person(channel, *person, false, &now), Level::Now, "{channel:?} {person:?} at {column:?} slot {slot} dnd {dnd}");
2916 }
2917 assert_eq!(a.person(channel, Person::Blocked, false, &now), Level::Never);
2918 assert_eq!(a.person(channel, Person::Always, true, &now), Level::Now);
2919 }
2920 for person in [Person::Stranger, Person::Hidden] {
2922 let mut usual = now.clone();
2923 usual.reachable = false;
2924 assert_eq!(a.person(Channel::Calls, person, false, &now), a.person(Channel::Calls, person, false, &usual), "{person:?} at {column:?}");
2925 }
2926 let mail = a.decide(&Event::of(Source::Mail { who: Who::Stranger, always: false, lane: Lane::Usual }).for_area(Some(Area::WORK)), &now);
2928 assert!(mail.shown && mail.told && mail.level == Level::Now, "{column:?}: {mail:?}");
2929 let message = |who: Who, group: bool| a.decide(&Event::of(Source::Message { via: Channel::Messages, who, group, always: false }), &now).level;
2931 assert_eq!((message(Who::Stranger, false), message(Who::Neutral, true)), (Level::Now, Level::Now));
2932 assert_eq!(message(Who::Blocked, false), Level::Never);
2933 }
2934 }
2935 let mut rows = Attention::usual();
2937 rows.set_source("conversation.c2", Column::Work, Level::Later).unwrap();
2938 let mut now = Now::time(Column::Work);
2939 now.reachable = true;
2940 let talk = |id: &str| rows.decide(&Event { sources: vec![id.to_string()], ..Event::of(Source::Message { via: Channel::Messages, who: Who::Neutral, group: false, always: false }) }, &now).level;
2941 assert_eq!(talk("conversation.c2"), Level::Now);
2942 let mut night = Now::time(Column::Sleep);
2944 night.reachable = true;
2945 assert_eq!(a.level(Row::Own(Kind::Dates), &night), a.level(Row::Own(Kind::Dates), &Now::time(Column::Sleep)));
2946 }
2947
2948 #[test]
2953 fn a_mode_lets_nobody_unknown_through_where_your_contacts_wait() {
2954 let a = Attention::usual();
2955 let unknown = Phone { screens: true, unknown: true };
2956 assert_eq!(a.silence(&[Column::Work], false, unknown).calls, Senders::Anyone);
2957 assert_eq!(a.silence(&[Column::Work, Column::Dnd], false, unknown).calls, Senders::Contacts);
2958 assert_eq!(a.silence(&[Column::Dnd], false, unknown).calls, Senders::Contacts);
2959 assert_ne!(a.silence(&[Column::Sleep], false, unknown).calls, Senders::Anyone);
2960 }
2961
2962 #[test]
2966 fn the_matrix_s_writes_leave_the_screening_s_rows_alone() {
2967 let hidden = Row::People(Channel::Calls, Person::Hidden);
2968 for preset in Preset::ALL {
2969 for column in Column::ALL {
2970 assert_eq!(preset.matrix().cell(hidden, column), Attention::usual().cell(hidden, column), "{preset:?} {column:?}");
2971 }
2972 }
2973 let mut moved = Attention::usual();
2974 moved.set(hidden, Column::Leisure, Level::Now).unwrap();
2975 assert_eq!(moved.changes_from(&Attention::usual()), 0);
2976 let dir = std::env::temp_dir().join(format!("sioul-attention-screened-{}", std::process::id()));
2977 let _ = std::fs::remove_dir_all(&dir);
2978 std::fs::create_dir_all(&dir).unwrap();
2979 let path = dir.join("config.toml");
2980 std::fs::write(&path, "").unwrap();
2981 let words = SettingValue::Texts(vec!["leisure".into()]);
2982 for key in ["attention.calls.hidden", "attention.calls.stranger", "reach.calls.stranger"] {
2983 assert!(apply(&path, &Config::default(), key, &words).unwrap_err().contains("screening"), "{key}");
2984 }
2985 apply(&path, &Config::default(), "attention.calls.safe", &SettingValue::Texts(vec!["leisure".into()])).unwrap();
2986 let _ = std::fs::remove_dir_all(&dir);
2987 }
2988
2989 #[test]
2990 fn what_each_mode_lets_through() {
2991 let a = Attention::usual();
2992 let phone = Phone { screens: true, unknown: false };
2993 let pause = a.silence(&[Column::Pause], false, phone);
2995 assert_eq!((pause.calls, pause.messages, pause.repeat, pause.conversations, pause.doses, pause.events), (Senders::Contacts, Senders::Starred, true, true, true, true));
2996 assert_eq!(a.silence(&[Column::Work], false, phone).messages, Senders::Starred);
2999 let free = a.silence(&[Column::Free], false, phone);
3001 assert_eq!(free.calls, Senders::Contacts);
3002 assert_eq!(a.silence(&[Column::Free], false, Phone::default()).calls, Senders::Starred, "without screening, starred only, as before");
3003 let nothing = a.silence(&[Column::Free], true, Phone::default());
3005 assert_eq!(nothing.calls, Senders::Starred);
3006 assert!(a.silence(&[Column::Pause], false, phone).events);
3008 let mut none = Attention::usual();
3010 for channel in Channel::ALL {
3011 for column in Column::ALL {
3012 none.set(Row::People(channel, Person::Always), column, Level::As).unwrap();
3013 }
3014 }
3015 let shut = none.silence(&[Column::Pause], false, phone);
3016 assert_eq!((shut.calls, shut.repeat, shut.conversations), (Senders::None, false, false));
3017 for (columns, nothing) in [(&[Column::Pause][..], false), (&[Column::Free][..], true), (&[Column::Leisure, Column::Dnd][..], false)] {
3020 let mut now = Now::time(Column::Leisure);
3021 now.times = columns.iter().copied().filter(|c| c.is_time()).collect();
3022 now.dnd = columns.contains(&Column::Dnd);
3023 now.nothing = nothing;
3024 assert_eq!(none.silence_at(&now, phone).calls, Senders::None, "{columns:?}");
3025 now.through = true;
3026 for phone in [phone, Phone::default()] {
3027 let through = none.silence_at(&now, phone);
3028 assert_eq!((through.calls, through.repeat, through.messages), (Senders::Anyone, true, Senders::None), "{columns:?}");
3029 }
3030 }
3031 let unknown = Phone { screens: true, unknown: true };
3035 assert_eq!(a.silence(&[Column::Work], false, unknown).calls, Senders::Anyone);
3036 assert_eq!(a.silence(&[Column::Pause], false, unknown).calls, Senders::Contacts, "never where your contacts wait");
3037 assert_eq!(a.silence(&[Column::Pause], false, phone).calls, Senders::Contacts);
3038 let mut loose = Attention::usual();
3039 loose.set(row("calls.stranger"), Column::Dnd, Level::Now).unwrap();
3040 assert_eq!(loose.silence(&[Column::Dnd], false, phone).calls, Senders::Contacts, "the strangers' row is the screening's now");
3041 assert_eq!(Attention::usual().silence(&[Column::Work], false, Phone { screens: false, unknown: true }).calls, Senders::Starred, "without screening, starred only");
3042 let mut only = none.clone();
3045 for column in Column::ALL {
3046 for person in [Person::Safe, Person::Neutral] {
3047 only.set(Row::People(Channel::Calls, person), column, Level::Later).unwrap();
3048 }
3049 }
3050 let mut loosened = only.clone();
3051 loosened.set(row("calls.restricted"), Column::Dnd, Level::Now).unwrap();
3052 assert_eq!(loosened.silence(&[Column::Dnd], false, phone).calls, Senders::Contacts, "the layer by itself");
3053 assert_eq!(loosened.silence(&[Column::Leisure, Column::Dnd], false, phone).calls, Senders::None, "over leisure, where the restricted wait");
3054 assert_eq!(only.silence(&[Column::Admin], false, phone).calls, Senders::None);
3056 assert_eq!(only.silence(&[Column::Work, Column::Admin], false, phone).calls, Senders::Contacts);
3057 assert_eq!(only.silence(&[Column::Work, Column::Admin, Column::Dnd], false, phone).calls, Senders::None);
3058 let mut admin = Now::time(Column::Admin);
3060 assert_eq!(only.silence_at(&admin, phone).calls, Senders::None);
3061 admin.week.work_hours = false;
3062 assert_eq!(only.silence_at(&admin, phone).calls, Senders::Contacts);
3063 }
3064
3065 #[test]
3066 fn older_keys_seed_what_was_changed_and_nothing_else() {
3067 assert_eq!(seeded(&Config::default()), Attention::usual());
3069 let config: Config = toml::from_str(
3072 "[reach]\nsafe = [\"work\", \"admin\", \"leisure\", \"meals\", \"sleep\", \"pause\"]\nneutral = [\"work\", \"admin\", \"leisure\"]\nrestricted = [\"work\"]\nstranger = [\"work\", \"admin\"]\n\
3073 [reach.calls]\nstranger = [\"work\"]\n\
3074 [notify]\nmail = [\"dnd:list-any\"]\ncodes = [\"pause:never\"]\nsites = [\"work:later\"]\n\
3075 [reminders]\ndoses_in_sleep = false\n",
3076 )
3077 .unwrap();
3078 let a = Attention::of(&config);
3079 assert_eq!(a.cell(row("mail.neutral"), Column::Leisure), Level::Now);
3080 assert_eq!(a.cell(row("mail.always"), Column::Dnd), Level::Now);
3081 assert_eq!(a.cell(row("doses"), Column::Sleep), Level::Later);
3082 assert_eq!(a.cell(row("codes"), Column::Pause), Level::Now, "fixed now");
3083 assert_eq!(a.cell(row("calls.stranger"), Column::Work), Level::Now);
3084 assert_eq!(a.cell(row("sites"), Column::Work), Level::Later);
3085 assert_eq!(a.cell(row("messages.safe"), Column::Sleep), Level::Later);
3087 let older: Config = toml::from_str("[reach]\nneutral = [\"work\", \"admin\", \"meals\"]\n").unwrap();
3089 let a = Attention::of(&older);
3090 assert_eq!(a.cell(row("mail.stranger"), Column::Meals), Level::Now);
3091 let nobody: Config = toml::from_str("[dnd]\npeople = false\n").unwrap();
3093 let a = Attention::of(&nobody);
3094 assert_eq!((a.cell(row("calls.always"), Column::Sleep), a.cell(row("mail.always"), Column::Free)), (Level::As, Level::As));
3095 let written: Config = toml::from_str("[dnd]\npeople = false\n[attention]\n").unwrap();
3097 assert_eq!(Attention::of(&written), Attention::usual());
3098 }
3099
3100 #[test]
3101 fn written_whole_seeded_once_and_the_older_keys_taken_out() {
3102 let dir = std::env::temp_dir().join(format!("sioul-attention-{}", std::process::id()));
3103 let _ = std::fs::remove_dir_all(&dir);
3104 std::fs::create_dir_all(&dir).unwrap();
3105 let path = dir.join("config.toml");
3106 std::fs::write(&path, "# Mine.\nlanguage = \"fr\"\n[reach]\nneutral = [\"work\", \"admin\", \"leisure\"]\nstranger = [\"work\", \"admin\"]\n[notify]\nsites = [\"work:later\"]\n[dnd]\nfocus = true\npeople = true\n").unwrap();
3107 let config = Config::load(&path).unwrap();
3108 apply(&path, &config, "attention.codes", &SettingValue::Texts(vec!["sleep:never".into()])).unwrap();
3110 let text = std::fs::read_to_string(&path).unwrap();
3111 assert!(text.contains("# Mine.") && text.contains("[attention]") && !text.contains("[reach]") && !text.contains("[notify]") && !text.contains("people"), "{text}");
3112 assert!(text.contains("focus = true"), "the rest of [dnd] kept: {text}");
3113 let config = Config::load(&path).unwrap();
3114 let a = Attention::of(&config);
3115 assert_eq!((a.cell(row("codes"), Column::Sleep), a.cell(row("mail.neutral"), Column::Leisure), a.cell(row("sites"), Column::Work)), (Level::Never, Level::Now, Level::Later));
3116 assert!(apply(&path, &config, "attention.doses", &SettingValue::Texts(vec!["work:later".into()])).is_err());
3118 assert!(apply(&path, &config, "attention.sites", &SettingValue::Texts(vec!["work".into()])).is_err());
3119 assert!(apply(&path, &config, "attention.mail.safe", &SettingValue::Texts(vec!["teatime".into()])).is_err());
3120 assert!(apply(&path, &config, "attention.mail.hidden", &SettingValue::Texts(vec!["work".into()])).is_err());
3121 apply(&path, &config, "notify.codes", &SettingValue::Texts(vec!["sleep".into()])).unwrap();
3123 let config = Config::load(&path).unwrap();
3124 assert!(config.attention.as_ref().unwrap().words("codes").is_none());
3125 apply(&path, &config, "reach.safe", &SettingValue::Texts(vec!["work".into(), "admin".into(), "sleep".into()])).unwrap();
3127 let a = Attention::of(&Config::load(&path).unwrap());
3128 assert_eq!((a.cell(row("mail.safe"), Column::Leisure), a.cell(row("mail.safe"), Column::Sleep)), (Level::Later, Level::Quiet));
3129 assert!(apply(&path, &config, "reach.calls.stranger", &SettingValue::Texts(vec!["work".into()])).is_err());
3131 assert_eq!(Attention::of(&Config::load(&path).unwrap()).cell(row("calls.stranger"), Column::Work), Level::Later);
3132 apply_preset(&path, &Config::load(&path).unwrap(), Preset::Quieter).unwrap();
3134 assert_eq!(Attention::of(&Config::load(&path).unwrap()), Preset::Quieter.matrix());
3135 let _ = std::fs::remove_dir_all(&dir);
3136 }
3137
3138 #[test]
3140 fn always_through_takes_them_off_the_blocked_list() {
3141 let dir = std::env::temp_dir().join(format!("sioul-unblock-{}", std::process::id()));
3142 let _ = std::fs::remove_dir_all(&dir);
3143 std::fs::create_dir_all(&dir).unwrap();
3144 let blocked = dir.join("blocked.txt");
3145 std::fs::write(&blocked, "alice@example.org\ntel:+33199000001\ncontact:uid-bob\nmallory@example.org\n").unwrap();
3146 let config: Config = toml::from_str(&format!("blocked_senders = \"{}\"\n[contacts]\nregion = \"FR\"\n", blocked.display().to_string().replace('\\', "/"))).unwrap();
3147 assert!(crate::porch::unblock_person(&config, &["Alice <alice@example.org>".into()], &["01 99 00 00 01".into()], "uid-bob").unwrap());
3148 assert_eq!(std::fs::read_to_string(&blocked).unwrap().lines().filter(|l| !l.trim().is_empty()).collect::<Vec<_>>(), ["mallory@example.org"]);
3149 assert!(!crate::porch::unblock_person(&config, &["carol@example.org".into()], &[], "").unwrap(), "nothing of theirs there");
3150 let _ = std::fs::remove_dir_all(&dir);
3151 }
3152
3153 #[test]
3154 fn slots_are_read_by_every_process() {
3155 let dir = std::env::temp_dir().join(format!("sioul-slots-{}", std::process::id()));
3156 let _ = std::fs::remove_dir_all(&dir);
3157 let path = dir.join(SLOTS_FILE);
3158 let today: jiff::Zoned = "2026-10-07T10:00[Europe/Paris]".parse().unwrap();
3159 let start = today.timestamp().as_second() + 3600;
3160 let slots = Slots { day: Some(today.date()), slots: vec![(start, start + 1800)] };
3161 assert!(slots.save(&path).unwrap());
3162 assert!(!slots.save(&path).unwrap(), "written only when it changed");
3163 let read = Slots::load(&path);
3164 let at = |offset: i64| jiff::Timestamp::from_second(start + offset).unwrap().to_zoned(today.time_zone().clone());
3165 assert!(read.at(&at(60)) && !read.at(&at(-60)) && !read.at(&at(1800)));
3166 assert_eq!(read.edges(), vec![start, start + 1800]);
3167 let old = Slots { day: Some(today.date().yesterday().unwrap()), slots: vec![(start, start + 1800)] };
3169 assert!(!old.at(&at(60)));
3170 assert_eq!(Slots::load(&dir.join("none.toml")), Slots::default());
3171 let _ = std::fs::remove_dir_all(&dir);
3172 }
3173
3174 #[test]
3181 fn texts_in_the_shade_come_while_the_phone_is_silenced() {
3182 let a = Attention::usual();
3183 let message = |person: Who, group: bool, now: &Now| a.decide(&Event::of(Source::Message { via: Channel::Messages, who: person, group, always: false }), now).level;
3184 let mut dnd = Now::time(Column::Work).layers(false, true);
3185 for (who, group) in [(Who::Safe, false), (Who::Neutral, false), (Who::Neutral, true)] {
3186 assert_eq!(message(who, group, &dnd), Level::Later, "{who:?} {group}: not silenced, it waits");
3187 }
3188 dnd.silenced = true;
3189 for (who, group) in [(Who::Safe, false), (Who::Neutral, false), (Who::Neutral, true)] {
3190 assert_eq!(message(who, group, &dnd), Level::Now, "{who:?} {group}: silenced, it comes without a sound");
3191 }
3192 assert_eq!(message(Who::Restricted, false, &dnd), Level::Later, "restricted: held under do-not-disturb, as before");
3193 let mut night = Now::time(Column::Sleep).layers(false, true);
3194 night.silenced = true;
3195 assert_eq!(message(Who::Neutral, false, &night), Level::Later, "at night, whatever");
3196 let mail = |now: &Now| a.decide(&Event::of(Source::Message { via: Channel::Mail, who: Who::Safe, group: false, always: false }), now).level;
3199 for column in [Column::Sleep, Column::Pause, Column::Work] {
3200 let mut now = Now::time(column).layers(false, column == Column::Work);
3201 now.silenced = true;
3202 assert_eq!(a.cell(Row::People(Channel::Mail, Person::Safe), if column == Column::Work { Column::Dnd } else { column }), Level::Quiet);
3203 assert_eq!(mail(&now), Level::Later, "mail at {column:?}, the phone silenced: held, as before");
3204 }
3205 let call = a.decide(&Event::of(Source::Call { who: Some(Who::Neutral), always: false, emergency: false, repeat: false }), &dnd);
3206 assert!(!call.told, "a call under do-not-disturb: voicemail");
3207 let silence = a.silence_at(&Now::time(Column::Work).layers(false, true), Phone::default());
3209 assert!(!matches!(silence.messages, Senders::Anyone | Senders::Contacts), "{silence:?}");
3210 for column in [Column::Leisure, Column::Meals] {
3212 assert_eq!(message(Who::Neutral, true, &Now::time(column)), Level::Now, "{column:?}");
3213 }
3214 assert!(choices(Row::People(Channel::Messages, Person::Stranger), Column::Dnd).contains(&Level::Quiet));
3216 assert!(!choices(Row::People(Channel::Messages, Person::Neutral), Column::Sleep).contains(&Level::Quiet));
3217 assert!(!choices(Row::People(Channel::Calls, Person::Neutral), Column::Dnd).contains(&Level::Quiet));
3218 let tr = Translator::new("en");
3219 assert_eq!(level_label(&tr, Row::People(Channel::Messages, Person::Neutral), Level::Quiet), "In the shade, without a sound");
3220 }
3221
3222 #[test]
3228 fn a_person_s_own_row_is_read_in_its_place() {
3229 let mut rows = Attention::usual();
3230 let person = person_row("contact:card-1");
3231 rows.set_source(&person, Column::Work, Level::Later).unwrap();
3232 rows.set_source(&person, Column::Leisure, Level::Now).unwrap();
3233 rows.set_source("app.org.example.chat", Column::Work, Level::Now).unwrap();
3234 rows.set_source("app.org.example.chat", Column::Sleep, Level::Now).unwrap();
3235 let work = Now::time(Column::Work);
3236 let text = |rows: &Attention, who: Who, always: bool, sources: Vec<String>, now: &Now| rows.decide(&Event::of(Source::Message { via: Channel::Messages, who, group: false, always }).from_sources(sources), now).level;
3237 let with = |conversation: &str| source_rows_with("org.example.chat", conversation, std::slice::from_ref(&person));
3238 assert_eq!(with("c1"), vec!["conversation.c1".to_string(), person.clone(), "app.org.example.chat".to_string()], "conversation, person, app");
3239 assert_eq!(text(&rows, Who::Neutral, false, with(""), &work), Level::Later, "the person's row over the app's");
3240 assert_eq!(text(&rows, Who::Neutral, false, with(""), &Now::time(Column::Sleep)), Level::Now, "the app's where the person's says nothing");
3241 rows.set_source("conversation.c1", Column::Work, Level::Now).unwrap();
3242 assert_eq!(text(&rows, Who::Neutral, false, with("c1"), &work), Level::Now, "the conversation's over the person's");
3243 assert_eq!(text(&rows, Who::Blocked, false, with("c1"), &work), Level::Never, "the blocked first");
3244 assert_eq!(text(&rows, Who::Neutral, true, with(""), &work), rows.decide(&Event::of(Source::Message { via: Channel::Messages, who: Who::Neutral, group: false, always: true }), &work).level, "Always through: no row of their own");
3245 let call = |rows: &Attention, who: Option<Who>, now: &Now| rows.decide(&Event::of(Source::Call { who, always: false, emergency: false, repeat: false }).from_sources(vec![person.clone()]), now).told;
3247 assert!(!call(&rows, Some(Who::Neutral), &work), "held at work by their row");
3248 let mut reachable = Now::time(Column::Work);
3250 reachable.reachable = true;
3251 assert!(call(&rows, Some(Who::Neutral), &reachable), "Reachable lifts their row for calls");
3252 assert_eq!(text(&rows, Who::Neutral, false, vec![person.clone()], &reachable), Level::Now, "and for texts");
3253 assert!(call(&rows, Some(Who::Neutral), &Now::time(Column::Leisure)));
3254 assert!(!call(&rows, Some(Who::Stranger), &Now::time(Column::Leisure)), "a number nobody knows: the screening's");
3255 rows.set_source(&person, Column::Dnd, Level::Quiet).unwrap();
3257 let mut dnd = Now::time(Column::Leisure).layers(false, true);
3258 dnd.silenced = true;
3259 assert_eq!(text(&rows, Who::Neutral, false, vec![person.clone()], &dnd), Level::Now);
3260 assert!(!call(&rows, Some(Who::Neutral), &dnd));
3261 assert!(rows.set_source(&person, Column::Work, Level::Quiet).is_err(), "the shade under do-not-disturb alone");
3262 assert!(rows.set_source(&person, Column::Work, Level::Gathered).is_err());
3263 rows.name_source(&person, "Jane");
3265 assert_eq!(rows.source_name(&person), "");
3266 assert!(rows.source_words(&person).iter().all(|w| !w.starts_with(NAME)), "{:?}", rows.source_words(&person));
3267 let config: Config = toml::from_str("[attention]\n\"person.contact:card-2\" = [\"work:later\", \"name=Jane\"]\n").unwrap();
3268 let read = Attention::of(&config);
3269 assert_eq!(read.source_name("person.contact:card-2"), "");
3270 assert_eq!(read.source("person.contact:card-2").map(|c| c[Column::Work.index()]), Some(Level::Later));
3271 }
3272
3273 #[test]
3277 fn the_presets_go_their_own_way_only() {
3278 let usual = Attention::usual();
3279 let (quieter, reachable) = (Preset::Quieter.matrix(), Preset::Reachable.matrix());
3280 for row in Row::ALL {
3281 for column in Column::ALL {
3282 let u = usual.cell(row, column);
3283 if matches!(u, Level::As | Level::Through) {
3284 continue;
3285 }
3286 assert!(quieter.cell(row, column) >= u, "Quieter lets more through at {} {column:?}", row.id());
3287 assert!(reachable.cell(row, column) <= u, "Reachable holds more at {} {column:?}", row.id());
3288 }
3289 }
3290 assert_eq!(quieter.cell(Row::People(Channel::Messages, Person::Groups), Column::Leisure), Level::Later);
3292 assert_eq!(quieter.cell(Row::People(Channel::Messages, Person::Neutral), Column::Dnd), Level::Later);
3293 }
3294
3295 #[test]
3296 fn presets_never_touch_a_fixed_cell() {
3297 for preset in Preset::ALL {
3298 let matrix = preset.matrix();
3299 for r in Row::ALL {
3300 for column in Column::ALL {
3301 if lock(r, column).is_some() {
3302 assert_eq!(matrix.cell(r, column), usual(r, column), "{preset:?} {r:?} {column:?}");
3303 }
3304 }
3305 }
3306 }
3307 assert!(Preset::Quieter.matrix().changes_from(&Attention::usual()) > 0 && Preset::Reachable.matrix().changes_from(&Attention::usual()) > 0);
3308 }
3309
3310 #[test]
3311 fn the_grid_in_both_languages() {
3312 for language in ["en", "fr"] {
3313 let tr = Translator::new(language);
3314 let mut a = Attention::usual();
3315 a.set(row("mail.neutral"), Column::Leisure, Level::Now).unwrap();
3316 let grid = grid(&a, &tr, &Row::ALL, Preset::Usual);
3317 assert_eq!((grid.columns.len(), grid.rows.len(), grid.marks.len(), grid.presets.len()), (9, 36, 8, 3));
3318 assert!(grid.rows.iter().find(|r| r.id == "mail.neutral").unwrap().changed && !grid.presets[0].current && grid.presets[0].changes == 1);
3319 assert!(!grid.changed.is_empty());
3320 let words = grid
3321 .columns
3322 .iter()
3323 .map(|c| c.label.clone())
3324 .chain(grid.marks.iter().map(|m| m.label.clone()))
3325 .chain(grid.presets.iter().map(|p| p.label.clone()))
3326 .chain([grid.changed.clone()])
3327 .chain(grid.rows.iter().flat_map(|r| [r.label.clone(), r.group.clone(), r.help.clone()].into_iter().chain(r.choices.iter().map(|c| c.label.clone())).chain(r.cells.iter().flat_map(|c| [c.locked.clone(), c.said.clone()]))));
3328 for said in words {
3329 assert!(!said.starts_with("attention-") && !said.contains('{') && !said.contains("sorte"), "{language}: {said}");
3330 if language == "fr" {
3331 for mark in [" :", " ;", " !", " ?", "« ", " »", "'"] {
3332 assert!(!said.contains(mark), "{language}: typography in {said:?}");
3333 }
3334 }
3335 }
3336 let codes = grid.rows.iter().find(|r| r.id == "codes").unwrap();
3337 assert!(codes.cells[4].locked.is_empty() && !codes.cells[5].locked.is_empty(), "sleep free, the pause fixed");
3338 let always = grid.rows.iter().find(|r| r.id == "mail.always").unwrap();
3339 assert_eq!(always.choices.iter().find(|c| c.id == "now").unwrap().mark, "★");
3340 assert!(!list_choice(&tr, Who::Neutral, &a, true).contains('{'));
3341 }
3342 assert_eq!(Translator::new("fr").text("attention-column-free", None), "Temps libre");
3343 assert_eq!(list_choice(&Translator::new("en"), Who::Neutral, &Attention::usual(), false), "Your contacts: work, admin");
3344 assert_eq!(list_choice(&Translator::new("en"), Who::Safe, &Attention::usual(), false), "Safe: any time");
3345 }
3346
3347 #[test]
3352 fn a_source_s_row_is_read_over_its_usual_row() {
3353 let app = "app.com.example.chat".to_string();
3354 let talk = "conversation.0123456789abcdef".to_string();
3355 let both = vec![talk.clone(), app.clone()];
3356 let message = |who: Who, group: bool| Event::of(Source::Message { via: Channel::Messages, who, group, always: false });
3357 let at = |column: Column| Now::time(column);
3358 let usual = Attention::usual();
3359 for who in [Who::Safe, Who::Neutral, Who::Restricted, Who::Stranger, Who::Blocked] {
3361 for group in [false, true] {
3362 for column in Column::TIMES {
3363 for (slot, dnd) in [(false, false), (true, false), (false, true)] {
3364 let now = at(column).layers(slot, dnd);
3365 assert_eq!(usual.decide(&message(who, group).from_sources(both.clone()), &now), usual.decide(&message(who, group), &now), "{who:?} {group} {column:?}");
3366 }
3367 }
3368 }
3369 }
3370 let mut a = Attention::usual();
3372 for column in Column::ALL {
3373 a.set_source(&app, column, if column == Column::Work { Level::Now } else { Level::Later }).unwrap();
3374 }
3375 let stranger = message(Who::Stranger, false).from_sources(both.clone());
3376 assert_eq!(a.decide(&stranger, &at(Column::Work)), Output { level: Level::Now, shown: true, told: true, pierce: false, step: Step::Chosen });
3377 for column in [Column::Admin, Column::Leisure, Column::Meals, Column::Sleep, Column::Pause, Column::Free] {
3378 assert_eq!(a.decide(&stranger, &at(column)).level, Level::Later, "{column:?}");
3379 }
3380 assert_eq!(a.decide(&message(Who::Safe, false).from_sources(both.clone()), &at(Column::Leisure)).level, Level::Later);
3382 assert_eq!(a.decide(&message(Who::Blocked, false).from_sources(both.clone()), &at(Column::Work)).step, Step::Blocked);
3383 assert_eq!(a.decide(&stranger, &at(Column::Work).layers(false, true)).level, Level::Later);
3385 let mut b = Attention::usual();
3387 b.set_source(&app, Column::Work, Level::Later).unwrap();
3388 assert_eq!(b.decide(&message(Who::Safe, false).from_sources(both.clone()), &at(Column::Leisure)), usual.decide(&message(Who::Safe, false), &at(Column::Leisure)));
3389 assert_eq!(b.decide(&message(Who::Stranger, false).from_sources(both.clone()), &at(Column::Leisure)).step, Step::Matrix);
3390 assert_eq!(b.decide(&message(Who::Safe, false).from_sources(both.clone()), &at(Column::Work)).level, Level::Later);
3391 let mut c = Attention::usual();
3393 c.set_source(&app, Column::Leisure, Level::Now).unwrap();
3394 assert_eq!(c.decide(&stranger, &at(Column::Leisure)).level, Level::Now);
3395 assert_eq!(c.decide(&stranger, &at(Column::Leisure).layers(false, true)).level, Level::Later);
3396 c.set_source(&app, Column::Dnd, Level::Now).unwrap();
3397 assert_eq!(c.decide(&stranger, &at(Column::Leisure).layers(false, true)).level, Level::Now);
3398 let listed = Event::of(Source::Message { via: Channel::Messages, who: Who::Neutral, group: false, always: true }).from_sources(both.clone());
3400 assert_eq!(a.decide(&listed, &at(Column::Sleep)).level, Level::Now);
3401 a.set_source(&talk, Column::Leisure, Level::Now).unwrap();
3403 assert_eq!(a.decide(&stranger, &at(Column::Leisure)).level, Level::Now);
3404 assert_eq!(a.decide(&message(Who::Stranger, false).from_sources(vec![app.clone()]), &at(Column::Leisure)).level, Level::Later);
3405 let mut d = Attention::usual();
3407 d.set_source(&app, Column::Work, Level::Gathered).unwrap();
3408 assert_eq!(d.decide(&Event::own(Kind::AppAutomatons).from_sources(both.clone()), &at(Column::Work)).level, Level::Gathered);
3409 assert_eq!(d.decide(&stranger, &at(Column::Work)).level, Level::Gathered);
3410 assert_eq!(d.decide(&Event::own(Kind::AppAutomatons).from_sources(both.clone()), &at(Column::Leisure)), usual.decide(&Event::own(Kind::AppAutomatons), &at(Column::Leisure)));
3411 let leisure_app = Event::own(Kind::AppAtOnce).for_area(Some(Area::WORK)).from_sources(both.clone());
3413 let mut e = Attention::usual();
3414 e.set_source(&app, Column::Leisure, Level::Now).unwrap();
3415 assert_eq!(e.decide(&leisure_app, &at(Column::Leisure)).step, Step::Chosen);
3416 assert_eq!(usual.decide(&leisure_app, &at(Column::Leisure)).step, Step::Area);
3417 let mut nothing = at(Column::Free);
3419 nothing.nothing = true;
3420 assert_eq!(b.decide(&message(Who::Safe, false).from_sources(both.clone()), ¬hing).level, Level::Later);
3421 e.set_source(&app, Column::Free, Level::Now).unwrap();
3422 assert_eq!(e.decide(&message(Who::Safe, false).from_sources(both.clone()), ¬hing).level, Level::Now);
3423 assert!(a.set_source(&app, Column::Work, Level::Quiet).is_err() && a.set_source("app.", Column::Work, Level::Now).is_err() && a.set_source("mail.safe", Column::Work, Level::Now).is_err());
3425 let mut f = Attention::usual();
3426 f.set_source(&app, Column::Work, Level::Now).unwrap();
3427 f.set_source(&app, Column::Work, Level::As).unwrap();
3428 assert!(f.source(&app).is_none() && f.sources().is_empty() && !f.has_sources(&both));
3429 assert_eq!(source_rows("com.example.chat", "0123456789abcdef"), both);
3430 assert_eq!(source_rows("com.example.chat", " "), vec![app.clone()]);
3431 for language in ["en", "fr"] {
3433 let tr = Translator::new(language);
3434 let lines = vec![SourceLine { id: app.clone(), label: "Chat".into(), group: "Apps".into(), group_id: "app-rows".into() }];
3435 let grid = source_grid(&a, &tr, &lines);
3436 assert_eq!((grid.columns.len(), grid.rows.len(), grid.marks.len(), grid.rows[0].cells.len()), (9, 1, 4, 9));
3437 assert!(grid.rows[0].changed && grid.rows[0].cells.iter().all(|c| c.choices.len() == 4 && c.locked.is_empty()));
3438 let said = source_sentence(&a, &tr, &app);
3439 let usual_said = source_sentence(&Attention::usual(), &tr, &app);
3440 let words = grid.marks.iter().map(|m| m.label.clone()).chain(grid.rows[0].cells.iter().map(|c| c.said.clone())).chain([grid.rows[0].help.clone(), said.clone(), usual_said, source_sentence(&b, &tr, &app)]);
3441 for text in words {
3442 assert!(!text.starts_with("attention-") && !text.contains('{') && !text.contains("sorte"), "{language}: {text}");
3443 if language == "fr" {
3444 for mark in [" :", " ;", " !", " ?", "« ", " »", "'"] {
3445 assert!(!text.contains(mark), "{language}: typography in {text:?}");
3446 }
3447 }
3448 }
3449 }
3450 assert_eq!(source_sentence(&a, &Translator::new("en"), &app), "At once: Work; held: Admin, Leisure, Meals, Sleep, Pause, Free time, Time for you and Do not disturb.");
3451 assert_eq!(source_sentence(&b, &Translator::new("en"), &app), "Held: Work; as usual the rest of the time.");
3452 assert_eq!(source_sentence(&b, &Translator::new("fr"), &app), "Retenues\u{202f}: Travail\u{202f}; comme d’habitude le reste du temps.");
3453 }
3454
3455 #[test]
3458 fn source_rows_written_and_read() {
3459 let dir = std::env::temp_dir().join(format!("sioul-attention-sources-{}", std::process::id()));
3460 let _ = std::fs::remove_dir_all(&dir);
3461 std::fs::create_dir_all(&dir).unwrap();
3462 let path = dir.join("config.toml");
3463 std::fs::write(&path, "[notify]\nsites = [\"work:later\"]\n").unwrap();
3464 let config = Config::load(&path).unwrap();
3465 let words = |w: &[&str]| SettingValue::Texts(w.iter().map(|s| s.to_string()).collect());
3466 apply(&path, &config, "attention.app.com.example.chat", &words(&["work", "admin:later", "leisure:as", "dnd:gathered", "name=Example Chat"])).unwrap();
3468 let text = std::fs::read_to_string(&path).unwrap();
3469 assert!(text.contains("\"app.com.example.chat\" = [\"work\", \"admin:later\", \"dnd:gathered\", \"name=Example Chat\"]") && !text.contains("[notify]"), "{text}");
3470 let a = Attention::of(&Config::load(&path).unwrap());
3471 assert_eq!(a.cell(row("sites"), Column::Work), Level::Later, "seeded from [notify]");
3472 let cells = a.source("app.com.example.chat").unwrap();
3473 assert_eq!((cells[0], cells[1], cells[2], cells[8]), (Level::Now, Level::Later, Level::As, Level::Gathered));
3474 assert_eq!(a.source_name("app.com.example.chat"), "Example Chat");
3475 apply(&path, &Config::load(&path).unwrap(), "attention.codes", &words(&["sleep:never"])).unwrap();
3477 assert!(Attention::of(&Config::load(&path).unwrap()).source("app.com.example.chat").is_some());
3478 assert!(apply(&path, &Config::load(&path).unwrap(), "attention.app.com.example.chat", &words(&["work:quiet"])).is_err());
3480 assert!(apply(&path, &Config::load(&path).unwrap(), "attention.app.com.example.chat", &words(&["teatime"])).is_err());
3481 apply(&path, &Config::load(&path).unwrap(), "attention.app.com.example.chat", &words(&["work:as", "admin:as", "dnd:as"])).unwrap();
3483 let text = std::fs::read_to_string(&path).unwrap();
3484 assert!(!text.contains("example.chat") && Attention::of(&Config::load(&path).unwrap()).sources().is_empty(), "{text}");
3485 assert!(Row::read("app.com.example.chat").is_none() && Row::read("conversation.0123").is_none());
3487 let _ = std::fs::remove_dir_all(&dir);
3488 }
3489}