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 pub regen: Option<String>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct CreditExpiry {
124 pub title: Option<String>,
125 pub expires_at: DateTime<Utc>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct RefreshInput {
131 pub entry_id: String,
134 pub vendor: String,
137 pub account: Option<String>,
138 pub rows: Vec<MetricRow>,
139 pub credits: Vec<CreditExpiry>,
140}
141
142pub fn decide(
157 input: &RefreshInput,
158 threshold: u8,
159 state: &mut NotifyState,
160 now: DateTime<Utc>,
161) -> Vec<Notification> {
162 migrate_anthropic_api_spend_key(input, state);
163 let mut fired = Vec::new();
164 let threshold = threshold as i32;
165 let rearm_below = threshold - HYSTERESIS_PCT;
166 for row in &input.rows {
167 let key = format!("{}::{}", input.entry_id, row.window);
168 let pct = row.percent as i32;
169 if pct >= threshold {
170 let armed = match state.entries.get(&key) {
171 None => true,
172 Some(_) if row.regen.is_some() => false,
175 Some(record) => reset_moved_later(row.reset_at, record.reset_at_snapshot),
176 };
177 if armed {
178 fired.push(threshold_notification(input, row, now));
179 state.entries.insert(
180 key,
181 KeyRecord {
182 notified_at: now,
183 reset_at_snapshot: row.reset_at,
184 },
185 );
186 }
187 } else if pct < rearm_below {
188 state.entries.remove(&key);
191 }
192 }
193 for credit in &input.credits {
194 let remaining = credit.expires_at.signed_duration_since(now).num_seconds();
195 if remaining <= 0 || remaining > CREDIT_WARNING_SECS {
196 continue;
197 }
198 let key = format!(
199 "{}::credit::{}",
200 input.entry_id,
201 credit
202 .expires_at
203 .to_rfc3339_opts(SecondsFormat::AutoSi, true)
204 );
205 if state.entries.contains_key(&key) {
206 continue;
207 }
208 fired.push(credit_notification(input, credit, now));
209 state.entries.insert(
210 key,
211 KeyRecord {
212 notified_at: now,
213 reset_at_snapshot: None,
214 },
215 );
216 }
217 fired
218}
219
220fn migrate_anthropic_api_spend_key(input: &RefreshInput, state: &mut NotifyState) {
224 if input.entry_id != "anthropic_api" && !input.entry_id.starts_with("anthropic_api@") {
225 return;
226 }
227 let prefix = format!("{}::", input.entry_id);
228 let stable = format!("{prefix}Spend (mo)");
229 let legacy: Vec<String> = state
230 .entries
231 .keys()
232 .filter(|key| {
233 let Some(label) = key.strip_prefix(&prefix) else {
234 return false;
235 };
236 label == "Completed daily buckets this month"
237 || (label.starts_with("Month to date (through ") && label.ends_with(')'))
238 })
239 .cloned()
240 .collect();
241 if legacy.is_empty() {
242 return;
243 }
244 let mut chosen = state.entries.get(&stable).cloned();
247 if chosen.is_none() {
248 chosen = legacy
249 .iter()
250 .filter_map(|key| state.entries.get(key).cloned())
251 .max_by_key(|record| record.notified_at);
252 }
253 for key in legacy {
254 state.entries.remove(&key);
255 }
256 if let Some(record) = chosen {
257 state.entries.insert(stable, record);
258 }
259}
260
261fn reset_moved_later(current: Option<DateTime<Utc>>, snapshot: Option<DateTime<Utc>>) -> bool {
266 match (current, snapshot) {
267 (Some(current), Some(snapshot)) => {
268 current.signed_duration_since(snapshot).num_seconds() > RESET_MOVE_TOLERANCE_SECS
269 }
270 (Some(_), None) => true,
271 _ => false,
272 }
273}
274
275fn threshold_notification(
276 input: &RefreshInput,
277 row: &MetricRow,
278 now: DateTime<Utc>,
279) -> Notification {
280 let who = match &input.account {
281 Some(account) => format!("{} · {}", input.vendor, account),
282 None => input.vendor.clone(),
283 };
284 let title = sanitize_untrusted_line(&format!("{} — {} at {}%", who, row.window, row.percent));
285 let mut body = format!("{}% of the {} used", row.percent, row.window);
286 if let Some(reset_at) = row.reset_at {
287 let remaining = countdown::format(Some(reset_at), now);
288 let action = match &row.regen {
289 Some(amount) if remaining == "now" => format!("regenerates {amount}"),
290 Some(amount) => format!("regenerates {amount} in"),
291 None => "resets".into(),
292 };
293 body.push_str(&format!(
294 " · {action} {remaining} ({})",
295 format::local_time_hm(reset_at)
296 ));
297 }
298 Notification {
299 title,
300 body: sanitize_untrusted_line(&body),
301 urgency: if row.percent >= 100 {
302 Urgency::Critical
303 } else {
304 Urgency::Normal
305 },
306 key: format!("{}::{}", input.entry_id, row.window),
307 }
308}
309
310fn credit_notification(
311 input: &RefreshInput,
312 credit: &CreditExpiry,
313 now: DateTime<Utc>,
314) -> Notification {
315 let label = credit.title.as_deref().unwrap_or("Reset credit");
316 let who = match &input.account {
317 Some(account) => format!("{} · {}", input.vendor, account),
318 None => input.vendor.clone(),
319 };
320 let title = sanitize_untrusted_line(&format!(
321 "{} — reset credit expires {}",
322 who,
323 format::local_date_hm(credit.expires_at)
324 ));
325 let body = sanitize_untrusted_line(&format!(
326 "{} — redeem within {} ({})",
327 label,
328 countdown::format(Some(credit.expires_at), now),
329 format::local_time_hm(credit.expires_at)
330 ));
331 Notification {
332 title,
333 body,
334 urgency: Urgency::Normal,
335 key: format!(
336 "{}::credit::{}",
337 input.entry_id,
338 credit
339 .expires_at
340 .to_rfc3339_opts(SecondsFormat::AutoSi, true)
341 ),
342 }
343}
344
345#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
352#[serde(transparent)]
353pub struct NotifyState {
354 entries: BTreeMap<String, KeyRecord>,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358struct KeyRecord {
359 notified_at: DateTime<Utc>,
360 reset_at_snapshot: Option<DateTime<Utc>>,
361}
362
363impl NotifyState {
364 #[cfg(test)]
365 fn is_notified(&self, key: &str) -> bool {
366 self.entries.contains_key(key)
367 }
368
369 fn read_from(path: &Path) -> Self {
372 let Ok(raw) = std::fs::read_to_string(path) else {
373 return Self::default();
374 };
375 serde_json::from_str(&raw).unwrap_or_default()
376 }
377
378 fn write_to(&self, path: &Path) -> crate::error::Result<()> {
381 let bytes = serde_json::to_vec(&self.entries)?;
382 crate::cache::atomic_write(path, &bytes)?;
383 #[cfg(unix)]
384 {
385 use std::os::unix::fs::PermissionsExt;
386 if let Ok(meta) = std::fs::metadata(path) {
387 let mut perms = meta.permissions();
388 perms.set_mode(0o600);
389 let _ = std::fs::set_permissions(path, perms);
390 }
391 }
392 Ok(())
393 }
394}
395
396pub trait NotifySink {
404 fn deliver(&mut self, notification: &Notification);
405}
406
407#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
409#[derive(Debug, Default)]
410pub struct NoopSink;
411
412#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
413impl NotifySink for NoopSink {
414 fn deliver(&mut self, _notification: &Notification) {}
415}
416
417#[cfg(target_os = "linux")]
422#[derive(Debug, Clone)]
423pub struct NotifySendSink {
424 program: String,
425}
426
427#[cfg(target_os = "linux")]
428impl Default for NotifySendSink {
429 fn default() -> Self {
430 Self::new()
431 }
432}
433
434#[cfg(target_os = "linux")]
435impl NotifySendSink {
436 pub fn new() -> Self {
437 Self {
438 program: "notify-send".to_string(),
439 }
440 }
441
442 pub fn with_program(program: impl Into<String>) -> Self {
445 Self {
446 program: program.into(),
447 }
448 }
449
450 fn argv(&self, notification: &Notification) -> Vec<String> {
455 let mut args = vec![
456 "-a".to_string(),
457 "ai-usagebar".to_string(),
458 "-c".to_string(),
459 "quota".to_string(),
460 "-u".to_string(),
461 notification.urgency.as_arg().to_string(),
462 ];
463 if notification.urgency == Urgency::Critical {
464 args.push("-t".to_string());
465 args.push(CRITICAL_TIMEOUT_MS.to_string());
466 }
467 args.push(notification.title.clone());
468 args.push(notification.body.clone());
469 args
470 }
471}
472
473#[cfg(target_os = "linux")]
474impl NotifySink for NotifySendSink {
475 fn deliver(&mut self, notification: &Notification) {
476 let spawned = std::process::Command::new(&self.program)
477 .args(self.argv(notification))
478 .stdin(std::process::Stdio::null())
479 .stdout(std::process::Stdio::null())
480 .stderr(std::process::Stdio::null())
481 .spawn();
482 if let Ok(child) = spawned {
483 reap(child);
484 }
485 }
486}
487
488#[cfg(any(target_os = "linux", target_os = "macos"))]
491fn reap(mut child: std::process::Child) {
492 let deadline = std::time::Instant::now() + SPAWN_KILL_AFTER;
493 loop {
494 match child.try_wait() {
495 Ok(Some(_)) => return,
496 Ok(None) => {}
497 Err(_) => return,
498 }
499 if std::time::Instant::now() >= deadline {
500 let _ = child.kill();
501 let _ = child.wait();
502 return;
503 }
504 std::thread::sleep(Duration::from_millis(25));
505 }
506}
507
508fn production_sink() -> Box<dyn NotifySink + Send> {
509 #[cfg(target_os = "linux")]
510 return Box::new(NotifySendSink::new());
511 #[cfg(target_os = "macos")]
512 return Box::new(MacNotificationSink);
513 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
514 return Box::new(NoopSink);
515}
516
517#[cfg(target_os = "macos")]
520struct MacNotificationSink;
521
522#[cfg(target_os = "macos")]
523impl NotifySink for MacNotificationSink {
524 fn deliver(&mut self, notification: &Notification) {
525 const SCRIPT: &str = "on run argv\n display notification (item 2 of argv) with title (item 1 of argv)\nend run";
526 let spawned = std::process::Command::new("/usr/bin/osascript")
527 .arg("-e")
528 .arg(SCRIPT)
529 .arg("--")
530 .arg(¬ification.title)
531 .arg(¬ification.body)
532 .stdin(std::process::Stdio::null())
533 .stdout(std::process::Stdio::null())
534 .stderr(std::process::Stdio::null())
535 .spawn();
536 if let Ok(child) = spawned {
537 reap(child);
538 }
539 }
540}
541
542pub async fn run(input: RefreshInput, threshold: u8) {
547 let Some(state_path) = default_state_path() else {
548 return;
549 };
550 let joined = tokio::task::spawn_blocking(move || {
553 let mut sink = production_sink();
554 run_at(
555 &input,
556 threshold,
557 &state_path,
558 LOCK_TIMEOUT,
559 sink.as_mut(),
560 Utc::now(),
561 );
562 });
563 let _ = joined.await;
564}
565
566pub fn default_state_path() -> Option<PathBuf> {
569 crate::cache::xdg_cache_dir()
570 .ok()
571 .map(|base| base.join("ai-usagebar").join("notifications.json"))
572}
573
574pub fn run_at(
580 input: &RefreshInput,
581 threshold: u8,
582 state_path: &Path,
583 lock_timeout: Duration,
584 sink: &mut dyn NotifySink,
585 now: DateTime<Utc>,
586) {
587 let lock_path = state_path.with_file_name(".notifications.lock");
588 let Ok(_guard) = crate::cache::acquire_lock(&lock_path, lock_timeout) else {
589 return;
590 };
591 let mut state = NotifyState::read_from(state_path);
592 let before = serde_json::to_string(&state.entries).unwrap_or_default();
593 let fired = decide(input, threshold, &mut state, now);
594 if serde_json::to_string(&state.entries).unwrap_or_default() != before
595 && state.write_to(state_path).is_err()
596 {
597 return;
600 }
601 for notification in &fired {
602 sink.deliver(notification);
603 }
604}
605
606impl RefreshInput {
609 pub(crate) fn from_tab(
616 tab: &crate::tui::app::TabId,
617 state: &crate::tui::app::TabState,
618 now: DateTime<Utc>,
619 ) -> Option<Self> {
620 use crate::tui::app::{TabSource, TabState};
621 use crate::tui::panels::sections_with_metadata_for;
622
623 let TabState::Ready(ready) = state else {
624 return None;
625 };
626 let (vendor, entry_id) = match &tab.source {
627 TabSource::Builtin(vendor) => (
628 vendor.display_name().to_string(),
629 crate::report::tab_id(tab),
630 ),
631 TabSource::Custom { name, .. } => (name.clone(), crate::report::tab_id(tab)),
632 };
633 let rows = metric_rows(sections_with_metadata_for(state, now, 0));
636 let credits = ready
637 .snapshot
638 .reset_credits()
639 .filter(|credits| credits.available > 0)
640 .map(|credits| {
641 credits
642 .credits
643 .iter()
644 .filter_map(|credit| {
645 credit.expires_at.map(|expires_at| CreditExpiry {
646 title: credit.title.clone(),
647 expires_at,
648 })
649 })
650 .collect()
651 })
652 .unwrap_or_default();
653 Some(Self {
654 entry_id,
655 vendor,
656 account: tab.account.clone(),
657 rows,
658 credits,
659 })
660 }
661}
662
663fn metric_rows(
675 sections: impl IntoIterator<Item = crate::tui::panels::SectionProjection>,
676) -> Vec<MetricRow> {
677 use crate::tui::panels::Section;
678
679 let mut heading: Option<String> = None;
682 let mut rows = Vec::new();
683 for projected in sections {
684 if projected.group.is_some() {
685 continue;
686 }
687 match projected.section {
688 Section::Text { label, value } if value.is_empty() => heading = Some(label),
689 Section::Metric { label, pct, .. } => {
690 let window = match &heading {
691 Some(heading) => format!("{heading} · {label}"),
692 None => label,
693 };
694 rows.push(MetricRow {
695 window,
696 percent: pct,
697 reset_at: projected.reset_at,
698 regen: projected.regen,
699 });
700 }
701 Section::Spacer => {}
702 _ => heading = None,
703 }
704 }
705 rows
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use chrono::TimeZone;
712 use tempfile::TempDir;
713
714 fn at(day: u32, hour: u32, min: u32) -> DateTime<Utc> {
715 Utc.with_ymd_and_hms(2026, 9, day, hour, min, 0).unwrap()
716 }
717
718 fn row(window: &str, percent: u16, reset_at: Option<DateTime<Utc>>) -> MetricRow {
719 MetricRow {
720 window: window.to_string(),
721 percent,
722 reset_at,
723 regen: None,
724 }
725 }
726
727 fn input(rows: Vec<MetricRow>) -> RefreshInput {
728 RefreshInput {
729 entry_id: "anthropic@gmail".into(),
730 vendor: "Claude".into(),
731 account: Some("gmail".into()),
732 rows,
733 credits: Vec::new(),
734 }
735 }
736
737 #[derive(Default)]
739 struct RecorderSink {
740 delivered: Vec<Notification>,
741 }
742
743 impl NotifySink for RecorderSink {
744 fn deliver(&mut self, notification: &Notification) {
745 self.delivered.push(notification.clone());
746 }
747 }
748
749 fn state_path() -> (TempDir, PathBuf) {
750 crate::cache::closed_temp_file("notifications.json", None)
751 }
752
753 #[test]
756 fn crossing_at_exactly_the_threshold_fires_normal() {
757 let mut state = NotifyState::default();
758 let now = at(23, 12, 0);
759 let reset = at(24, 12, 0);
760 let fired = decide(
761 &input(vec![row("Session (5h)", 97, Some(reset))]),
762 97,
763 &mut state,
764 now,
765 );
766 assert_eq!(fired.len(), 1);
767 assert_eq!(fired[0].urgency, Urgency::Normal);
768 assert_eq!(fired[0].key, "anthropic@gmail::Session (5h)");
769 assert_eq!(fired[0].title, "Claude · gmail — Session (5h) at 97%");
770 assert_eq!(
774 fired[0].body,
775 format!(
776 "97% of the Session (5h) used · resets 1d 0h ({})",
777 format::local_time_hm(reset)
778 )
779 );
780 }
781
782 #[test]
783 fn below_the_threshold_fires_nothing() {
784 let mut state = NotifyState::default();
785 let fired = decide(
786 &input(vec![row("Session (5h)", 96, None)]),
787 97,
788 &mut state,
789 at(23, 12, 0),
790 );
791 assert!(fired.is_empty());
792 assert!(state.entries.is_empty());
793 }
794
795 #[test]
796 fn exhausted_windows_fire_critical() {
797 let mut state = NotifyState::default();
798 let fired = decide(
799 &input(vec![row("Weekly (7d)", 100, None)]),
800 97,
801 &mut state,
802 at(23, 12, 0),
803 );
804 assert_eq!(fired.len(), 1);
805 assert_eq!(fired[0].urgency, Urgency::Critical);
806 assert_eq!(fired[0].title, "Claude · gmail — Weekly (7d) at 100%");
807 }
808
809 #[test]
810 fn the_same_crossing_never_fires_twice() {
811 let mut state = NotifyState::default();
812 let high = input(vec![row("Weekly (7d)", 98, Some(at(30, 0, 0)))]);
813 let now = at(23, 12, 0);
814 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
815 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
816 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
817 }
818
819 #[test]
822 fn hysteresis_rearms_only_below_threshold_minus_seven() {
823 let mut state = NotifyState::default();
824 let now = at(23, 12, 0);
825 let fires_at = |pct: u16, state: &mut NotifyState| {
826 let case = input(vec![row("Weekly (7d)", pct, None)]);
827 decide(&case, 97, state, now).len()
828 };
829
830 assert_eq!(fires_at(97, &mut state), 1, "first crossing fires");
831 assert_eq!(fires_at(96, &mut state), 0, "hovering does not re-fire");
832 assert_eq!(fires_at(90, &mut state), 0);
834 assert!(state.is_notified("anthropic@gmail::Weekly (7d)"));
835 assert_eq!(fires_at(89, &mut state), 0);
837 assert!(!state.is_notified("anthropic@gmail::Weekly (7d)"));
838 assert_eq!(fires_at(97, &mut state), 1, "re-armed crossing fires");
840 }
841
842 #[test]
843 fn a_jittered_reset_instant_is_the_same_window() {
844 let mut state = NotifyState::default();
849 let now = at(23, 12, 0);
850 let reset = at(23, 17, 0);
851 let first = input(vec![row("Session (5h)", 100, Some(reset))]);
852 assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
853
854 for drift in [
855 chrono::Duration::milliseconds(488),
856 chrono::Duration::milliseconds(700),
857 chrono::Duration::seconds(59),
858 ] {
859 let jittered = input(vec![row("Session (5h)", 100, Some(reset + drift))]);
860 assert_eq!(
861 decide(&jittered, 97, &mut state, now).len(),
862 0,
863 "{drift:?} later is the same window"
864 );
865 }
866
867 let next_window = input(vec![row(
869 "Session (5h)",
870 100,
871 Some(reset + chrono::Duration::hours(5)),
872 )]);
873 assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
874 }
875
876 #[test]
877 fn a_later_reset_rearms_the_key() {
878 let mut state = NotifyState::default();
879 let now = at(23, 12, 0);
880 let first = input(vec![row("Session (5h)", 98, Some(at(23, 17, 0)))]);
881 assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
882
883 assert_eq!(decide(&first, 97, &mut state, now).len(), 0);
885 let earlier = input(vec![row("Session (5h)", 98, Some(at(23, 16, 0)))]);
887 assert_eq!(decide(&earlier, 97, &mut state, now).len(), 0);
888 let absent = input(vec![row("Session (5h)", 98, None)]);
889 assert_eq!(decide(&absent, 97, &mut state, now).len(), 0);
890 let next_window = input(vec![row("Session (5h)", 98, Some(at(24, 2, 0)))]);
892 assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
893 }
894
895 #[test]
896 fn a_reset_appearing_where_none_was_reported_rearms() {
897 let mut state = NotifyState::default();
898 let now = at(23, 12, 0);
899 let without = input(vec![row("Weekly quota", 99, None)]);
900 assert_eq!(decide(&without, 97, &mut state, now).len(), 1);
901 assert_eq!(decide(&without, 97, &mut state, now).len(), 0);
902 let with = input(vec![row("Weekly quota", 99, Some(at(30, 0, 0)))]);
903 assert_eq!(
904 decide(&with, 97, &mut state, now).len(),
905 1,
906 "the vendor starting to report a reset is a new window"
907 );
908 }
909
910 #[test]
911 fn a_refill_tick_is_not_a_new_window() {
912 let mut state = NotifyState::default();
916 let now = at(23, 12, 0);
917 let tick = chrono::Duration::minutes(202);
918 let refilling = |percent: u16, reset_at: DateTime<Utc>| {
919 let mut metric = row("Weekly credits", percent, Some(reset_at));
920 metric.regen = Some("2%".into());
921 input(vec![metric])
922 };
923 let next = at(23, 15, 22);
924
925 let exhausted = refilling(100, next);
926 assert_eq!(decide(&exhausted, 97, &mut state, now).len(), 1);
927 let refilled = refilling(98, next + tick);
929 assert_eq!(decide(&refilled, 97, &mut state, now).len(), 0);
930 let hovering = refilling(97, next + tick * 2);
931 assert_eq!(decide(&hovering, 97, &mut state, now).len(), 0);
932
933 let dropped = refilling(89, next + tick * 6);
935 assert_eq!(decide(&dropped, 97, &mut state, now).len(), 0);
936 let crossed = refilling(97, next + tick * 7);
937 assert_eq!(decide(&crossed, 97, &mut state, now).len(), 1);
938 }
939
940 #[test]
943 fn absent_reset_at_omits_the_eta_from_the_body() {
944 let mut state = NotifyState::default();
945 let fired = decide(
946 &input(vec![row("Weekly (7d)", 97, None)]),
947 97,
948 &mut state,
949 at(23, 12, 0),
950 );
951 assert_eq!(fired[0].body, "97% of the Weekly (7d) used");
952 assert!(!fired[0].body.contains("resets"));
953 }
954
955 #[test]
956 fn account_label_is_omitted_from_the_title_when_absent() {
957 let mut state = NotifyState::default();
958 let mut plain = input(vec![row("Weekly (7d)", 97, None)]);
959 plain.entry_id = "zai".into();
960 plain.vendor = "Z.AI".into();
961 plain.account = None;
962 let fired = decide(&plain, 97, &mut state, at(23, 12, 0));
963 assert_eq!(fired[0].title, "Z.AI — Weekly (7d) at 97%");
964 }
965
966 #[test]
967 fn untrusted_labels_are_sanitized_at_the_subprocess_boundary() {
968 let mut state = NotifyState::default();
969 let mut hostile = input(vec![row("W\u{1b}[31m", 97, None)]);
970 hostile.account = Some("gmail\n\u{202E}evil".into());
971 let fired = decide(&hostile, 97, &mut state, at(23, 12, 0));
972 assert!(
973 !fired[0].title.chars().any(|c| c.is_control()),
974 "{}",
975 fired[0].title
976 );
977 assert!(!fired[0].title.contains('\u{202E}'), "{}", fired[0].title);
978 assert!(!fired[0].body.contains('\u{1B}'), "{}", fired[0].body);
979 }
980
981 #[test]
984 fn banked_credit_fires_only_inside_the_48h_window() {
985 let now = at(23, 12, 0);
986 for (offset_secs, expected) in [(47 * 3600, 1), (48 * 3600, 1), (48 * 3600 + 1, 0), (-1, 0)]
987 {
988 let mut state = NotifyState::default();
989 let case = RefreshInput {
990 credits: vec![CreditExpiry {
991 title: Some("Full reset".into()),
992 expires_at: now + chrono::Duration::seconds(offset_secs),
993 }],
994 ..input(Vec::new())
995 };
996 let fired = decide(&case, 97, &mut state, now);
997 assert_eq!(fired.len(), expected, "offset {offset_secs}s");
998 }
999 }
1000
1001 #[test]
1002 fn banked_credit_title_and_body_name_the_expiry() {
1003 let now = at(23, 12, 0);
1004 let mut state = NotifyState::default();
1005 let case = RefreshInput {
1006 credits: vec![CreditExpiry {
1007 title: Some("Full reset (Weekly + 5 hr)".into()),
1008 expires_at: now + chrono::Duration::hours(3),
1009 }],
1010 ..input(Vec::new())
1011 };
1012 let fired = decide(&case, 97, &mut state, now);
1013 assert_eq!(fired.len(), 1);
1014 assert_eq!(fired[0].urgency, Urgency::Normal);
1015 assert!(
1016 fired[0]
1017 .title
1018 .starts_with("Claude · gmail — reset credit expires "),
1019 "{}",
1020 fired[0].title
1021 );
1022 assert!(
1023 fired[0]
1024 .body
1025 .starts_with("Full reset (Weekly + 5 hr) — redeem within 3h"),
1026 "{}",
1027 fired[0].body
1028 );
1029 }
1030
1031 #[test]
1032 fn banked_credit_dedupes_on_the_expiry_instant() {
1033 let now = at(23, 12, 0);
1034 let mut state = NotifyState::default();
1035 let expiry = now + chrono::Duration::hours(10);
1036 let case = RefreshInput {
1037 credits: vec![CreditExpiry {
1038 title: None,
1039 expires_at: expiry,
1040 }],
1041 ..input(Vec::new())
1042 };
1043 assert_eq!(decide(&case, 97, &mut state, now).len(), 1);
1044 assert_eq!(
1045 decide(&case, 97, &mut state, now).len(),
1046 0,
1047 "same expiry is one notification"
1048 );
1049 assert!(
1050 state
1051 .entries
1052 .keys()
1053 .all(|key| key.starts_with("anthropic@gmail::credit::")),
1054 "{:?}",
1055 state.entries.keys().collect::<Vec<_>>()
1056 );
1057 let later = RefreshInput {
1059 credits: vec![CreditExpiry {
1060 title: None,
1061 expires_at: now + chrono::Duration::hours(20),
1062 }],
1063 ..input(Vec::new())
1064 };
1065 assert_eq!(decide(&later, 97, &mut state, now).len(), 1);
1066 }
1067
1068 #[test]
1069 fn credit_expiry_alerts_identify_each_named_account() {
1070 use crate::tui::app::{ReadyTab, TabId, TabState};
1071 use crate::usage::{
1072 OpenAiSnapshot, OpenAiSource, ResetCredit, ResetCredits, VendorSnapshot,
1073 };
1074
1075 let now = at(23, 12, 0);
1076 let expiry = now + chrono::Duration::hours(3);
1077 let ready = TabState::Ready(Box::new(ReadyTab {
1078 snapshot: VendorSnapshot::Openai(OpenAiSnapshot {
1079 plan: "ChatGPT Pro".into(),
1080 session: None,
1081 weekly: None,
1082 code_review: None,
1083 additional_limits: Vec::new(),
1084 unavailable_models: Vec::new(),
1085 credits: None,
1086 reset_credits: Some(ResetCredits {
1087 available: 1,
1088 credits: vec![ResetCredit {
1089 title: Some("Full reset".into()),
1090 expires_at: Some(expiry),
1091 }],
1092 }),
1093 source: OpenAiSource::CodexOauth,
1094 }),
1095 stale: false,
1096 last_error: None,
1097 fetched_at: Some(now),
1098 display: Default::default(),
1099 }));
1100 let mut state = NotifyState::default();
1101 for account in [Some("personal"), Some("work"), None] {
1102 let tab = account.map_or_else(
1103 || TabId::vendor(crate::vendor::VendorId::Openai),
1104 |label| TabId::account_for(crate::vendor::VendorId::Openai, label),
1105 );
1106 let input = RefreshInput::from_tab(&tab, &ready, now).unwrap();
1107 let fired = decide(&input, 97, &mut state, now);
1108 assert_eq!(fired.len(), 1);
1109 let who = account.map_or(input.vendor.clone(), |label| {
1110 format!("{} · {label}", input.vendor)
1111 });
1112 assert_eq!(
1113 fired[0].title,
1114 format!(
1115 "{who} — reset credit expires {}",
1116 format::local_date_hm(expiry)
1117 )
1118 );
1119 assert!(fired[0].body.starts_with("Full reset — redeem within 3h"));
1120 assert_eq!(fired[0].urgency, Urgency::Normal);
1121 assert!(
1122 fired[0]
1123 .key
1124 .starts_with(&format!("{}::credit::", input.entry_id))
1125 );
1126 assert!(decide(&input, 97, &mut state, now).is_empty());
1127 }
1128 }
1129
1130 #[test]
1133 fn state_round_trips_through_disk_and_tolerates_corruption() {
1134 let (dir, path) = state_path();
1135 let mut state = NotifyState::default();
1136 state.entries.insert(
1137 "anthropic::Weekly (7d)".into(),
1138 KeyRecord {
1139 notified_at: at(23, 12, 0),
1140 reset_at_snapshot: Some(at(30, 0, 0)),
1141 },
1142 );
1143 state.write_to(&path).unwrap();
1144 assert_eq!(NotifyState::read_from(&path), state);
1145
1146 std::fs::write(&path, "{ not json").unwrap();
1147 assert_eq!(NotifyState::read_from(&path), NotifyState::default());
1148 drop(dir);
1149 }
1150
1151 #[test]
1152 #[cfg(unix)]
1153 fn state_file_is_mode_0600() {
1154 use std::os::unix::fs::PermissionsExt;
1155 let (dir, path) = state_path();
1156 NotifyState::default().write_to(&path).unwrap();
1157 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1158 assert_eq!(mode & 0o777, 0o600);
1159 drop(dir);
1160 }
1161
1162 #[test]
1163 fn rearm_survives_a_simulated_restart() {
1164 let (dir, path) = state_path();
1165 let now = at(23, 12, 0);
1166 let mut state = NotifyState::read_from(&path);
1167
1168 let high = input(vec![row("Weekly (7d)", 97, None)]);
1169 let low = input(vec![row("Weekly (7d)", 89, None)]);
1170 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
1171 state.write_to(&path).unwrap();
1172
1173 let mut state = NotifyState::read_from(&path);
1175 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
1176
1177 decide(&low, 97, &mut state, now);
1179 state.write_to(&path).unwrap();
1180
1181 let mut state = NotifyState::read_from(&path);
1183 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
1184 drop(dir);
1185 }
1186
1187 #[test]
1188 fn anthropic_api_daily_notification_keys_migrate_without_refiring() {
1189 let (dir, path) = state_path();
1190 let old = at(17, 9, 0);
1191 let newest = at(18, 9, 0);
1192 let mut state = NotifyState::default();
1193 for (label, notified_at) in [
1194 ("Month to date (through Sep 17)", old),
1195 ("Month to date (through Sep 18)", newest),
1196 ("Completed daily buckets this month", old),
1197 ] {
1198 state.entries.insert(
1199 format!("anthropic_api::{label}"),
1200 KeyRecord {
1201 notified_at,
1202 reset_at_snapshot: None,
1203 },
1204 );
1205 }
1206 state.write_to(&path).unwrap();
1207 let input = RefreshInput {
1208 entry_id: "anthropic_api".into(),
1209 vendor: "Anthropic API".into(),
1210 account: None,
1211 rows: vec![row("Spend (mo)", 98, None)],
1212 credits: Vec::new(),
1213 };
1214 let mut sink = RecorderSink::default();
1215 run_at(
1216 &input,
1217 97,
1218 &path,
1219 Duration::from_secs(2),
1220 &mut sink,
1221 at(19, 9, 0),
1222 );
1223
1224 assert!(
1225 sink.delivered.is_empty(),
1226 "the existing crossing remains notified"
1227 );
1228 let migrated = NotifyState::read_from(&path);
1229 assert_eq!(migrated.entries.len(), 1);
1230 assert_eq!(
1231 migrated.entries["anthropic_api::Spend (mo)"].notified_at,
1232 newest
1233 );
1234 assert!(
1235 migrated
1236 .entries
1237 .keys()
1238 .all(|key| !key.contains("Month to date") && !key.contains("Completed daily"))
1239 );
1240 drop(dir);
1241 }
1242
1243 #[test]
1244 fn an_existing_stable_anthropic_api_notification_record_wins_migration() {
1245 let stable_at = at(16, 9, 0);
1246 let mut state = NotifyState::default();
1247 for (label, notified_at) in [
1248 ("Spend (mo)", stable_at),
1249 ("Month to date (through Sep 18)", at(18, 9, 0)),
1250 ] {
1251 state.entries.insert(
1252 format!("anthropic_api@work::{label}"),
1253 KeyRecord {
1254 notified_at,
1255 reset_at_snapshot: None,
1256 },
1257 );
1258 }
1259 let input = RefreshInput {
1260 entry_id: "anthropic_api@work".into(),
1261 vendor: "Anthropic API".into(),
1262 account: Some("work".into()),
1263 rows: Vec::new(),
1264 credits: Vec::new(),
1265 };
1266 assert!(decide(&input, 97, &mut state, at(19, 9, 0)).is_empty());
1267 assert_eq!(state.entries.len(), 1);
1268 assert_eq!(
1269 state.entries["anthropic_api@work::Spend (mo)"].notified_at,
1270 stable_at
1271 );
1272 }
1273
1274 #[test]
1277 fn run_at_persists_marks_and_delivers_through_the_sink() {
1278 let (dir, path) = state_path();
1279 let mut sink = RecorderSink::default();
1280 let input = input(vec![row("Weekly (7d)", 97, None)]);
1281 run_at(
1282 &input,
1283 97,
1284 &path,
1285 Duration::from_secs(2),
1286 &mut sink,
1287 at(23, 12, 0),
1288 );
1289 assert_eq!(sink.delivered.len(), 1);
1290
1291 let mut second = RecorderSink::default();
1293 run_at(
1294 &input,
1295 97,
1296 &path,
1297 Duration::from_secs(2),
1298 &mut second,
1299 at(23, 12, 0),
1300 );
1301 assert!(second.delivered.is_empty());
1302 assert!(path.exists());
1303 drop(dir);
1304 }
1305
1306 #[test]
1307 fn lock_contention_skips_notifying_entirely() {
1308 let (dir, path) = state_path();
1309 let lock_path = path.with_file_name(".notifications.lock");
1310 let _held = crate::cache::acquire_lock(&lock_path, Duration::from_secs(5)).unwrap();
1311
1312 let mut sink = RecorderSink::default();
1313 let input = input(vec![row("Weekly (7d)", 100, None)]);
1314 run_at(
1315 &input,
1316 97,
1317 &path,
1318 Duration::from_millis(100),
1319 &mut sink,
1320 at(23, 12, 0),
1321 );
1322 assert!(
1323 sink.delivered.is_empty(),
1324 "a missed notification beats a double one"
1325 );
1326 assert!(!path.exists(), "no state without the lock");
1327 drop(dir);
1328 }
1329
1330 #[test]
1331 fn an_unwritable_state_file_skips_delivery_rather_than_repeating() {
1332 let dir = tempfile::TempDir::new().unwrap();
1333 let state_path = dir.path().join("notifications.json");
1336 std::fs::create_dir(&state_path).unwrap();
1337 let mut sink = RecorderSink::default();
1338 let input = input(vec![row("Weekly (7d)", 97, None)]);
1339 run_at(
1340 &input,
1341 97,
1342 &state_path,
1343 Duration::from_secs(2),
1344 &mut sink,
1345 at(23, 12, 0),
1346 );
1347 assert!(sink.delivered.is_empty());
1348 }
1349
1350 #[test]
1354 #[cfg(target_os = "linux")]
1355 fn a_critical_toast_asks_for_a_timeout_and_a_normal_one_does_not() {
1356 let sink = NotifySendSink::new();
1357 let toast = |urgency| Notification {
1358 title: "Claude — Session (5h) at 100%".to_string(),
1359 body: "resets in 2h".to_string(),
1360 urgency,
1361 key: "anthropic::Session (5h)".to_string(),
1362 };
1363 let critical_toast = toast(Urgency::Critical);
1364
1365 let critical = sink.argv(&critical_toast);
1366 assert!(
1367 critical
1368 .windows(2)
1369 .any(|pair| pair[0] == "-t" && pair[1] == "30000"),
1370 "a critical toast asks for the cap: {critical:?}"
1371 );
1372
1373 let normal = sink.argv(&toast(Urgency::Normal));
1374 assert!(
1375 !normal.iter().any(|arg| arg == "-t"),
1376 "a normal toast leaves the timeout to the daemon: {normal:?}"
1377 );
1378
1379 let tail = &critical[critical.len() - 2..];
1382 assert_eq!(tail[0], critical_toast.title);
1383 assert_eq!(tail[1], critical_toast.body);
1384 }
1385
1386 #[test]
1390 #[cfg(target_os = "linux")]
1391 fn a_missing_notifier_binary_is_a_silent_no_op() {
1392 let (dir, path) = state_path();
1393 let mut sink = NotifySendSink::with_program("/nonexistent/notify-send");
1394 let input = input(vec![row("Weekly (7d)", 97, None)]);
1395 run_at(
1396 &input,
1397 97,
1398 &path,
1399 Duration::from_secs(2),
1400 &mut sink,
1401 at(23, 12, 0),
1402 );
1403 assert!(
1404 path.exists(),
1405 "state bookkeeping is independent of delivery"
1406 );
1407 assert!(
1408 NotifyState::read_from(&path).is_notified("anthropic@gmail::Weekly (7d)"),
1409 "the crossing is marked even though nothing was delivered"
1410 );
1411 drop(dir);
1412 }
1413
1414 #[test]
1415 fn noop_sink_records_nothing_and_never_fails() {
1416 let (dir, path) = state_path();
1417 let mut sink = NoopSink;
1418 let input = input(vec![row("Weekly (7d)", 97, None)]);
1419 run_at(
1420 &input,
1421 97,
1422 &path,
1423 Duration::from_secs(2),
1424 &mut sink,
1425 at(23, 12, 0),
1426 );
1427 assert!(path.exists());
1428 drop(dir);
1429 }
1430
1431 #[test]
1434 fn from_tab_projects_rows_account_and_credits() {
1435 use crate::tui::app::{ReadyTab, TabId, TabState};
1436 use crate::usage::{
1437 AnthropicSnapshot, ResetCredit, ResetCredits, UsageWindow, VendorSnapshot,
1438 };
1439
1440 let reset = at(24, 2, 0);
1441 let state = TabState::Ready(Box::new(ReadyTab {
1442 snapshot: VendorSnapshot::Anthropic(AnthropicSnapshot {
1443 plan: "Claude Max 20x".into(),
1444 session: Some(UsageWindow {
1445 utilization_pct: 98,
1446 resets_at: Some(reset),
1447 window_duration: chrono::Duration::hours(5),
1448 }),
1449 weekly: UsageWindow {
1450 utilization_pct: 42,
1451 resets_at: None,
1452 window_duration: chrono::Duration::days(7),
1453 },
1454 sonnet: None,
1455 scoped: vec![],
1456 extra: None,
1457 reset_credits: ResetCredits {
1461 available: 1,
1462 credits: vec![ResetCredit {
1463 title: Some("Opus 5.5 launch reset".into()),
1464 expires_at: Some(at(25, 0, 0)),
1465 }],
1466 },
1467 cloud_credits: None,
1468 usage_credits: None,
1469 }),
1470 stale: false,
1471 last_error: None,
1472 fetched_at: None,
1473 display: Default::default(),
1474 }));
1475
1476 let tab = TabId::account("gmail");
1477 let projected =
1478 RefreshInput::from_tab(&tab, &state, at(23, 12, 0)).expect("ready tab projects");
1479 assert_eq!(projected.entry_id, "anthropic@gmail");
1480 assert_eq!(projected.vendor, "Claude");
1481 assert_eq!(projected.account.as_deref(), Some("gmail"));
1482 assert_eq!(
1483 projected.rows,
1484 vec![
1485 row("Session (5h)", 98, Some(reset)),
1486 row("Weekly (7d)", 42, None),
1487 ]
1488 );
1489 assert_eq!(
1490 projected.credits,
1491 vec![CreditExpiry {
1492 title: Some("Opus 5.5 launch reset".into()),
1493 expires_at: at(25, 0, 0),
1494 }]
1495 );
1496
1497 let state = TabState::Ready(Box::new(ReadyTab {
1499 snapshot: VendorSnapshot::Openai(crate::usage::OpenAiSnapshot {
1500 plan: "ChatGPT Pro".into(),
1501 session: None,
1502 weekly: None,
1503 code_review: None,
1504 additional_limits: Vec::new(),
1505 unavailable_models: Vec::new(),
1506 credits: None,
1507 reset_credits: Some(ResetCredits {
1508 available: 2,
1509 credits: vec![ResetCredit {
1510 title: Some("Full reset".into()),
1511 expires_at: Some(at(25, 0, 0)),
1512 }],
1513 }),
1514 source: crate::usage::OpenAiSource::CodexOauth,
1515 }),
1516 stale: false,
1517 last_error: None,
1518 fetched_at: None,
1519 display: Default::default(),
1520 }));
1521 let projected = RefreshInput::from_tab(
1522 &TabId::vendor(crate::vendor::VendorId::Openai),
1523 &state,
1524 at(23, 12, 0),
1525 )
1526 .expect("ready tab projects");
1527 assert_eq!(projected.entry_id, "openai");
1528 assert_eq!(
1529 projected.credits,
1530 vec![CreditExpiry {
1531 title: Some("Full reset".into()),
1532 expires_at: at(25, 0, 0),
1533 }]
1534 );
1535 }
1536
1537 #[test]
1538 fn synthetic_notifications_describe_the_partial_refill() {
1539 use crate::tui::app::{ReadyTab, TabId, TabState};
1540 use crate::usage::{SyntheticSnapshot, UsageWindow, VendorSnapshot, WeeklyCredits};
1541
1542 let now = at(23, 12, 0);
1543 let reset = at(23, 15, 0);
1544 let tab = TabId::vendor(crate::vendor::VendorId::Synthetic);
1545 for (credits, next_regen_percent, amount) in [
1546 (None, Some(2.0), "2%"),
1547 (
1548 Some(WeeklyCredits {
1549 used: 30.0,
1550 max: 30.0,
1551 next_regen: 0.6,
1552 }),
1553 None,
1554 "0.60 credits",
1555 ),
1556 ] {
1557 for percent in [97, 100] {
1558 let state = TabState::Ready(Box::new(ReadyTab {
1559 snapshot: VendorSnapshot::Synthetic(SyntheticSnapshot {
1560 plan: None,
1561 weekly: UsageWindow {
1562 utilization_pct: percent,
1563 resets_at: Some(reset),
1564 window_duration: chrono::Duration::zero(),
1565 },
1566 credits: credits.clone(),
1567 next_regen_percent,
1568 utilization_precise: f64::from(percent),
1569 }),
1570 stale: false,
1571 last_error: None,
1572 fetched_at: Some(now),
1573 display: Default::default(),
1574 }));
1575 let input = RefreshInput::from_tab(&tab, &state, now).unwrap();
1576 let mut notify_state = NotifyState::default();
1577 let fired = decide(&input, 97, &mut notify_state, now);
1578
1579 assert_eq!(fired.len(), 1);
1580 assert_eq!(fired[0].key, "synthetic::Weekly credits");
1581 assert_eq!(
1582 fired[0].body,
1583 format!(
1584 "{percent}% of the Weekly credits used · regenerates {amount} in 3h 00m ({})",
1585 format::local_time_hm(reset)
1586 )
1587 );
1588 assert_eq!(
1589 fired[0].urgency,
1590 if percent == 100 {
1591 Urgency::Critical
1592 } else {
1593 Urgency::Normal
1594 }
1595 );
1596 assert!(decide(&input, 97, &mut notify_state, now).is_empty());
1597 }
1598 }
1599 }
1600
1601 #[test]
1602 fn a_refill_due_now_has_no_future_countdown() {
1603 let now = at(23, 12, 0);
1604 let mut refill = row("Weekly credits", 100, Some(now));
1605 refill.regen = Some("2%".into());
1606 let fired = decide(&input(vec![refill]), 97, &mut NotifyState::default(), now);
1607 assert_eq!(
1608 fired[0].body,
1609 format!(
1610 "100% of the Weekly credits used · regenerates 2% now ({})",
1611 format::local_time_hm(now)
1612 )
1613 );
1614 }
1615
1616 #[test]
1617 fn from_tab_skips_grouped_slices_and_unavailable_credits() {
1618 use crate::tui::app::{ReadyTab, TabId, TabState};
1619 use crate::usage::{
1620 ResetCredit, ResetCredits, SuperGrokPeriod, SuperGrokProduct, SuperGrokSnapshot,
1621 VendorSnapshot,
1622 };
1623
1624 let state = TabState::Ready(Box::new(ReadyTab {
1625 snapshot: VendorSnapshot::SuperGrok(SuperGrokSnapshot {
1626 plan: "SuperGrok Heavy".into(),
1627 account: "scope".into(),
1628 weekly_pct: 97,
1629 period: SuperGrokPeriod::Weekly,
1630 reset_at: Some(at(26, 0, 0)),
1631 prepaid_balance: None,
1632 on_demand_used_cents: None,
1633 on_demand_cap_cents: None,
1634 reset_credits: ResetCredits {
1636 available: 0,
1637 credits: vec![ResetCredit {
1638 title: None,
1639 expires_at: Some(at(25, 0, 0)),
1640 }],
1641 },
1642 products: vec![SuperGrokProduct {
1643 label: "Grok Build".into(),
1644 percent: 94,
1645 }],
1646 }),
1647 stale: false,
1648 last_error: None,
1649 fetched_at: None,
1650 display: Default::default(),
1651 }));
1652 let projected = RefreshInput::from_tab(
1653 &TabId::vendor(crate::vendor::VendorId::Supergrok),
1654 &state,
1655 at(23, 12, 0),
1656 )
1657 .expect("ready tab projects");
1658 assert_eq!(projected.rows.len(), 1, "{:?}", projected.rows);
1661 assert!(projected.credits.is_empty());
1662 }
1663
1664 fn antigravity_ready(
1665 session: i32,
1666 third_party_session: i32,
1667 weekly: i32,
1668 third_party_weekly: i32,
1669 ) -> crate::tui::app::TabState {
1670 use crate::tui::app::{ReadyTab, TabState};
1671 use crate::usage::{AntigravitySnapshot, AntigravitySource, UsageWindow, VendorSnapshot};
1672
1673 let window = |pct: i32, resets_at: DateTime<Utc>, hours: i64| {
1674 Some(UsageWindow {
1675 utilization_pct: pct,
1676 resets_at: Some(resets_at),
1677 window_duration: chrono::Duration::hours(hours),
1678 })
1679 };
1680 TabState::Ready(Box::new(ReadyTab {
1681 snapshot: VendorSnapshot::Antigravity(AntigravitySnapshot {
1682 plan: "Google AI Pro".into(),
1683 account: "acct:test".into(),
1684 source: AntigravitySource::Local,
1685 session: window(session, at(24, 2, 21), 5),
1686 weekly: window(weekly, at(26, 1, 34), 168),
1687 third_party_session: window(third_party_session, at(24, 2, 19), 5),
1688 third_party_weekly: window(third_party_weekly, at(30, 19, 19), 168),
1689 model_pools: Vec::new(),
1690 }),
1691 stale: false,
1692 last_error: None,
1693 fetched_at: None,
1694 display: Default::default(),
1695 }))
1696 }
1697
1698 #[test]
1702 fn from_tab_names_repeated_labels_by_their_heading() {
1703 use crate::tui::app::TabId;
1704
1705 let projected = RefreshInput::from_tab(
1706 &TabId::vendor(crate::vendor::VendorId::Antigravity),
1707 &antigravity_ready(3, 100, 98, 56),
1708 at(23, 22, 0),
1709 )
1710 .expect("ready tab projects");
1711 let windows: Vec<_> = projected
1712 .rows
1713 .iter()
1714 .map(|row| row.window.as_str())
1715 .collect();
1716 assert_eq!(
1717 windows,
1718 [
1719 "Session · Gemini",
1720 "Session · Claude & GPT OSS",
1721 "Weekly · Gemini",
1722 "Weekly · Claude & GPT OSS",
1723 ]
1724 );
1725 }
1726
1727 #[test]
1732 fn a_window_whose_label_repeats_notifies_once() {
1733 use crate::tui::app::TabId;
1734
1735 let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1736 let state = antigravity_ready(3, 100, 98, 56);
1737 let mut notify_state = NotifyState::default();
1738 let fired: Vec<Vec<String>> = (0..4)
1739 .map(|refresh| {
1740 let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh);
1741 let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1742 decide(&input, 97, &mut notify_state, now)
1743 .into_iter()
1744 .map(|notification| notification.title)
1745 .collect()
1746 })
1747 .collect();
1748 assert_eq!(
1749 fired[0],
1750 [
1751 "Antigravity — Session · Claude & GPT OSS at 100%",
1752 "Antigravity — Weekly · Gemini at 98%",
1753 ]
1754 );
1755 assert!(
1756 fired[1..].iter().all(Vec::is_empty),
1757 "later refreshes must stay quiet: {fired:?}"
1758 );
1759 }
1760
1761 #[test]
1763 fn a_window_keeps_its_key_when_its_sibling_disappears_or_returns() {
1764 use crate::tui::app::{TabId, TabState};
1765 use crate::usage::VendorSnapshot;
1766
1767 let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1768 for starts_with_session in [false, true] {
1769 let mut notify_state = NotifyState::default();
1770 let steps = [
1774 (starts_with_session, 98, 1),
1775 (!starts_with_session, 98, 0),
1776 (starts_with_session, 98, 0),
1777 (false, 20, 0),
1778 (true, 98, 1),
1779 (false, 98, 0),
1780 ];
1781 for (refresh, (has_session, weekly_pct, expected)) in steps.into_iter().enumerate() {
1782 let mut state = antigravity_ready(3, 10, weekly_pct, 56);
1783 let TabState::Ready(ready) = &mut state else {
1784 unreachable!();
1785 };
1786 let VendorSnapshot::Antigravity(snapshot) = &mut ready.snapshot else {
1787 unreachable!();
1788 };
1789 if !has_session {
1790 snapshot.session = None;
1791 snapshot.third_party_session = None;
1792 }
1793 let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh as i64);
1794 let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1795 let fired = decide(&input, 97, &mut notify_state, now);
1796 assert_eq!(
1797 fired.len(),
1798 expected,
1799 "starts_with_session={starts_with_session}, refresh={refresh}: {fired:?}"
1800 );
1801 if let Some(notification) = fired.first() {
1802 assert_eq!(notification.key, "antigravity::Weekly · Gemini");
1803 }
1804 }
1805 }
1806 }
1807
1808 #[test]
1812 fn a_spend_over_the_threshold_notifies_once_as_the_covered_day_moves() {
1813 use crate::tui::app::{ReadyTab, TabId, TabState};
1814 use crate::usage::{AnthropicApiSnapshot, VendorSnapshot};
1815
1816 let tab = TabId::vendor(crate::vendor::VendorId::AnthropicApi);
1817 let mut notify_state = NotifyState::default();
1818 let fired: Vec<usize> = (17..=19)
1819 .map(|day| {
1820 let state = TabState::Ready(Box::new(ReadyTab {
1821 snapshot: VendorSnapshot::AnthropicApi(AnthropicApiSnapshot {
1822 spent: 98.0,
1823 limit: Some(100.0),
1824 covered_through: chrono::NaiveDate::from_ymd_opt(2026, 9, day),
1825 }),
1826 stale: false,
1827 last_error: None,
1828 fetched_at: None,
1829 display: Default::default(),
1830 }));
1831 let now = at(day + 1, 9, 0);
1832 let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1833 decide(&input, 97, &mut notify_state, now).len()
1834 })
1835 .collect();
1836 assert_eq!(fired, [1, 0, 0]);
1837 }
1838
1839 #[test]
1842 fn metric_names_follow_heading_scope_even_for_unique_labels() {
1843 use crate::balance::MetricHeadline;
1844 use crate::pacing::PaceSeverity;
1845 use crate::tui::panels::{Section, SectionProjection};
1846
1847 let projection = |section| SectionProjection {
1848 section,
1849 reset_at: None,
1850 window: None,
1851 group: None,
1852 model_pool: false,
1853 headline: MetricHeadline::Percent,
1854 used_cents: None,
1855 limit_cents: None,
1856 regen: None,
1857 percent_precise: None,
1858 reading: None,
1859 };
1860 let metric = |label: &str, pct| {
1861 projection(Section::Metric {
1862 label: label.into(),
1863 pct,
1864 severity: PaceSeverity::Low,
1865 value_label: format!("{pct}%"),
1866 footnote: String::new(),
1867 })
1868 };
1869 let heading = |label: &str| {
1870 projection(Section::Text {
1871 label: label.into(),
1872 value: String::new(),
1873 })
1874 };
1875 let rows = metric_rows([
1876 metric("Unscoped", 20),
1877 heading("Session"),
1878 projection(Section::Spacer),
1879 metric("General", 40),
1880 metric("Video", 10),
1881 projection(Section::Spacer),
1882 heading("Weekly"),
1883 metric("General", 60),
1884 projection(Section::Text {
1885 label: "Info".into(),
1886 value: "Details".into(),
1887 }),
1888 metric("Other", 30),
1889 ]);
1890 let windows: Vec<_> = rows.iter().map(|row| row.window.as_str()).collect();
1891 assert_eq!(
1892 windows,
1893 [
1894 "Unscoped",
1895 "Session · General",
1896 "Session · Video",
1897 "Weekly · General",
1898 "Other",
1899 ]
1900 );
1901 }
1902
1903 #[test]
1904 fn from_tab_returns_none_for_unready_states() {
1905 use crate::tui::app::{TabId, TabState};
1906 assert!(
1907 RefreshInput::from_tab(
1908 &TabId::vendor(crate::vendor::VendorId::Zai),
1909 &TabState::Loading,
1910 at(23, 12, 0)
1911 )
1912 .is_none()
1913 );
1914 assert!(
1915 RefreshInput::from_tab(
1916 &TabId::vendor(crate::vendor::VendorId::Zai),
1917 &TabState::error("not signed in"),
1918 at(23, 12, 0)
1919 )
1920 .is_none()
1921 );
1922 }
1923}