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