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#[cfg(target_os = "linux")]
74const CRITICAL_TIMEOUT_MS: &str = "30000";
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Urgency {
79 Normal,
81 Critical,
83}
84
85#[cfg(target_os = "linux")]
86impl Urgency {
87 fn as_arg(self) -> &'static str {
88 match self {
89 Self::Normal => "normal",
90 Self::Critical => "critical",
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct Notification {
99 pub title: String,
100 pub body: String,
101 pub urgency: Urgency,
102 pub key: String,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct MetricRow {
108 pub window: String,
111 pub percent: u16,
112 pub reset_at: Option<DateTime<Utc>>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct CreditExpiry {
121 pub title: Option<String>,
122 pub expires_at: DateTime<Utc>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct RefreshInput {
128 pub entry_id: String,
131 pub vendor: String,
134 pub account: Option<String>,
135 pub rows: Vec<MetricRow>,
136 pub credits: Vec<CreditExpiry>,
137}
138
139pub fn decide(
152 input: &RefreshInput,
153 threshold: u8,
154 state: &mut NotifyState,
155 now: DateTime<Utc>,
156) -> Vec<Notification> {
157 let mut fired = Vec::new();
158 let threshold = threshold as i32;
159 let rearm_below = threshold - HYSTERESIS_PCT;
160 for row in &input.rows {
161 let key = format!("{}::{}", input.entry_id, row.window);
162 let pct = row.percent as i32;
163 if pct >= threshold {
164 let armed = match state.entries.get(&key) {
165 None => true,
166 Some(record) => reset_moved_later(row.reset_at, record.reset_at_snapshot),
167 };
168 if armed {
169 fired.push(threshold_notification(input, row, now));
170 state.entries.insert(
171 key,
172 KeyRecord {
173 notified_at: now,
174 reset_at_snapshot: row.reset_at,
175 },
176 );
177 }
178 } else if pct < rearm_below {
179 state.entries.remove(&key);
182 }
183 }
184 for credit in &input.credits {
185 let remaining = credit.expires_at.signed_duration_since(now).num_seconds();
186 if remaining <= 0 || remaining > CREDIT_WARNING_SECS {
187 continue;
188 }
189 let key = format!(
190 "{}::credit::{}",
191 input.entry_id,
192 credit
193 .expires_at
194 .to_rfc3339_opts(SecondsFormat::AutoSi, true)
195 );
196 if state.entries.contains_key(&key) {
197 continue;
198 }
199 fired.push(credit_notification(input, credit, now));
200 state.entries.insert(
201 key,
202 KeyRecord {
203 notified_at: now,
204 reset_at_snapshot: None,
205 },
206 );
207 }
208 fired
209}
210
211fn reset_moved_later(current: Option<DateTime<Utc>>, snapshot: Option<DateTime<Utc>>) -> bool {
216 match (current, snapshot) {
217 (Some(current), Some(snapshot)) => {
218 current.signed_duration_since(snapshot).num_seconds() > RESET_MOVE_TOLERANCE_SECS
219 }
220 (Some(_), None) => true,
221 _ => false,
222 }
223}
224
225fn threshold_notification(
226 input: &RefreshInput,
227 row: &MetricRow,
228 now: DateTime<Utc>,
229) -> Notification {
230 let who = match &input.account {
231 Some(account) => format!("{} · {}", input.vendor, account),
232 None => input.vendor.clone(),
233 };
234 let title = sanitize_untrusted_line(&format!("{} — {} at {}%", who, row.window, row.percent));
235 let mut body = format!("{}% of the {} used", row.percent, row.window);
236 if let Some(reset_at) = row.reset_at {
237 body.push_str(&format!(
238 " · resets {} ({})",
239 countdown::format(Some(reset_at), now),
240 format::local_time_hm(reset_at)
241 ));
242 }
243 Notification {
244 title,
245 body: sanitize_untrusted_line(&body),
246 urgency: if row.percent >= 100 {
247 Urgency::Critical
248 } else {
249 Urgency::Normal
250 },
251 key: format!("{}::{}", input.entry_id, row.window),
252 }
253}
254
255fn credit_notification(
256 input: &RefreshInput,
257 credit: &CreditExpiry,
258 now: DateTime<Utc>,
259) -> Notification {
260 let label = credit.title.as_deref().unwrap_or("Reset credit");
261 let title = sanitize_untrusted_line(&format!(
262 "{} — reset credit expires {}",
263 input.vendor,
264 format::local_date_hm(credit.expires_at)
265 ));
266 let body = sanitize_untrusted_line(&format!(
267 "{} — redeem within {} ({})",
268 label,
269 countdown::format(Some(credit.expires_at), now),
270 format::local_time_hm(credit.expires_at)
271 ));
272 Notification {
273 title,
274 body,
275 urgency: Urgency::Normal,
276 key: format!(
277 "{}::credit::{}",
278 input.entry_id,
279 credit
280 .expires_at
281 .to_rfc3339_opts(SecondsFormat::AutoSi, true)
282 ),
283 }
284}
285
286#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(transparent)]
294pub struct NotifyState {
295 entries: BTreeMap<String, KeyRecord>,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299struct KeyRecord {
300 notified_at: DateTime<Utc>,
301 reset_at_snapshot: Option<DateTime<Utc>>,
302}
303
304impl NotifyState {
305 #[cfg(test)]
306 fn is_notified(&self, key: &str) -> bool {
307 self.entries.contains_key(key)
308 }
309
310 fn read_from(path: &Path) -> Self {
313 let Ok(raw) = std::fs::read_to_string(path) else {
314 return Self::default();
315 };
316 serde_json::from_str(&raw).unwrap_or_default()
317 }
318
319 fn write_to(&self, path: &Path) -> crate::error::Result<()> {
322 let bytes = serde_json::to_vec(&self.entries)?;
323 crate::cache::atomic_write(path, &bytes)?;
324 #[cfg(unix)]
325 {
326 use std::os::unix::fs::PermissionsExt;
327 if let Ok(meta) = std::fs::metadata(path) {
328 let mut perms = meta.permissions();
329 perms.set_mode(0o600);
330 let _ = std::fs::set_permissions(path, perms);
331 }
332 }
333 Ok(())
334 }
335}
336
337pub trait NotifySink {
345 fn deliver(&mut self, notification: &Notification);
346}
347
348#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
350#[derive(Debug, Default)]
351pub struct NoopSink;
352
353#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
354impl NotifySink for NoopSink {
355 fn deliver(&mut self, _notification: &Notification) {}
356}
357
358#[cfg(target_os = "linux")]
363#[derive(Debug, Clone)]
364pub struct NotifySendSink {
365 program: String,
366}
367
368#[cfg(target_os = "linux")]
369impl Default for NotifySendSink {
370 fn default() -> Self {
371 Self::new()
372 }
373}
374
375#[cfg(target_os = "linux")]
376impl NotifySendSink {
377 pub fn new() -> Self {
378 Self {
379 program: "notify-send".to_string(),
380 }
381 }
382
383 pub fn with_program(program: impl Into<String>) -> Self {
386 Self {
387 program: program.into(),
388 }
389 }
390
391 fn argv(&self, notification: &Notification) -> Vec<String> {
396 let mut args = vec![
397 "-a".to_string(),
398 "ai-usagebar".to_string(),
399 "-c".to_string(),
400 "quota".to_string(),
401 "-u".to_string(),
402 notification.urgency.as_arg().to_string(),
403 ];
404 if notification.urgency == Urgency::Critical {
405 args.push("-t".to_string());
406 args.push(CRITICAL_TIMEOUT_MS.to_string());
407 }
408 args.push(notification.title.clone());
409 args.push(notification.body.clone());
410 args
411 }
412}
413
414#[cfg(target_os = "linux")]
415impl NotifySink for NotifySendSink {
416 fn deliver(&mut self, notification: &Notification) {
417 let spawned = std::process::Command::new(&self.program)
418 .args(self.argv(notification))
419 .stdin(std::process::Stdio::null())
420 .stdout(std::process::Stdio::null())
421 .stderr(std::process::Stdio::null())
422 .spawn();
423 if let Ok(child) = spawned {
424 reap(child);
425 }
426 }
427}
428
429#[cfg(any(target_os = "linux", target_os = "macos"))]
432fn reap(mut child: std::process::Child) {
433 let deadline = std::time::Instant::now() + SPAWN_KILL_AFTER;
434 loop {
435 match child.try_wait() {
436 Ok(Some(_)) => return,
437 Ok(None) => {}
438 Err(_) => return,
439 }
440 if std::time::Instant::now() >= deadline {
441 let _ = child.kill();
442 let _ = child.wait();
443 return;
444 }
445 std::thread::sleep(Duration::from_millis(25));
446 }
447}
448
449fn production_sink() -> Box<dyn NotifySink + Send> {
450 #[cfg(target_os = "linux")]
451 return Box::new(NotifySendSink::new());
452 #[cfg(target_os = "macos")]
453 return Box::new(MacNotificationSink);
454 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
455 return Box::new(NoopSink);
456}
457
458#[cfg(target_os = "macos")]
461struct MacNotificationSink;
462
463#[cfg(target_os = "macos")]
464impl NotifySink for MacNotificationSink {
465 fn deliver(&mut self, notification: &Notification) {
466 const SCRIPT: &str = "on run argv\n display notification (item 2 of argv) with title (item 1 of argv)\nend run";
467 let spawned = std::process::Command::new("/usr/bin/osascript")
468 .arg("-e")
469 .arg(SCRIPT)
470 .arg("--")
471 .arg(¬ification.title)
472 .arg(¬ification.body)
473 .stdin(std::process::Stdio::null())
474 .stdout(std::process::Stdio::null())
475 .stderr(std::process::Stdio::null())
476 .spawn();
477 if let Ok(child) = spawned {
478 reap(child);
479 }
480 }
481}
482
483pub async fn run(input: RefreshInput, threshold: u8) {
488 let Some(state_path) = default_state_path() else {
489 return;
490 };
491 let joined = tokio::task::spawn_blocking(move || {
494 let mut sink = production_sink();
495 run_at(
496 &input,
497 threshold,
498 &state_path,
499 LOCK_TIMEOUT,
500 sink.as_mut(),
501 Utc::now(),
502 );
503 });
504 let _ = joined.await;
505}
506
507pub fn default_state_path() -> Option<PathBuf> {
510 crate::cache::xdg_cache_dir()
511 .ok()
512 .map(|base| base.join("ai-usagebar").join("notifications.json"))
513}
514
515pub fn run_at(
521 input: &RefreshInput,
522 threshold: u8,
523 state_path: &Path,
524 lock_timeout: Duration,
525 sink: &mut dyn NotifySink,
526 now: DateTime<Utc>,
527) {
528 let lock_path = state_path.with_file_name(".notifications.lock");
529 let Ok(_guard) = crate::cache::acquire_lock(&lock_path, lock_timeout) else {
530 return;
531 };
532 let mut state = NotifyState::read_from(state_path);
533 let before = serde_json::to_string(&state.entries).unwrap_or_default();
534 let fired = decide(input, threshold, &mut state, now);
535 if serde_json::to_string(&state.entries).unwrap_or_default() != before
536 && state.write_to(state_path).is_err()
537 {
538 return;
541 }
542 for notification in &fired {
543 sink.deliver(notification);
544 }
545}
546
547impl RefreshInput {
550 pub(crate) fn from_tab(
557 tab: &crate::tui::app::TabId,
558 state: &crate::tui::app::TabState,
559 now: DateTime<Utc>,
560 ) -> Option<Self> {
561 use crate::tui::app::{TabSource, TabState};
562 use crate::tui::panels::sections_with_metadata_for;
563
564 let TabState::Ready(ready) = state else {
565 return None;
566 };
567 let (vendor, entry_id) = match &tab.source {
568 TabSource::Builtin(vendor) => (
569 vendor.display_name().to_string(),
570 crate::report::tab_id(tab),
571 ),
572 TabSource::Custom { name, .. } => (name.clone(), crate::report::tab_id(tab)),
573 };
574 let rows = metric_rows(sections_with_metadata_for(state, now, 0));
577 let credits = ready
578 .snapshot
579 .reset_credits()
580 .filter(|credits| credits.available > 0)
581 .map(|credits| {
582 credits
583 .credits
584 .iter()
585 .filter_map(|credit| {
586 credit.expires_at.map(|expires_at| CreditExpiry {
587 title: credit.title.clone(),
588 expires_at,
589 })
590 })
591 .collect()
592 })
593 .unwrap_or_default();
594 Some(Self {
595 entry_id,
596 vendor,
597 account: tab.account.clone(),
598 rows,
599 credits,
600 })
601 }
602}
603
604fn metric_rows(
616 sections: impl IntoIterator<Item = crate::tui::panels::SectionProjection>,
617) -> Vec<MetricRow> {
618 use crate::tui::panels::Section;
619
620 let mut heading: Option<String> = None;
623 let mut rows = Vec::new();
624 for projected in sections {
625 if projected.group.is_some() {
626 continue;
627 }
628 match projected.section {
629 Section::Text { label, value } if value.is_empty() => heading = Some(label),
630 Section::Metric { label, pct, .. } => {
631 let window = match &heading {
632 Some(heading) => format!("{heading} · {label}"),
633 None => label,
634 };
635 rows.push(MetricRow {
636 window,
637 percent: pct,
638 reset_at: projected.reset_at,
639 });
640 }
641 Section::Spacer => {}
642 _ => heading = None,
643 }
644 }
645 rows
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use chrono::TimeZone;
652 use tempfile::TempDir;
653
654 fn at(day: u32, hour: u32, min: u32) -> DateTime<Utc> {
655 Utc.with_ymd_and_hms(2026, 9, day, hour, min, 0).unwrap()
656 }
657
658 fn row(window: &str, percent: u16, reset_at: Option<DateTime<Utc>>) -> MetricRow {
659 MetricRow {
660 window: window.to_string(),
661 percent,
662 reset_at,
663 }
664 }
665
666 fn input(rows: Vec<MetricRow>) -> RefreshInput {
667 RefreshInput {
668 entry_id: "anthropic@gmail".into(),
669 vendor: "Claude".into(),
670 account: Some("gmail".into()),
671 rows,
672 credits: Vec::new(),
673 }
674 }
675
676 #[derive(Default)]
678 struct RecorderSink {
679 delivered: Vec<Notification>,
680 }
681
682 impl NotifySink for RecorderSink {
683 fn deliver(&mut self, notification: &Notification) {
684 self.delivered.push(notification.clone());
685 }
686 }
687
688 fn state_path() -> (TempDir, PathBuf) {
689 crate::cache::closed_temp_file("notifications.json", None)
690 }
691
692 #[test]
695 fn crossing_at_exactly_the_threshold_fires_normal() {
696 let mut state = NotifyState::default();
697 let now = at(23, 12, 0);
698 let reset = at(24, 12, 0);
699 let fired = decide(
700 &input(vec![row("Session (5h)", 97, Some(reset))]),
701 97,
702 &mut state,
703 now,
704 );
705 assert_eq!(fired.len(), 1);
706 assert_eq!(fired[0].urgency, Urgency::Normal);
707 assert_eq!(fired[0].key, "anthropic@gmail::Session (5h)");
708 assert_eq!(fired[0].title, "Claude · gmail — Session (5h) at 97%");
709 assert_eq!(
713 fired[0].body,
714 format!(
715 "97% of the Session (5h) used · resets 1d 0h ({})",
716 format::local_time_hm(reset)
717 )
718 );
719 }
720
721 #[test]
722 fn below_the_threshold_fires_nothing() {
723 let mut state = NotifyState::default();
724 let fired = decide(
725 &input(vec![row("Session (5h)", 96, None)]),
726 97,
727 &mut state,
728 at(23, 12, 0),
729 );
730 assert!(fired.is_empty());
731 assert!(state.entries.is_empty());
732 }
733
734 #[test]
735 fn exhausted_windows_fire_critical() {
736 let mut state = NotifyState::default();
737 let fired = decide(
738 &input(vec![row("Weekly (7d)", 100, None)]),
739 97,
740 &mut state,
741 at(23, 12, 0),
742 );
743 assert_eq!(fired.len(), 1);
744 assert_eq!(fired[0].urgency, Urgency::Critical);
745 assert_eq!(fired[0].title, "Claude · gmail — Weekly (7d) at 100%");
746 }
747
748 #[test]
749 fn the_same_crossing_never_fires_twice() {
750 let mut state = NotifyState::default();
751 let high = input(vec![row("Weekly (7d)", 98, Some(at(30, 0, 0)))]);
752 let now = at(23, 12, 0);
753 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
754 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
755 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
756 }
757
758 #[test]
761 fn hysteresis_rearms_only_below_threshold_minus_seven() {
762 let mut state = NotifyState::default();
763 let now = at(23, 12, 0);
764 let fires_at = |pct: u16, state: &mut NotifyState| {
765 let case = input(vec![row("Weekly (7d)", pct, None)]);
766 decide(&case, 97, state, now).len()
767 };
768
769 assert_eq!(fires_at(97, &mut state), 1, "first crossing fires");
770 assert_eq!(fires_at(96, &mut state), 0, "hovering does not re-fire");
771 assert_eq!(fires_at(90, &mut state), 0);
773 assert!(state.is_notified("anthropic@gmail::Weekly (7d)"));
774 assert_eq!(fires_at(89, &mut state), 0);
776 assert!(!state.is_notified("anthropic@gmail::Weekly (7d)"));
777 assert_eq!(fires_at(97, &mut state), 1, "re-armed crossing fires");
779 }
780
781 #[test]
782 fn a_jittered_reset_instant_is_the_same_window() {
783 let mut state = NotifyState::default();
788 let now = at(23, 12, 0);
789 let reset = at(23, 17, 0);
790 let first = input(vec![row("Session (5h)", 100, Some(reset))]);
791 assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
792
793 for drift in [
794 chrono::Duration::milliseconds(488),
795 chrono::Duration::milliseconds(700),
796 chrono::Duration::seconds(59),
797 ] {
798 let jittered = input(vec![row("Session (5h)", 100, Some(reset + drift))]);
799 assert_eq!(
800 decide(&jittered, 97, &mut state, now).len(),
801 0,
802 "{drift:?} later is the same window"
803 );
804 }
805
806 let next_window = input(vec![row(
808 "Session (5h)",
809 100,
810 Some(reset + chrono::Duration::hours(5)),
811 )]);
812 assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
813 }
814
815 #[test]
816 fn a_later_reset_rearms_the_key() {
817 let mut state = NotifyState::default();
818 let now = at(23, 12, 0);
819 let first = input(vec![row("Session (5h)", 98, Some(at(23, 17, 0)))]);
820 assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
821
822 assert_eq!(decide(&first, 97, &mut state, now).len(), 0);
824 let earlier = input(vec![row("Session (5h)", 98, Some(at(23, 16, 0)))]);
826 assert_eq!(decide(&earlier, 97, &mut state, now).len(), 0);
827 let absent = input(vec![row("Session (5h)", 98, None)]);
828 assert_eq!(decide(&absent, 97, &mut state, now).len(), 0);
829 let next_window = input(vec![row("Session (5h)", 98, Some(at(24, 2, 0)))]);
831 assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
832 }
833
834 #[test]
835 fn a_reset_appearing_where_none_was_reported_rearms() {
836 let mut state = NotifyState::default();
837 let now = at(23, 12, 0);
838 let without = input(vec![row("Weekly quota", 99, None)]);
839 assert_eq!(decide(&without, 97, &mut state, now).len(), 1);
840 assert_eq!(decide(&without, 97, &mut state, now).len(), 0);
841 let with = input(vec![row("Weekly quota", 99, Some(at(30, 0, 0)))]);
842 assert_eq!(
843 decide(&with, 97, &mut state, now).len(),
844 1,
845 "the vendor starting to report a reset is a new window"
846 );
847 }
848
849 #[test]
852 fn absent_reset_at_omits_the_eta_from_the_body() {
853 let mut state = NotifyState::default();
854 let fired = decide(
855 &input(vec![row("Weekly (7d)", 97, None)]),
856 97,
857 &mut state,
858 at(23, 12, 0),
859 );
860 assert_eq!(fired[0].body, "97% of the Weekly (7d) used");
861 assert!(!fired[0].body.contains("resets"));
862 }
863
864 #[test]
865 fn account_label_is_omitted_from_the_title_when_absent() {
866 let mut state = NotifyState::default();
867 let mut plain = input(vec![row("Weekly (7d)", 97, None)]);
868 plain.entry_id = "zai".into();
869 plain.vendor = "Z.AI".into();
870 plain.account = None;
871 let fired = decide(&plain, 97, &mut state, at(23, 12, 0));
872 assert_eq!(fired[0].title, "Z.AI — Weekly (7d) at 97%");
873 }
874
875 #[test]
876 fn untrusted_labels_are_sanitized_at_the_subprocess_boundary() {
877 let mut state = NotifyState::default();
878 let mut hostile = input(vec![row("W\u{1b}[31m", 97, None)]);
879 hostile.account = Some("gmail\n\u{202E}evil".into());
880 let fired = decide(&hostile, 97, &mut state, at(23, 12, 0));
881 assert!(
882 !fired[0].title.chars().any(|c| c.is_control()),
883 "{}",
884 fired[0].title
885 );
886 assert!(!fired[0].title.contains('\u{202E}'), "{}", fired[0].title);
887 assert!(!fired[0].body.contains('\u{1B}'), "{}", fired[0].body);
888 }
889
890 #[test]
893 fn banked_credit_fires_only_inside_the_48h_window() {
894 let now = at(23, 12, 0);
895 for (offset_secs, expected) in [(47 * 3600, 1), (48 * 3600, 1), (48 * 3600 + 1, 0), (-1, 0)]
896 {
897 let mut state = NotifyState::default();
898 let case = RefreshInput {
899 credits: vec![CreditExpiry {
900 title: Some("Full reset".into()),
901 expires_at: now + chrono::Duration::seconds(offset_secs),
902 }],
903 ..input(Vec::new())
904 };
905 let fired = decide(&case, 97, &mut state, now);
906 assert_eq!(fired.len(), expected, "offset {offset_secs}s");
907 }
908 }
909
910 #[test]
911 fn banked_credit_title_and_body_name_the_expiry() {
912 let now = at(23, 12, 0);
913 let mut state = NotifyState::default();
914 let case = RefreshInput {
915 credits: vec![CreditExpiry {
916 title: Some("Full reset (Weekly + 5 hr)".into()),
917 expires_at: now + chrono::Duration::hours(3),
918 }],
919 ..input(Vec::new())
920 };
921 let fired = decide(&case, 97, &mut state, now);
922 assert_eq!(fired.len(), 1);
923 assert_eq!(fired[0].urgency, Urgency::Normal);
924 assert!(
925 fired[0].title.starts_with("Claude — reset credit expires "),
926 "{}",
927 fired[0].title
928 );
929 assert!(
930 fired[0]
931 .body
932 .starts_with("Full reset (Weekly + 5 hr) — redeem within 3h"),
933 "{}",
934 fired[0].body
935 );
936 }
937
938 #[test]
939 fn banked_credit_dedupes_on_the_expiry_instant() {
940 let now = at(23, 12, 0);
941 let mut state = NotifyState::default();
942 let expiry = now + chrono::Duration::hours(10);
943 let case = RefreshInput {
944 credits: vec![CreditExpiry {
945 title: None,
946 expires_at: expiry,
947 }],
948 ..input(Vec::new())
949 };
950 assert_eq!(decide(&case, 97, &mut state, now).len(), 1);
951 assert_eq!(
952 decide(&case, 97, &mut state, now).len(),
953 0,
954 "same expiry is one notification"
955 );
956 assert!(
957 state
958 .entries
959 .keys()
960 .all(|key| key.starts_with("anthropic@gmail::credit::")),
961 "{:?}",
962 state.entries.keys().collect::<Vec<_>>()
963 );
964 let later = RefreshInput {
966 credits: vec![CreditExpiry {
967 title: None,
968 expires_at: now + chrono::Duration::hours(20),
969 }],
970 ..input(Vec::new())
971 };
972 assert_eq!(decide(&later, 97, &mut state, now).len(), 1);
973 }
974
975 #[test]
978 fn state_round_trips_through_disk_and_tolerates_corruption() {
979 let (dir, path) = state_path();
980 let mut state = NotifyState::default();
981 state.entries.insert(
982 "anthropic::Weekly (7d)".into(),
983 KeyRecord {
984 notified_at: at(23, 12, 0),
985 reset_at_snapshot: Some(at(30, 0, 0)),
986 },
987 );
988 state.write_to(&path).unwrap();
989 assert_eq!(NotifyState::read_from(&path), state);
990
991 std::fs::write(&path, "{ not json").unwrap();
992 assert_eq!(NotifyState::read_from(&path), NotifyState::default());
993 drop(dir);
994 }
995
996 #[test]
997 #[cfg(unix)]
998 fn state_file_is_mode_0600() {
999 use std::os::unix::fs::PermissionsExt;
1000 let (dir, path) = state_path();
1001 NotifyState::default().write_to(&path).unwrap();
1002 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1003 assert_eq!(mode & 0o777, 0o600);
1004 drop(dir);
1005 }
1006
1007 #[test]
1008 fn rearm_survives_a_simulated_restart() {
1009 let (dir, path) = state_path();
1010 let now = at(23, 12, 0);
1011 let mut state = NotifyState::read_from(&path);
1012
1013 let high = input(vec![row("Weekly (7d)", 97, None)]);
1014 let low = input(vec![row("Weekly (7d)", 89, None)]);
1015 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
1016 state.write_to(&path).unwrap();
1017
1018 let mut state = NotifyState::read_from(&path);
1020 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
1021
1022 decide(&low, 97, &mut state, now);
1024 state.write_to(&path).unwrap();
1025
1026 let mut state = NotifyState::read_from(&path);
1028 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
1029 drop(dir);
1030 }
1031
1032 #[test]
1035 fn run_at_persists_marks_and_delivers_through_the_sink() {
1036 let (dir, path) = state_path();
1037 let mut sink = RecorderSink::default();
1038 let input = input(vec![row("Weekly (7d)", 97, None)]);
1039 run_at(
1040 &input,
1041 97,
1042 &path,
1043 Duration::from_secs(2),
1044 &mut sink,
1045 at(23, 12, 0),
1046 );
1047 assert_eq!(sink.delivered.len(), 1);
1048
1049 let mut second = RecorderSink::default();
1051 run_at(
1052 &input,
1053 97,
1054 &path,
1055 Duration::from_secs(2),
1056 &mut second,
1057 at(23, 12, 0),
1058 );
1059 assert!(second.delivered.is_empty());
1060 assert!(path.exists());
1061 drop(dir);
1062 }
1063
1064 #[test]
1065 fn lock_contention_skips_notifying_entirely() {
1066 let (dir, path) = state_path();
1067 let lock_path = path.with_file_name(".notifications.lock");
1068 let _held = crate::cache::acquire_lock(&lock_path, Duration::from_secs(5)).unwrap();
1069
1070 let mut sink = RecorderSink::default();
1071 let input = input(vec![row("Weekly (7d)", 100, None)]);
1072 run_at(
1073 &input,
1074 97,
1075 &path,
1076 Duration::from_millis(100),
1077 &mut sink,
1078 at(23, 12, 0),
1079 );
1080 assert!(
1081 sink.delivered.is_empty(),
1082 "a missed notification beats a double one"
1083 );
1084 assert!(!path.exists(), "no state without the lock");
1085 drop(dir);
1086 }
1087
1088 #[test]
1089 fn an_unwritable_state_file_skips_delivery_rather_than_repeating() {
1090 let dir = tempfile::TempDir::new().unwrap();
1091 let state_path = dir.path().join("notifications.json");
1094 std::fs::create_dir(&state_path).unwrap();
1095 let mut sink = RecorderSink::default();
1096 let input = input(vec![row("Weekly (7d)", 97, None)]);
1097 run_at(
1098 &input,
1099 97,
1100 &state_path,
1101 Duration::from_secs(2),
1102 &mut sink,
1103 at(23, 12, 0),
1104 );
1105 assert!(sink.delivered.is_empty());
1106 }
1107
1108 #[test]
1112 #[cfg(target_os = "linux")]
1113 fn a_critical_toast_asks_for_a_timeout_and_a_normal_one_does_not() {
1114 let sink = NotifySendSink::new();
1115 let toast = |urgency| Notification {
1116 title: "Claude — Session (5h) at 100%".to_string(),
1117 body: "resets in 2h".to_string(),
1118 urgency,
1119 key: "anthropic::Session (5h)".to_string(),
1120 };
1121 let critical_toast = toast(Urgency::Critical);
1122
1123 let critical = sink.argv(&critical_toast);
1124 assert!(
1125 critical
1126 .windows(2)
1127 .any(|pair| pair[0] == "-t" && pair[1] == "30000"),
1128 "a critical toast asks for the cap: {critical:?}"
1129 );
1130
1131 let normal = sink.argv(&toast(Urgency::Normal));
1132 assert!(
1133 !normal.iter().any(|arg| arg == "-t"),
1134 "a normal toast leaves the timeout to the daemon: {normal:?}"
1135 );
1136
1137 let tail = &critical[critical.len() - 2..];
1140 assert_eq!(tail[0], critical_toast.title);
1141 assert_eq!(tail[1], critical_toast.body);
1142 }
1143
1144 #[test]
1148 #[cfg(target_os = "linux")]
1149 fn a_missing_notifier_binary_is_a_silent_no_op() {
1150 let (dir, path) = state_path();
1151 let mut sink = NotifySendSink::with_program("/nonexistent/notify-send");
1152 let input = input(vec![row("Weekly (7d)", 97, None)]);
1153 run_at(
1154 &input,
1155 97,
1156 &path,
1157 Duration::from_secs(2),
1158 &mut sink,
1159 at(23, 12, 0),
1160 );
1161 assert!(
1162 path.exists(),
1163 "state bookkeeping is independent of delivery"
1164 );
1165 assert!(
1166 NotifyState::read_from(&path).is_notified("anthropic@gmail::Weekly (7d)"),
1167 "the crossing is marked even though nothing was delivered"
1168 );
1169 drop(dir);
1170 }
1171
1172 #[test]
1173 fn noop_sink_records_nothing_and_never_fails() {
1174 let (dir, path) = state_path();
1175 let mut sink = NoopSink;
1176 let input = input(vec![row("Weekly (7d)", 97, None)]);
1177 run_at(
1178 &input,
1179 97,
1180 &path,
1181 Duration::from_secs(2),
1182 &mut sink,
1183 at(23, 12, 0),
1184 );
1185 assert!(path.exists());
1186 drop(dir);
1187 }
1188
1189 #[test]
1192 fn from_tab_projects_rows_account_and_credits() {
1193 use crate::tui::app::{ReadyTab, TabId, TabState};
1194 use crate::usage::{
1195 AnthropicSnapshot, ResetCredit, ResetCredits, UsageWindow, VendorSnapshot,
1196 };
1197
1198 let reset = at(24, 2, 0);
1199 let state = TabState::Ready(Box::new(ReadyTab {
1200 snapshot: VendorSnapshot::Anthropic(AnthropicSnapshot {
1201 plan: "Claude Max 20x".into(),
1202 session: UsageWindow {
1203 utilization_pct: 98,
1204 resets_at: Some(reset),
1205 window_duration: chrono::Duration::hours(5),
1206 },
1207 weekly: UsageWindow {
1208 utilization_pct: 42,
1209 resets_at: None,
1210 window_duration: chrono::Duration::days(7),
1211 },
1212 sonnet: None,
1213 scoped: vec![],
1214 extra: None,
1215 reset_credits: ResetCredits {
1219 available: 1,
1220 credits: vec![ResetCredit {
1221 title: Some("Opus 5.5 launch reset".into()),
1222 expires_at: Some(at(25, 0, 0)),
1223 }],
1224 },
1225 }),
1226 stale: false,
1227 last_error: None,
1228 fetched_at: None,
1229 display: Default::default(),
1230 }));
1231
1232 let tab = TabId::account("gmail");
1233 let projected =
1234 RefreshInput::from_tab(&tab, &state, at(23, 12, 0)).expect("ready tab projects");
1235 assert_eq!(projected.entry_id, "anthropic@gmail");
1236 assert_eq!(projected.vendor, "Claude");
1237 assert_eq!(projected.account.as_deref(), Some("gmail"));
1238 assert_eq!(
1239 projected.rows,
1240 vec![
1241 row("Session (5h)", 98, Some(reset)),
1242 row("Weekly (7d)", 42, None),
1243 ]
1244 );
1245 assert_eq!(
1246 projected.credits,
1247 vec![CreditExpiry {
1248 title: Some("Opus 5.5 launch reset".into()),
1249 expires_at: at(25, 0, 0),
1250 }]
1251 );
1252
1253 let state = TabState::Ready(Box::new(ReadyTab {
1255 snapshot: VendorSnapshot::Openai(crate::usage::OpenAiSnapshot {
1256 plan: "ChatGPT Pro".into(),
1257 session: None,
1258 weekly: None,
1259 code_review: None,
1260 additional_limits: Vec::new(),
1261 unavailable_models: Vec::new(),
1262 credits: None,
1263 reset_credits: ResetCredits {
1264 available: 2,
1265 credits: vec![ResetCredit {
1266 title: Some("Full reset".into()),
1267 expires_at: Some(at(25, 0, 0)),
1268 }],
1269 },
1270 source: crate::usage::OpenAiSource::CodexOauth,
1271 }),
1272 stale: false,
1273 last_error: None,
1274 fetched_at: None,
1275 display: Default::default(),
1276 }));
1277 let projected = RefreshInput::from_tab(
1278 &TabId::vendor(crate::vendor::VendorId::Openai),
1279 &state,
1280 at(23, 12, 0),
1281 )
1282 .expect("ready tab projects");
1283 assert_eq!(projected.entry_id, "openai");
1284 assert_eq!(
1285 projected.credits,
1286 vec![CreditExpiry {
1287 title: Some("Full reset".into()),
1288 expires_at: at(25, 0, 0),
1289 }]
1290 );
1291 }
1292
1293 #[test]
1294 fn from_tab_skips_grouped_slices_and_unavailable_credits() {
1295 use crate::tui::app::{ReadyTab, TabId, TabState};
1296 use crate::usage::{
1297 ResetCredit, ResetCredits, SuperGrokPeriod, SuperGrokProduct, SuperGrokSnapshot,
1298 VendorSnapshot,
1299 };
1300
1301 let state = TabState::Ready(Box::new(ReadyTab {
1302 snapshot: VendorSnapshot::SuperGrok(SuperGrokSnapshot {
1303 plan: "SuperGrok Heavy".into(),
1304 account: "scope".into(),
1305 weekly_pct: 97,
1306 period: SuperGrokPeriod::Weekly,
1307 reset_at: Some(at(26, 0, 0)),
1308 prepaid_balance: None,
1309 reset_credits: ResetCredits {
1311 available: 0,
1312 credits: vec![ResetCredit {
1313 title: None,
1314 expires_at: Some(at(25, 0, 0)),
1315 }],
1316 },
1317 products: vec![SuperGrokProduct {
1318 label: "Grok Build".into(),
1319 percent: 94,
1320 }],
1321 }),
1322 stale: false,
1323 last_error: None,
1324 fetched_at: None,
1325 display: Default::default(),
1326 }));
1327 let projected = RefreshInput::from_tab(
1328 &TabId::vendor(crate::vendor::VendorId::Supergrok),
1329 &state,
1330 at(23, 12, 0),
1331 )
1332 .expect("ready tab projects");
1333 assert_eq!(projected.rows.len(), 1, "{:?}", projected.rows);
1336 assert!(projected.credits.is_empty());
1337 }
1338
1339 fn antigravity_ready(
1340 session: i32,
1341 third_party_session: i32,
1342 weekly: i32,
1343 third_party_weekly: i32,
1344 ) -> crate::tui::app::TabState {
1345 use crate::tui::app::{ReadyTab, TabState};
1346 use crate::usage::{AntigravitySnapshot, AntigravitySource, UsageWindow, VendorSnapshot};
1347
1348 let window = |pct: i32, resets_at: DateTime<Utc>, hours: i64| {
1349 Some(UsageWindow {
1350 utilization_pct: pct,
1351 resets_at: Some(resets_at),
1352 window_duration: chrono::Duration::hours(hours),
1353 })
1354 };
1355 TabState::Ready(Box::new(ReadyTab {
1356 snapshot: VendorSnapshot::Antigravity(AntigravitySnapshot {
1357 plan: "Google AI Pro".into(),
1358 account: "acct:test".into(),
1359 source: AntigravitySource::Local,
1360 session: window(session, at(24, 2, 21), 5),
1361 weekly: window(weekly, at(26, 1, 34), 168),
1362 third_party_session: window(third_party_session, at(24, 2, 19), 5),
1363 third_party_weekly: window(third_party_weekly, at(30, 19, 19), 168),
1364 model_pools: Vec::new(),
1365 }),
1366 stale: false,
1367 last_error: None,
1368 fetched_at: None,
1369 display: Default::default(),
1370 }))
1371 }
1372
1373 #[test]
1377 fn from_tab_names_repeated_labels_by_their_heading() {
1378 use crate::tui::app::TabId;
1379
1380 let projected = RefreshInput::from_tab(
1381 &TabId::vendor(crate::vendor::VendorId::Antigravity),
1382 &antigravity_ready(3, 100, 98, 56),
1383 at(23, 22, 0),
1384 )
1385 .expect("ready tab projects");
1386 let windows: Vec<_> = projected
1387 .rows
1388 .iter()
1389 .map(|row| row.window.as_str())
1390 .collect();
1391 assert_eq!(
1392 windows,
1393 [
1394 "Session · Gemini",
1395 "Session · Claude & GPT OSS",
1396 "Weekly · Gemini",
1397 "Weekly · Claude & GPT OSS",
1398 ]
1399 );
1400 }
1401
1402 #[test]
1407 fn a_window_whose_label_repeats_notifies_once() {
1408 use crate::tui::app::TabId;
1409
1410 let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1411 let state = antigravity_ready(3, 100, 98, 56);
1412 let mut notify_state = NotifyState::default();
1413 let fired: Vec<Vec<String>> = (0..4)
1414 .map(|refresh| {
1415 let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh);
1416 let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1417 decide(&input, 97, &mut notify_state, now)
1418 .into_iter()
1419 .map(|notification| notification.title)
1420 .collect()
1421 })
1422 .collect();
1423 assert_eq!(
1424 fired[0],
1425 [
1426 "Antigravity — Session · Claude & GPT OSS at 100%",
1427 "Antigravity — Weekly · Gemini at 98%",
1428 ]
1429 );
1430 assert!(
1431 fired[1..].iter().all(Vec::is_empty),
1432 "later refreshes must stay quiet: {fired:?}"
1433 );
1434 }
1435
1436 #[test]
1438 fn a_window_keeps_its_key_when_its_sibling_disappears_or_returns() {
1439 use crate::tui::app::{TabId, TabState};
1440 use crate::usage::VendorSnapshot;
1441
1442 let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1443 for starts_with_session in [false, true] {
1444 let mut notify_state = NotifyState::default();
1445 let steps = [
1449 (starts_with_session, 98, 1),
1450 (!starts_with_session, 98, 0),
1451 (starts_with_session, 98, 0),
1452 (false, 20, 0),
1453 (true, 98, 1),
1454 (false, 98, 0),
1455 ];
1456 for (refresh, (has_session, weekly_pct, expected)) in steps.into_iter().enumerate() {
1457 let mut state = antigravity_ready(3, 10, weekly_pct, 56);
1458 let TabState::Ready(ready) = &mut state else {
1459 unreachable!();
1460 };
1461 let VendorSnapshot::Antigravity(snapshot) = &mut ready.snapshot else {
1462 unreachable!();
1463 };
1464 if !has_session {
1465 snapshot.session = None;
1466 snapshot.third_party_session = None;
1467 }
1468 let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh as i64);
1469 let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1470 let fired = decide(&input, 97, &mut notify_state, now);
1471 assert_eq!(
1472 fired.len(),
1473 expected,
1474 "starts_with_session={starts_with_session}, refresh={refresh}: {fired:?}"
1475 );
1476 if let Some(notification) = fired.first() {
1477 assert_eq!(notification.key, "antigravity::Weekly · Gemini");
1478 }
1479 }
1480 }
1481 }
1482
1483 #[test]
1486 fn metric_names_follow_heading_scope_even_for_unique_labels() {
1487 use crate::balance::MetricHeadline;
1488 use crate::pacing::PaceSeverity;
1489 use crate::tui::panels::{Section, SectionProjection};
1490
1491 let projection = |section| SectionProjection {
1492 section,
1493 reset_at: None,
1494 window: None,
1495 group: None,
1496 model_pool: false,
1497 headline: MetricHeadline::Percent,
1498 used_cents: None,
1499 limit_cents: None,
1500 };
1501 let metric = |label: &str, pct| {
1502 projection(Section::Metric {
1503 label: label.into(),
1504 pct,
1505 severity: PaceSeverity::Low,
1506 value_label: format!("{pct}%"),
1507 footnote: String::new(),
1508 })
1509 };
1510 let heading = |label: &str| {
1511 projection(Section::Text {
1512 label: label.into(),
1513 value: String::new(),
1514 })
1515 };
1516 let rows = metric_rows([
1517 metric("Unscoped", 20),
1518 heading("Session"),
1519 projection(Section::Spacer),
1520 metric("General", 40),
1521 metric("Video", 10),
1522 projection(Section::Spacer),
1523 heading("Weekly"),
1524 metric("General", 60),
1525 projection(Section::Text {
1526 label: "Info".into(),
1527 value: "Details".into(),
1528 }),
1529 metric("Other", 30),
1530 ]);
1531 let windows: Vec<_> = rows.iter().map(|row| row.window.as_str()).collect();
1532 assert_eq!(
1533 windows,
1534 [
1535 "Unscoped",
1536 "Session · General",
1537 "Session · Video",
1538 "Weekly · General",
1539 "Other",
1540 ]
1541 );
1542 }
1543
1544 #[test]
1545 fn from_tab_returns_none_for_unready_states() {
1546 use crate::tui::app::{TabId, TabState};
1547 assert!(
1548 RefreshInput::from_tab(
1549 &TabId::vendor(crate::vendor::VendorId::Zai),
1550 &TabState::Loading,
1551 at(23, 12, 0)
1552 )
1553 .is_none()
1554 );
1555 assert!(
1556 RefreshInput::from_tab(
1557 &TabId::vendor(crate::vendor::VendorId::Zai),
1558 &TabState::error("not signed in"),
1559 at(23, 12, 0)
1560 )
1561 .is_none()
1562 );
1563 }
1564}