Skip to main content

ai_usagebar/
notify.rs

1//! Quota-threshold desktop notifications.
2//!
3//! After a *fresh* fetch (never a cached, stale, or failed one — see
4//! [`crate::outcome::Outcome::off_the_wire`]), the metric rows a refresh
5//! produced are checked against a configurable threshold. A window that
6//! crosses the threshold raises one notification per crossing, and a banked
7//! reset credit that expires soon raises one per credit.
8//!
9//! Layout:
10//! - [`decide`] is the pure decision function: rows + threshold + previous
11//!   state + now → notifications, with two-band urgency and hysteresis
12//!   re-arming.
13//! - [`NotifyState`] is the persisted dedupe map
14//!   (`~/.cache/ai-usagebar/notifications.json`), written atomically under
15//!   the same flock discipline as the vendor caches.
16//! - [`NotifySink`] delivers through `notify-send` on Linux and Notification
17//!   Center on macOS, without changing the decision or persisted state.
18//!
19//! Everything here is best-effort: a missing notifier, an unwritable state
20//! file, or a contended lock is a silent skip — a missed notification beats
21//! a double one, and nothing in this module may change a caller's return
22//! value or exit code.
23//!
24//! No burn-rate extrapolation anywhere: reset times in bodies are only the
25//! vendor-reported absolute instants.
26
27use 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
38/// Hysteresis band, in percentage points. A notified key re-arms only when
39/// usage drops `threshold - 7` or further, so a window hovering at the
40/// threshold (97 → 96 → 97) does not re-fire on every refresh.
41const HYSTERESIS_PCT: i32 = 7;
42
43/// How much later a reported reset instant has to be before it counts as a new
44/// window. Vendors report that instant with sub-second precision that jitters
45/// between fetches — Anthropic's five-hour window came back 0.7s apart on two
46/// fetches four minutes apart — and a rolling window slides its reset forward
47/// with the refresh interval, so a strict comparison re-armed the key and
48/// re-notified on refresh with nothing changed. Ninety minutes clears every
49/// refresh interval this ships with (the bar's default is five minutes and its
50/// maximum an hour; the tray refreshes every ten at most) and stays far below
51/// the shortest window, so a real new window still moves the instant by more
52/// than this.
53const RESET_MOVE_TOLERANCE_SECS: i64 = 90 * 60;
54
55/// How long before a banked reset credit's expiry the warning fires.
56const CREDIT_WARNING_SECS: i64 = 48 * 3600;
57
58/// How long to wait for the state lock before skipping the whole check.
59/// Deliberately short: notifications are never worth stalling a refresh for.
60const LOCK_TIMEOUT: Duration = Duration::from_secs(2);
61
62/// A notifier that hangs must not hang the bar with it.
63#[cfg(any(target_os = "linux", target_os = "macos"))]
64const SPAWN_KILL_AFTER: Duration = Duration::from_secs(5);
65
66/// Timeout a critical toast asks `notify-send` for, in milliseconds.
67///
68/// Omarchy's notification plugin treats critical urgency as immortal and
69/// ignores the sender's timeout, so a 100% toast stays until it is dismissed.
70/// [omacom/omarchy#12964](https://github.com/omacom/omarchy/pull/12964)
71/// honours a positive timeout instead, capped at 30s, while a critical toast
72/// sent without one still stays — hence asking for the cap.
73#[cfg(target_os = "linux")]
74const CRITICAL_TIMEOUT_MS: &str = "30000";
75
76/// Urgency band for one notification. `notify-send`'s `-u` maps directly.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Urgency {
79    /// `threshold <= pct < 100`.
80    Normal,
81    /// The window is exhausted (`pct >= 100`).
82    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/// One notification to deliver. `key` is the dedupe identity the state map
96/// is keyed by; title/body are already sanitized for a subprocess boundary.
97#[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/// One metric row of a refresh, as the shared panel projection shapes it.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct MetricRow {
108    /// Window label ("Session (5h)", "Weekly (7d)"). Stable per vendor, so it
109    /// is the window half of the dedupe key.
110    pub window: String,
111    pub percent: u16,
112    /// Vendor-reported absolute reset instant. `None` when the vendor does
113    /// not state one — then the body carries no ETA, by rule.
114    pub reset_at: Option<DateTime<Utc>>,
115}
116
117/// One banked reset credit with a known expiry (Codex/SuperGrok
118/// `reset_credits.credits[]`).
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct CreditExpiry {
121    pub title: Option<String>,
122    pub expires_at: DateTime<Utc>,
123}
124
125/// The notification-relevant projection of one vendor entry's refresh.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct RefreshInput {
128    /// The report entry id (`vendor` or `vendor@account`) — the entry half of
129    /// the dedupe key.
130    pub entry_id: String,
131    /// Canonical vendor display name (`VendorId::display_name`, or the
132    /// configured name of a `[[custom]]` provider).
133    pub vendor: String,
134    pub account: Option<String>,
135    pub rows: Vec<MetricRow>,
136    pub credits: Vec<CreditExpiry>,
137}
138
139/// Decide which notifications one refresh raises.
140///
141/// `state` is mutated to the post-decision map; the caller persists it (see
142/// [`run_at`]) so re-arms survive restarts. Rules:
143///
144/// - Fire when `percent >= threshold` and the key is not marked notified.
145///   Band: `>= 100` is [`Urgency::Critical`], otherwise [`Urgency::Normal`].
146/// - A key re-arms when usage drops below `threshold - 7`, or when the
147///   window's `reset_at` moves to a later instant (a new window). The same
148///   crossing therefore never fires twice.
149/// - A credit expiring within 48h (and not yet expired) fires once per
150///   expiry instant, at [`Urgency::Normal`].
151pub fn decide(
152    input: &RefreshInput,
153    threshold: u8,
154    state: &mut NotifyState,
155    now: DateTime<Utc>,
156) -> Vec<Notification> {
157    let mut fired = Vec::new();
158    let threshold = threshold as i32;
159    let rearm_below = threshold - HYSTERESIS_PCT;
160    for row in &input.rows {
161        let key = format!("{}::{}", input.entry_id, row.window);
162        let pct = row.percent as i32;
163        if pct >= threshold {
164            let armed = match state.entries.get(&key) {
165                None => true,
166                Some(record) => reset_moved_later(row.reset_at, record.reset_at_snapshot),
167            };
168            if armed {
169                fired.push(threshold_notification(input, row, now));
170                state.entries.insert(
171                    key,
172                    KeyRecord {
173                        notified_at: now,
174                        reset_at_snapshot: row.reset_at,
175                    },
176                );
177            }
178        } else if pct < rearm_below {
179            // Drop below the hysteresis band: forget the crossing so the next
180            // one can fire. Persisted by the caller like any other change.
181            state.entries.remove(&key);
182        }
183    }
184    for credit in &input.credits {
185        let remaining = credit.expires_at.signed_duration_since(now).num_seconds();
186        if remaining <= 0 || remaining > CREDIT_WARNING_SECS {
187            continue;
188        }
189        let key = format!(
190            "{}::credit::{}",
191            input.entry_id,
192            credit
193                .expires_at
194                .to_rfc3339_opts(SecondsFormat::AutoSi, true)
195        );
196        if state.entries.contains_key(&key) {
197            continue;
198        }
199        fired.push(credit_notification(input, credit, now));
200        state.entries.insert(
201            key,
202            KeyRecord {
203                notified_at: now,
204                reset_at_snapshot: None,
205            },
206        );
207    }
208    fired
209}
210
211/// `true` when `current` is a later reset than `snapshot` — a `None → Some`
212/// change counts (the vendor started reporting a reset), `Some → None` and
213/// backwards moves do not, and so do not moves within
214/// [`RESET_MOVE_TOLERANCE_SECS`], which are the same window reported again.
215fn reset_moved_later(current: Option<DateTime<Utc>>, snapshot: Option<DateTime<Utc>>) -> bool {
216    match (current, snapshot) {
217        (Some(current), Some(snapshot)) => {
218            current.signed_duration_since(snapshot).num_seconds() > RESET_MOVE_TOLERANCE_SECS
219        }
220        (Some(_), None) => true,
221        _ => false,
222    }
223}
224
225fn threshold_notification(
226    input: &RefreshInput,
227    row: &MetricRow,
228    now: DateTime<Utc>,
229) -> Notification {
230    let who = match &input.account {
231        Some(account) => format!("{} · {}", input.vendor, account),
232        None => input.vendor.clone(),
233    };
234    let title = sanitize_untrusted_line(&format!("{} — {} at {}%", who, row.window, row.percent));
235    let mut body = format!("{}% of the {} used", row.percent, row.window);
236    if let Some(reset_at) = row.reset_at {
237        body.push_str(&format!(
238            " · resets {} ({})",
239            countdown::format(Some(reset_at), now),
240            format::local_time_hm(reset_at)
241        ));
242    }
243    Notification {
244        title,
245        body: sanitize_untrusted_line(&body),
246        urgency: if row.percent >= 100 {
247            Urgency::Critical
248        } else {
249            Urgency::Normal
250        },
251        key: format!("{}::{}", input.entry_id, row.window),
252    }
253}
254
255fn credit_notification(
256    input: &RefreshInput,
257    credit: &CreditExpiry,
258    now: DateTime<Utc>,
259) -> Notification {
260    let label = credit.title.as_deref().unwrap_or("Reset credit");
261    let title = sanitize_untrusted_line(&format!(
262        "{} — reset credit expires {}",
263        input.vendor,
264        format::local_date_hm(credit.expires_at)
265    ));
266    let body = sanitize_untrusted_line(&format!(
267        "{} — redeem within {} ({})",
268        label,
269        countdown::format(Some(credit.expires_at), now),
270        format::local_time_hm(credit.expires_at)
271    ));
272    Notification {
273        title,
274        body,
275        urgency: Urgency::Normal,
276        key: format!(
277            "{}::credit::{}",
278            input.entry_id,
279            credit
280                .expires_at
281                .to_rfc3339_opts(SecondsFormat::AutoSi, true)
282        ),
283    }
284}
285
286// ─── Persisted dedupe state ────────────────────────────────────────────────
287
288/// Map of dedupe key → when it was notified and the reset instant that was
289/// current at the time. `notified_at` is bookkeeping (inspectable state);
290/// re-arming reads `reset_at_snapshot`. On disk this is the bare map, not a
291/// wrapper object.
292#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(transparent)]
294pub struct NotifyState {
295    entries: BTreeMap<String, KeyRecord>,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299struct KeyRecord {
300    notified_at: DateTime<Utc>,
301    reset_at_snapshot: Option<DateTime<Utc>>,
302}
303
304impl NotifyState {
305    #[cfg(test)]
306    fn is_notified(&self, key: &str) -> bool {
307        self.entries.contains_key(key)
308    }
309
310    /// Read the state file. Missing or corrupt counts as nothing notified —
311    /// a corrupt file must never pin a vendor silent or double-fire forever.
312    fn read_from(path: &Path) -> Self {
313        let Ok(raw) = std::fs::read_to_string(path) else {
314            return Self::default();
315        };
316        serde_json::from_str(&raw).unwrap_or_default()
317    }
318
319    /// Atomic write (tempfile + persist) with mode 0600, matching the cache
320    /// sidecar discipline.
321    fn write_to(&self, path: &Path) -> crate::error::Result<()> {
322        let bytes = serde_json::to_vec(&self.entries)?;
323        crate::cache::atomic_write(path, &bytes)?;
324        #[cfg(unix)]
325        {
326            use std::os::unix::fs::PermissionsExt;
327            if let Ok(meta) = std::fs::metadata(path) {
328                let mut perms = meta.permissions();
329                perms.set_mode(0o600);
330                let _ = std::fs::set_permissions(path, perms);
331            }
332        }
333        Ok(())
334    }
335}
336
337// ─── Delivery seam ─────────────────────────────────────────────────────────
338
339/// Delivery backend. Implementations own one platform each.
340///
341/// `deliver` returns nothing and must never panic: a failing or missing
342/// notifier is ALWAYS a silent no-op, and nothing in this module may change
343/// a caller's return value or exit code.
344pub trait NotifySink {
345    fn deliver(&mut self, notification: &Notification);
346}
347
348/// The do-nothing sink for platforms without a desktop delivery backend.
349#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
350#[derive(Debug, Default)]
351pub struct NoopSink;
352
353#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
354impl NotifySink for NoopSink {
355    fn deliver(&mut self, _notification: &Notification) {}
356}
357
358/// Linux `notify-send` sink. argv only — never a shell — with stderr nulled:
359/// the notification daemon's complaints are not the bar's problem. A missing
360/// binary or a failed spawn is swallowed; a hanging one is killed after
361/// [`SPAWN_KILL_AFTER`].
362#[cfg(target_os = "linux")]
363#[derive(Debug, Clone)]
364pub struct NotifySendSink {
365    program: String,
366}
367
368#[cfg(target_os = "linux")]
369impl Default for NotifySendSink {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375#[cfg(target_os = "linux")]
376impl NotifySendSink {
377    pub fn new() -> Self {
378        Self {
379            program: "notify-send".to_string(),
380        }
381    }
382
383    /// Point at a specific executable — the seam the failing-notifier guard
384    /// test uses, and the way a future desktop integration pins a path.
385    pub fn with_program(program: impl Into<String>) -> Self {
386        Self {
387            program: program.into(),
388        }
389    }
390
391    /// `notify-send` arguments for one notification, program name aside.
392    ///
393    /// The title and body come last: `notify-send` reads the trailing
394    /// positionals as those two, so every switch has to precede them.
395    fn argv(&self, notification: &Notification) -> Vec<String> {
396        let mut args = vec![
397            "-a".to_string(),
398            "ai-usagebar".to_string(),
399            "-c".to_string(),
400            "quota".to_string(),
401            "-u".to_string(),
402            notification.urgency.as_arg().to_string(),
403        ];
404        if notification.urgency == Urgency::Critical {
405            args.push("-t".to_string());
406            args.push(CRITICAL_TIMEOUT_MS.to_string());
407        }
408        args.push(notification.title.clone());
409        args.push(notification.body.clone());
410        args
411    }
412}
413
414#[cfg(target_os = "linux")]
415impl NotifySink for NotifySendSink {
416    fn deliver(&mut self, notification: &Notification) {
417        let spawned = std::process::Command::new(&self.program)
418            .args(self.argv(notification))
419            .stdin(std::process::Stdio::null())
420            .stdout(std::process::Stdio::null())
421            .stderr(std::process::Stdio::null())
422            .spawn();
423        if let Ok(child) = spawned {
424            reap(child);
425        }
426    }
427}
428
429/// Wait for the notifier, killing it at the cap. Desktop delivery normally
430/// returns in milliseconds; the cap only matters when the service is wedged.
431#[cfg(any(target_os = "linux", target_os = "macos"))]
432fn reap(mut child: std::process::Child) {
433    let deadline = std::time::Instant::now() + SPAWN_KILL_AFTER;
434    loop {
435        match child.try_wait() {
436            Ok(Some(_)) => return,
437            Ok(None) => {}
438            Err(_) => return,
439        }
440        if std::time::Instant::now() >= deadline {
441            let _ = child.kill();
442            let _ = child.wait();
443            return;
444        }
445        std::thread::sleep(Duration::from_millis(25));
446    }
447}
448
449fn production_sink() -> Box<dyn NotifySink + Send> {
450    #[cfg(target_os = "linux")]
451    return Box::new(NotifySendSink::new());
452    #[cfg(target_os = "macos")]
453    return Box::new(MacNotificationSink);
454    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
455    return Box::new(NoopSink);
456}
457
458/// AppleScript's Standard Additions sends a native Notification Center banner.
459/// Passing title and body as argv keeps provider text out of the script source.
460#[cfg(target_os = "macos")]
461struct MacNotificationSink;
462
463#[cfg(target_os = "macos")]
464impl NotifySink for MacNotificationSink {
465    fn deliver(&mut self, notification: &Notification) {
466        const SCRIPT: &str = "on run argv\n display notification (item 2 of argv) with title (item 1 of argv)\nend run";
467        let spawned = std::process::Command::new("/usr/bin/osascript")
468            .arg("-e")
469            .arg(SCRIPT)
470            .arg("--")
471            .arg(&notification.title)
472            .arg(&notification.body)
473            .stdin(std::process::Stdio::null())
474            .stdout(std::process::Stdio::null())
475            .stderr(std::process::Stdio::null())
476            .spawn();
477        if let Ok(child) = spawned {
478            reap(child);
479        }
480    }
481}
482
483// ─── Orchestration ─────────────────────────────────────────────────────────
484
485/// Production entry point: the real state path and platform sink. Returns
486/// nothing, always — the caller wraps it in `let _ =`.
487pub async fn run(input: RefreshInput, threshold: u8) {
488    let Some(state_path) = default_state_path() else {
489        return;
490    };
491    // The lock wait and the bounded notifier spawn are blocking; the TUI runs
492    // a current-thread runtime, so neither belongs on the reactor thread.
493    let joined = tokio::task::spawn_blocking(move || {
494        let mut sink = production_sink();
495        run_at(
496            &input,
497            threshold,
498            &state_path,
499            LOCK_TIMEOUT,
500            sink.as_mut(),
501            Utc::now(),
502        );
503    });
504    let _ = joined.await;
505}
506
507/// `~/.cache/ai-usagebar/notifications.json` (or under `$XDG_CACHE_HOME`).
508/// Tests never call this — they pass their own path to [`run_at`].
509pub fn default_state_path() -> Option<PathBuf> {
510    crate::cache::xdg_cache_dir()
511        .ok()
512        .map(|base| base.join("ai-usagebar").join("notifications.json"))
513}
514
515/// The whole check, serialized against any concurrent writer by the same
516/// flock pattern the vendor caches use. On lock timeout the check is skipped
517/// entirely — a missed notification beats a double one. State is persisted
518/// BEFORE delivery: a write failure then costs a missed notification (the
519/// preferred failure) rather than a repeat.
520pub fn run_at(
521    input: &RefreshInput,
522    threshold: u8,
523    state_path: &Path,
524    lock_timeout: Duration,
525    sink: &mut dyn NotifySink,
526    now: DateTime<Utc>,
527) {
528    let lock_path = state_path.with_file_name(".notifications.lock");
529    let Ok(_guard) = crate::cache::acquire_lock(&lock_path, lock_timeout) else {
530        return;
531    };
532    let mut state = NotifyState::read_from(state_path);
533    let before = serde_json::to_string(&state.entries).unwrap_or_default();
534    let fired = decide(input, threshold, &mut state, now);
535    if serde_json::to_string(&state.entries).unwrap_or_default() != before
536        && state.write_to(state_path).is_err()
537    {
538        // Could not persist the marks: delivering anyway would repeat the
539        // notification on the next fresh fetch. Skip instead.
540        return;
541    }
542    for notification in &fired {
543        sink.deliver(notification);
544    }
545}
546
547// ─── Shared-assembly projection ────────────────────────────────────────────
548
549impl RefreshInput {
550    /// Project one tab's completed state into the notification input. Rows
551    /// come from the same panel projection every frontend reads; grouped
552    /// sub-rows (SuperGrok's product slices) are skipped — they share the
553    /// overall pool's window, and one notification per pool is the point.
554    /// Banked credits ride along only when the vendor reports them and the
555    /// report would surface them (`available > 0`).
556    pub(crate) fn from_tab(
557        tab: &crate::tui::app::TabId,
558        state: &crate::tui::app::TabState,
559        now: DateTime<Utc>,
560    ) -> Option<Self> {
561        use crate::tui::app::{TabSource, TabState};
562        use crate::tui::panels::sections_with_metadata_for;
563
564        let TabState::Ready(ready) = state else {
565            return None;
566        };
567        let (vendor, entry_id) = match &tab.source {
568            TabSource::Builtin(vendor) => (
569                vendor.display_name().to_string(),
570                crate::report::tab_id(tab),
571            ),
572            TabSource::Custom { name, .. } => (name.clone(), crate::report::tab_id(tab)),
573        };
574        // The tolerance only shapes pacing footnotes, which the notification
575        // body never reads; any value behaves identically here.
576        let rows = metric_rows(sections_with_metadata_for(state, now, 0));
577        let credits = ready
578            .snapshot
579            .reset_credits()
580            .filter(|credits| credits.available > 0)
581            .map(|credits| {
582                credits
583                    .credits
584                    .iter()
585                    .filter_map(|credit| {
586                        credit.expires_at.map(|expires_at| CreditExpiry {
587                            title: credit.title.clone(),
588                            expires_at,
589                        })
590                    })
591                    .collect()
592            })
593            .unwrap_or_default();
594        Some(Self {
595            entry_id,
596            vendor,
597            account: tab.account.clone(),
598            rows,
599            credits,
600        })
601    }
602}
603
604/// The metric rows one refresh checks, each named by the window it measures.
605///
606/// Grouped sub-rows are skipped (see [`RefreshInput::from_tab`]). A label is
607/// normally unique within an entry, but Antigravity and MiniMax lay a
608/// `Session` and a `Weekly` heading over the same pool labels, so "Gemini"
609/// named two windows. Sharing one dedupe key, the row under the re-arm band
610/// cleared the record its namesake over the threshold had just written, and
611/// the same notification fired again on every refresh. Metrics under a heading
612/// always include it in their name ("Weekly · Gemini"), even if no other row
613/// currently has the same label: a missing sibling must not change a window's
614/// identity. Metrics without a heading keep their bare names.
615fn metric_rows(
616    sections: impl IntoIterator<Item = crate::tui::panels::SectionProjection>,
617) -> Vec<MetricRow> {
618    use crate::tui::panels::Section;
619
620    // A heading is a text row with no value; it covers the metrics below it
621    // until any other row except a spacer.
622    let mut heading: Option<String> = None;
623    let mut rows = Vec::new();
624    for projected in sections {
625        if projected.group.is_some() {
626            continue;
627        }
628        match projected.section {
629            Section::Text { label, value } if value.is_empty() => heading = Some(label),
630            Section::Metric { label, pct, .. } => {
631                let window = match &heading {
632                    Some(heading) => format!("{heading} · {label}"),
633                    None => label,
634                };
635                rows.push(MetricRow {
636                    window,
637                    percent: pct,
638                    reset_at: projected.reset_at,
639                });
640            }
641            Section::Spacer => {}
642            _ => heading = None,
643        }
644    }
645    rows
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use chrono::TimeZone;
652    use tempfile::TempDir;
653
654    fn at(day: u32, hour: u32, min: u32) -> DateTime<Utc> {
655        Utc.with_ymd_and_hms(2026, 9, day, hour, min, 0).unwrap()
656    }
657
658    fn row(window: &str, percent: u16, reset_at: Option<DateTime<Utc>>) -> MetricRow {
659        MetricRow {
660            window: window.to_string(),
661            percent,
662            reset_at,
663        }
664    }
665
666    fn input(rows: Vec<MetricRow>) -> RefreshInput {
667        RefreshInput {
668            entry_id: "anthropic@gmail".into(),
669            vendor: "Claude".into(),
670            account: Some("gmail".into()),
671            rows,
672            credits: Vec::new(),
673        }
674    }
675
676    /// Sink that records deliveries instead of spawning anything.
677    #[derive(Default)]
678    struct RecorderSink {
679        delivered: Vec<Notification>,
680    }
681
682    impl NotifySink for RecorderSink {
683        fn deliver(&mut self, notification: &Notification) {
684            self.delivered.push(notification.clone());
685        }
686    }
687
688    fn state_path() -> (TempDir, PathBuf) {
689        crate::cache::closed_temp_file("notifications.json", None)
690    }
691
692    // — Decision: threshold crossing ————————————————
693
694    #[test]
695    fn crossing_at_exactly_the_threshold_fires_normal() {
696        let mut state = NotifyState::default();
697        let now = at(23, 12, 0);
698        let reset = at(24, 12, 0);
699        let fired = decide(
700            &input(vec![row("Session (5h)", 97, Some(reset))]),
701            97,
702            &mut state,
703            now,
704        );
705        assert_eq!(fired.len(), 1);
706        assert_eq!(fired[0].urgency, Urgency::Normal);
707        assert_eq!(fired[0].key, "anthropic@gmail::Session (5h)");
708        assert_eq!(fired[0].title, "Claude · gmail — Session (5h) at 97%");
709        // Sep 23 12:00 → Sep 24 12:00 is exactly one day: "1d 0h", with the
710        // reset's local clock time beside it (rendered by the shared helper,
711        // so the assertion holds on any machine timezone).
712        assert_eq!(
713            fired[0].body,
714            format!(
715                "97% of the Session (5h) used · resets 1d 0h ({})",
716                format::local_time_hm(reset)
717            )
718        );
719    }
720
721    #[test]
722    fn below_the_threshold_fires_nothing() {
723        let mut state = NotifyState::default();
724        let fired = decide(
725            &input(vec![row("Session (5h)", 96, None)]),
726            97,
727            &mut state,
728            at(23, 12, 0),
729        );
730        assert!(fired.is_empty());
731        assert!(state.entries.is_empty());
732    }
733
734    #[test]
735    fn exhausted_windows_fire_critical() {
736        let mut state = NotifyState::default();
737        let fired = decide(
738            &input(vec![row("Weekly (7d)", 100, None)]),
739            97,
740            &mut state,
741            at(23, 12, 0),
742        );
743        assert_eq!(fired.len(), 1);
744        assert_eq!(fired[0].urgency, Urgency::Critical);
745        assert_eq!(fired[0].title, "Claude · gmail — Weekly (7d) at 100%");
746    }
747
748    #[test]
749    fn the_same_crossing_never_fires_twice() {
750        let mut state = NotifyState::default();
751        let high = input(vec![row("Weekly (7d)", 98, Some(at(30, 0, 0)))]);
752        let now = at(23, 12, 0);
753        assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
754        assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
755        assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
756    }
757
758    // — Decision: hysteresis re-arm ——————————————————
759
760    #[test]
761    fn hysteresis_rearms_only_below_threshold_minus_seven() {
762        let mut state = NotifyState::default();
763        let now = at(23, 12, 0);
764        let fires_at = |pct: u16, state: &mut NotifyState| {
765            let case = input(vec![row("Weekly (7d)", pct, None)]);
766            decide(&case, 97, state, now).len()
767        };
768
769        assert_eq!(fires_at(97, &mut state), 1, "first crossing fires");
770        assert_eq!(fires_at(96, &mut state), 0, "hovering does not re-fire");
771        // 90 == threshold - 7: still inside the band, still armed-out.
772        assert_eq!(fires_at(90, &mut state), 0);
773        assert!(state.is_notified("anthropic@gmail::Weekly (7d)"));
774        // Below the band: the mark is dropped…
775        assert_eq!(fires_at(89, &mut state), 0);
776        assert!(!state.is_notified("anthropic@gmail::Weekly (7d)"));
777        // …so the next crossing is a new crossing.
778        assert_eq!(fires_at(97, &mut state), 1, "re-armed crossing fires");
779    }
780
781    #[test]
782    fn a_jittered_reset_instant_is_the_same_window() {
783        // Observed live on Anthropic's five-hour window: two fetches four
784        // minutes apart reported the same window's reset 0.70s apart, and the
785        // strict comparison re-armed the key and re-notified on refresh with
786        // nothing changed.
787        let mut state = NotifyState::default();
788        let now = at(23, 12, 0);
789        let reset = at(23, 17, 0);
790        let first = input(vec![row("Session (5h)", 100, Some(reset))]);
791        assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
792
793        for drift in [
794            chrono::Duration::milliseconds(488),
795            chrono::Duration::milliseconds(700),
796            chrono::Duration::seconds(59),
797        ] {
798            let jittered = input(vec![row("Session (5h)", 100, Some(reset + drift))]);
799            assert_eq!(
800                decide(&jittered, 97, &mut state, now).len(),
801                0,
802                "{drift:?} later is the same window"
803            );
804        }
805
806        // A window that really rolled over moves the instant by its length.
807        let next_window = input(vec![row(
808            "Session (5h)",
809            100,
810            Some(reset + chrono::Duration::hours(5)),
811        )]);
812        assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
813    }
814
815    #[test]
816    fn a_later_reset_rearms_the_key() {
817        let mut state = NotifyState::default();
818        let now = at(23, 12, 0);
819        let first = input(vec![row("Session (5h)", 98, Some(at(23, 17, 0)))]);
820        assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
821
822        // Same reset, still above the threshold: no re-fire.
823        assert_eq!(decide(&first, 97, &mut state, now).len(), 0);
824        // Earlier or absent resets are not new windows.
825        let earlier = input(vec![row("Session (5h)", 98, Some(at(23, 16, 0)))]);
826        assert_eq!(decide(&earlier, 97, &mut state, now).len(), 0);
827        let absent = input(vec![row("Session (5h)", 98, None)]);
828        assert_eq!(decide(&absent, 97, &mut state, now).len(), 0);
829        // A later reset is a new window: fires again.
830        let next_window = input(vec![row("Session (5h)", 98, Some(at(24, 2, 0)))]);
831        assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
832    }
833
834    #[test]
835    fn a_reset_appearing_where_none_was_reported_rearms() {
836        let mut state = NotifyState::default();
837        let now = at(23, 12, 0);
838        let without = input(vec![row("Weekly quota", 99, None)]);
839        assert_eq!(decide(&without, 97, &mut state, now).len(), 1);
840        assert_eq!(decide(&without, 97, &mut state, now).len(), 0);
841        let with = input(vec![row("Weekly quota", 99, Some(at(30, 0, 0)))]);
842        assert_eq!(
843            decide(&with, 97, &mut state, now).len(),
844            1,
845            "the vendor starting to report a reset is a new window"
846        );
847    }
848
849    // — Decision: copy ——————————————————————————————
850
851    #[test]
852    fn absent_reset_at_omits_the_eta_from_the_body() {
853        let mut state = NotifyState::default();
854        let fired = decide(
855            &input(vec![row("Weekly (7d)", 97, None)]),
856            97,
857            &mut state,
858            at(23, 12, 0),
859        );
860        assert_eq!(fired[0].body, "97% of the Weekly (7d) used");
861        assert!(!fired[0].body.contains("resets"));
862    }
863
864    #[test]
865    fn account_label_is_omitted_from_the_title_when_absent() {
866        let mut state = NotifyState::default();
867        let mut plain = input(vec![row("Weekly (7d)", 97, None)]);
868        plain.entry_id = "zai".into();
869        plain.vendor = "Z.AI".into();
870        plain.account = None;
871        let fired = decide(&plain, 97, &mut state, at(23, 12, 0));
872        assert_eq!(fired[0].title, "Z.AI — Weekly (7d) at 97%");
873    }
874
875    #[test]
876    fn untrusted_labels_are_sanitized_at_the_subprocess_boundary() {
877        let mut state = NotifyState::default();
878        let mut hostile = input(vec![row("W\u{1b}[31m", 97, None)]);
879        hostile.account = Some("gmail\n\u{202E}evil".into());
880        let fired = decide(&hostile, 97, &mut state, at(23, 12, 0));
881        assert!(
882            !fired[0].title.chars().any(|c| c.is_control()),
883            "{}",
884            fired[0].title
885        );
886        assert!(!fired[0].title.contains('\u{202E}'), "{}", fired[0].title);
887        assert!(!fired[0].body.contains('\u{1B}'), "{}", fired[0].body);
888    }
889
890    // — Decision: banked credits ————————————————————
891
892    #[test]
893    fn banked_credit_fires_only_inside_the_48h_window() {
894        let now = at(23, 12, 0);
895        for (offset_secs, expected) in [(47 * 3600, 1), (48 * 3600, 1), (48 * 3600 + 1, 0), (-1, 0)]
896        {
897            let mut state = NotifyState::default();
898            let case = RefreshInput {
899                credits: vec![CreditExpiry {
900                    title: Some("Full reset".into()),
901                    expires_at: now + chrono::Duration::seconds(offset_secs),
902                }],
903                ..input(Vec::new())
904            };
905            let fired = decide(&case, 97, &mut state, now);
906            assert_eq!(fired.len(), expected, "offset {offset_secs}s");
907        }
908    }
909
910    #[test]
911    fn banked_credit_title_and_body_name_the_expiry() {
912        let now = at(23, 12, 0);
913        let mut state = NotifyState::default();
914        let case = RefreshInput {
915            credits: vec![CreditExpiry {
916                title: Some("Full reset (Weekly + 5 hr)".into()),
917                expires_at: now + chrono::Duration::hours(3),
918            }],
919            ..input(Vec::new())
920        };
921        let fired = decide(&case, 97, &mut state, now);
922        assert_eq!(fired.len(), 1);
923        assert_eq!(fired[0].urgency, Urgency::Normal);
924        assert!(
925            fired[0].title.starts_with("Claude — reset credit expires "),
926            "{}",
927            fired[0].title
928        );
929        assert!(
930            fired[0]
931                .body
932                .starts_with("Full reset (Weekly + 5 hr) — redeem within 3h"),
933            "{}",
934            fired[0].body
935        );
936    }
937
938    #[test]
939    fn banked_credit_dedupes_on_the_expiry_instant() {
940        let now = at(23, 12, 0);
941        let mut state = NotifyState::default();
942        let expiry = now + chrono::Duration::hours(10);
943        let case = RefreshInput {
944            credits: vec![CreditExpiry {
945                title: None,
946                expires_at: expiry,
947            }],
948            ..input(Vec::new())
949        };
950        assert_eq!(decide(&case, 97, &mut state, now).len(), 1);
951        assert_eq!(
952            decide(&case, 97, &mut state, now).len(),
953            0,
954            "same expiry is one notification"
955        );
956        assert!(
957            state
958                .entries
959                .keys()
960                .all(|key| key.starts_with("anthropic@gmail::credit::")),
961            "{:?}",
962            state.entries.keys().collect::<Vec<_>>()
963        );
964        // A different expiry instant is a different credit.
965        let later = RefreshInput {
966            credits: vec![CreditExpiry {
967                title: None,
968                expires_at: now + chrono::Duration::hours(20),
969            }],
970            ..input(Vec::new())
971        };
972        assert_eq!(decide(&later, 97, &mut state, now).len(), 1);
973    }
974
975    // — State persistence ———————————————————————————
976
977    #[test]
978    fn state_round_trips_through_disk_and_tolerates_corruption() {
979        let (dir, path) = state_path();
980        let mut state = NotifyState::default();
981        state.entries.insert(
982            "anthropic::Weekly (7d)".into(),
983            KeyRecord {
984                notified_at: at(23, 12, 0),
985                reset_at_snapshot: Some(at(30, 0, 0)),
986            },
987        );
988        state.write_to(&path).unwrap();
989        assert_eq!(NotifyState::read_from(&path), state);
990
991        std::fs::write(&path, "{ not json").unwrap();
992        assert_eq!(NotifyState::read_from(&path), NotifyState::default());
993        drop(dir);
994    }
995
996    #[test]
997    #[cfg(unix)]
998    fn state_file_is_mode_0600() {
999        use std::os::unix::fs::PermissionsExt;
1000        let (dir, path) = state_path();
1001        NotifyState::default().write_to(&path).unwrap();
1002        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1003        assert_eq!(mode & 0o777, 0o600);
1004        drop(dir);
1005    }
1006
1007    #[test]
1008    fn rearm_survives_a_simulated_restart() {
1009        let (dir, path) = state_path();
1010        let now = at(23, 12, 0);
1011        let mut state = NotifyState::read_from(&path);
1012
1013        let high = input(vec![row("Weekly (7d)", 97, None)]);
1014        let low = input(vec![row("Weekly (7d)", 89, None)]);
1015        assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
1016        state.write_to(&path).unwrap();
1017
1018        // New process: same crossing persists.
1019        let mut state = NotifyState::read_from(&path);
1020        assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
1021
1022        // New process: usage drops below the band, mark cleared and written.
1023        decide(&low, 97, &mut state, now);
1024        state.write_to(&path).unwrap();
1025
1026        // New process: the crossing fires again.
1027        let mut state = NotifyState::read_from(&path);
1028        assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
1029        drop(dir);
1030    }
1031
1032    // — Orchestration ————————————————————————————————
1033
1034    #[test]
1035    fn run_at_persists_marks_and_delivers_through_the_sink() {
1036        let (dir, path) = state_path();
1037        let mut sink = RecorderSink::default();
1038        let input = input(vec![row("Weekly (7d)", 97, None)]);
1039        run_at(
1040            &input,
1041            97,
1042            &path,
1043            Duration::from_secs(2),
1044            &mut sink,
1045            at(23, 12, 0),
1046        );
1047        assert_eq!(sink.delivered.len(), 1);
1048
1049        // Second run with a fresh sink: marked, nothing delivered.
1050        let mut second = RecorderSink::default();
1051        run_at(
1052            &input,
1053            97,
1054            &path,
1055            Duration::from_secs(2),
1056            &mut second,
1057            at(23, 12, 0),
1058        );
1059        assert!(second.delivered.is_empty());
1060        assert!(path.exists());
1061        drop(dir);
1062    }
1063
1064    #[test]
1065    fn lock_contention_skips_notifying_entirely() {
1066        let (dir, path) = state_path();
1067        let lock_path = path.with_file_name(".notifications.lock");
1068        let _held = crate::cache::acquire_lock(&lock_path, Duration::from_secs(5)).unwrap();
1069
1070        let mut sink = RecorderSink::default();
1071        let input = input(vec![row("Weekly (7d)", 100, None)]);
1072        run_at(
1073            &input,
1074            97,
1075            &path,
1076            Duration::from_millis(100),
1077            &mut sink,
1078            at(23, 12, 0),
1079        );
1080        assert!(
1081            sink.delivered.is_empty(),
1082            "a missed notification beats a double one"
1083        );
1084        assert!(!path.exists(), "no state without the lock");
1085        drop(dir);
1086    }
1087
1088    #[test]
1089    fn an_unwritable_state_file_skips_delivery_rather_than_repeating() {
1090        let dir = tempfile::TempDir::new().unwrap();
1091        // A directory where the state file should be makes the atomic write
1092        // fail: persisting the mark is a precondition of delivering.
1093        let state_path = dir.path().join("notifications.json");
1094        std::fs::create_dir(&state_path).unwrap();
1095        let mut sink = RecorderSink::default();
1096        let input = input(vec![row("Weekly (7d)", 97, None)]);
1097        run_at(
1098            &input,
1099            97,
1100            &state_path,
1101            Duration::from_secs(2),
1102            &mut sink,
1103            at(23, 12, 0),
1104        );
1105        assert!(sink.delivered.is_empty());
1106    }
1107
1108    /// A 100% toast asks for the 30s Omarchy honours once it stops treating
1109    /// critical urgency as immortal; a normal one leaves the timeout to the
1110    /// daemon, which is where Omarchy's own 8s default lives.
1111    #[test]
1112    #[cfg(target_os = "linux")]
1113    fn a_critical_toast_asks_for_a_timeout_and_a_normal_one_does_not() {
1114        let sink = NotifySendSink::new();
1115        let toast = |urgency| Notification {
1116            title: "Claude — Session (5h) at 100%".to_string(),
1117            body: "resets in 2h".to_string(),
1118            urgency,
1119            key: "anthropic::Session (5h)".to_string(),
1120        };
1121        let critical_toast = toast(Urgency::Critical);
1122
1123        let critical = sink.argv(&critical_toast);
1124        assert!(
1125            critical
1126                .windows(2)
1127                .any(|pair| pair[0] == "-t" && pair[1] == "30000"),
1128            "a critical toast asks for the cap: {critical:?}"
1129        );
1130
1131        let normal = sink.argv(&toast(Urgency::Normal));
1132        assert!(
1133            !normal.iter().any(|arg| arg == "-t"),
1134            "a normal toast leaves the timeout to the daemon: {normal:?}"
1135        );
1136
1137        // `notify-send` reads the trailing positionals as title and body, so
1138        // the switch this test is about must not land after them.
1139        let tail = &critical[critical.len() - 2..];
1140        assert_eq!(tail[0], critical_toast.title);
1141        assert_eq!(tail[1], critical_toast.body);
1142    }
1143
1144    /// The guard test for the whole feature: a notifier that cannot even be
1145    /// spawned must not make the check error, panic, or skip the state
1146    /// bookkeeping. `run_at` has no failure channel at all — this pins that.
1147    #[test]
1148    #[cfg(target_os = "linux")]
1149    fn a_missing_notifier_binary_is_a_silent_no_op() {
1150        let (dir, path) = state_path();
1151        let mut sink = NotifySendSink::with_program("/nonexistent/notify-send");
1152        let input = input(vec![row("Weekly (7d)", 97, None)]);
1153        run_at(
1154            &input,
1155            97,
1156            &path,
1157            Duration::from_secs(2),
1158            &mut sink,
1159            at(23, 12, 0),
1160        );
1161        assert!(
1162            path.exists(),
1163            "state bookkeeping is independent of delivery"
1164        );
1165        assert!(
1166            NotifyState::read_from(&path).is_notified("anthropic@gmail::Weekly (7d)"),
1167            "the crossing is marked even though nothing was delivered"
1168        );
1169        drop(dir);
1170    }
1171
1172    #[test]
1173    fn noop_sink_records_nothing_and_never_fails() {
1174        let (dir, path) = state_path();
1175        let mut sink = NoopSink;
1176        let input = input(vec![row("Weekly (7d)", 97, None)]);
1177        run_at(
1178            &input,
1179            97,
1180            &path,
1181            Duration::from_secs(2),
1182            &mut sink,
1183            at(23, 12, 0),
1184        );
1185        assert!(path.exists());
1186        drop(dir);
1187    }
1188
1189    // — Projection from the shared assembly ——————————
1190
1191    #[test]
1192    fn from_tab_projects_rows_account_and_credits() {
1193        use crate::tui::app::{ReadyTab, TabId, TabState};
1194        use crate::usage::{
1195            AnthropicSnapshot, ResetCredit, ResetCredits, UsageWindow, VendorSnapshot,
1196        };
1197
1198        let reset = at(24, 2, 0);
1199        let state = TabState::Ready(Box::new(ReadyTab {
1200            snapshot: VendorSnapshot::Anthropic(AnthropicSnapshot {
1201                plan: "Claude Max 20x".into(),
1202                session: UsageWindow {
1203                    utilization_pct: 98,
1204                    resets_at: Some(reset),
1205                    window_duration: chrono::Duration::hours(5),
1206                },
1207                weekly: UsageWindow {
1208                    utilization_pct: 42,
1209                    resets_at: None,
1210                    window_duration: chrono::Duration::days(7),
1211                },
1212                sonnet: None,
1213                scoped: vec![],
1214                extra: None,
1215                // Claude banks resets too, and the expiry warning is the
1216                // whole point of carrying them: a grant the sidebar lists but
1217                // the notifier ignores is a half-delivered feature.
1218                reset_credits: ResetCredits {
1219                    available: 1,
1220                    credits: vec![ResetCredit {
1221                        title: Some("Opus 5.5 launch reset".into()),
1222                        expires_at: Some(at(25, 0, 0)),
1223                    }],
1224                },
1225            }),
1226            stale: false,
1227            last_error: None,
1228            fetched_at: None,
1229            display: Default::default(),
1230        }));
1231
1232        let tab = TabId::account("gmail");
1233        let projected =
1234            RefreshInput::from_tab(&tab, &state, at(23, 12, 0)).expect("ready tab projects");
1235        assert_eq!(projected.entry_id, "anthropic@gmail");
1236        assert_eq!(projected.vendor, "Claude");
1237        assert_eq!(projected.account.as_deref(), Some("gmail"));
1238        assert_eq!(
1239            projected.rows,
1240            vec![
1241                row("Session (5h)", 98, Some(reset)),
1242                row("Weekly (7d)", 42, None),
1243            ]
1244        );
1245        assert_eq!(
1246            projected.credits,
1247            vec![CreditExpiry {
1248                title: Some("Opus 5.5 launch reset".into()),
1249                expires_at: at(25, 0, 0),
1250            }]
1251        );
1252
1253        // A Codex snapshot carries its banked credits through.
1254        let state = TabState::Ready(Box::new(ReadyTab {
1255            snapshot: VendorSnapshot::Openai(crate::usage::OpenAiSnapshot {
1256                plan: "ChatGPT Pro".into(),
1257                session: None,
1258                weekly: None,
1259                code_review: None,
1260                additional_limits: Vec::new(),
1261                unavailable_models: Vec::new(),
1262                credits: None,
1263                reset_credits: ResetCredits {
1264                    available: 2,
1265                    credits: vec![ResetCredit {
1266                        title: Some("Full reset".into()),
1267                        expires_at: Some(at(25, 0, 0)),
1268                    }],
1269                },
1270                source: crate::usage::OpenAiSource::CodexOauth,
1271            }),
1272            stale: false,
1273            last_error: None,
1274            fetched_at: None,
1275            display: Default::default(),
1276        }));
1277        let projected = RefreshInput::from_tab(
1278            &TabId::vendor(crate::vendor::VendorId::Openai),
1279            &state,
1280            at(23, 12, 0),
1281        )
1282        .expect("ready tab projects");
1283        assert_eq!(projected.entry_id, "openai");
1284        assert_eq!(
1285            projected.credits,
1286            vec![CreditExpiry {
1287                title: Some("Full reset".into()),
1288                expires_at: at(25, 0, 0),
1289            }]
1290        );
1291    }
1292
1293    #[test]
1294    fn from_tab_skips_grouped_slices_and_unavailable_credits() {
1295        use crate::tui::app::{ReadyTab, TabId, TabState};
1296        use crate::usage::{
1297            ResetCredit, ResetCredits, SuperGrokPeriod, SuperGrokProduct, SuperGrokSnapshot,
1298            VendorSnapshot,
1299        };
1300
1301        let state = TabState::Ready(Box::new(ReadyTab {
1302            snapshot: VendorSnapshot::SuperGrok(SuperGrokSnapshot {
1303                plan: "SuperGrok Heavy".into(),
1304                account: "scope".into(),
1305                weekly_pct: 97,
1306                period: SuperGrokPeriod::Weekly,
1307                reset_at: Some(at(26, 0, 0)),
1308                prepaid_balance: None,
1309                // available == 0: the report would not surface these either.
1310                reset_credits: ResetCredits {
1311                    available: 0,
1312                    credits: vec![ResetCredit {
1313                        title: None,
1314                        expires_at: Some(at(25, 0, 0)),
1315                    }],
1316                },
1317                products: vec![SuperGrokProduct {
1318                    label: "Grok Build".into(),
1319                    percent: 94,
1320                }],
1321            }),
1322            stale: false,
1323            last_error: None,
1324            fetched_at: None,
1325            display: Default::default(),
1326        }));
1327        let projected = RefreshInput::from_tab(
1328            &TabId::vendor(crate::vendor::VendorId::Supergrok),
1329            &state,
1330            at(23, 12, 0),
1331        )
1332        .expect("ready tab projects");
1333        // Only the overall meter — the grouped Grok Build slice shares its
1334        // window and must not raise a second notification.
1335        assert_eq!(projected.rows.len(), 1, "{:?}", projected.rows);
1336        assert!(projected.credits.is_empty());
1337    }
1338
1339    fn antigravity_ready(
1340        session: i32,
1341        third_party_session: i32,
1342        weekly: i32,
1343        third_party_weekly: i32,
1344    ) -> crate::tui::app::TabState {
1345        use crate::tui::app::{ReadyTab, TabState};
1346        use crate::usage::{AntigravitySnapshot, AntigravitySource, UsageWindow, VendorSnapshot};
1347
1348        let window = |pct: i32, resets_at: DateTime<Utc>, hours: i64| {
1349            Some(UsageWindow {
1350                utilization_pct: pct,
1351                resets_at: Some(resets_at),
1352                window_duration: chrono::Duration::hours(hours),
1353            })
1354        };
1355        TabState::Ready(Box::new(ReadyTab {
1356            snapshot: VendorSnapshot::Antigravity(AntigravitySnapshot {
1357                plan: "Google AI Pro".into(),
1358                account: "acct:test".into(),
1359                source: AntigravitySource::Local,
1360                session: window(session, at(24, 2, 21), 5),
1361                weekly: window(weekly, at(26, 1, 34), 168),
1362                third_party_session: window(third_party_session, at(24, 2, 19), 5),
1363                third_party_weekly: window(third_party_weekly, at(30, 19, 19), 168),
1364                model_pools: Vec::new(),
1365            }),
1366            stale: false,
1367            last_error: None,
1368            fetched_at: None,
1369            display: Default::default(),
1370        }))
1371    }
1372
1373    /// Antigravity lays "Session" and "Weekly" headings over the same two
1374    /// pool labels; each window needs its own name, or the four rows share
1375    /// two dedupe keys.
1376    #[test]
1377    fn from_tab_names_repeated_labels_by_their_heading() {
1378        use crate::tui::app::TabId;
1379
1380        let projected = RefreshInput::from_tab(
1381            &TabId::vendor(crate::vendor::VendorId::Antigravity),
1382            &antigravity_ready(3, 100, 98, 56),
1383            at(23, 22, 0),
1384        )
1385        .expect("ready tab projects");
1386        let windows: Vec<_> = projected
1387            .rows
1388            .iter()
1389            .map(|row| row.window.as_str())
1390            .collect();
1391        assert_eq!(
1392            windows,
1393            [
1394                "Session · Gemini",
1395                "Session · Claude & GPT OSS",
1396                "Weekly · Gemini",
1397                "Weekly · Claude & GPT OSS",
1398            ]
1399        );
1400    }
1401
1402    /// The reported bug: a weekly Gemini window at 98% beside a session one at
1403    /// 3%, and a session Claude window at 100% beside a weekly one at 56%. Under
1404    /// one key per label, the low twin cleared the record its high twin had
1405    /// just written, so both notifications fired again on every refresh.
1406    #[test]
1407    fn a_window_whose_label_repeats_notifies_once() {
1408        use crate::tui::app::TabId;
1409
1410        let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1411        let state = antigravity_ready(3, 100, 98, 56);
1412        let mut notify_state = NotifyState::default();
1413        let fired: Vec<Vec<String>> = (0..4)
1414            .map(|refresh| {
1415                let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh);
1416                let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1417                decide(&input, 97, &mut notify_state, now)
1418                    .into_iter()
1419                    .map(|notification| notification.title)
1420                    .collect()
1421            })
1422            .collect();
1423        assert_eq!(
1424            fired[0],
1425            [
1426                "Antigravity — Session · Claude & GPT OSS at 100%",
1427                "Antigravity — Weekly · Gemini at 98%",
1428            ]
1429        );
1430        assert!(
1431            fired[1..].iter().all(Vec::is_empty),
1432            "later refreshes must stay quiet: {fired:?}"
1433        );
1434    }
1435
1436    /// A window's identity must not depend on whether its sibling arrived.
1437    #[test]
1438    fn a_window_keeps_its_key_when_its_sibling_disappears_or_returns() {
1439        use crate::tui::app::{TabId, TabState};
1440        use crate::usage::VendorSnapshot;
1441
1442        let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1443        for starts_with_session in [false, true] {
1444            let mut notify_state = NotifyState::default();
1445            // The unchanged weekly quota fires only once, regardless of
1446            // which snapshot shape arrived first. A low reading while the
1447            // session is absent must also re-arm that same weekly key.
1448            let steps = [
1449                (starts_with_session, 98, 1),
1450                (!starts_with_session, 98, 0),
1451                (starts_with_session, 98, 0),
1452                (false, 20, 0),
1453                (true, 98, 1),
1454                (false, 98, 0),
1455            ];
1456            for (refresh, (has_session, weekly_pct, expected)) in steps.into_iter().enumerate() {
1457                let mut state = antigravity_ready(3, 10, weekly_pct, 56);
1458                let TabState::Ready(ready) = &mut state else {
1459                    unreachable!();
1460                };
1461                let VendorSnapshot::Antigravity(snapshot) = &mut ready.snapshot else {
1462                    unreachable!();
1463                };
1464                if !has_session {
1465                    snapshot.session = None;
1466                    snapshot.third_party_session = None;
1467                }
1468                let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh as i64);
1469                let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1470                let fired = decide(&input, 97, &mut notify_state, now);
1471                assert_eq!(
1472                    fired.len(),
1473                    expected,
1474                    "starts_with_session={starts_with_session}, refresh={refresh}: {fired:?}"
1475                );
1476                if let Some(notification) = fired.first() {
1477                    assert_eq!(notification.key, "antigravity::Weekly · Gemini");
1478                }
1479            }
1480        }
1481    }
1482
1483    /// Heading context applies to unique labels too, survives spacers, and
1484    /// ends at a non-heading row. Unscoped metrics keep their bare names.
1485    #[test]
1486    fn metric_names_follow_heading_scope_even_for_unique_labels() {
1487        use crate::balance::MetricHeadline;
1488        use crate::pacing::PaceSeverity;
1489        use crate::tui::panels::{Section, SectionProjection};
1490
1491        let projection = |section| SectionProjection {
1492            section,
1493            reset_at: None,
1494            window: None,
1495            group: None,
1496            model_pool: false,
1497            headline: MetricHeadline::Percent,
1498            used_cents: None,
1499            limit_cents: None,
1500        };
1501        let metric = |label: &str, pct| {
1502            projection(Section::Metric {
1503                label: label.into(),
1504                pct,
1505                severity: PaceSeverity::Low,
1506                value_label: format!("{pct}%"),
1507                footnote: String::new(),
1508            })
1509        };
1510        let heading = |label: &str| {
1511            projection(Section::Text {
1512                label: label.into(),
1513                value: String::new(),
1514            })
1515        };
1516        let rows = metric_rows([
1517            metric("Unscoped", 20),
1518            heading("Session"),
1519            projection(Section::Spacer),
1520            metric("General", 40),
1521            metric("Video", 10),
1522            projection(Section::Spacer),
1523            heading("Weekly"),
1524            metric("General", 60),
1525            projection(Section::Text {
1526                label: "Info".into(),
1527                value: "Details".into(),
1528            }),
1529            metric("Other", 30),
1530        ]);
1531        let windows: Vec<_> = rows.iter().map(|row| row.window.as_str()).collect();
1532        assert_eq!(
1533            windows,
1534            [
1535                "Unscoped",
1536                "Session · General",
1537                "Session · Video",
1538                "Weekly · General",
1539                "Other",
1540            ]
1541        );
1542    }
1543
1544    #[test]
1545    fn from_tab_returns_none_for_unready_states() {
1546        use crate::tui::app::{TabId, TabState};
1547        assert!(
1548            RefreshInput::from_tab(
1549                &TabId::vendor(crate::vendor::VendorId::Zai),
1550                &TabState::Loading,
1551                at(23, 12, 0)
1552            )
1553            .is_none()
1554        );
1555        assert!(
1556            RefreshInput::from_tab(
1557                &TabId::vendor(crate::vendor::VendorId::Zai),
1558                &TabState::error("not signed in"),
1559                at(23, 12, 0)
1560            )
1561            .is_none()
1562        );
1563    }
1564}