1use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29use std::time::Duration;
30
31use chrono::{DateTime, SecondsFormat, Utc};
32use serde::{Deserialize, Serialize};
33
34use crate::countdown;
35use crate::display::sanitize_untrusted_line;
36use crate::format;
37
38const HYSTERESIS_PCT: i32 = 7;
42
43const CREDIT_WARNING_SECS: i64 = 48 * 3600;
45
46const LOCK_TIMEOUT: Duration = Duration::from_secs(2);
49
50#[cfg(any(target_os = "linux", target_os = "macos"))]
52const SPAWN_KILL_AFTER: Duration = Duration::from_secs(5);
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Urgency {
57 Normal,
59 Critical,
61}
62
63#[cfg(target_os = "linux")]
64impl Urgency {
65 fn as_arg(self) -> &'static str {
66 match self {
67 Self::Normal => "normal",
68 Self::Critical => "critical",
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Notification {
77 pub title: String,
78 pub body: String,
79 pub urgency: Urgency,
80 pub key: String,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct MetricRow {
86 pub window: String,
89 pub percent: u16,
90 pub reset_at: Option<DateTime<Utc>>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct CreditExpiry {
99 pub title: Option<String>,
100 pub expires_at: DateTime<Utc>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct RefreshInput {
106 pub entry_id: String,
109 pub vendor: String,
112 pub account: Option<String>,
113 pub rows: Vec<MetricRow>,
114 pub credits: Vec<CreditExpiry>,
115}
116
117pub fn decide(
130 input: &RefreshInput,
131 threshold: u8,
132 state: &mut NotifyState,
133 now: DateTime<Utc>,
134) -> Vec<Notification> {
135 let mut fired = Vec::new();
136 let threshold = threshold as i32;
137 let rearm_below = threshold - HYSTERESIS_PCT;
138 for row in &input.rows {
139 let key = format!("{}::{}", input.entry_id, row.window);
140 let pct = row.percent as i32;
141 if pct >= threshold {
142 let armed = match state.entries.get(&key) {
143 None => true,
144 Some(record) => reset_moved_later(row.reset_at, record.reset_at_snapshot),
145 };
146 if armed {
147 fired.push(threshold_notification(input, row, now));
148 state.entries.insert(
149 key,
150 KeyRecord {
151 notified_at: now,
152 reset_at_snapshot: row.reset_at,
153 },
154 );
155 }
156 } else if pct < rearm_below {
157 state.entries.remove(&key);
160 }
161 }
162 for credit in &input.credits {
163 let remaining = credit.expires_at.signed_duration_since(now).num_seconds();
164 if remaining <= 0 || remaining > CREDIT_WARNING_SECS {
165 continue;
166 }
167 let key = format!(
168 "{}::credit::{}",
169 input.entry_id,
170 credit
171 .expires_at
172 .to_rfc3339_opts(SecondsFormat::AutoSi, true)
173 );
174 if state.entries.contains_key(&key) {
175 continue;
176 }
177 fired.push(credit_notification(input, credit, now));
178 state.entries.insert(
179 key,
180 KeyRecord {
181 notified_at: now,
182 reset_at_snapshot: None,
183 },
184 );
185 }
186 fired
187}
188
189fn reset_moved_later(current: Option<DateTime<Utc>>, snapshot: Option<DateTime<Utc>>) -> bool {
193 match (current, snapshot) {
194 (Some(current), Some(snapshot)) => current > snapshot,
195 (Some(_), None) => true,
196 _ => false,
197 }
198}
199
200fn threshold_notification(
201 input: &RefreshInput,
202 row: &MetricRow,
203 now: DateTime<Utc>,
204) -> Notification {
205 let who = match &input.account {
206 Some(account) => format!("{} · {}", input.vendor, account),
207 None => input.vendor.clone(),
208 };
209 let title = sanitize_untrusted_line(&format!("{} — {} at {}%", who, row.window, row.percent));
210 let mut body = format!("{}% of the {} used", row.percent, row.window);
211 if let Some(reset_at) = row.reset_at {
212 body.push_str(&format!(
213 " · resets {} ({})",
214 countdown::format(Some(reset_at), now),
215 format::local_time_hm(reset_at)
216 ));
217 }
218 Notification {
219 title,
220 body: sanitize_untrusted_line(&body),
221 urgency: if row.percent >= 100 {
222 Urgency::Critical
223 } else {
224 Urgency::Normal
225 },
226 key: format!("{}::{}", input.entry_id, row.window),
227 }
228}
229
230fn credit_notification(
231 input: &RefreshInput,
232 credit: &CreditExpiry,
233 now: DateTime<Utc>,
234) -> Notification {
235 let label = credit.title.as_deref().unwrap_or("Reset credit");
236 let title = sanitize_untrusted_line(&format!(
237 "{} — reset credit expires {}",
238 input.vendor,
239 format::local_date_hm(credit.expires_at)
240 ));
241 let body = sanitize_untrusted_line(&format!(
242 "{} — redeem within {} ({})",
243 label,
244 countdown::format(Some(credit.expires_at), now),
245 format::local_time_hm(credit.expires_at)
246 ));
247 Notification {
248 title,
249 body,
250 urgency: Urgency::Normal,
251 key: format!(
252 "{}::credit::{}",
253 input.entry_id,
254 credit
255 .expires_at
256 .to_rfc3339_opts(SecondsFormat::AutoSi, true)
257 ),
258 }
259}
260
261#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(transparent)]
269pub struct NotifyState {
270 entries: BTreeMap<String, KeyRecord>,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274struct KeyRecord {
275 notified_at: DateTime<Utc>,
276 reset_at_snapshot: Option<DateTime<Utc>>,
277}
278
279impl NotifyState {
280 #[cfg(test)]
281 fn is_notified(&self, key: &str) -> bool {
282 self.entries.contains_key(key)
283 }
284
285 fn read_from(path: &Path) -> Self {
288 let Ok(raw) = std::fs::read_to_string(path) else {
289 return Self::default();
290 };
291 serde_json::from_str(&raw).unwrap_or_default()
292 }
293
294 fn write_to(&self, path: &Path) -> crate::error::Result<()> {
297 let bytes = serde_json::to_vec(&self.entries)?;
298 crate::cache::atomic_write(path, &bytes)?;
299 #[cfg(unix)]
300 {
301 use std::os::unix::fs::PermissionsExt;
302 if let Ok(meta) = std::fs::metadata(path) {
303 let mut perms = meta.permissions();
304 perms.set_mode(0o600);
305 let _ = std::fs::set_permissions(path, perms);
306 }
307 }
308 Ok(())
309 }
310}
311
312pub trait NotifySink {
320 fn deliver(&mut self, notification: &Notification);
321}
322
323#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
325#[derive(Debug, Default)]
326pub struct NoopSink;
327
328#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
329impl NotifySink for NoopSink {
330 fn deliver(&mut self, _notification: &Notification) {}
331}
332
333#[cfg(target_os = "linux")]
338#[derive(Debug, Clone)]
339pub struct NotifySendSink {
340 program: String,
341}
342
343#[cfg(target_os = "linux")]
344impl Default for NotifySendSink {
345 fn default() -> Self {
346 Self::new()
347 }
348}
349
350#[cfg(target_os = "linux")]
351impl NotifySendSink {
352 pub fn new() -> Self {
353 Self {
354 program: "notify-send".to_string(),
355 }
356 }
357
358 pub fn with_program(program: impl Into<String>) -> Self {
361 Self {
362 program: program.into(),
363 }
364 }
365}
366
367#[cfg(target_os = "linux")]
368impl NotifySink for NotifySendSink {
369 fn deliver(&mut self, notification: &Notification) {
370 let spawned = std::process::Command::new(&self.program)
371 .arg("-a")
372 .arg("ai-usagebar")
373 .arg("-c")
374 .arg("quota")
375 .arg("-u")
376 .arg(notification.urgency.as_arg())
377 .arg(¬ification.title)
378 .arg(¬ification.body)
379 .stdin(std::process::Stdio::null())
380 .stdout(std::process::Stdio::null())
381 .stderr(std::process::Stdio::null())
382 .spawn();
383 if let Ok(child) = spawned {
384 reap(child);
385 }
386 }
387}
388
389#[cfg(any(target_os = "linux", target_os = "macos"))]
392fn reap(mut child: std::process::Child) {
393 let deadline = std::time::Instant::now() + SPAWN_KILL_AFTER;
394 loop {
395 match child.try_wait() {
396 Ok(Some(_)) => return,
397 Ok(None) => {}
398 Err(_) => return,
399 }
400 if std::time::Instant::now() >= deadline {
401 let _ = child.kill();
402 let _ = child.wait();
403 return;
404 }
405 std::thread::sleep(Duration::from_millis(25));
406 }
407}
408
409fn production_sink() -> Box<dyn NotifySink + Send> {
410 #[cfg(target_os = "linux")]
411 return Box::new(NotifySendSink::new());
412 #[cfg(target_os = "macos")]
413 return Box::new(MacNotificationSink);
414 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
415 return Box::new(NoopSink);
416}
417
418#[cfg(target_os = "macos")]
421struct MacNotificationSink;
422
423#[cfg(target_os = "macos")]
424impl NotifySink for MacNotificationSink {
425 fn deliver(&mut self, notification: &Notification) {
426 const SCRIPT: &str = "on run argv\n display notification (item 2 of argv) with title (item 1 of argv)\nend run";
427 let spawned = std::process::Command::new("/usr/bin/osascript")
428 .arg("-e")
429 .arg(SCRIPT)
430 .arg("--")
431 .arg(¬ification.title)
432 .arg(¬ification.body)
433 .stdin(std::process::Stdio::null())
434 .stdout(std::process::Stdio::null())
435 .stderr(std::process::Stdio::null())
436 .spawn();
437 if let Ok(child) = spawned {
438 reap(child);
439 }
440 }
441}
442
443pub async fn run(input: RefreshInput, threshold: u8) {
448 let Some(state_path) = default_state_path() else {
449 return;
450 };
451 let joined = tokio::task::spawn_blocking(move || {
454 let mut sink = production_sink();
455 run_at(
456 &input,
457 threshold,
458 &state_path,
459 LOCK_TIMEOUT,
460 sink.as_mut(),
461 Utc::now(),
462 );
463 });
464 let _ = joined.await;
465}
466
467pub fn default_state_path() -> Option<PathBuf> {
470 crate::cache::xdg_cache_dir()
471 .ok()
472 .map(|base| base.join("ai-usagebar").join("notifications.json"))
473}
474
475pub fn run_at(
481 input: &RefreshInput,
482 threshold: u8,
483 state_path: &Path,
484 lock_timeout: Duration,
485 sink: &mut dyn NotifySink,
486 now: DateTime<Utc>,
487) {
488 let lock_path = state_path.with_file_name(".notifications.lock");
489 let Ok(_guard) = crate::cache::acquire_lock(&lock_path, lock_timeout) else {
490 return;
491 };
492 let mut state = NotifyState::read_from(state_path);
493 let before = serde_json::to_string(&state.entries).unwrap_or_default();
494 let fired = decide(input, threshold, &mut state, now);
495 if serde_json::to_string(&state.entries).unwrap_or_default() != before
496 && state.write_to(state_path).is_err()
497 {
498 return;
501 }
502 for notification in &fired {
503 sink.deliver(notification);
504 }
505}
506
507impl RefreshInput {
510 pub(crate) fn from_tab(
517 tab: &crate::tui::app::TabId,
518 state: &crate::tui::app::TabState,
519 now: DateTime<Utc>,
520 ) -> Option<Self> {
521 use crate::tui::app::{TabSource, TabState};
522 use crate::tui::panels::{Section, sections_with_metadata_for};
523
524 let TabState::Ready(ready) = state else {
525 return None;
526 };
527 let (vendor, entry_id) = match &tab.source {
528 TabSource::Builtin(vendor) => (
529 vendor.display_name().to_string(),
530 crate::report::tab_id(tab),
531 ),
532 TabSource::Custom { name, .. } => (name.clone(), crate::report::tab_id(tab)),
533 };
534 let rows = sections_with_metadata_for(state, now, 0)
537 .into_iter()
538 .filter(|projected| projected.group.is_none())
539 .filter_map(|projected| match projected.section {
540 Section::Metric { label, pct, .. } => Some(MetricRow {
541 window: label,
542 percent: pct,
543 reset_at: projected.reset_at,
544 }),
545 _ => None,
546 })
547 .collect();
548 let credits = ready
549 .snapshot
550 .reset_credits()
551 .filter(|credits| credits.available > 0)
552 .map(|credits| {
553 credits
554 .credits
555 .iter()
556 .filter_map(|credit| {
557 credit.expires_at.map(|expires_at| CreditExpiry {
558 title: credit.title.clone(),
559 expires_at,
560 })
561 })
562 .collect()
563 })
564 .unwrap_or_default();
565 Some(Self {
566 entry_id,
567 vendor,
568 account: tab.account.clone(),
569 rows,
570 credits,
571 })
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use chrono::TimeZone;
579 use tempfile::TempDir;
580
581 fn at(day: u32, hour: u32, min: u32) -> DateTime<Utc> {
582 Utc.with_ymd_and_hms(2026, 9, day, hour, min, 0).unwrap()
583 }
584
585 fn row(window: &str, percent: u16, reset_at: Option<DateTime<Utc>>) -> MetricRow {
586 MetricRow {
587 window: window.to_string(),
588 percent,
589 reset_at,
590 }
591 }
592
593 fn input(rows: Vec<MetricRow>) -> RefreshInput {
594 RefreshInput {
595 entry_id: "anthropic@gmail".into(),
596 vendor: "Claude".into(),
597 account: Some("gmail".into()),
598 rows,
599 credits: Vec::new(),
600 }
601 }
602
603 #[derive(Default)]
605 struct RecorderSink {
606 delivered: Vec<Notification>,
607 }
608
609 impl NotifySink for RecorderSink {
610 fn deliver(&mut self, notification: &Notification) {
611 self.delivered.push(notification.clone());
612 }
613 }
614
615 fn state_path() -> (TempDir, PathBuf) {
616 crate::cache::closed_temp_file("notifications.json", None)
617 }
618
619 #[test]
622 fn crossing_at_exactly_the_threshold_fires_normal() {
623 let mut state = NotifyState::default();
624 let now = at(23, 12, 0);
625 let reset = at(24, 12, 0);
626 let fired = decide(
627 &input(vec![row("Session (5h)", 97, Some(reset))]),
628 97,
629 &mut state,
630 now,
631 );
632 assert_eq!(fired.len(), 1);
633 assert_eq!(fired[0].urgency, Urgency::Normal);
634 assert_eq!(fired[0].key, "anthropic@gmail::Session (5h)");
635 assert_eq!(fired[0].title, "Claude · gmail — Session (5h) at 97%");
636 assert_eq!(
640 fired[0].body,
641 format!(
642 "97% of the Session (5h) used · resets 1d 0h ({})",
643 format::local_time_hm(reset)
644 )
645 );
646 }
647
648 #[test]
649 fn below_the_threshold_fires_nothing() {
650 let mut state = NotifyState::default();
651 let fired = decide(
652 &input(vec![row("Session (5h)", 96, None)]),
653 97,
654 &mut state,
655 at(23, 12, 0),
656 );
657 assert!(fired.is_empty());
658 assert!(state.entries.is_empty());
659 }
660
661 #[test]
662 fn exhausted_windows_fire_critical() {
663 let mut state = NotifyState::default();
664 let fired = decide(
665 &input(vec![row("Weekly (7d)", 100, None)]),
666 97,
667 &mut state,
668 at(23, 12, 0),
669 );
670 assert_eq!(fired.len(), 1);
671 assert_eq!(fired[0].urgency, Urgency::Critical);
672 assert_eq!(fired[0].title, "Claude · gmail — Weekly (7d) at 100%");
673 }
674
675 #[test]
676 fn the_same_crossing_never_fires_twice() {
677 let mut state = NotifyState::default();
678 let high = input(vec![row("Weekly (7d)", 98, Some(at(30, 0, 0)))]);
679 let now = at(23, 12, 0);
680 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
681 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
682 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
683 }
684
685 #[test]
688 fn hysteresis_rearms_only_below_threshold_minus_seven() {
689 let mut state = NotifyState::default();
690 let now = at(23, 12, 0);
691 let fires_at = |pct: u16, state: &mut NotifyState| {
692 let case = input(vec![row("Weekly (7d)", pct, None)]);
693 decide(&case, 97, state, now).len()
694 };
695
696 assert_eq!(fires_at(97, &mut state), 1, "first crossing fires");
697 assert_eq!(fires_at(96, &mut state), 0, "hovering does not re-fire");
698 assert_eq!(fires_at(90, &mut state), 0);
700 assert!(state.is_notified("anthropic@gmail::Weekly (7d)"));
701 assert_eq!(fires_at(89, &mut state), 0);
703 assert!(!state.is_notified("anthropic@gmail::Weekly (7d)"));
704 assert_eq!(fires_at(97, &mut state), 1, "re-armed crossing fires");
706 }
707
708 #[test]
709 fn a_later_reset_rearms_the_key() {
710 let mut state = NotifyState::default();
711 let now = at(23, 12, 0);
712 let first = input(vec![row("Session (5h)", 98, Some(at(23, 17, 0)))]);
713 assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
714
715 assert_eq!(decide(&first, 97, &mut state, now).len(), 0);
717 let earlier = input(vec![row("Session (5h)", 98, Some(at(23, 16, 0)))]);
719 assert_eq!(decide(&earlier, 97, &mut state, now).len(), 0);
720 let absent = input(vec![row("Session (5h)", 98, None)]);
721 assert_eq!(decide(&absent, 97, &mut state, now).len(), 0);
722 let next_window = input(vec![row("Session (5h)", 98, Some(at(24, 2, 0)))]);
724 assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
725 }
726
727 #[test]
728 fn a_reset_appearing_where_none_was_reported_rearms() {
729 let mut state = NotifyState::default();
730 let now = at(23, 12, 0);
731 let without = input(vec![row("Weekly quota", 99, None)]);
732 assert_eq!(decide(&without, 97, &mut state, now).len(), 1);
733 assert_eq!(decide(&without, 97, &mut state, now).len(), 0);
734 let with = input(vec![row("Weekly quota", 99, Some(at(30, 0, 0)))]);
735 assert_eq!(
736 decide(&with, 97, &mut state, now).len(),
737 1,
738 "the vendor starting to report a reset is a new window"
739 );
740 }
741
742 #[test]
745 fn absent_reset_at_omits_the_eta_from_the_body() {
746 let mut state = NotifyState::default();
747 let fired = decide(
748 &input(vec![row("Weekly (7d)", 97, None)]),
749 97,
750 &mut state,
751 at(23, 12, 0),
752 );
753 assert_eq!(fired[0].body, "97% of the Weekly (7d) used");
754 assert!(!fired[0].body.contains("resets"));
755 }
756
757 #[test]
758 fn account_label_is_omitted_from_the_title_when_absent() {
759 let mut state = NotifyState::default();
760 let mut plain = input(vec![row("Weekly (7d)", 97, None)]);
761 plain.entry_id = "zai".into();
762 plain.vendor = "Z.AI".into();
763 plain.account = None;
764 let fired = decide(&plain, 97, &mut state, at(23, 12, 0));
765 assert_eq!(fired[0].title, "Z.AI — Weekly (7d) at 97%");
766 }
767
768 #[test]
769 fn untrusted_labels_are_sanitized_at_the_subprocess_boundary() {
770 let mut state = NotifyState::default();
771 let mut hostile = input(vec![row("W\u{1b}[31m", 97, None)]);
772 hostile.account = Some("gmail\n\u{202E}evil".into());
773 let fired = decide(&hostile, 97, &mut state, at(23, 12, 0));
774 assert!(
775 !fired[0].title.chars().any(|c| c.is_control()),
776 "{}",
777 fired[0].title
778 );
779 assert!(!fired[0].title.contains('\u{202E}'), "{}", fired[0].title);
780 assert!(!fired[0].body.contains('\u{1B}'), "{}", fired[0].body);
781 }
782
783 #[test]
786 fn banked_credit_fires_only_inside_the_48h_window() {
787 let now = at(23, 12, 0);
788 for (offset_secs, expected) in [(47 * 3600, 1), (48 * 3600, 1), (48 * 3600 + 1, 0), (-1, 0)]
789 {
790 let mut state = NotifyState::default();
791 let case = RefreshInput {
792 credits: vec![CreditExpiry {
793 title: Some("Full reset".into()),
794 expires_at: now + chrono::Duration::seconds(offset_secs),
795 }],
796 ..input(Vec::new())
797 };
798 let fired = decide(&case, 97, &mut state, now);
799 assert_eq!(fired.len(), expected, "offset {offset_secs}s");
800 }
801 }
802
803 #[test]
804 fn banked_credit_title_and_body_name_the_expiry() {
805 let now = at(23, 12, 0);
806 let mut state = NotifyState::default();
807 let case = RefreshInput {
808 credits: vec![CreditExpiry {
809 title: Some("Full reset (Weekly + 5 hr)".into()),
810 expires_at: now + chrono::Duration::hours(3),
811 }],
812 ..input(Vec::new())
813 };
814 let fired = decide(&case, 97, &mut state, now);
815 assert_eq!(fired.len(), 1);
816 assert_eq!(fired[0].urgency, Urgency::Normal);
817 assert!(
818 fired[0].title.starts_with("Claude — reset credit expires "),
819 "{}",
820 fired[0].title
821 );
822 assert!(
823 fired[0]
824 .body
825 .starts_with("Full reset (Weekly + 5 hr) — redeem within 3h"),
826 "{}",
827 fired[0].body
828 );
829 }
830
831 #[test]
832 fn banked_credit_dedupes_on_the_expiry_instant() {
833 let now = at(23, 12, 0);
834 let mut state = NotifyState::default();
835 let expiry = now + chrono::Duration::hours(10);
836 let case = RefreshInput {
837 credits: vec![CreditExpiry {
838 title: None,
839 expires_at: expiry,
840 }],
841 ..input(Vec::new())
842 };
843 assert_eq!(decide(&case, 97, &mut state, now).len(), 1);
844 assert_eq!(
845 decide(&case, 97, &mut state, now).len(),
846 0,
847 "same expiry is one notification"
848 );
849 assert!(
850 state
851 .entries
852 .keys()
853 .all(|key| key.starts_with("anthropic@gmail::credit::")),
854 "{:?}",
855 state.entries.keys().collect::<Vec<_>>()
856 );
857 let later = RefreshInput {
859 credits: vec![CreditExpiry {
860 title: None,
861 expires_at: now + chrono::Duration::hours(20),
862 }],
863 ..input(Vec::new())
864 };
865 assert_eq!(decide(&later, 97, &mut state, now).len(), 1);
866 }
867
868 #[test]
871 fn state_round_trips_through_disk_and_tolerates_corruption() {
872 let (dir, path) = state_path();
873 let mut state = NotifyState::default();
874 state.entries.insert(
875 "anthropic::Weekly (7d)".into(),
876 KeyRecord {
877 notified_at: at(23, 12, 0),
878 reset_at_snapshot: Some(at(30, 0, 0)),
879 },
880 );
881 state.write_to(&path).unwrap();
882 assert_eq!(NotifyState::read_from(&path), state);
883
884 std::fs::write(&path, "{ not json").unwrap();
885 assert_eq!(NotifyState::read_from(&path), NotifyState::default());
886 drop(dir);
887 }
888
889 #[test]
890 #[cfg(unix)]
891 fn state_file_is_mode_0600() {
892 use std::os::unix::fs::PermissionsExt;
893 let (dir, path) = state_path();
894 NotifyState::default().write_to(&path).unwrap();
895 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
896 assert_eq!(mode & 0o777, 0o600);
897 drop(dir);
898 }
899
900 #[test]
901 fn rearm_survives_a_simulated_restart() {
902 let (dir, path) = state_path();
903 let now = at(23, 12, 0);
904 let mut state = NotifyState::read_from(&path);
905
906 let high = input(vec![row("Weekly (7d)", 97, None)]);
907 let low = input(vec![row("Weekly (7d)", 89, None)]);
908 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
909 state.write_to(&path).unwrap();
910
911 let mut state = NotifyState::read_from(&path);
913 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
914
915 decide(&low, 97, &mut state, now);
917 state.write_to(&path).unwrap();
918
919 let mut state = NotifyState::read_from(&path);
921 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
922 drop(dir);
923 }
924
925 #[test]
928 fn run_at_persists_marks_and_delivers_through_the_sink() {
929 let (dir, path) = state_path();
930 let mut sink = RecorderSink::default();
931 let input = input(vec![row("Weekly (7d)", 97, None)]);
932 run_at(
933 &input,
934 97,
935 &path,
936 Duration::from_secs(2),
937 &mut sink,
938 at(23, 12, 0),
939 );
940 assert_eq!(sink.delivered.len(), 1);
941
942 let mut second = RecorderSink::default();
944 run_at(
945 &input,
946 97,
947 &path,
948 Duration::from_secs(2),
949 &mut second,
950 at(23, 12, 0),
951 );
952 assert!(second.delivered.is_empty());
953 assert!(path.exists());
954 drop(dir);
955 }
956
957 #[test]
958 fn lock_contention_skips_notifying_entirely() {
959 let (dir, path) = state_path();
960 let lock_path = path.with_file_name(".notifications.lock");
961 let _held = crate::cache::acquire_lock(&lock_path, Duration::from_secs(5)).unwrap();
962
963 let mut sink = RecorderSink::default();
964 let input = input(vec![row("Weekly (7d)", 100, None)]);
965 run_at(
966 &input,
967 97,
968 &path,
969 Duration::from_millis(100),
970 &mut sink,
971 at(23, 12, 0),
972 );
973 assert!(
974 sink.delivered.is_empty(),
975 "a missed notification beats a double one"
976 );
977 assert!(!path.exists(), "no state without the lock");
978 drop(dir);
979 }
980
981 #[test]
982 fn an_unwritable_state_file_skips_delivery_rather_than_repeating() {
983 let dir = tempfile::TempDir::new().unwrap();
984 let state_path = dir.path().join("notifications.json");
987 std::fs::create_dir(&state_path).unwrap();
988 let mut sink = RecorderSink::default();
989 let input = input(vec![row("Weekly (7d)", 97, None)]);
990 run_at(
991 &input,
992 97,
993 &state_path,
994 Duration::from_secs(2),
995 &mut sink,
996 at(23, 12, 0),
997 );
998 assert!(sink.delivered.is_empty());
999 }
1000
1001 #[test]
1005 #[cfg(target_os = "linux")]
1006 fn a_missing_notifier_binary_is_a_silent_no_op() {
1007 let (dir, path) = state_path();
1008 let mut sink = NotifySendSink::with_program("/nonexistent/notify-send");
1009 let input = input(vec![row("Weekly (7d)", 97, None)]);
1010 run_at(
1011 &input,
1012 97,
1013 &path,
1014 Duration::from_secs(2),
1015 &mut sink,
1016 at(23, 12, 0),
1017 );
1018 assert!(
1019 path.exists(),
1020 "state bookkeeping is independent of delivery"
1021 );
1022 assert!(
1023 NotifyState::read_from(&path).is_notified("anthropic@gmail::Weekly (7d)"),
1024 "the crossing is marked even though nothing was delivered"
1025 );
1026 drop(dir);
1027 }
1028
1029 #[test]
1030 fn noop_sink_records_nothing_and_never_fails() {
1031 let (dir, path) = state_path();
1032 let mut sink = NoopSink;
1033 let input = input(vec![row("Weekly (7d)", 97, None)]);
1034 run_at(
1035 &input,
1036 97,
1037 &path,
1038 Duration::from_secs(2),
1039 &mut sink,
1040 at(23, 12, 0),
1041 );
1042 assert!(path.exists());
1043 drop(dir);
1044 }
1045
1046 #[test]
1049 fn from_tab_projects_rows_account_and_credits() {
1050 use crate::tui::app::{ReadyTab, TabId, TabState};
1051 use crate::usage::{
1052 AnthropicSnapshot, ResetCredit, ResetCredits, UsageWindow, VendorSnapshot,
1053 };
1054
1055 let reset = at(24, 2, 0);
1056 let state = TabState::Ready(Box::new(ReadyTab {
1057 snapshot: VendorSnapshot::Anthropic(AnthropicSnapshot {
1058 plan: "Claude Max 20x".into(),
1059 session: UsageWindow {
1060 utilization_pct: 98,
1061 resets_at: Some(reset),
1062 window_duration: chrono::Duration::hours(5),
1063 },
1064 weekly: UsageWindow {
1065 utilization_pct: 42,
1066 resets_at: None,
1067 window_duration: chrono::Duration::days(7),
1068 },
1069 sonnet: None,
1070 scoped: vec![],
1071 extra: None,
1072 reset_credits: ResetCredits {
1076 available: 1,
1077 credits: vec![ResetCredit {
1078 title: Some("Opus 5.5 launch reset".into()),
1079 expires_at: Some(at(25, 0, 0)),
1080 }],
1081 },
1082 }),
1083 stale: false,
1084 last_error: None,
1085 fetched_at: None,
1086 display: Default::default(),
1087 }));
1088
1089 let tab = TabId::account("gmail");
1090 let projected =
1091 RefreshInput::from_tab(&tab, &state, at(23, 12, 0)).expect("ready tab projects");
1092 assert_eq!(projected.entry_id, "anthropic@gmail");
1093 assert_eq!(projected.vendor, "Claude");
1094 assert_eq!(projected.account.as_deref(), Some("gmail"));
1095 assert_eq!(
1096 projected.rows,
1097 vec![
1098 row("Session (5h)", 98, Some(reset)),
1099 row("Weekly (7d)", 42, None),
1100 ]
1101 );
1102 assert_eq!(
1103 projected.credits,
1104 vec![CreditExpiry {
1105 title: Some("Opus 5.5 launch reset".into()),
1106 expires_at: at(25, 0, 0),
1107 }]
1108 );
1109
1110 let state = TabState::Ready(Box::new(ReadyTab {
1112 snapshot: VendorSnapshot::Openai(crate::usage::OpenAiSnapshot {
1113 plan: "ChatGPT Pro".into(),
1114 session: None,
1115 weekly: None,
1116 code_review: None,
1117 additional_limits: Vec::new(),
1118 unavailable_models: Vec::new(),
1119 credits: None,
1120 reset_credits: ResetCredits {
1121 available: 2,
1122 credits: vec![ResetCredit {
1123 title: Some("Full reset".into()),
1124 expires_at: Some(at(25, 0, 0)),
1125 }],
1126 },
1127 source: crate::usage::OpenAiSource::CodexOauth,
1128 }),
1129 stale: false,
1130 last_error: None,
1131 fetched_at: None,
1132 display: Default::default(),
1133 }));
1134 let projected = RefreshInput::from_tab(
1135 &TabId::vendor(crate::vendor::VendorId::Openai),
1136 &state,
1137 at(23, 12, 0),
1138 )
1139 .expect("ready tab projects");
1140 assert_eq!(projected.entry_id, "openai");
1141 assert_eq!(
1142 projected.credits,
1143 vec![CreditExpiry {
1144 title: Some("Full reset".into()),
1145 expires_at: at(25, 0, 0),
1146 }]
1147 );
1148 }
1149
1150 #[test]
1151 fn from_tab_skips_grouped_slices_and_unavailable_credits() {
1152 use crate::tui::app::{ReadyTab, TabId, TabState};
1153 use crate::usage::{
1154 ResetCredit, ResetCredits, SuperGrokPeriod, SuperGrokProduct, SuperGrokSnapshot,
1155 VendorSnapshot,
1156 };
1157
1158 let state = TabState::Ready(Box::new(ReadyTab {
1159 snapshot: VendorSnapshot::SuperGrok(SuperGrokSnapshot {
1160 plan: "SuperGrok Heavy".into(),
1161 account: "scope".into(),
1162 weekly_pct: 97,
1163 period: SuperGrokPeriod::Weekly,
1164 reset_at: Some(at(26, 0, 0)),
1165 prepaid_balance: None,
1166 reset_credits: ResetCredits {
1168 available: 0,
1169 credits: vec![ResetCredit {
1170 title: None,
1171 expires_at: Some(at(25, 0, 0)),
1172 }],
1173 },
1174 products: vec![SuperGrokProduct {
1175 label: "Grok Build".into(),
1176 percent: 94,
1177 }],
1178 }),
1179 stale: false,
1180 last_error: None,
1181 fetched_at: None,
1182 display: Default::default(),
1183 }));
1184 let projected = RefreshInput::from_tab(
1185 &TabId::vendor(crate::vendor::VendorId::Supergrok),
1186 &state,
1187 at(23, 12, 0),
1188 )
1189 .expect("ready tab projects");
1190 assert_eq!(projected.rows.len(), 1, "{:?}", projected.rows);
1193 assert!(projected.credits.is_empty());
1194 }
1195
1196 #[test]
1197 fn from_tab_returns_none_for_unready_states() {
1198 use crate::tui::app::{TabId, TabState};
1199 assert!(
1200 RefreshInput::from_tab(
1201 &TabId::vendor(crate::vendor::VendorId::Zai),
1202 &TabState::Loading,
1203 at(23, 12, 0)
1204 )
1205 .is_none()
1206 );
1207 assert!(
1208 RefreshInput::from_tab(
1209 &TabId::vendor(crate::vendor::VendorId::Zai),
1210 &TabState::error("not signed in"),
1211 at(23, 12, 0)
1212 )
1213 .is_none()
1214 );
1215 }
1216}