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