From 1d094f938d2e7cb017e937b6165c8f5adc8c792f Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:20:48 +0700 Subject: [PATCH 1/2] feat(proof): seed provider usage snapshot for proof runs --- AGENTS.md | 2 +- .../src-tauri/src/commands/providers.rs | 6 + apps/desktop-tauri/src-tauri/src/main.rs | 12 + .../src-tauri/src/proof_harness.rs | 363 +++++++++++++++++- docs/WINDOWS_PROOF.md | 7 + 5 files changed, 388 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cdae508890..6ef98b581e 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ - **Settings**: `%config%/CodexBar/settings.json` via `Settings::load` / `save` and `secure_file` (DPAPI-capable on Windows). Frontend `updateSettings` patch → save → `codexbar:settings-updated` / float-bar config events. - **Tray**: `tray_bridge` + `tray_menu`. Icon pixels from shared `codexbar::tray::{render_bar_icon_rgba, render_percent_icon_rgba}`. - **Float bar**: `floatbar/` owns the auxiliary always-on-top window. The builder must pin `.theme(Some(tauri::Theme::Dark))` — WebView2 resolves `prefers-color-scheme` on a shared process profile; an unpinned window flips other webviews under theme `auto`. -- **Proof harness**: env `CODEXBAR_PROOF_MODE` (e.g. `settings:menu`) opens a target surface and suppresses blur-dismiss for automation / CUA capture. +- **Proof harness**: env `CODEXBAR_PROOF_MODE` (e.g. `settings:menu`) opens a target surface and suppresses blur-dismiss for automation / CUA capture. `CODEXBAR_SEED_USAGE_JSON=` seeds one synthetic bridge-shaped Codex `ProviderUsageSnapshot` into the provider cache at launch (pinned against refresh eviction; malformed files are warned about and skipped). ## Key Directories diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index fce7da696c..7cbf1acd1a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -292,6 +292,12 @@ fn begin_provider_refresh( } fn provider_cache_can_skip_refresh(guard: &AppState, force: bool) -> bool { + // Proof-harness seed: pin the synthetic snapshot for the whole run so a + // periodic auto-refresh cannot overwrite seeded capture conditions. + if !force && crate::proof_harness::seed_usage_json_active() && !guard.provider_cache.is_empty() + { + return true; + } !force && !guard.provider_cache.is_empty() && is_provider_cache_fresh(guard.provider_cache_updated_at, PROVIDER_CACHE_STALE_AFTER) diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 222c65b833..fa6a2924c0 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -124,6 +124,18 @@ fn main() { let mut initial_state = AppState::new(); initial_state.proof_config = proof_config; + // Proof-harness seed: CODEXBAR_SEED_USAGE_JSON plants one synthetic Codex + // ProviderUsageSnapshot before the event loop and any WebView read. The + // cache timestamp makes the seeded cache count as fresh so the first + // frontend refresh-if-stale call does not evict the synthetic data. + if let Some(snapshot) = proof_harness::seed_usage_snapshot_from_env() { + tracing::info!( + "proof-harness: seeded provider snapshot for '{}'", + snapshot.provider_id + ); + initial_state.provider_cache.push(snapshot); + initial_state.provider_cache_updated_at = Some(std::time::Instant::now()); + } tauri::Builder::default() .manage(Mutex::new(initial_state)) diff --git a/apps/desktop-tauri/src-tauri/src/proof_harness.rs b/apps/desktop-tauri/src-tauri/src/proof_harness.rs index 39d8e1168f..4a4bd6f0a2 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_harness.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_harness.rs @@ -15,12 +15,22 @@ //! In proof mode the shell immediately transitions to the requested surface //! and suppresses blur-dismiss so the window stays visible for automated //! screenshot capture. +//! +//! `CODEXBAR_SEED_USAGE_JSON=` additionally seeds one synthetic, +//! bridge-shaped Codex [`ProviderUsageSnapshot`] into the provider cache at +//! launch (before the first event/WebView read) and pins it against refresh +//! eviction for the run. Malformed files log a warning and the shell +//! continues without seeding — proof runs must never crash on the seed. use std::sync::Mutex; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager}; +use crate::commands::{ + CostSnapshotBridge, NamedRateWindowSnapshot, PaceSnapshot, ProviderUsageSnapshot, + RateWindowSnapshot, +}; use crate::shell; use crate::state::AppState; use crate::surface::SurfaceMode; @@ -242,6 +252,285 @@ pub fn is_proof_mode(app: &AppHandle) -> bool { .unwrap_or(false) } +// ── Provider-usage seed (CODEXBAR_SEED_USAGE_JSON) ─────────────────── + +/// Environment variable pointing at a JSON file with one synthetic, +/// bridge-shaped `ProviderUsageSnapshot` for the codex provider. +pub const SEED_USAGE_ENV_VAR: &str = "CODEXBAR_SEED_USAGE_JSON"; + +/// Whether a seed path was configured at launch. While set, the provider +/// cache is pinned fresh so the synthetic snapshot is never evicted by an +/// automatic refresh during a proof/capture run. +pub fn seed_usage_json_active() -> bool { + std::env::var_os(SEED_USAGE_ENV_VAR).is_some() +} + +/// Read and validate the seed file referenced by `CODEXBAR_SEED_USAGE_JSON`. +/// +/// Returns `None` (with a warn, never a crash) when the variable is unset, +/// the file is unreadable, the JSON is malformed, or the snapshot is not +/// for the `codex` provider. +pub fn seed_usage_snapshot_from_env() -> Option { + let path = std::env::var_os(SEED_USAGE_ENV_VAR)?; + let path = std::path::PathBuf::from(path); + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(err) => { + tracing::warn!( + "{SEED_USAGE_ENV_VAR}: cannot read {}: {err}", + path.display() + ); + return None; + } + }; + let seed: SeedProviderUsageSnapshot = match serde_json::from_str(&raw) { + Ok(seed) => seed, + Err(err) => { + tracing::warn!( + "{SEED_USAGE_ENV_VAR}: malformed JSON in {}: {err}", + path.display() + ); + return None; + } + }; + if seed.provider_id != "codex" { + tracing::warn!( + "{SEED_USAGE_ENV_VAR}: snapshot providerId '{}' is not 'codex', ignoring", + seed.provider_id + ); + return None; + } + Some(seed.into_bridge()) +} + +/// Deserializable mirror of the bridge JSON shape (`bridge.ts` +/// `ProviderUsageSnapshot`, camelCase). Bridge structs are Serialize-only / +/// reference external crates, so the seed parses into this DTO and maps over. +/// Everything but `providerId` and `primary` is optional. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SeedProviderUsageSnapshot { + provider_id: String, + #[serde(default = "default_seed_display_name")] + display_name: String, + primary: SeedRateWindow, + #[serde(default)] + primary_label: Option, + #[serde(default)] + secondary: Option, + #[serde(default)] + secondary_label: Option, + #[serde(default)] + model_specific: Option, + #[serde(default)] + tertiary: Option, + #[serde(default)] + tertiary_label: Option, + #[serde(default)] + extra_rate_windows: Vec, + #[serde(default)] + cost: Option, + #[serde(default)] + plan_name: Option, + #[serde(default)] + account_email: Option, + #[serde(default = "default_seed_source_label")] + source_label: String, + /// RFC 3339; defaults to launch time so the card renders as fresh. + #[serde(default)] + updated_at: Option, + #[serde(default)] + error: Option, + #[serde(default)] + pace: Option, + #[serde(default)] + account_organization: Option, + #[serde(default)] + tray_status_label: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SeedRateWindow { + used_percent: f64, + #[serde(default)] + remaining_percent: Option, + #[serde(default)] + window_minutes: Option, + #[serde(default)] + resets_at: Option, + #[serde(default)] + reset_description: Option, + #[serde(default)] + is_exhausted: bool, + #[serde(default)] + is_informational: bool, + #[serde(default)] + reserve_percent: Option, + #[serde(default)] + reserve_description: Option, + #[serde(default)] + reserve_will_last_to_reset: bool, + #[serde(default)] + reserve_eta_seconds: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SeedNamedRateWindow { + id: String, + title: String, + window: SeedRateWindow, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SeedCost { + used: f64, + #[serde(default)] + limit: Option, + #[serde(default)] + remaining: Option, + #[serde(default = "default_seed_currency")] + currency_code: String, + #[serde(default = "default_seed_period")] + period: String, + #[serde(default)] + resets_at: Option, + #[serde(default)] + formatted_used: Option, + #[serde(default)] + formatted_limit: Option, + #[serde(default)] + balance: Option, + #[serde(default)] + formatted_balance: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SeedPace { + stage: String, + delta_percent: f64, + will_last_to_reset: bool, + #[serde(default)] + eta_seconds: Option, + #[serde(default)] + expected_used_percent: f64, + #[serde(default)] + actual_used_percent: f64, +} + +fn default_seed_display_name() -> String { + "Codex".to_string() +} + +fn default_seed_source_label() -> String { + "seed".to_string() +} + +fn default_seed_currency() -> String { + "USD".to_string() +} + +fn default_seed_period() -> String { + "month".to_string() +} + +impl SeedRateWindow { + fn into_bridge(self) -> RateWindowSnapshot { + RateWindowSnapshot { + used_percent: self.used_percent, + remaining_percent: self + .remaining_percent + .unwrap_or_else(|| 100.0 - self.used_percent.clamp(0.0, 100.0)), + window_minutes: self.window_minutes, + resets_at: self.resets_at, + reset_description: self.reset_description, + is_exhausted: self.is_exhausted, + is_informational: self.is_informational, + reserve_percent: self.reserve_percent, + reserve_description: self.reserve_description, + reserve_will_last_to_reset: self.reserve_will_last_to_reset, + reserve_eta_seconds: self.reserve_eta_seconds, + } + } +} + +fn pace_stage_from_seed(raw: &str) -> Option<&'static str> { + match raw { + "on_track" => Some("on_track"), + "slightly_ahead" => Some("slightly_ahead"), + "ahead" => Some("ahead"), + "far_ahead" => Some("far_ahead"), + "slightly_behind" => Some("slightly_behind"), + "behind" => Some("behind"), + "far_behind" => Some("far_behind"), + _ => None, + } +} + +impl SeedProviderUsageSnapshot { + fn into_bridge(self) -> ProviderUsageSnapshot { + ProviderUsageSnapshot { + provider_id: self.provider_id, + display_name: self.display_name, + primary: self.primary.into_bridge(), + primary_label: self.primary_label, + secondary: self.secondary.map(SeedRateWindow::into_bridge), + secondary_label: self.secondary_label, + model_specific: self.model_specific.map(SeedRateWindow::into_bridge), + tertiary: self.tertiary.map(SeedRateWindow::into_bridge), + tertiary_label: self.tertiary_label, + extra_rate_windows: self + .extra_rate_windows + .into_iter() + .map(|extra| NamedRateWindowSnapshot { + id: extra.id, + title: extra.title, + window: extra.window.into_bridge(), + }) + .collect(), + cost: self.cost.map(|cost| CostSnapshotBridge { + used: cost.used, + limit: cost.limit, + remaining: cost.remaining, + currency_code: cost.currency_code, + period: cost.period, + resets_at: cost.resets_at, + formatted_used: cost + .formatted_used + .unwrap_or_else(|| format!("${:.2}", cost.used)), + formatted_limit: cost.formatted_limit, + balance: cost.balance, + formatted_balance: cost.formatted_balance, + }), + plan_name: self.plan_name, + account_email: self.account_email, + source_label: self.source_label, + updated_at: self + .updated_at + .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()), + error: self.error, + pace: self.pace.and_then(|pace| { + pace_stage_from_seed(&pace.stage).map(|stage| PaceSnapshot { + stage, + delta_percent: pace.delta_percent, + will_last_to_reset: pace.will_last_to_reset, + eta_seconds: pace.eta_seconds, + expected_used_percent: pace.expected_used_percent, + actual_used_percent: pace.actual_used_percent, + }) + }), + account_organization: self.account_organization, + tray_status_label: self.tray_status_label, + fetch_duration_ms: None, + wayfinder_usage: None, + session_equivalent_forecast: None, + } + } +} + fn proof_payload_is_supported(surface_mode: SurfaceMode, payload: Option<&str>) -> bool { match (surface_mode, payload) { (SurfaceMode::Hidden | SurfaceMode::TrayPanel, None) => true, @@ -402,4 +691,76 @@ mod tests { assert_eq!(cfg.surface_target(), SurfaceTarget::Dashboard); }); } + + #[test] + fn state_carries_codex_snapshot_from_seed() { + let _guard = ENV_LOCK.lock().unwrap(); + // Unique temp file; left behind on purpose (tiny, in %TEMP%). + let path = std::env::temp_dir().join(format!( + "codexbar-seed-usage-test-{}-{}.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let raw = serde_json::json!({ + "providerId": "codex", + "displayName": "Codex", + "primary": { + "usedPercent": 61.0, + "windowMinutes": 300, + "resetsAt": "2099-01-02T03:04:05Z", + "resetDescription": "seeded 5h", + }, + "primaryLabel": "Session", + "secondary": { "usedPercent": 74.0, "windowMinutes": 10080 }, + "secondaryLabel": "Weekly", + "extraRateWindows": [ + { + "id": "reset-credits", + "title": "Reset Credits", + "window": { + "usedPercent": 0.0, + "resetsAt": "2099-01-09T00:00:00Z", + "resetDescription": "2 reset credits available", + "isInformational": true, + }, + }, + ], + }); + std::fs::write(&path, raw.to_string()).unwrap(); + + let prev = std::env::var(SEED_USAGE_ENV_VAR).ok(); + unsafe { std::env::set_var(SEED_USAGE_ENV_VAR, &path) }; + let state_seeded = seed_usage_snapshot_from_env().map(|snapshot| { + let mut state = AppState::new(); + state.provider_cache.push(snapshot); + state.provider_cache_updated_at = Some(std::time::Instant::now()); + state.provider_cache + }); + match prev { + Some(value) => unsafe { std::env::set_var(SEED_USAGE_ENV_VAR, value) }, + None => unsafe { std::env::remove_var(SEED_USAGE_ENV_VAR) }, + } + + let cache = state_seeded.expect("seed parses into a snapshot"); + assert_eq!(cache.len(), 1); + let snapshot = &cache[0]; + assert_eq!(snapshot.provider_id, "codex"); + assert_eq!(snapshot.primary.used_percent, 61.0); + assert_eq!( + snapshot.primary.resets_at.as_deref(), + Some("2099-01-02T03:04:05Z") + ); + assert_eq!(snapshot.primary.remaining_percent, 39.0); + assert_eq!(snapshot.primary_label.as_deref(), Some("Session")); + assert_eq!( + snapshot.secondary.as_ref().map(|s| s.used_percent), + Some(74.0) + ); + assert_eq!(snapshot.extra_rate_windows.len(), 1); + assert_eq!(snapshot.extra_rate_windows[0].id, "reset-credits"); + assert!(snapshot.extra_rate_windows[0].window.is_informational); + } } diff --git a/docs/WINDOWS_PROOF.md b/docs/WINDOWS_PROOF.md index 8d680144f9..ec7f8d055d 100644 --- a/docs/WINDOWS_PROOF.md +++ b/docs/WINDOWS_PROOF.md @@ -30,6 +30,13 @@ cargo test cargo test -p codexbar-desktop-tauri cargo clippy -p codexbar-desktop-tauri --all-targets -- -D warnings +# Proof/CUA captures: set `CODEXBAR_PROOF_MODE` (e.g. `trayPanel`) to open a +# surface with blur-dismiss suppressed; optionally set +# `CODEXBAR_SEED_USAGE_JSON=` to plant one synthetic bridge-shaped +# Codex ProviderUsageSnapshot into the provider cache at launch (malformed +# files are warned about and ignored) — the seeded cache is pinned against +# refresh eviction for the duration of the run. + # Frontend unit tests + bundle cd apps\desktop-tauri pnpm install --frozen-lockfile From 4424006152a236acdd07a1cb7fb6a6d2a1257734 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:50:35 +0700 Subject: [PATCH 2/2] Thermo-nuclear: eliminate Seed* DTOs, extract pure parse function --- .../src-tauri/src/commands/bridge.rs | 86 ++++- .../src-tauri/src/proof_harness.rs | 358 +++++------------- 2 files changed, 166 insertions(+), 278 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 664c6d9f22..270e792e2a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -2,22 +2,39 @@ use super::*; // ── Bridge snapshot types ──────────────────────────────────────────── -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RateWindowSnapshot { pub used_percent: f64, + /// Defaults to `100.0` when absent in JSON (e.g. proof-seed files). + #[serde(default = "default_full_remaining")] pub remaining_percent: f64, + #[serde(default)] pub window_minutes: Option, + #[serde(default)] pub resets_at: Option, + #[serde(default)] pub reset_description: Option, + #[serde(default)] pub is_exhausted: bool, + #[serde(default)] pub is_informational: bool, + #[serde(default)] pub reserve_percent: Option, + #[serde(default)] pub reserve_description: Option, + #[serde(default)] pub reserve_will_last_to_reset: bool, + #[serde(default)] pub reserve_eta_seconds: Option, } +/// Serde default for [`RateWindowSnapshot::remaining_percent`] — the common +/// case for a fresh window (0 %% used → 100 %% remaining). +fn default_full_remaining() -> f64 { + 100.0 +} + impl RateWindowSnapshot { pub(super) fn from_rate_window(rw: &RateWindow) -> Self { Self { @@ -50,22 +67,41 @@ impl RateWindowSnapshot { } } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CostSnapshotBridge { pub used: f64, + #[serde(default)] pub limit: Option, + #[serde(default)] pub remaining: Option, + #[serde(default = "default_currency")] pub currency_code: String, + #[serde(default = "default_cost_period")] pub period: String, + #[serde(default)] pub resets_at: Option, + /// Defaults to `format!("${:.2}", used)` when absent (filled by + /// [`parse_seed_usage_snapshot`](crate::proof_harness::parse_seed_usage_snapshot)). + #[serde(default)] pub formatted_used: String, + #[serde(default)] pub formatted_limit: Option, + #[serde(default)] pub balance: Option, + #[serde(default)] pub formatted_balance: Option, } -#[derive(Debug, Clone, Serialize)] +fn default_currency() -> String { + "USD".to_string() +} + +fn default_cost_period() -> String { + "month".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NamedRateWindowSnapshot { pub id: String, @@ -74,19 +110,23 @@ pub struct NamedRateWindowSnapshot { } /// Pace prediction snapshot for tray/bridge display. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PaceSnapshot { - pub stage: &'static str, + pub stage: String, pub delta_percent: f64, + #[serde(default)] pub will_last_to_reset: bool, + #[serde(default)] pub eta_seconds: Option, + #[serde(default)] pub expected_used_percent: f64, + #[serde(default)] pub actual_used_percent: f64, } /// Session-equivalent weekly forecast for Claude/Codex menu secondary line. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionEquivalentForecastSnapshot { pub estimated_windows_to_exhaust_weekly: f64, @@ -98,34 +138,62 @@ pub struct SessionEquivalentForecastSnapshot { } /// A frontend-friendly snapshot of one provider's usage data. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProviderUsageSnapshot { + #[serde(default)] pub tertiary_label: Option, pub provider_id: String, + #[serde(default = "default_display_name")] pub display_name: String, pub primary: RateWindowSnapshot, + #[serde(default)] pub primary_label: Option, + #[serde(default)] pub secondary: Option, + #[serde(default)] pub secondary_label: Option, + #[serde(default)] pub model_specific: Option, + #[serde(default)] pub tertiary: Option, + #[serde(default)] pub extra_rate_windows: Vec, + #[serde(default)] pub cost: Option, + #[serde(default)] pub plan_name: Option, + #[serde(default)] pub account_email: Option, + #[serde(default = "default_source_label")] pub source_label: String, + /// Defaults to launch time when absent so the card renders as fresh. + #[serde(default)] pub updated_at: String, + #[serde(default)] pub error: Option, + #[serde(default)] pub pace: Option, + #[serde(default)] pub account_organization: Option, + #[serde(default)] pub tray_status_label: Option, + #[serde(default)] pub fetch_duration_ms: Option, + #[serde(default)] pub wayfinder_usage: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing_if = "Option::is_none", default)] pub session_equivalent_forecast: Option, } +fn default_display_name() -> String { + "Codex".to_string() +} + +fn default_source_label() -> String { + "seed".to_string() +} + /// Provider payload after applying settings-driven cross-surface presentation. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -190,7 +258,7 @@ impl ProviderUsageSnapshot { .and_then(|window| codexbar::core::UsagePace::weekly(window, None, 10080)); let pace = primary_pace.as_ref().map(|p| PaceSnapshot { - stage: pace_stage_str(p.stage), + stage: pace_stage_str(p.stage).to_string(), delta_percent: p.delta_percent, will_last_to_reset: p.will_last_to_reset, eta_seconds: p.eta_seconds, diff --git a/apps/desktop-tauri/src-tauri/src/proof_harness.rs b/apps/desktop-tauri/src-tauri/src/proof_harness.rs index 4a4bd6f0a2..00b81a09f2 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_harness.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_harness.rs @@ -24,13 +24,10 @@ use std::sync::Mutex; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use tauri::{AppHandle, Manager}; -use crate::commands::{ - CostSnapshotBridge, NamedRateWindowSnapshot, PaceSnapshot, ProviderUsageSnapshot, - RateWindowSnapshot, -}; +use crate::commands::{CostSnapshotBridge, ProviderUsageSnapshot, RateWindowSnapshot}; use crate::shell; use crate::state::AppState; use crate::surface::SurfaceMode; @@ -283,251 +280,64 @@ pub fn seed_usage_snapshot_from_env() -> Option { return None; } }; - let seed: SeedProviderUsageSnapshot = match serde_json::from_str(&raw) { - Ok(seed) => seed, - Err(err) => { - tracing::warn!( - "{SEED_USAGE_ENV_VAR}: malformed JSON in {}: {err}", - path.display() - ); - return None; + match parse_seed_usage_snapshot(&raw) { + Ok(snapshot) => Some(snapshot), + Err(msg) => { + tracing::warn!("{SEED_USAGE_ENV_VAR}: {msg} in {}", path.display()); + None } - }; - if seed.provider_id != "codex" { - tracing::warn!( - "{SEED_USAGE_ENV_VAR}: snapshot providerId '{}' is not 'codex', ignoring", - seed.provider_id - ); - return None; } - Some(seed.into_bridge()) } -/// Deserializable mirror of the bridge JSON shape (`bridge.ts` -/// `ProviderUsageSnapshot`, camelCase). Bridge structs are Serialize-only / -/// reference external crates, so the seed parses into this DTO and maps over. -/// Everything but `providerId` and `primary` is optional. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SeedProviderUsageSnapshot { - provider_id: String, - #[serde(default = "default_seed_display_name")] - display_name: String, - primary: SeedRateWindow, - #[serde(default)] - primary_label: Option, - #[serde(default)] - secondary: Option, - #[serde(default)] - secondary_label: Option, - #[serde(default)] - model_specific: Option, - #[serde(default)] - tertiary: Option, - #[serde(default)] - tertiary_label: Option, - #[serde(default)] - extra_rate_windows: Vec, - #[serde(default)] - cost: Option, - #[serde(default)] - plan_name: Option, - #[serde(default)] - account_email: Option, - #[serde(default = "default_seed_source_label")] - source_label: String, - /// RFC 3339; defaults to launch time so the card renders as fresh. - #[serde(default)] - updated_at: Option, - #[serde(default)] - error: Option, - #[serde(default)] - pace: Option, - #[serde(default)] - account_organization: Option, - #[serde(default)] - tray_status_label: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SeedRateWindow { - used_percent: f64, - #[serde(default)] - remaining_percent: Option, - #[serde(default)] - window_minutes: Option, - #[serde(default)] - resets_at: Option, - #[serde(default)] - reset_description: Option, - #[serde(default)] - is_exhausted: bool, - #[serde(default)] - is_informational: bool, - #[serde(default)] - reserve_percent: Option, - #[serde(default)] - reserve_description: Option, - #[serde(default)] - reserve_will_last_to_reset: bool, - #[serde(default)] - reserve_eta_seconds: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SeedNamedRateWindow { - id: String, - title: String, - window: SeedRateWindow, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SeedCost { - used: f64, - #[serde(default)] - limit: Option, - #[serde(default)] - remaining: Option, - #[serde(default = "default_seed_currency")] - currency_code: String, - #[serde(default = "default_seed_period")] - period: String, - #[serde(default)] - resets_at: Option, - #[serde(default)] - formatted_used: Option, - #[serde(default)] - formatted_limit: Option, - #[serde(default)] - balance: Option, - #[serde(default)] - formatted_balance: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SeedPace { - stage: String, - delta_percent: f64, - will_last_to_reset: bool, - #[serde(default)] - eta_seconds: Option, - #[serde(default)] - expected_used_percent: f64, - #[serde(default)] - actual_used_percent: f64, -} - -fn default_seed_display_name() -> String { - "Codex".to_string() -} - -fn default_seed_source_label() -> String { - "seed".to_string() -} - -fn default_seed_currency() -> String { - "USD".to_string() -} +/// Parse a seed-usage JSON string directly into a canonical +/// [`ProviderUsageSnapshot`]. +/// +/// The canonical bridge types carry `#[serde(default)]` on optional/derived +/// fields so the seed JSON can omit them. This function fills in the +/// computed defaults that serde cannot express from sibling fields +/// (`remainingPercent` from `usedPercent`, `formattedUsed` from `used`, +/// `updatedAt` from the current time) and validates that the snapshot is +/// for the `codex` provider. +/// +/// Pure: no env vars, no files, no global state. +pub fn parse_seed_usage_snapshot(json: &str) -> Result { + let mut snapshot: ProviderUsageSnapshot = + serde_json::from_str(json).map_err(|e| format!("malformed JSON: {e}"))?; + + if snapshot.provider_id != "codex" { + return Err(format!( + "snapshot providerId '{}' is not 'codex', ignoring", + snapshot.provider_id + )); + } -fn default_seed_period() -> String { - "month".to_string() -} + normalize_rate_window(&mut snapshot.primary); + snapshot.secondary.as_mut().map(normalize_rate_window); + snapshot.model_specific.as_mut().map(normalize_rate_window); + snapshot.tertiary.as_mut().map(normalize_rate_window); + for extra in &mut snapshot.extra_rate_windows { + normalize_rate_window(&mut extra.window); + } + snapshot.cost.as_mut().map(normalize_cost); -impl SeedRateWindow { - fn into_bridge(self) -> RateWindowSnapshot { - RateWindowSnapshot { - used_percent: self.used_percent, - remaining_percent: self - .remaining_percent - .unwrap_or_else(|| 100.0 - self.used_percent.clamp(0.0, 100.0)), - window_minutes: self.window_minutes, - resets_at: self.resets_at, - reset_description: self.reset_description, - is_exhausted: self.is_exhausted, - is_informational: self.is_informational, - reserve_percent: self.reserve_percent, - reserve_description: self.reserve_description, - reserve_will_last_to_reset: self.reserve_will_last_to_reset, - reserve_eta_seconds: self.reserve_eta_seconds, - } + if snapshot.updated_at.is_empty() { + snapshot.updated_at = chrono::Utc::now().to_rfc3339(); } + + Ok(snapshot) } -fn pace_stage_from_seed(raw: &str) -> Option<&'static str> { - match raw { - "on_track" => Some("on_track"), - "slightly_ahead" => Some("slightly_ahead"), - "ahead" => Some("ahead"), - "far_ahead" => Some("far_ahead"), - "slightly_behind" => Some("slightly_behind"), - "behind" => Some("behind"), - "far_behind" => Some("far_behind"), - _ => None, - } +/// Recompute `remaining_percent` from `used_percent` (matching the canonical +/// [`RateWindowSnapshot::from_rate_window`] behaviour) so the seed JSON can +/// omit it. +fn normalize_rate_window(w: &mut RateWindowSnapshot) { + w.remaining_percent = 100.0 - w.used_percent.clamp(0.0, 100.0); } -impl SeedProviderUsageSnapshot { - fn into_bridge(self) -> ProviderUsageSnapshot { - ProviderUsageSnapshot { - provider_id: self.provider_id, - display_name: self.display_name, - primary: self.primary.into_bridge(), - primary_label: self.primary_label, - secondary: self.secondary.map(SeedRateWindow::into_bridge), - secondary_label: self.secondary_label, - model_specific: self.model_specific.map(SeedRateWindow::into_bridge), - tertiary: self.tertiary.map(SeedRateWindow::into_bridge), - tertiary_label: self.tertiary_label, - extra_rate_windows: self - .extra_rate_windows - .into_iter() - .map(|extra| NamedRateWindowSnapshot { - id: extra.id, - title: extra.title, - window: extra.window.into_bridge(), - }) - .collect(), - cost: self.cost.map(|cost| CostSnapshotBridge { - used: cost.used, - limit: cost.limit, - remaining: cost.remaining, - currency_code: cost.currency_code, - period: cost.period, - resets_at: cost.resets_at, - formatted_used: cost - .formatted_used - .unwrap_or_else(|| format!("${:.2}", cost.used)), - formatted_limit: cost.formatted_limit, - balance: cost.balance, - formatted_balance: cost.formatted_balance, - }), - plan_name: self.plan_name, - account_email: self.account_email, - source_label: self.source_label, - updated_at: self - .updated_at - .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()), - error: self.error, - pace: self.pace.and_then(|pace| { - pace_stage_from_seed(&pace.stage).map(|stage| PaceSnapshot { - stage, - delta_percent: pace.delta_percent, - will_last_to_reset: pace.will_last_to_reset, - eta_seconds: pace.eta_seconds, - expected_used_percent: pace.expected_used_percent, - actual_used_percent: pace.actual_used_percent, - }) - }), - account_organization: self.account_organization, - tray_status_label: self.tray_status_label, - fetch_duration_ms: None, - wayfinder_usage: None, - session_equivalent_forecast: None, - } +/// Fill `formatted_used` from `used` when the seed JSON omits it. +fn normalize_cost(c: &mut CostSnapshotBridge) { + if c.formatted_used.is_empty() { + c.formatted_used = format!("${:.2}", c.used); } } @@ -693,18 +503,8 @@ mod tests { } #[test] - fn state_carries_codex_snapshot_from_seed() { - let _guard = ENV_LOCK.lock().unwrap(); - // Unique temp file; left behind on purpose (tiny, in %TEMP%). - let path = std::env::temp_dir().join(format!( - "codexbar-seed-usage-test-{}-{}.json", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let raw = serde_json::json!({ + fn parse_seed_snapshot_decodes_codex_usage() { + let json = serde_json::json!({ "providerId": "codex", "displayName": "Codex", "primary": { @@ -728,25 +528,10 @@ mod tests { }, }, ], - }); - std::fs::write(&path, raw.to_string()).unwrap(); - - let prev = std::env::var(SEED_USAGE_ENV_VAR).ok(); - unsafe { std::env::set_var(SEED_USAGE_ENV_VAR, &path) }; - let state_seeded = seed_usage_snapshot_from_env().map(|snapshot| { - let mut state = AppState::new(); - state.provider_cache.push(snapshot); - state.provider_cache_updated_at = Some(std::time::Instant::now()); - state.provider_cache - }); - match prev { - Some(value) => unsafe { std::env::set_var(SEED_USAGE_ENV_VAR, value) }, - None => unsafe { std::env::remove_var(SEED_USAGE_ENV_VAR) }, - } + }) + .to_string(); - let cache = state_seeded.expect("seed parses into a snapshot"); - assert_eq!(cache.len(), 1); - let snapshot = &cache[0]; + let snapshot = parse_seed_usage_snapshot(&json).expect("seed parses into a snapshot"); assert_eq!(snapshot.provider_id, "codex"); assert_eq!(snapshot.primary.used_percent, 61.0); assert_eq!( @@ -759,8 +544,43 @@ mod tests { snapshot.secondary.as_ref().map(|s| s.used_percent), Some(74.0) ); + assert_eq!( + snapshot.secondary.as_ref().map(|s| s.remaining_percent), + Some(26.0) + ); assert_eq!(snapshot.extra_rate_windows.len(), 1); assert_eq!(snapshot.extra_rate_windows[0].id, "reset-credits"); assert!(snapshot.extra_rate_windows[0].window.is_informational); + // Defaults applied: displayName, sourceLabel, updatedAt + assert_eq!(snapshot.display_name, "Codex"); + assert_eq!(snapshot.source_label, "seed"); + assert!(!snapshot.updated_at.is_empty()); + } + + #[test] + fn parse_seed_snapshot_rejects_non_codex_provider() { + let json = r#"{"providerId": "claude", "primary": {"usedPercent": 10.0}}"#; + assert!(parse_seed_usage_snapshot(json).is_err()); + } + + #[test] + fn parse_seed_snapshot_rejects_malformed_json() { + assert!(parse_seed_usage_snapshot("{not json}").is_err()); + } + + #[test] + fn parse_seed_snapshot_fills_cost_defaults() { + let json = r#"{ + "providerId": "codex", + "primary": {"usedPercent": 50.0}, + "cost": {"used": 12.5, "limit": 100.0} + }"#; + let snapshot = parse_seed_usage_snapshot(json).unwrap(); + let cost = snapshot.cost.expect("cost present"); + assert_eq!(cost.used, 12.5); + assert_eq!(cost.limit, Some(100.0)); + assert_eq!(cost.currency_code, "USD"); + assert_eq!(cost.period, "month"); + assert_eq!(cost.formatted_used, "$12.50"); } }