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