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