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::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 = metric_rows(sections_with_metadata_for(state, now, 0));
552 let credits = ready
553 .snapshot
554 .reset_credits()
555 .filter(|credits| credits.available > 0)
556 .map(|credits| {
557 credits
558 .credits
559 .iter()
560 .filter_map(|credit| {
561 credit.expires_at.map(|expires_at| CreditExpiry {
562 title: credit.title.clone(),
563 expires_at,
564 })
565 })
566 .collect()
567 })
568 .unwrap_or_default();
569 Some(Self {
570 entry_id,
571 vendor,
572 account: tab.account.clone(),
573 rows,
574 credits,
575 })
576 }
577}
578
579fn metric_rows(
591 sections: impl IntoIterator<Item = crate::tui::panels::SectionProjection>,
592) -> Vec<MetricRow> {
593 use crate::tui::panels::Section;
594
595 let mut heading: Option<String> = None;
598 let mut rows = Vec::new();
599 for projected in sections {
600 if projected.group.is_some() {
601 continue;
602 }
603 match projected.section {
604 Section::Text { label, value } if value.is_empty() => heading = Some(label),
605 Section::Metric { label, pct, .. } => {
606 let window = match &heading {
607 Some(heading) => format!("{heading} · {label}"),
608 None => label,
609 };
610 rows.push(MetricRow {
611 window,
612 percent: pct,
613 reset_at: projected.reset_at,
614 });
615 }
616 Section::Spacer => {}
617 _ => heading = None,
618 }
619 }
620 rows
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use chrono::TimeZone;
627 use tempfile::TempDir;
628
629 fn at(day: u32, hour: u32, min: u32) -> DateTime<Utc> {
630 Utc.with_ymd_and_hms(2026, 9, day, hour, min, 0).unwrap()
631 }
632
633 fn row(window: &str, percent: u16, reset_at: Option<DateTime<Utc>>) -> MetricRow {
634 MetricRow {
635 window: window.to_string(),
636 percent,
637 reset_at,
638 }
639 }
640
641 fn input(rows: Vec<MetricRow>) -> RefreshInput {
642 RefreshInput {
643 entry_id: "anthropic@gmail".into(),
644 vendor: "Claude".into(),
645 account: Some("gmail".into()),
646 rows,
647 credits: Vec::new(),
648 }
649 }
650
651 #[derive(Default)]
653 struct RecorderSink {
654 delivered: Vec<Notification>,
655 }
656
657 impl NotifySink for RecorderSink {
658 fn deliver(&mut self, notification: &Notification) {
659 self.delivered.push(notification.clone());
660 }
661 }
662
663 fn state_path() -> (TempDir, PathBuf) {
664 crate::cache::closed_temp_file("notifications.json", None)
665 }
666
667 #[test]
670 fn crossing_at_exactly_the_threshold_fires_normal() {
671 let mut state = NotifyState::default();
672 let now = at(23, 12, 0);
673 let reset = at(24, 12, 0);
674 let fired = decide(
675 &input(vec![row("Session (5h)", 97, Some(reset))]),
676 97,
677 &mut state,
678 now,
679 );
680 assert_eq!(fired.len(), 1);
681 assert_eq!(fired[0].urgency, Urgency::Normal);
682 assert_eq!(fired[0].key, "anthropic@gmail::Session (5h)");
683 assert_eq!(fired[0].title, "Claude · gmail — Session (5h) at 97%");
684 assert_eq!(
688 fired[0].body,
689 format!(
690 "97% of the Session (5h) used · resets 1d 0h ({})",
691 format::local_time_hm(reset)
692 )
693 );
694 }
695
696 #[test]
697 fn below_the_threshold_fires_nothing() {
698 let mut state = NotifyState::default();
699 let fired = decide(
700 &input(vec![row("Session (5h)", 96, None)]),
701 97,
702 &mut state,
703 at(23, 12, 0),
704 );
705 assert!(fired.is_empty());
706 assert!(state.entries.is_empty());
707 }
708
709 #[test]
710 fn exhausted_windows_fire_critical() {
711 let mut state = NotifyState::default();
712 let fired = decide(
713 &input(vec![row("Weekly (7d)", 100, None)]),
714 97,
715 &mut state,
716 at(23, 12, 0),
717 );
718 assert_eq!(fired.len(), 1);
719 assert_eq!(fired[0].urgency, Urgency::Critical);
720 assert_eq!(fired[0].title, "Claude · gmail — Weekly (7d) at 100%");
721 }
722
723 #[test]
724 fn the_same_crossing_never_fires_twice() {
725 let mut state = NotifyState::default();
726 let high = input(vec![row("Weekly (7d)", 98, Some(at(30, 0, 0)))]);
727 let now = at(23, 12, 0);
728 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
729 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
730 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
731 }
732
733 #[test]
736 fn hysteresis_rearms_only_below_threshold_minus_seven() {
737 let mut state = NotifyState::default();
738 let now = at(23, 12, 0);
739 let fires_at = |pct: u16, state: &mut NotifyState| {
740 let case = input(vec![row("Weekly (7d)", pct, None)]);
741 decide(&case, 97, state, now).len()
742 };
743
744 assert_eq!(fires_at(97, &mut state), 1, "first crossing fires");
745 assert_eq!(fires_at(96, &mut state), 0, "hovering does not re-fire");
746 assert_eq!(fires_at(90, &mut state), 0);
748 assert!(state.is_notified("anthropic@gmail::Weekly (7d)"));
749 assert_eq!(fires_at(89, &mut state), 0);
751 assert!(!state.is_notified("anthropic@gmail::Weekly (7d)"));
752 assert_eq!(fires_at(97, &mut state), 1, "re-armed crossing fires");
754 }
755
756 #[test]
757 fn a_jittered_reset_instant_is_the_same_window() {
758 let mut state = NotifyState::default();
763 let now = at(23, 12, 0);
764 let reset = at(23, 17, 0);
765 let first = input(vec![row("Session (5h)", 100, Some(reset))]);
766 assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
767
768 for drift in [
769 chrono::Duration::milliseconds(488),
770 chrono::Duration::milliseconds(700),
771 chrono::Duration::seconds(59),
772 ] {
773 let jittered = input(vec![row("Session (5h)", 100, Some(reset + drift))]);
774 assert_eq!(
775 decide(&jittered, 97, &mut state, now).len(),
776 0,
777 "{drift:?} later is the same window"
778 );
779 }
780
781 let next_window = input(vec![row(
783 "Session (5h)",
784 100,
785 Some(reset + chrono::Duration::hours(5)),
786 )]);
787 assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
788 }
789
790 #[test]
791 fn a_later_reset_rearms_the_key() {
792 let mut state = NotifyState::default();
793 let now = at(23, 12, 0);
794 let first = input(vec![row("Session (5h)", 98, Some(at(23, 17, 0)))]);
795 assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
796
797 assert_eq!(decide(&first, 97, &mut state, now).len(), 0);
799 let earlier = input(vec![row("Session (5h)", 98, Some(at(23, 16, 0)))]);
801 assert_eq!(decide(&earlier, 97, &mut state, now).len(), 0);
802 let absent = input(vec![row("Session (5h)", 98, None)]);
803 assert_eq!(decide(&absent, 97, &mut state, now).len(), 0);
804 let next_window = input(vec![row("Session (5h)", 98, Some(at(24, 2, 0)))]);
806 assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
807 }
808
809 #[test]
810 fn a_reset_appearing_where_none_was_reported_rearms() {
811 let mut state = NotifyState::default();
812 let now = at(23, 12, 0);
813 let without = input(vec![row("Weekly quota", 99, None)]);
814 assert_eq!(decide(&without, 97, &mut state, now).len(), 1);
815 assert_eq!(decide(&without, 97, &mut state, now).len(), 0);
816 let with = input(vec![row("Weekly quota", 99, Some(at(30, 0, 0)))]);
817 assert_eq!(
818 decide(&with, 97, &mut state, now).len(),
819 1,
820 "the vendor starting to report a reset is a new window"
821 );
822 }
823
824 #[test]
827 fn absent_reset_at_omits_the_eta_from_the_body() {
828 let mut state = NotifyState::default();
829 let fired = decide(
830 &input(vec![row("Weekly (7d)", 97, None)]),
831 97,
832 &mut state,
833 at(23, 12, 0),
834 );
835 assert_eq!(fired[0].body, "97% of the Weekly (7d) used");
836 assert!(!fired[0].body.contains("resets"));
837 }
838
839 #[test]
840 fn account_label_is_omitted_from_the_title_when_absent() {
841 let mut state = NotifyState::default();
842 let mut plain = input(vec![row("Weekly (7d)", 97, None)]);
843 plain.entry_id = "zai".into();
844 plain.vendor = "Z.AI".into();
845 plain.account = None;
846 let fired = decide(&plain, 97, &mut state, at(23, 12, 0));
847 assert_eq!(fired[0].title, "Z.AI — Weekly (7d) at 97%");
848 }
849
850 #[test]
851 fn untrusted_labels_are_sanitized_at_the_subprocess_boundary() {
852 let mut state = NotifyState::default();
853 let mut hostile = input(vec![row("W\u{1b}[31m", 97, None)]);
854 hostile.account = Some("gmail\n\u{202E}evil".into());
855 let fired = decide(&hostile, 97, &mut state, at(23, 12, 0));
856 assert!(
857 !fired[0].title.chars().any(|c| c.is_control()),
858 "{}",
859 fired[0].title
860 );
861 assert!(!fired[0].title.contains('\u{202E}'), "{}", fired[0].title);
862 assert!(!fired[0].body.contains('\u{1B}'), "{}", fired[0].body);
863 }
864
865 #[test]
868 fn banked_credit_fires_only_inside_the_48h_window() {
869 let now = at(23, 12, 0);
870 for (offset_secs, expected) in [(47 * 3600, 1), (48 * 3600, 1), (48 * 3600 + 1, 0), (-1, 0)]
871 {
872 let mut state = NotifyState::default();
873 let case = RefreshInput {
874 credits: vec![CreditExpiry {
875 title: Some("Full reset".into()),
876 expires_at: now + chrono::Duration::seconds(offset_secs),
877 }],
878 ..input(Vec::new())
879 };
880 let fired = decide(&case, 97, &mut state, now);
881 assert_eq!(fired.len(), expected, "offset {offset_secs}s");
882 }
883 }
884
885 #[test]
886 fn banked_credit_title_and_body_name_the_expiry() {
887 let now = at(23, 12, 0);
888 let mut state = NotifyState::default();
889 let case = RefreshInput {
890 credits: vec![CreditExpiry {
891 title: Some("Full reset (Weekly + 5 hr)".into()),
892 expires_at: now + chrono::Duration::hours(3),
893 }],
894 ..input(Vec::new())
895 };
896 let fired = decide(&case, 97, &mut state, now);
897 assert_eq!(fired.len(), 1);
898 assert_eq!(fired[0].urgency, Urgency::Normal);
899 assert!(
900 fired[0].title.starts_with("Claude — reset credit expires "),
901 "{}",
902 fired[0].title
903 );
904 assert!(
905 fired[0]
906 .body
907 .starts_with("Full reset (Weekly + 5 hr) — redeem within 3h"),
908 "{}",
909 fired[0].body
910 );
911 }
912
913 #[test]
914 fn banked_credit_dedupes_on_the_expiry_instant() {
915 let now = at(23, 12, 0);
916 let mut state = NotifyState::default();
917 let expiry = now + chrono::Duration::hours(10);
918 let case = RefreshInput {
919 credits: vec![CreditExpiry {
920 title: None,
921 expires_at: expiry,
922 }],
923 ..input(Vec::new())
924 };
925 assert_eq!(decide(&case, 97, &mut state, now).len(), 1);
926 assert_eq!(
927 decide(&case, 97, &mut state, now).len(),
928 0,
929 "same expiry is one notification"
930 );
931 assert!(
932 state
933 .entries
934 .keys()
935 .all(|key| key.starts_with("anthropic@gmail::credit::")),
936 "{:?}",
937 state.entries.keys().collect::<Vec<_>>()
938 );
939 let later = RefreshInput {
941 credits: vec![CreditExpiry {
942 title: None,
943 expires_at: now + chrono::Duration::hours(20),
944 }],
945 ..input(Vec::new())
946 };
947 assert_eq!(decide(&later, 97, &mut state, now).len(), 1);
948 }
949
950 #[test]
953 fn state_round_trips_through_disk_and_tolerates_corruption() {
954 let (dir, path) = state_path();
955 let mut state = NotifyState::default();
956 state.entries.insert(
957 "anthropic::Weekly (7d)".into(),
958 KeyRecord {
959 notified_at: at(23, 12, 0),
960 reset_at_snapshot: Some(at(30, 0, 0)),
961 },
962 );
963 state.write_to(&path).unwrap();
964 assert_eq!(NotifyState::read_from(&path), state);
965
966 std::fs::write(&path, "{ not json").unwrap();
967 assert_eq!(NotifyState::read_from(&path), NotifyState::default());
968 drop(dir);
969 }
970
971 #[test]
972 #[cfg(unix)]
973 fn state_file_is_mode_0600() {
974 use std::os::unix::fs::PermissionsExt;
975 let (dir, path) = state_path();
976 NotifyState::default().write_to(&path).unwrap();
977 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
978 assert_eq!(mode & 0o777, 0o600);
979 drop(dir);
980 }
981
982 #[test]
983 fn rearm_survives_a_simulated_restart() {
984 let (dir, path) = state_path();
985 let now = at(23, 12, 0);
986 let mut state = NotifyState::read_from(&path);
987
988 let high = input(vec![row("Weekly (7d)", 97, None)]);
989 let low = input(vec![row("Weekly (7d)", 89, None)]);
990 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
991 state.write_to(&path).unwrap();
992
993 let mut state = NotifyState::read_from(&path);
995 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
996
997 decide(&low, 97, &mut state, now);
999 state.write_to(&path).unwrap();
1000
1001 let mut state = NotifyState::read_from(&path);
1003 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
1004 drop(dir);
1005 }
1006
1007 #[test]
1010 fn run_at_persists_marks_and_delivers_through_the_sink() {
1011 let (dir, path) = state_path();
1012 let mut sink = RecorderSink::default();
1013 let input = input(vec![row("Weekly (7d)", 97, None)]);
1014 run_at(
1015 &input,
1016 97,
1017 &path,
1018 Duration::from_secs(2),
1019 &mut sink,
1020 at(23, 12, 0),
1021 );
1022 assert_eq!(sink.delivered.len(), 1);
1023
1024 let mut second = RecorderSink::default();
1026 run_at(
1027 &input,
1028 97,
1029 &path,
1030 Duration::from_secs(2),
1031 &mut second,
1032 at(23, 12, 0),
1033 );
1034 assert!(second.delivered.is_empty());
1035 assert!(path.exists());
1036 drop(dir);
1037 }
1038
1039 #[test]
1040 fn lock_contention_skips_notifying_entirely() {
1041 let (dir, path) = state_path();
1042 let lock_path = path.with_file_name(".notifications.lock");
1043 let _held = crate::cache::acquire_lock(&lock_path, Duration::from_secs(5)).unwrap();
1044
1045 let mut sink = RecorderSink::default();
1046 let input = input(vec![row("Weekly (7d)", 100, None)]);
1047 run_at(
1048 &input,
1049 97,
1050 &path,
1051 Duration::from_millis(100),
1052 &mut sink,
1053 at(23, 12, 0),
1054 );
1055 assert!(
1056 sink.delivered.is_empty(),
1057 "a missed notification beats a double one"
1058 );
1059 assert!(!path.exists(), "no state without the lock");
1060 drop(dir);
1061 }
1062
1063 #[test]
1064 fn an_unwritable_state_file_skips_delivery_rather_than_repeating() {
1065 let dir = tempfile::TempDir::new().unwrap();
1066 let state_path = dir.path().join("notifications.json");
1069 std::fs::create_dir(&state_path).unwrap();
1070 let mut sink = RecorderSink::default();
1071 let input = input(vec![row("Weekly (7d)", 97, None)]);
1072 run_at(
1073 &input,
1074 97,
1075 &state_path,
1076 Duration::from_secs(2),
1077 &mut sink,
1078 at(23, 12, 0),
1079 );
1080 assert!(sink.delivered.is_empty());
1081 }
1082
1083 #[test]
1087 #[cfg(target_os = "linux")]
1088 fn a_missing_notifier_binary_is_a_silent_no_op() {
1089 let (dir, path) = state_path();
1090 let mut sink = NotifySendSink::with_program("/nonexistent/notify-send");
1091 let input = input(vec![row("Weekly (7d)", 97, None)]);
1092 run_at(
1093 &input,
1094 97,
1095 &path,
1096 Duration::from_secs(2),
1097 &mut sink,
1098 at(23, 12, 0),
1099 );
1100 assert!(
1101 path.exists(),
1102 "state bookkeeping is independent of delivery"
1103 );
1104 assert!(
1105 NotifyState::read_from(&path).is_notified("anthropic@gmail::Weekly (7d)"),
1106 "the crossing is marked even though nothing was delivered"
1107 );
1108 drop(dir);
1109 }
1110
1111 #[test]
1112 fn noop_sink_records_nothing_and_never_fails() {
1113 let (dir, path) = state_path();
1114 let mut sink = NoopSink;
1115 let input = input(vec![row("Weekly (7d)", 97, None)]);
1116 run_at(
1117 &input,
1118 97,
1119 &path,
1120 Duration::from_secs(2),
1121 &mut sink,
1122 at(23, 12, 0),
1123 );
1124 assert!(path.exists());
1125 drop(dir);
1126 }
1127
1128 #[test]
1131 fn from_tab_projects_rows_account_and_credits() {
1132 use crate::tui::app::{ReadyTab, TabId, TabState};
1133 use crate::usage::{
1134 AnthropicSnapshot, ResetCredit, ResetCredits, UsageWindow, VendorSnapshot,
1135 };
1136
1137 let reset = at(24, 2, 0);
1138 let state = TabState::Ready(Box::new(ReadyTab {
1139 snapshot: VendorSnapshot::Anthropic(AnthropicSnapshot {
1140 plan: "Claude Max 20x".into(),
1141 session: UsageWindow {
1142 utilization_pct: 98,
1143 resets_at: Some(reset),
1144 window_duration: chrono::Duration::hours(5),
1145 },
1146 weekly: UsageWindow {
1147 utilization_pct: 42,
1148 resets_at: None,
1149 window_duration: chrono::Duration::days(7),
1150 },
1151 sonnet: None,
1152 scoped: vec![],
1153 extra: None,
1154 reset_credits: ResetCredits {
1158 available: 1,
1159 credits: vec![ResetCredit {
1160 title: Some("Opus 5.5 launch reset".into()),
1161 expires_at: Some(at(25, 0, 0)),
1162 }],
1163 },
1164 }),
1165 stale: false,
1166 last_error: None,
1167 fetched_at: None,
1168 display: Default::default(),
1169 }));
1170
1171 let tab = TabId::account("gmail");
1172 let projected =
1173 RefreshInput::from_tab(&tab, &state, at(23, 12, 0)).expect("ready tab projects");
1174 assert_eq!(projected.entry_id, "anthropic@gmail");
1175 assert_eq!(projected.vendor, "Claude");
1176 assert_eq!(projected.account.as_deref(), Some("gmail"));
1177 assert_eq!(
1178 projected.rows,
1179 vec![
1180 row("Session (5h)", 98, Some(reset)),
1181 row("Weekly (7d)", 42, None),
1182 ]
1183 );
1184 assert_eq!(
1185 projected.credits,
1186 vec![CreditExpiry {
1187 title: Some("Opus 5.5 launch reset".into()),
1188 expires_at: at(25, 0, 0),
1189 }]
1190 );
1191
1192 let state = TabState::Ready(Box::new(ReadyTab {
1194 snapshot: VendorSnapshot::Openai(crate::usage::OpenAiSnapshot {
1195 plan: "ChatGPT Pro".into(),
1196 session: None,
1197 weekly: None,
1198 code_review: None,
1199 additional_limits: Vec::new(),
1200 unavailable_models: Vec::new(),
1201 credits: None,
1202 reset_credits: ResetCredits {
1203 available: 2,
1204 credits: vec![ResetCredit {
1205 title: Some("Full reset".into()),
1206 expires_at: Some(at(25, 0, 0)),
1207 }],
1208 },
1209 source: crate::usage::OpenAiSource::CodexOauth,
1210 }),
1211 stale: false,
1212 last_error: None,
1213 fetched_at: None,
1214 display: Default::default(),
1215 }));
1216 let projected = RefreshInput::from_tab(
1217 &TabId::vendor(crate::vendor::VendorId::Openai),
1218 &state,
1219 at(23, 12, 0),
1220 )
1221 .expect("ready tab projects");
1222 assert_eq!(projected.entry_id, "openai");
1223 assert_eq!(
1224 projected.credits,
1225 vec![CreditExpiry {
1226 title: Some("Full reset".into()),
1227 expires_at: at(25, 0, 0),
1228 }]
1229 );
1230 }
1231
1232 #[test]
1233 fn from_tab_skips_grouped_slices_and_unavailable_credits() {
1234 use crate::tui::app::{ReadyTab, TabId, TabState};
1235 use crate::usage::{
1236 ResetCredit, ResetCredits, SuperGrokPeriod, SuperGrokProduct, SuperGrokSnapshot,
1237 VendorSnapshot,
1238 };
1239
1240 let state = TabState::Ready(Box::new(ReadyTab {
1241 snapshot: VendorSnapshot::SuperGrok(SuperGrokSnapshot {
1242 plan: "SuperGrok Heavy".into(),
1243 account: "scope".into(),
1244 weekly_pct: 97,
1245 period: SuperGrokPeriod::Weekly,
1246 reset_at: Some(at(26, 0, 0)),
1247 prepaid_balance: None,
1248 reset_credits: ResetCredits {
1250 available: 0,
1251 credits: vec![ResetCredit {
1252 title: None,
1253 expires_at: Some(at(25, 0, 0)),
1254 }],
1255 },
1256 products: vec![SuperGrokProduct {
1257 label: "Grok Build".into(),
1258 percent: 94,
1259 }],
1260 }),
1261 stale: false,
1262 last_error: None,
1263 fetched_at: None,
1264 display: Default::default(),
1265 }));
1266 let projected = RefreshInput::from_tab(
1267 &TabId::vendor(crate::vendor::VendorId::Supergrok),
1268 &state,
1269 at(23, 12, 0),
1270 )
1271 .expect("ready tab projects");
1272 assert_eq!(projected.rows.len(), 1, "{:?}", projected.rows);
1275 assert!(projected.credits.is_empty());
1276 }
1277
1278 fn antigravity_ready(
1279 session: i32,
1280 third_party_session: i32,
1281 weekly: i32,
1282 third_party_weekly: i32,
1283 ) -> crate::tui::app::TabState {
1284 use crate::tui::app::{ReadyTab, TabState};
1285 use crate::usage::{AntigravitySnapshot, AntigravitySource, UsageWindow, VendorSnapshot};
1286
1287 let window = |pct: i32, resets_at: DateTime<Utc>, hours: i64| {
1288 Some(UsageWindow {
1289 utilization_pct: pct,
1290 resets_at: Some(resets_at),
1291 window_duration: chrono::Duration::hours(hours),
1292 })
1293 };
1294 TabState::Ready(Box::new(ReadyTab {
1295 snapshot: VendorSnapshot::Antigravity(AntigravitySnapshot {
1296 plan: "Google AI Pro".into(),
1297 account: "acct:test".into(),
1298 source: AntigravitySource::Local,
1299 session: window(session, at(24, 2, 21), 5),
1300 weekly: window(weekly, at(26, 1, 34), 168),
1301 third_party_session: window(third_party_session, at(24, 2, 19), 5),
1302 third_party_weekly: window(third_party_weekly, at(30, 19, 19), 168),
1303 }),
1304 stale: false,
1305 last_error: None,
1306 fetched_at: None,
1307 display: Default::default(),
1308 }))
1309 }
1310
1311 #[test]
1315 fn from_tab_names_repeated_labels_by_their_heading() {
1316 use crate::tui::app::TabId;
1317
1318 let projected = RefreshInput::from_tab(
1319 &TabId::vendor(crate::vendor::VendorId::Antigravity),
1320 &antigravity_ready(3, 100, 98, 56),
1321 at(23, 22, 0),
1322 )
1323 .expect("ready tab projects");
1324 let windows: Vec<_> = projected
1325 .rows
1326 .iter()
1327 .map(|row| row.window.as_str())
1328 .collect();
1329 assert_eq!(
1330 windows,
1331 [
1332 "Session · Gemini",
1333 "Session · Claude & GPT OSS",
1334 "Weekly · Gemini",
1335 "Weekly · Claude & GPT OSS",
1336 ]
1337 );
1338 }
1339
1340 #[test]
1345 fn a_window_whose_label_repeats_notifies_once() {
1346 use crate::tui::app::TabId;
1347
1348 let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1349 let state = antigravity_ready(3, 100, 98, 56);
1350 let mut notify_state = NotifyState::default();
1351 let fired: Vec<Vec<String>> = (0..4)
1352 .map(|refresh| {
1353 let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh);
1354 let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1355 decide(&input, 97, &mut notify_state, now)
1356 .into_iter()
1357 .map(|notification| notification.title)
1358 .collect()
1359 })
1360 .collect();
1361 assert_eq!(
1362 fired[0],
1363 [
1364 "Antigravity — Session · Claude & GPT OSS at 100%",
1365 "Antigravity — Weekly · Gemini at 98%",
1366 ]
1367 );
1368 assert!(
1369 fired[1..].iter().all(Vec::is_empty),
1370 "later refreshes must stay quiet: {fired:?}"
1371 );
1372 }
1373
1374 #[test]
1376 fn a_window_keeps_its_key_when_its_sibling_disappears_or_returns() {
1377 use crate::tui::app::{TabId, TabState};
1378 use crate::usage::VendorSnapshot;
1379
1380 let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1381 for starts_with_session in [false, true] {
1382 let mut notify_state = NotifyState::default();
1383 let steps = [
1387 (starts_with_session, 98, 1),
1388 (!starts_with_session, 98, 0),
1389 (starts_with_session, 98, 0),
1390 (false, 20, 0),
1391 (true, 98, 1),
1392 (false, 98, 0),
1393 ];
1394 for (refresh, (has_session, weekly_pct, expected)) in steps.into_iter().enumerate() {
1395 let mut state = antigravity_ready(3, 10, weekly_pct, 56);
1396 let TabState::Ready(ready) = &mut state else {
1397 unreachable!();
1398 };
1399 let VendorSnapshot::Antigravity(snapshot) = &mut ready.snapshot else {
1400 unreachable!();
1401 };
1402 if !has_session {
1403 snapshot.session = None;
1404 snapshot.third_party_session = None;
1405 }
1406 let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh as i64);
1407 let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1408 let fired = decide(&input, 97, &mut notify_state, now);
1409 assert_eq!(
1410 fired.len(),
1411 expected,
1412 "starts_with_session={starts_with_session}, refresh={refresh}: {fired:?}"
1413 );
1414 if let Some(notification) = fired.first() {
1415 assert_eq!(notification.key, "antigravity::Weekly · Gemini");
1416 }
1417 }
1418 }
1419 }
1420
1421 #[test]
1424 fn metric_names_follow_heading_scope_even_for_unique_labels() {
1425 use crate::balance::MetricHeadline;
1426 use crate::pacing::PaceSeverity;
1427 use crate::tui::panels::{Section, SectionProjection};
1428
1429 let projection = |section| SectionProjection {
1430 section,
1431 reset_at: None,
1432 window: None,
1433 group: None,
1434 headline: MetricHeadline::Percent,
1435 used_cents: None,
1436 limit_cents: None,
1437 };
1438 let metric = |label: &str, pct| {
1439 projection(Section::Metric {
1440 label: label.into(),
1441 pct,
1442 severity: PaceSeverity::Low,
1443 value_label: format!("{pct}%"),
1444 footnote: String::new(),
1445 })
1446 };
1447 let heading = |label: &str| {
1448 projection(Section::Text {
1449 label: label.into(),
1450 value: String::new(),
1451 })
1452 };
1453 let rows = metric_rows([
1454 metric("Unscoped", 20),
1455 heading("Session"),
1456 projection(Section::Spacer),
1457 metric("General", 40),
1458 metric("Video", 10),
1459 projection(Section::Spacer),
1460 heading("Weekly"),
1461 metric("General", 60),
1462 projection(Section::Text {
1463 label: "Info".into(),
1464 value: "Details".into(),
1465 }),
1466 metric("Other", 30),
1467 ]);
1468 let windows: Vec<_> = rows.iter().map(|row| row.window.as_str()).collect();
1469 assert_eq!(
1470 windows,
1471 [
1472 "Unscoped",
1473 "Session · General",
1474 "Session · Video",
1475 "Weekly · General",
1476 "Other",
1477 ]
1478 );
1479 }
1480
1481 #[test]
1482 fn from_tab_returns_none_for_unready_states() {
1483 use crate::tui::app::{TabId, TabState};
1484 assert!(
1485 RefreshInput::from_tab(
1486 &TabId::vendor(crate::vendor::VendorId::Zai),
1487 &TabState::Loading,
1488 at(23, 12, 0)
1489 )
1490 .is_none()
1491 );
1492 assert!(
1493 RefreshInput::from_tab(
1494 &TabId::vendor(crate::vendor::VendorId::Zai),
1495 &TabState::error("not signed in"),
1496 at(23, 12, 0)
1497 )
1498 .is_none()
1499 );
1500 }
1501}