Skip to main content

ai_usagebar/
notify.rs

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