1use crate::config::{config_dir, state_dir};
30use serde::{Deserialize, Serialize};
31use std::collections::BTreeMap;
32use std::path::{Path, PathBuf};
33
34pub const SWITCH_FILE: &str = "do-not-disturb.toml";
36pub const PEOPLE_FILE: &str = "dnd-people.toml";
38pub const MARGIN_MS: i64 = 60_000;
42pub const FOCUS_GRACE: i64 = 30 * 60;
45pub const FOCUS_OPEN: i64 = 180 * 60;
47pub const LIVE_DAYS: i64 = 7;
49pub const COMPUTER_QUIET: i64 = 30 * 60;
56
57fn yes() -> bool {
58 true
59}
60
61fn is_false(value: &bool) -> bool {
62 !*value
63}
64
65pub const VIA_SYSTEM: &str = "system";
67
68#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
70pub struct DndSettings {
71 #[serde(default = "yes")]
73 pub button: bool,
74 #[serde(default)]
76 pub focus: bool,
77 #[serde(default = "yes")]
80 pub pauses: bool,
81 #[serde(default)]
83 pub sleep: bool,
84 #[serde(default = "yes")]
88 pub people: bool,
89 #[serde(default = "yes")]
91 pub background: bool,
92}
93
94impl Default for DndSettings {
95 fn default() -> Self {
96 DndSettings { button: true, focus: false, pauses: true, sleep: false, people: true, background: true }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
102#[serde(rename_all = "kebab-case")]
103pub enum Why {
104 Paused,
105 FreeTime,
106 Sleep,
107 Focus,
108 Manual,
109}
110
111impl Why {
112 pub fn id(self) -> &'static str {
113 match self {
114 Why::Paused => "paused",
115 Why::FreeTime => "free-time",
116 Why::Sleep => "sleep",
117 Why::Focus => "focus",
118 Why::Manual => "manual",
119 }
120 }
121
122 pub fn parse(id: &str) -> Option<Why> {
123 [Why::Paused, Why::FreeTime, Why::Sleep, Why::Focus, Why::Manual].into_iter().find(|w| w.id() == id)
124 }
125
126 pub fn global(self) -> bool {
128 matches!(self, Why::Sleep | Why::Focus | Why::Manual)
129 }
130}
131
132#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
136pub struct Device {
137 #[serde(default)]
139 pub kind: String,
140 #[serde(default)]
142 pub name: String,
143 #[serde(default)]
145 pub pressed: i64,
146 #[serde(default)]
148 pub on: bool,
149 #[serde(default)]
151 pub until: i64,
152 #[serde(default)]
154 pub seen: i64,
155 #[serde(default)]
157 pub why: String,
158 #[serde(default)]
160 pub silenced: bool,
161 #[serde(default)]
163 pub line: String,
164 #[serde(default)]
166 pub at: i64,
167 #[serde(default, skip_serializing_if = "String::is_empty")]
170 pub via: String,
171 #[serde(default, skip_serializing_if = "is_false")]
175 pub turned_off: bool,
176 #[serde(default, skip_serializing_if = "is_false")]
179 pub system_on: bool,
180 #[serde(default, skip_serializing_if = "is_false")]
183 pub closed: bool,
184 #[serde(flatten)]
186 pub other: toml::Table,
187}
188
189impl Device {
190 pub fn closing(&mut self, still: bool) {
195 self.closed = true;
196 self.silenced = still;
197 self.turned_off = false;
198 self.system_on = false;
199 self.line = String::new();
200 }
201}
202
203#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
205pub struct Switch {
206 #[serde(default)]
207 pub device: BTreeMap<String, Device>,
208 #[serde(flatten)]
210 pub other: toml::Table,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct Press {
216 pub at: i64,
218 pub on: bool,
219 pub until: i64,
221 pub from: String,
223 pub via: String,
225}
226
227impl Switch {
228 pub fn default_path() -> PathBuf {
229 state_dir().join(SWITCH_FILE)
230 }
231
232 pub fn read(path: &Path) -> Result<Switch, String> {
235 match std::fs::read_to_string(path) {
236 Ok(text) => toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display())),
237 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Switch::default()),
238 Err(e) => Err(format!("{}: {e}", path.display())),
239 }
240 }
241
242 pub fn load(path: &Path) -> Switch {
244 Switch::read(path).unwrap_or_default()
245 }
246
247 pub fn latest(&self) -> Option<Press> {
251 let presses: Vec<(&String, &Device)> = self.device.iter().filter(|(_, d)| d.pressed > 0).collect();
252 let (from, last) = presses.iter().max_by_key(|(id, d)| (d.pressed, (*id).clone()))?;
253 let mut winner = (*from, *last);
254 if !last.on {
255 let tied = presses.iter().filter(|(id, d)| *id != *from && d.on && last.pressed - d.pressed <= MARGIN_MS && last.seen < d.pressed).max_by_key(|(id, d)| (d.pressed, (*id).clone()));
257 if let Some((id, d)) = tied {
258 winner = (*id, *d);
259 }
260 }
261 Some(Press { at: winner.1.pressed, on: winner.1.on, until: winner.1.until, from: winner.0.clone(), via: winner.1.via.clone() })
262 }
263
264 pub fn latest_known(&self, except: Option<&str>) -> i64 {
266 self.device.iter().filter(|(id, _)| Some(id.as_str()) != except).map(|(_, d)| d.pressed).max().unwrap_or(0)
267 }
268
269 pub fn press(&mut self, here: &str, on: bool, until: i64, now_ms: i64) -> i64 {
272 self.press_via(here, on, until, now_ms, "")
273 }
274
275 pub fn press_via(&mut self, here: &str, on: bool, until: i64, now_ms: i64, via: &str) -> i64 {
278 let stamp = now_ms.max(self.latest_known(None) + 1);
279 let seen = self.latest_known(Some(here));
280 let own = self.device.entry(here.to_string()).or_default();
281 own.pressed = stamp;
282 own.on = on;
283 own.until = if on { until.max(0) } else { 0 };
284 own.seen = seen;
285 own.at = stamp;
286 own.via = via.to_string();
287 stamp
288 }
289
290 pub fn save(&self, path: &Path) -> Result<(), String> {
292 write_whole(path, &toml::to_string(self).map_err(|e| e.to_string())?)
293 }
294}
295
296pub fn change_own(path: &Path, here: &str, change: impl FnOnce(&mut Switch) -> bool) -> Result<Switch, String> {
301 crate::filelock::with_lock(path, || {
302 let mut switch = Switch::read(path)?;
303 if !here.is_empty() && change(&mut switch) {
304 switch.save(path)?;
306 }
307 Ok(switch)
308 })
309}
310
311fn write_whole(path: &Path, text: &str) -> Result<(), String> {
313 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
314 if let Some(parent) = path.parent() {
315 std::fs::create_dir_all(parent).map_err(fail)?;
316 }
317 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
318 let fresh = path.with_file_name(format!(".{name}.{}.new", std::process::id()));
319 std::fs::write(&fresh, text).map_err(fail)?;
320 std::fs::rename(&fresh, path).map_err(|e| {
321 let _ = std::fs::remove_file(&fresh);
322 fail(e)
323 })
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Eq)]
330pub struct Sources {
331 pub paused: Option<i64>,
333 pub free: Option<(i64, i64)>,
335 pub sleep: Option<(i64, i64)>,
337 pub focus: Option<(i64, i64)>,
339}
340
341pub fn focus(running: &crate::timelog::Running, now: i64) -> Option<(i64, i64)> {
345 if running.paused_at.is_some() || running.start <= 0 {
346 return None;
347 }
348 let lasts = if running.planned > 0 { i64::from(running.planned) * 60 + FOCUS_GRACE } else { FOCUS_OPEN };
349 let end = running.start + running.paused.max(0) + lasts;
350 (now < end).then_some((running.start, end))
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
355pub struct Held {
356 pub why: Why,
357 pub since: i64,
359 pub until: Option<i64>,
362 pub from: String,
364}
365
366#[derive(Debug, Clone, Default, PartialEq, Eq)]
368pub struct Now {
369 pub holds: Vec<Held>,
370}
371
372impl Now {
373 pub fn on(&self) -> bool {
374 !self.holds.is_empty()
375 }
376
377 pub fn why(&self) -> Option<Why> {
379 self.holds.first().map(|h| h.why)
380 }
381
382 pub fn has(&self, why: Why) -> bool {
383 self.holds.iter().any(|h| h.why == why)
384 }
385
386 pub fn global(&self) -> bool {
388 self.holds.iter().any(|h| h.why.global())
389 }
390
391 pub fn until(&self) -> Option<i64> {
394 if self.holds.is_empty() || self.holds.iter().any(|h| h.until.is_none() || h.why == Why::Focus) {
395 return None;
396 }
397 self.holds.iter().filter_map(|h| h.until).max()
398 }
399
400 pub fn next_end(&self) -> Option<i64> {
402 self.holds.iter().filter_map(|h| h.until).min()
403 }
404
405 pub fn global_why(&self) -> Option<Why> {
407 self.holds.iter().map(|h| h.why).find(|w| w.global())
408 }
409
410 pub fn gates(&self) -> bool {
414 self.has(Why::Focus) || self.has(Why::Manual)
415 }
416}
417
418pub fn heard(holds: bool, system_on: bool) -> Option<bool> {
425 (holds != system_on).then_some(system_on)
426}
427
428pub fn held_by_system(now: &Now, press: Option<&Press>, here: &str) -> bool {
435 let mut global = now.holds.iter().filter(|h| h.why.global());
436 let only_manual = matches!((global.next(), global.next()), (Some(h), None) if h.why == Why::Manual);
437 only_manual && press.is_some_and(|p| p.on && p.from == here && p.via == VIA_SYSTEM)
438}
439
440pub fn now(settings: &DndSettings, sources: &Sources, press: Option<&Press>, now: i64) -> Now {
445 let pressed_off_after = |since: i64| press.is_some_and(|p| !p.on && p.at > since.saturating_mul(1000));
447 let mut holds = Vec::new();
448 let mut add = |why: Why, since: i64, until: Option<i64>| {
449 if !pressed_off_after(since) {
450 holds.push(Held { why, since, until, from: String::new() });
451 }
452 };
453 if settings.pauses {
454 if let Some(since) = sources.paused {
455 add(Why::Paused, since, None);
456 } else if let Some((since, end)) = sources.free.filter(|(since, end)| *since <= now && now < *end) {
457 add(Why::FreeTime, since, Some(end));
458 }
459 }
460 if settings.sleep
461 && let Some((since, until)) = sources.sleep.filter(|(_, until)| now < *until)
462 {
463 add(Why::Sleep, since, Some(until));
464 }
465 if settings.focus
466 && let Some((since, end)) = sources.focus.filter(|(_, end)| now < *end)
467 {
468 add(Why::Focus, since, Some(end));
469 }
470 if let Some(p) = press.filter(|p| p.on) {
471 let until = (p.until > 0).then_some(p.until / 1000);
472 if until.is_none_or(|u| now < u) {
473 holds.push(Held { why: Why::Manual, since: p.at / 1000, until, from: p.from.clone() });
474 }
475 }
476 holds.sort_by_key(|h| h.why);
477 Now { holds }
478}
479
480#[derive(Debug, Clone, PartialEq, Eq)]
484pub enum Follows {
485 Silenced,
487 Cannot(String),
489 Behind,
492 Older,
495 Closed,
498}
499
500pub fn others(switch: &Switch, here: &str, heard: &[(String, i64)], now: i64, since: i64, registered: &dyn Fn(&str) -> bool) -> Vec<(String, Follows)> {
509 let live = |at: i64| now - at <= LIVE_DAYS * 86_400;
510 let mut ids: Vec<String> = heard.iter().filter(|(id, at)| id != here && live(*at)).map(|(id, _)| id.clone()).collect();
511 ids.extend(switch.device.iter().filter(|(id, d)| *id != here && d.at > 0 && live(d.at / 1000)).map(|(id, _)| id.clone()));
513 ids.sort();
514 ids.dedup();
515 ids.into_iter()
516 .map(|id| {
517 let news = heard.iter().filter(|(other, _)| *other == id).map(|(_, at)| *at).chain(switch.device.get(&id).map(|d| d.at / 1000)).max().unwrap_or(0);
519 let follows = match switch.device.get(&id) {
520 Some(d) if d.closed => Follows::Closed,
521 Some(d) if d.why.is_empty() || d.at.saturating_add(MARGIN_MS) < since => Follows::Behind,
522 Some(d) if d.silenced && d.kind == "computer" && now - news > COMPUTER_QUIET => Follows::Behind,
523 Some(d) if d.silenced => Follows::Silenced,
524 Some(d) => Follows::Cannot(d.line.clone()),
525 None if !registered(&id) => Follows::Older,
526 None => Follows::Behind,
527 };
528 (id, follows)
529 })
530 .collect()
531}
532
533#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
535pub struct Said {
536 pub line: String,
538 pub why: String,
540 pub details: Vec<String>,
542 pub everywhere: bool,
544 pub here: bool,
546}
547
548#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
550pub struct Here<'a> {
551 pub silenced: bool,
553 pub line: &'a str,
555 pub turned_off: bool,
558}
559
560pub fn said(now_: &Now, here: Here<'_>, others: &[(String, Follows)], until: &str, name_of: &dyn Fn(&str) -> String, tr: &crate::i18n::Translator) -> Said {
566 let (here_silenced, here_line) = (here.silenced, here.line);
567 let Some(why) = now_.why() else { return Said::default() };
568 let mut args = crate::i18n::args();
569 args.set("until", until.to_string());
570 let with_until = |key: &str| if until.is_empty() { tr.text(key, None) } else { tr.text(&format!("{key}-until"), Some(&args)) };
571 let following = others.iter().filter(|(_, f)| *f == Follows::Silenced).count();
572 let everywhere = here_silenced && following == others.len();
573 let not_following: Vec<&Follows> = others.iter().map(|(_, f)| f).filter(|f| **f != Follows::Silenced).collect();
576 let waiting = !not_following.is_empty() && not_following.iter().all(|f| **f == Follows::Behind);
577 let line = if here.turned_off {
578 with_until("dnd-turned-off-here")
579 } else if !here_silenced {
580 if following > 0 { with_until("dnd-elsewhere") } else { with_until("dnd-own-only") }
581 } else if others.is_empty() {
582 with_until("dnd-here-only")
583 } else if everywhere {
584 with_until("dnd-everywhere")
585 } else if waiting {
586 with_until("dnd-waiting")
587 } else if following == 0 {
588 with_until("dnd-here-only")
589 } else {
590 with_until("dnd-not-everywhere")
591 };
592 let device = |key: &str, place: String, line: Option<&str>| {
594 let mut args = crate::i18n::args();
595 args.set("device", place);
596 if let Some(line) = line {
597 args.set("line", line.to_string());
598 }
599 tr.text(key, Some(&args))
600 };
601 let on = |id: &str| {
602 let mut args = crate::i18n::args();
603 args.set("name", name_of(id));
604 tr.text("dnd-device-on", Some(&args))
605 };
606 let here_name = tr.text("dnd-device-here", None);
607 let mut details = vec![if here_silenced { device("dnd-device-silenced", here_name, None) } else { device("dnd-device-cannot", here_name, Some(here_line)) }];
608 for (id, follows) in others {
609 details.push(match follows {
610 Follows::Silenced => device("dnd-device-silenced", on(id), None),
611 Follows::Cannot(words) if !words.is_empty() => device("dnd-device-cannot", on(id), Some(words)),
612 Follows::Cannot(_) => device("dnd-device-unsilenced", on(id), None),
613 Follows::Behind => device("dnd-device-behind", on(id), None),
614 Follows::Older => device("dnd-device-older", on(id), None),
615 Follows::Closed => device("dnd-device-closed", on(id), None),
616 });
617 }
618 let why_words = match why {
619 Why::Manual => {
620 let from = now_.holds.iter().find(|h| h.why == Why::Manual).map(|h| h.from.clone()).unwrap_or_default();
621 if from.is_empty() {
622 tr.text("dnd-why-manual", None)
623 } else {
624 let mut args = crate::i18n::args();
625 args.set("device", name_of(&from));
626 tr.text("dnd-why-manual-from", Some(&args))
627 }
628 }
629 other => tr.text(&format!("dnd-why-{}", other.id()), None),
630 };
631 Said { line, why: why_words, details, everywhere, here: here_silenced }
632}
633
634#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
638pub struct Person {
639 pub id: String,
641 #[serde(default)]
642 pub name: String,
643 #[serde(default, skip_serializing_if = "Vec::is_empty")]
644 pub phones: Vec<String>,
645 #[serde(default, skip_serializing_if = "Vec::is_empty")]
646 pub emails: Vec<String>,
647 #[serde(default, skip_serializing_if = "String::is_empty")]
649 pub contact: String,
650 #[serde(flatten)]
653 pub other: toml::Table,
654}
655
656#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
658pub struct People {
659 #[serde(default, rename = "person", skip_serializing_if = "Vec::is_empty")]
660 pub people: Vec<Person>,
661 #[serde(flatten)]
663 pub other: toml::Table,
664}
665
666fn address_key(text: &str) -> String {
668 let text = text.trim();
669 let inner = text.rsplit_once('<').map_or(text, |(_, rest)| rest.trim_end_matches('>'));
670 inner.trim().trim_start_matches("mailto:").to_lowercase()
671}
672
673pub fn new_id() -> String {
675 use std::hash::{BuildHasher, Hasher};
676 use std::sync::atomic::{AtomicU64, Ordering};
677 static COUNT: AtomicU64 = AtomicU64::new(0);
678 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
679 let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
680 hasher.write_u128(now.as_nanos());
681 hasher.write_u64(COUNT.fetch_add(1, Ordering::Relaxed));
682 hasher.write_u32(std::process::id());
683 format!("{:x}{:016x}", now.as_secs(), hasher.finish())
684}
685
686impl People {
687 pub fn default_path() -> PathBuf {
688 config_dir().join(PEOPLE_FILE)
689 }
690
691 pub fn read(path: &Path) -> Result<People, String> {
695 match std::fs::read_to_string(path) {
696 Ok(text) => toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display())),
697 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(People::default()),
698 Err(e) => Err(format!("{}: {e}", path.display())),
699 }
700 }
701
702 pub fn load(path: &Path) -> People {
704 People::read(path).unwrap_or_default()
705 }
706
707 pub fn save(&self, path: &Path) -> Result<(), String> {
708 write_whole(path, &toml::to_string(self).map_err(|e| e.to_string())?)
709 }
710
711 pub fn admits_address(&self, address: &str) -> bool {
713 let key = address_key(address);
714 !key.is_empty() && key.contains('@') && self.people.iter().any(|p| p.emails.iter().any(|e| address_key(e) == key))
715 }
716
717 pub fn admits_number(&self, number: &str, region: Option<&crate::phones::Region>) -> bool {
720 let key = crate::phones::key(number, region);
721 crate::phones::is_whole(&key) && self.people.iter().any(|p| p.phones.iter().any(|n| crate::phones::key(n, region) == key))
722 }
723
724 pub fn add(&mut self, mut person: Person, region: Option<&crate::phones::Region>) -> String {
727 person.emails.retain(|e| !address_key(e).is_empty());
728 person.phones.retain(|n| !n.trim().is_empty());
729 let same = |p: &Person| {
730 (!person.contact.is_empty() && p.contact == person.contact)
731 || person.emails.iter().any(|e| p.emails.iter().any(|x| address_key(x) == address_key(e)))
732 || person.phones.iter().any(|n| {
733 let key = crate::phones::key(n, region);
734 crate::phones::is_whole(&key) && p.phones.iter().any(|x| crate::phones::key(x, region) == key)
735 })
736 };
737 if let Some(known) = self.people.iter_mut().find(|p| same(p)) {
738 for email in person.emails {
739 if !known.emails.iter().any(|x| address_key(x) == address_key(&email)) {
740 known.emails.push(email.trim().to_string());
741 }
742 }
743 for number in person.phones {
744 let key = crate::phones::key(&number, region);
745 if !known.phones.iter().any(|x| crate::phones::key(x, region) == key) {
746 known.phones.push(number.trim().to_string());
747 }
748 }
749 if known.name.trim().is_empty() {
750 known.name = person.name;
751 }
752 if known.contact.is_empty() {
753 known.contact = person.contact;
754 }
755 return known.id.clone();
756 }
757 if person.id.is_empty() {
758 person.id = new_id();
759 }
760 person.emails = person.emails.iter().map(|e| e.trim().to_string()).collect();
761 person.phones = person.phones.iter().map(|n| n.trim().to_string()).collect();
762 let id = person.id.clone();
763 self.people.push(person);
764 id
765 }
766
767 pub fn remove(&mut self, id: &str) -> bool {
769 let before = self.people.len();
770 self.people.retain(|p| p.id != id);
771 self.people.len() != before
772 }
773}
774
775pub fn take_off(people: &mut People, addresses: &[String], numbers: &[String], card: &str, region: Option<&crate::phones::Region>) -> bool {
781 let keys: Vec<String> = numbers.iter().map(|n| crate::phones::key(n, region)).filter(|k| crate::phones::is_whole(k)).collect();
782 let card = card.trim();
783 let mut gone: Vec<String> = Vec::new();
784 let mut changed = false;
785 for person in &mut people.people {
786 let before = (person.emails.len(), person.phones.len());
787 person.emails.retain(|e| !addresses.iter().any(|a| address_key(a) == address_key(e)));
788 person.phones.retain(|n| !keys.contains(&crate::phones::key(n, region)));
789 let whole = !card.is_empty() && person.contact == card;
790 if whole || before != (person.emails.len(), person.phones.len()) {
791 changed = true;
792 if whole || (person.emails.is_empty() && person.phones.is_empty()) {
793 gone.push(person.id.clone());
794 }
795 }
796 }
797 people.people.retain(|p| !gone.contains(&p.id));
798 changed
799}
800
801pub fn change_people(path: &Path, change: impl FnOnce(&mut People)) -> Result<People, String> {
804 crate::filelock::with_lock(path, || {
805 let mut people = People::read(path)?;
806 let before = people.clone();
807 change(&mut people);
808 if people != before {
809 people.save(path)?;
810 }
811 Ok(people)
812 })
813}
814
815pub fn from_contact(contact: &crate::contacts::Contact) -> Person {
817 Person {
818 id: String::new(),
819 name: contact.name.trim().to_string(),
820 phones: contact.phones.iter().map(|p| p.value.trim().to_string()).filter(|v| !v.is_empty()).collect(),
821 emails: contact.emails.iter().map(|e| e.value.trim().to_string()).filter(|v| !v.is_empty()).collect(),
822 contact: contact.uid.clone(),
823 other: toml::Table::new(),
824 }
825}
826
827pub fn from_safe(entries: &[String], contacts: &[crate::contacts::Contact]) -> (Vec<Person>, usize) {
833 let (mut found, mut patterns) = (Vec::new(), 0);
834 for entry in entries {
835 let entry = entry.trim();
836 if let Some(category) = crate::porch::category_of(entry) {
837 found.extend(contacts.iter().filter(|c| crate::contacts::in_category(c, category)).map(from_contact));
838 } else if let Some(uid) = crate::porch::card_of(entry) {
839 found.extend(contacts.iter().filter(|c| c.uid.trim() == uid).map(from_contact));
840 } else if let Some(number) = crate::porch::number_of(entry).filter(|n| !n.contains('*')) {
841 found.push(Person { phones: vec![number.to_string()], ..Person::default() });
842 } else if entry.starts_with('@') || !entry.contains('@') || entry.contains('*') {
843 patterns += 1;
844 } else {
845 match crate::contacts::by_address(contacts, entry) {
846 Some(card) => found.push(from_contact(card)),
847 None => found.push(Person { name: String::new(), emails: vec![entry.to_string()], ..Person::default() }),
848 }
849 }
850 }
851 (found, patterns)
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857 use crate::i18n::Translator;
858
859 fn device(pressed: i64, on: bool, seen: i64) -> Device {
860 Device { pressed, on, seen, ..Device::default() }
861 }
862
863 fn at(silenced: bool, line: &str) -> Here<'_> {
865 Here { silenced, line, turned_off: false }
866 }
867
868 fn switch(devices: &[(&str, Device)]) -> Switch {
869 Switch { device: devices.iter().map(|(id, d)| (id.to_string(), d.clone())).collect(), ..Switch::default() }
870 }
871
872 #[test]
873 fn the_latest_press_wins_whatever_device_made_it() {
874 let s = switch(&[("desk", device(1_000_000, true, 0)), ("phone", device(1_200_000, false, 1_000_000))]);
876 let latest = s.latest().unwrap();
877 assert!(!latest.on && latest.from == "phone");
878 let s = switch(&[("desk", device(1_300_000, true, 1_200_000)), ("phone", device(1_200_000, false, 1_000_000))]);
880 assert!(s.latest().unwrap().on);
881 assert_eq!(Switch::default().latest(), None);
883 }
884
885 #[test]
886 fn a_press_after_seeing_another_comes_after_it_whatever_the_clocks() {
887 let mut s = switch(&[("phone", device(5_000_000, true, 0))]);
888 let stamp = s.press("desk", false, 0, 5_000_000 - 120_000);
891 assert_eq!(stamp, 5_000_001);
892 assert_eq!(s.device["desk"].seen, 5_000_000);
893 let latest = s.latest().unwrap();
894 assert!(!latest.on && latest.from == "desk");
895 }
896
897 #[test]
898 fn two_presses_that_did_not_see_each_other_within_a_minute_turn_it_on() {
899 let s = switch(&[("desk", device(10_000_000, true, 0)), ("phone", device(10_040_000, false, 0))]);
901 let latest = s.latest().unwrap();
902 assert!(latest.on && latest.from == "desk");
903 let s = switch(&[("desk", device(10_000_000, true, 0)), ("phone", device(10_120_000, false, 0))]);
905 assert!(!s.latest().unwrap().on);
906 let s = switch(&[("desk", device(10_000_000, true, 0)), ("phone", device(10_040_000, false, 10_000_000))]);
908 assert!(!s.latest().unwrap().on);
909 let s = switch(&[("desk", device(10_000_000, false, 0)), ("phone", device(10_040_000, true, 0))]);
911 assert!(s.latest().unwrap().on);
912 }
913
914 #[test]
915 fn until_ends_it_by_each_devices_clock() {
916 let settings = DndSettings::default();
917 let press = Press { at: 1_000_000, on: true, until: 1_600_000, from: "desk".into(), via: String::new() };
918 let held = now(&settings, &Sources::default(), Some(&press), 1_500);
919 assert_eq!(held.why(), Some(Why::Manual));
920 assert_eq!(held.until(), Some(1_600));
921 assert!(!now(&settings, &Sources::default(), Some(&press), 1_600).on());
922 }
923
924 #[test]
925 fn each_trigger_is_a_setting_and_the_pause_comes_first() {
926 let sources = Sources { paused: Some(100), free: Some((50, 900)), sleep: Some((80, 2000)), focus: Some((90, 1000)) };
927 let none = DndSettings { pauses: false, sleep: false, focus: false, ..DndSettings::default() };
928 assert!(!now(&none, &sources, None, 500).on());
929 let all = DndSettings { pauses: true, sleep: true, focus: true, ..DndSettings::default() };
930 let held = now(&all, &sources, None, 500);
931 assert_eq!(held.holds.iter().map(|h| h.why).collect::<Vec<_>>(), vec![Why::Paused, Why::Sleep, Why::Focus]);
933 assert_eq!(held.why(), Some(Why::Paused));
934 assert!(held.global() && held.global_why() == Some(Why::Sleep));
935 assert_eq!(held.until(), None);
937 let free = Sources { free: Some((50, 900)), ..Sources::default() };
939 let held = now(&all, &free, None, 500);
940 assert_eq!((held.why(), held.until()), (Some(Why::FreeTime), Some(900)));
941 assert!(!held.global());
942 assert!(!now(&all, &free, None, 900).on(), "Free time over at its end");
943 }
944
945 #[test]
946 fn pressed_off_after_a_reason_began_holds_it_off_until_it_ends() {
947 let settings = DndSettings { focus: true, sleep: true, ..DndSettings::default() };
948 let focus = Sources { focus: Some((1_000, 5_000)), ..Sources::default() };
949 let off_after = Press { at: 2_000_000, on: false, until: 0, from: "phone".into(), via: String::new() };
950 assert!(!now(&settings, &focus, Some(&off_after), 3_000).on(), "the person turned it off during the session");
951 let later = Sources { focus: Some((2_500, 6_000)), ..Sources::default() };
953 assert!(now(&settings, &later, Some(&off_after), 3_000).has(Why::Focus));
954 let on = Press { on: true, ..off_after.clone() };
956 let held = now(&settings, &focus, Some(&on), 3_000);
957 assert!(held.has(Why::Focus) && held.has(Why::Manual));
958 let off_before = Press { at: 500_000, ..off_after };
959 assert!(now(&settings, &focus, Some(&off_before), 3_000).has(Why::Focus));
960 }
961
962 #[test]
963 fn mail_and_sioul_s_own_notifications_are_gated_by_the_switch_and_focus_only() {
964 let settings = DndSettings { focus: true, sleep: true, ..DndSettings::default() };
965 let press = Press { at: 1_000_000, on: true, until: 0, from: "desk".into(), via: String::new() };
966 assert!(now(&settings, &Sources::default(), Some(&press), 2_000).gates());
967 assert!(now(&settings, &Sources { focus: Some((1_000, 9_000)), ..Sources::default() }, None, 2_000).gates());
968 assert!(!now(&settings, &Sources { sleep: Some((1_000, 9_000)), ..Sources::default() }, None, 2_000).gates());
970 assert!(!now(&settings, &Sources { paused: Some(1_000), ..Sources::default() }, None, 2_000).gates());
971 assert!(!now(&settings, &Sources::default(), None, 2_000).gates());
972 }
973
974 #[test]
975 fn a_forgotten_focus_session_lets_you_be_reached_again() {
976 let mut running = crate::timelog::Running { task: "t".into(), start: 1_000, planned: 25, paused_at: None, paused: 0 };
977 assert_eq!(focus(&running, 1_100), Some((1_000, 1_000 + 25 * 60 + FOCUS_GRACE)));
978 assert_eq!(focus(&running, 1_000 + 25 * 60 + FOCUS_GRACE), None);
979 running.paused = 600;
981 assert_eq!(focus(&running, 1_100).map(|(_, end)| end), Some(1_000 + 600 + 25 * 60 + FOCUS_GRACE));
982 running.paused_at = Some(1_200);
983 assert_eq!(focus(&running, 1_300), None);
984 let open = crate::timelog::Running { task: "t".into(), start: 1_000, ..crate::timelog::Running::default() };
986 assert_eq!(focus(&open, 1_100).map(|(_, end)| end), Some(1_000 + FOCUS_OPEN));
987 }
988
989 #[test]
990 fn the_status_says_where_it_holds() {
991 let tr = Translator::new("en");
992 let settings = DndSettings::default();
993 let press = Press { at: 1_000_000, on: true, until: 0, from: "phone".into(), via: String::new() };
994 let held = now(&settings, &Sources::default(), Some(&press), 2_000);
995 let name = |id: &str| if id == "phone" { "your phone".to_string() } else { id.to_string() };
996 let all = [("phone".to_string(), Follows::Silenced)];
997 let s = said(&held, at(true, ""), &all, "15:00", &name, &tr);
998 assert_eq!(s.line, "Do not disturb, on every device, until 15:00.");
999 assert!(s.everywhere && s.here);
1000 assert_eq!(s.why, "Turned on from your phone.");
1001 assert_eq!(s.details, vec!["Here: silenced.", "On your phone: silenced."]);
1002 let s = said(&held, at(true, ""), &[], "", &name, &tr);
1003 assert_eq!(s.line, "Do not disturb, here only.");
1004 let behind = [("phone".to_string(), Follows::Behind), ("desk".to_string(), Follows::Silenced)];
1006 let s = said(&held, at(true, ""), &behind, "", &name, &tr);
1007 assert_eq!(s.line, "Do not disturb here; your other devices' news is on its way.");
1008 assert_eq!(s.details[1], "On your phone: waiting for its news.");
1009 let s = said(&held, at(true, ""), &[("phone".to_string(), Follows::Behind)], "15:00", &name, &tr);
1010 assert_eq!(s.line, "Do not disturb here until 15:00; your other devices' news is on its way.");
1011 let s = said(&held, at(true, ""), &[("phone".to_string(), Follows::Older)], "", &name, &tr);
1013 assert_eq!(s.line, "Do not disturb, here only.");
1014 assert_eq!(s.details[1], "On your phone: an older Sioul, which cannot follow until it is updated.");
1015 let cannot = [("phone".to_string(), Follows::Cannot("Your phone is not silenced: Sioul does not have Android's “Do Not Disturb access”.".into()))];
1016 let s = said(&held, at(true, ""), &cannot, "", &name, &tr);
1017 assert_eq!(s.line, "Do not disturb, here only.");
1018 assert_eq!(s.details[1], "On your phone: Your phone is not silenced: Sioul does not have Android's “Do Not Disturb access”.");
1019 let s = said(&held, at(false, "This computer's notifications cannot be silenced by Sioul."), &all, "", &name, &tr);
1021 assert_eq!(s.line, "Do not disturb, on your other devices; this one keeps only Sioul's own notifications back.");
1022 assert!(!s.everywhere);
1023 assert_eq!(said(&Now::default(), at(true, ""), &all, "", &name, &tr), Said::default());
1025 let fr = Translator::new("fr");
1027 let s = said(&held, at(true, ""), &all, "15:00", &name, &fr);
1028 assert_eq!(s.line, "Ne pas déranger, sur tous vos appareils, jusqu’à 15:00.");
1029 assert_eq!(s.details[0], "Ici\u{202f}: en silence.");
1030 assert_eq!(s.details[1], "Sur your phone\u{202f}: en silence.");
1031 }
1032
1033 #[test]
1034 fn others_count_when_heard_lately_and_follow_by_their_own_table() {
1035 let now_s = 1_000_000;
1036 let mut s = Switch::default();
1037 s.device.insert("phone".into(), Device { why: "manual".into(), silenced: true, at: now_s * 1000, ..Device::default() });
1038 s.device.insert("old".into(), Device { why: "manual".into(), silenced: true, at: (now_s - 30 * 86_400) * 1000, ..Device::default() });
1039 s.device.insert("laptop".into(), Device { why: "manual".into(), silenced: false, line: "Windows".into(), at: now_s * 1000, ..Device::default() });
1040 let heard = vec![("phone".to_string(), now_s - 60), ("tablet".to_string(), now_s - 3600), ("gone".to_string(), now_s - 10 * 86_400), ("desk".to_string(), now_s)];
1041 let registered = |_: &str| true;
1042 let followed = others(&s, "desk", &heard, now_s, (now_s - 60) * 1000, ®istered);
1043 assert_eq!(followed, vec![("laptop".to_string(), Follows::Cannot("Windows".into())), ("phone".to_string(), Follows::Silenced), ("tablet".to_string(), Follows::Behind)]);
1044 let later = others(&s, "desk", &heard, now_s, (now_s + 120) * 1000, ®istered);
1046 assert!(later.iter().all(|(_, f)| *f == Follows::Behind), "{later:?}");
1047 let close = others(&s, "desk", &heard, now_s, (now_s + 30) * 1000, ®istered);
1048 assert!(close.iter().any(|(_, f)| *f == Follows::Silenced), "{close:?}");
1049 let older = others(&s, "desk", &heard, now_s, (now_s - 60) * 1000, &|id: &str| id != "tablet");
1051 assert!(older.contains(&("tablet".to_string(), Follows::Older)), "{older:?}");
1052 }
1053
1054 #[test]
1055 fn only_this_devices_table_is_written_and_a_broken_file_is_left_alone() {
1056 let dir = std::env::temp_dir().join(format!("sioul-everywhere-{}-{}", std::process::id(), line!()));
1057 let _ = std::fs::remove_dir_all(&dir);
1058 let path = dir.join(SWITCH_FILE);
1059 std::fs::create_dir_all(&dir).unwrap();
1061 std::fs::write(&path, "[device.\"phone-1\"]\nkind = \"phone\"\npressed = 7\non = true\n").unwrap();
1062 let s = change_own(&path, "desk-1", |s| {
1063 s.press("desk-1", false, 0, 1_000);
1064 true
1065 })
1066 .unwrap();
1067 assert_eq!(s.device.len(), 2);
1068 let again = Switch::read(&path).unwrap();
1069 assert_eq!(again.device["phone-1"].pressed, 7, "the other's table untouched");
1070 assert!(!again.device["desk-1"].on && again.device["desk-1"].seen == 7);
1071 std::fs::write(&path, "[device.\"phone-1\"\nbroken").unwrap();
1073 assert!(change_own(&path, "desk-1", |_| true).is_err());
1074 assert!(std::fs::read_to_string(&path).unwrap().contains("broken"));
1075 let _ = std::fs::remove_dir_all(&dir);
1076 }
1077
1078 #[test]
1079 fn the_list_admits_its_people_by_address_and_number() {
1080 let fr = crate::phones::region_named("FR");
1081 let mut people = People::default();
1082 let alice = people.add(Person { name: "Alice".into(), phones: vec!["04 65 71 23 45".into()], emails: vec!["Alice@Example.org".into()], ..Person::default() }, fr);
1083 assert!(people.admits_address("alice@example.org"));
1084 assert!(people.admits_address("Alice Martin <ALICE@example.org>"));
1085 assert!(!people.admits_address("bob@example.org"));
1086 assert!(!people.admits_address(""));
1087 assert!(people.admits_number("+33 4 65 71 23 45", fr));
1088 assert!(people.admits_number("0465712345", fr));
1089 assert!(!people.admits_number("0465712346", fr));
1090 let mut short = People::default();
1092 short.add(Person { phones: vec!["3631".into()], ..Person::default() }, fr);
1093 assert!(!short.admits_number("3631", fr));
1094 let again = people.add(Person { name: "A.".into(), phones: vec!["+33465712345".into()], emails: vec!["alice@work.example".into()], ..Person::default() }, fr);
1096 assert_eq!(again, alice);
1097 assert_eq!(people.people.len(), 1);
1098 assert_eq!(people.people[0].emails, vec!["Alice@Example.org", "alice@work.example"]);
1099 assert_eq!(people.people[0].name, "Alice", "the name kept");
1100 assert!(people.remove(&alice) && people.people.is_empty());
1101 }
1102
1103 #[test]
1104 fn someone_blocked_comes_off_the_list() {
1105 let fr = crate::phones::region_named("FR");
1106 let mut people = People::default();
1107 let alice = people.add(Person { name: "Alice".into(), phones: vec!["04 65 71 23 45".into()], emails: vec!["alice@example.org".into(), "alice@work.example".into()], ..Person::default() }, fr);
1108 people.add(Person { name: "Bob".into(), emails: vec!["bob@example.org".into()], contact: "uid-bob".into(), ..Person::default() }, fr);
1109 assert!(take_off(&mut people, &["Alice <ALICE@example.org>".into()], &[], "", fr));
1111 assert_eq!(people.people.iter().find(|p| p.id == alice).map(|p| p.emails.clone()), Some(vec!["alice@work.example".to_string()]));
1112 assert!(!take_off(&mut people, &["carol@example.org".into()], &["+33 1 99 00 00 01".into()], "", fr));
1114 assert!(take_off(&mut people, &[], &[], "uid-bob", fr));
1116 assert!(take_off(&mut people, &["alice@work.example".into()], &["+33465712345".into()], "", fr));
1117 assert!(people.people.is_empty());
1118 }
1119
1120 #[test]
1121 fn the_list_travels_one_person_at_a_time_and_is_never_read_empty() {
1122 let dir = std::env::temp_dir().join(format!("sioul-everywhere-{}-{}", std::process::id(), line!()));
1123 let _ = std::fs::remove_dir_all(&dir);
1124 let path = dir.join(PEOPLE_FILE);
1125 change_people(&path, |p| {
1126 p.add(Person { name: "Alice".into(), emails: vec!["alice@example.org".into()], ..Person::default() }, None);
1127 })
1128 .unwrap();
1129 let text = std::fs::read_to_string(&path).unwrap();
1130 assert!(text.contains("[[person]]") && text.contains("alice@example.org"), "{text}");
1131 std::fs::write(&path, format!("{text}\n[[person]]\nid = \"b\"\nname = \"Bob\"\nphones = [\"+33536490002\"]\n")).unwrap();
1133 let people = change_people(&path, |p| {
1134 p.add(Person { name: "Carol".into(), emails: vec!["carol@example.org".into()], ..Person::default() }, None);
1135 })
1136 .unwrap();
1137 assert_eq!(people.people.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Alice", "Bob", "Carol"]);
1138 std::fs::write(&path, "[[person]\nbroken").unwrap();
1140 assert!(People::read(&path).is_err());
1141 assert!(change_people(&path, |p| p.people.clear()).is_err());
1142 assert!(std::fs::read_to_string(&path).unwrap().contains("broken"));
1143 assert!(People::load(&path).people.is_empty(), "a question gets nobody");
1144 let _ = std::fs::remove_dir_all(&dir);
1145 }
1146
1147 #[test]
1148 fn the_safe_list_brings_its_people_with_their_cards() {
1149 let card = |name: &str, email: &str, phone: &str, category: &str| crate::contacts::Contact {
1150 uid: format!("uid-{name}"),
1151 name: name.into(),
1152 emails: vec![crate::contacts::Labeled { label: String::new(), value: email.into() }],
1153 phones: vec![crate::contacts::Labeled { label: String::new(), value: phone.into() }],
1154 categories: if category.is_empty() { Vec::new() } else { vec![category.into()] },
1155 ..crate::contacts::Contact::default()
1156 };
1157 let contacts = vec![card("Alice", "alice@example.org", "+33 4 65 71 23 45", ""), card("Bob", "bob@example.org", "+33 1 99 00 00 01", "Famille"), card("Eve", "eve@example.org", "", "")];
1158 let safe = ["alice@example.org", "category:famille", "@example.com", "*@example.net", "dan@example.net", "contact:uid-Eve", "tel:+33 3 53 01 00 07", "tel:+33899*"].map(String::from).to_vec();
1159 let (people, patterns) = from_safe(&safe, &contacts);
1160 assert_eq!(patterns, 3, "a domain, a pattern, a prefix");
1161 assert_eq!(people.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Alice", "Bob", "", "Eve", ""]);
1162 assert_eq!(people[0].phones, vec!["+33 4 65 71 23 45"]);
1163 assert_eq!(people[0].contact, "uid-Alice");
1164 assert_eq!(people[2].emails, vec!["dan@example.net"]);
1165 assert_eq!((people[3].contact.as_str(), people[4].phones.clone()), ("uid-Eve", vec!["+33 3 53 01 00 07".to_string()]));
1166 }
1167
1168 #[test]
1169 fn settings_have_their_defaults_and_old_files_read() {
1170 let config: crate::config::Config = toml::from_str("").unwrap();
1171 assert_eq!(config.dnd, DndSettings::default());
1172 assert!(config.dnd.button && config.dnd.pauses && config.dnd.people && !config.dnd.focus && !config.dnd.sleep);
1173 let config: crate::config::Config = toml::from_str("[dnd]\nfocus = true\n").unwrap();
1174 assert!(config.dnd.focus && config.dnd.pauses);
1175 let s: Switch = toml::from_str("[device.a]\npressed = 5\non = true\nfuture = \"x\"\n").unwrap();
1177 assert!(s.latest().unwrap().on);
1178 assert!(toml::to_string(&s).unwrap().contains("future = \"x\""), "a later version's field written back");
1179 let p: People = toml::from_str("[[person]]\nid = \"a\"\nname = \"A\"\nring = \"loud\"\n").unwrap();
1180 assert!(toml::to_string(&p).unwrap().contains("ring = \"loud\""));
1181 }
1182
1183 #[test]
1184 fn every_word_has_both_languages() {
1185 for language in ["en", "fr"] {
1186 let tr = Translator::new(language);
1187 for key in [
1188 "dnd-everywhere", "dnd-everywhere-until", "dnd-here-only", "dnd-here-only-until", "dnd-waiting", "dnd-waiting-until", "dnd-not-everywhere", "dnd-not-everywhere-until", "dnd-elsewhere", "dnd-elsewhere-until", "dnd-own-only", "dnd-own-only-until",
1189 "dnd-device-silenced", "dnd-device-cannot", "dnd-device-unsilenced", "dnd-device-behind", "dnd-device-older", "dnd-device-here", "dnd-device-on", "dnd-device-phone", "dnd-device-other",
1190 "dnd-why-manual", "dnd-why-manual-from", "dnd-why-focus", "dnd-why-sleep", "dnd-why-paused", "dnd-why-free-time",
1191 "dnd-turned-off-here", "dnd-turned-off-here-until", "dnd-device-closed",
1192 ] {
1193 let mut args = crate::i18n::args();
1194 args.set("until", "15:00");
1195 args.set("device", "X");
1196 args.set("line", "Y.");
1197 args.set("name", "Z");
1198 let words = tr.text(key, Some(&args));
1199 assert!(!words.is_empty() && words != key && !words.contains('{'), "{language}: {key}: {words}");
1200 }
1201 }
1202 }
1203
1204 #[test]
1205 fn a_change_of_the_system_presses_only_when_it_disagrees() {
1206 assert_eq!(heard(false, true), Some(true));
1208 assert_eq!(heard(true, false), Some(false));
1210 assert_eq!(heard(true, true), None);
1212 assert_eq!(heard(false, false), None);
1213 }
1214
1215 #[test]
1216 fn a_press_heard_from_the_system_says_so_and_holds_like_any() {
1217 let mut s = switch(&[("desk", device(1_000_000, true, 0))]);
1218 let stamp = s.press_via("phone", false, 0, 900_000, VIA_SYSTEM);
1219 assert_eq!(stamp, 1_000_001);
1221 let latest = s.latest().unwrap();
1222 assert!(!latest.on && latest.from == "phone" && latest.via == VIA_SYSTEM);
1223 s.press("phone", true, 0, 2_000_000);
1225 assert_eq!(s.latest().unwrap().via, "");
1226 assert!(!toml::to_string(&s).unwrap().contains("via"), "an empty word is not written");
1227 s.press_via("desk", true, 0, 3_000_000, VIA_SYSTEM);
1229 let text = toml::to_string(&s).unwrap();
1230 assert!(text.contains("via = \"system\""), "{text}");
1231 let again: Switch = toml::from_str(&text).unwrap();
1232 assert_eq!(again.latest().unwrap().via, VIA_SYSTEM);
1233 let old: Switch = toml::from_str("[device.a]\npressed = 5\non = true\n").unwrap();
1234 assert_eq!(old.latest().unwrap().via, "");
1235 let settings = DndSettings { focus: true, ..DndSettings::default() };
1237 let focus = Sources { focus: Some((1_000, 9_000)), ..Sources::default() };
1238 let off = Press { at: 2_000_000, on: false, until: 0, from: "phone".into(), via: VIA_SYSTEM.into() };
1239 assert!(!now(&settings, &focus, Some(&off), 3_000).on());
1240 }
1241
1242 #[test]
1243 fn the_switch_held_by_the_system_alone_adds_no_mode() {
1244 let settings = DndSettings { sleep: true, focus: true, ..DndSettings::default() };
1245 let heard_here = Press { at: 1_000_000, on: true, until: 0, from: "phone".into(), via: VIA_SYSTEM.into() };
1246 let held = now(&settings, &Sources::default(), Some(&heard_here), 2_000);
1247 assert!(held_by_system(&held, Some(&heard_here), "phone"));
1249 assert!(!held_by_system(&held, Some(&heard_here), "desk"));
1251 let pressed = Press { via: String::new(), ..heard_here.clone() };
1253 assert!(!held_by_system(&held, Some(&pressed), "phone"));
1254 let night = now(&settings, &Sources { sleep: Some((1_500, 9_000)), ..Sources::default() }, Some(&heard_here), 2_000);
1256 assert!(!held_by_system(&night, Some(&heard_here), "phone"));
1257 let paused = now(&DndSettings::default(), &Sources { paused: Some(1_500), ..Sources::default() }, Some(&heard_here), 2_000);
1259 assert!(held_by_system(&paused, Some(&heard_here), "phone"));
1260 assert!(!held_by_system(&Now::default(), Some(&heard_here), "phone"));
1262 }
1263
1264 #[test]
1265 fn a_nightly_schedule_turns_it_on_then_off_on_every_device() {
1266 let settings = DndSettings::default();
1267 let mut s = Switch::default();
1268 let night = 22 * 3_600;
1270 let before = now(&settings, &Sources::default(), s.latest().as_ref(), night);
1271 assert_eq!(heard(before.on(), true), Some(true));
1272 s.press_via("phone", true, 0, night * 1000, VIA_SYSTEM);
1273 let held = now(&settings, &Sources::default(), s.latest().as_ref(), night + 60);
1275 assert!(held.on() && held.has(Why::Manual));
1276 assert!(!held_by_system(&held, s.latest().as_ref(), "desk"));
1277 assert!(held_by_system(&held, s.latest().as_ref(), "phone"));
1278 let morning = 31 * 3_600;
1280 let still = now(&settings, &Sources::default(), s.latest().as_ref(), morning);
1281 assert_eq!(heard(still.on(), false), Some(false));
1282 s.press_via("phone", false, 0, morning * 1000, VIA_SYSTEM);
1283 assert!(!now(&settings, &Sources::default(), s.latest().as_ref(), morning + 60).on(), "off on every device");
1284 let next = night + 86_400;
1286 assert_eq!(heard(now(&settings, &Sources::default(), s.latest().as_ref(), next).on(), true), Some(true));
1287 }
1288
1289 #[test]
1290 fn a_press_made_off_line_meets_the_latest() {
1291 let s = switch(&[
1294 ("phone", Device { pressed: 36_000_000, on: true, seen: 0, via: VIA_SYSTEM.into(), ..Device::default() }),
1295 ("desk", device(36_300_000, false, 0)),
1296 ]);
1297 assert!(!s.latest().unwrap().on, "the computer's later off");
1298 let s = switch(&[
1300 ("phone", Device { pressed: 36_000_000, on: true, seen: 0, via: VIA_SYSTEM.into(), ..Device::default() }),
1301 ("desk", device(36_030_000, false, 0)),
1302 ]);
1303 let latest = s.latest().unwrap();
1304 assert!(latest.on && latest.from == "phone" && latest.via == VIA_SYSTEM);
1305 }
1306
1307 #[test]
1308 fn a_computer_closed_or_quiet_is_said_so() {
1309 let tr = Translator::new("en");
1310 let now_s = 1_000_000;
1311 let since = (now_s - 3_600) * 1000;
1312 let silenced = |at_s: i64| Device { kind: "computer".into(), name: "laptop".into(), why: "manual".into(), silenced: true, at: at_s * 1000, ..Device::default() };
1313 let mut quit = silenced(now_s - 600);
1315 quit.closing(false);
1316 assert!(quit.closed && !quit.silenced && quit.line.is_empty());
1317 let s = Switch { device: [("laptop".to_string(), quit.clone())].into(), ..Switch::default() };
1318 let text = toml::to_string(&s).unwrap();
1319 assert!(text.contains("closed = true"), "{text}");
1320 let read: Switch = toml::from_str(&text).unwrap();
1321 let heard = vec![("laptop".to_string(), now_s - 600)];
1322 let followed = others(&read, "phone", &heard, now_s, since, &|_| true);
1323 assert_eq!(followed, vec![("laptop".to_string(), Follows::Closed)]);
1324 let press = Press { at: since + 1, on: true, until: 0, from: "phone".into(), via: String::new() };
1325 let held = now(&DndSettings::default(), &Sources::default(), Some(&press), now_s);
1326 let name = |id: &str| id.to_string();
1327 let said_ = said(&held, at(true, ""), &followed, "", &name, &tr);
1328 assert_eq!(said_.details[1], "On laptop: Sioul is closed.");
1329 assert_eq!(said_.line, "Do not disturb, here only.");
1330 assert_eq!(said(&held, at(true, ""), &followed, "", &name, &Translator::new("fr")).details[1], "Sur laptop\u{202f}: Sioul est fermé.");
1331 let mut early = silenced(now_s - 10 * 3_600);
1333 early.closing(false);
1334 let s = Switch { device: [("laptop".to_string(), early)].into(), ..Switch::default() };
1335 assert_eq!(others(&s, "phone", &heard, now_s, since, &|_| true)[0].1, Follows::Closed);
1336 let crashed = Switch { device: [("laptop".to_string(), silenced(now_s - 2_000))].into(), ..Switch::default() };
1338 let quiet = vec![("laptop".to_string(), now_s - 2_000)];
1339 assert_eq!(others(&crashed, "phone", &quiet, now_s, since, &|_| true)[0].1, Follows::Behind);
1340 let lately = vec![("laptop".to_string(), now_s - 600)];
1342 assert_eq!(others(&crashed, "phone", &lately, now_s, since, &|_| true)[0].1, Follows::Silenced);
1343 let phone = Device { kind: "phone".into(), ..silenced(now_s - 2_000) };
1345 let s = Switch { device: [("phone".to_string(), phone)].into(), ..Switch::default() };
1346 let quiet_phone = vec![("phone".to_string(), now_s - 2_000)];
1347 assert_eq!(others(&s, "laptop", &quiet_phone, now_s, since, &|_| true)[0].1, Follows::Silenced);
1348 }
1349
1350 #[test]
1351 fn a_device_turned_off_by_hand_says_so() {
1352 let tr = Translator::new("en");
1353 let press = Press { at: 1_000_000, on: true, until: 0, from: "desk".into(), via: String::new() };
1354 let held = now(&DndSettings::default(), &Sources::default(), Some(&press), 2_000);
1355 let name = |id: &str| id.to_string();
1356 let here = Here { silenced: false, line: "You turned do-not-disturb off on this phone.", turned_off: true };
1357 let s = said(&held, here, &[("desk".to_string(), Follows::Silenced)], "", &name, &tr);
1358 assert_eq!(s.line, "Do not disturb on your other devices; you turned it off here.");
1359 assert_eq!(s.details[0], "Here: You turned do-not-disturb off on this phone.");
1360 assert!(!s.here && !s.everywhere);
1361 let s = said(&held, here, &[], "15:00", &name, &Translator::new("fr"));
1362 assert_eq!(s.line, "Ne pas déranger jusqu\u{2019}à 15:00 sur vos autres appareils\u{202f}; vous l\u{2019}avez désactivé ici.");
1363 }
1364}