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