diff --git a/apps/desktop-tauri/src-tauri/src/auto_resume.rs b/apps/desktop-tauri/src-tauri/src/auto_resume.rs index 35260c2648b..385412fedba 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_resume.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_resume.rs @@ -18,6 +18,9 @@ use crate::commands::{ProviderUsageSnapshot, RateWindowSnapshot}; use crate::state::AppState; use tauri::Manager; +mod session_target; +use session_target::*; + const MAX_SESSION_ID_LEN: usize = 256; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -459,158 +462,6 @@ fn begin_resume_attempt( Some((target, operation)) } -async fn capture_session( - provider: ProviderId, - token_account_id: Option, - quota_source: ResumeSource, -) -> Option { - // AgentSession has no account identity. A managed token account therefore - // cannot be positively correlated with a discovered process; do not arm - // a session that could belong to another account. - if token_account_id.is_some() { - return None; - } - let result = LocalAgentSessionScanner::default().scan().await; - if result.error.is_some() { - return None; - } - capture_target_from_sessions(provider, &result.sessions, token_account_id, quota_source) -} - -fn capture_target_from_sessions( - provider: ProviderId, - sessions: &[AgentSession], - token_account_id: Option, - quota_source: ResumeSource, -) -> Option { - if token_account_id.is_some() { - return None; - } - let mut candidates = sessions - .iter() - .filter_map(|session| target_from_active_session(provider, session, None, quota_source)); - let target = candidates.next()?; - // Scanner ordering is an activity/display concern, not an account - // correlation. Multiple live candidates are ambiguous, so fail closed. - candidates.next().is_none().then_some(target) -} - -fn target_from_active_session( - provider: ProviderId, - session: &AgentSession, - token_account_id: Option, - quota_source: ResumeSource, -) -> Option { - (session.state == AgentSessionState::Active).then_some(())?; - session.pid?; - target_from_session(provider, session, token_account_id, quota_source) -} - -fn target_from_session( - provider: ProviderId, - session: &AgentSession, - token_account_id: Option, - quota_source: ResumeSource, -) -> Option { - let expected = match provider { - ProviderId::Codex => AgentSessionProvider::Codex, - ProviderId::Claude => AgentSessionProvider::Claude, - _ => return None, - }; - if session.provider != expected - || session.source != AgentSessionSource::Cli - || !is_local_host(&session.host) - { - return None; - } - let session_id = valid_session_id(&session.id)?.to_string(); - let cwd = canonical_workspace(session.workspace.cwd.as_deref())?; - let transcript_path = session - .transcript_path - .as_deref() - .and_then(canonical_transcript); - Some(ResumeTarget { - provider, - quota_source, - session_id, - cwd, - transcript_path, - token_account_id, - }) -} - -fn matching_session<'a>( - target: &ResumeTarget, - sessions: &'a [AgentSession], -) -> Option<&'a AgentSession> { - sessions.iter().find(|session| { - target_from_session( - target.provider, - session, - target.token_account_id, - target.quota_source, - ) - .is_some_and(|candidate| { - candidate.provider == target.provider - && candidate.session_id == target.session_id - && candidate.cwd == target.cwd - }) - }) -} - -fn is_local_host(host: &str) -> bool { - let host = host.trim(); - if host.eq_ignore_ascii_case("localhost") { - return true; - } - std::env::var("COMPUTERNAME") - .ok() - .is_some_and(|local| !local.trim().is_empty() && local.trim().eq_ignore_ascii_case(host)) -} - -fn canonical_workspace(raw: Option<&str>) -> Option { - let raw = raw?.trim(); - if raw.is_empty() { - return None; - } - let path = Path::new(raw); - if !path.is_absolute() { - return None; - } - std::fs::canonicalize(path) - .ok() - .filter(|path| path.is_dir()) -} - -fn canonical_transcript(raw: &str) -> Option { - let path = Path::new(raw.trim()); - path.is_file() - .then(|| std::fs::canonicalize(path).ok()) - .flatten() -} - -fn transcript_is_still_present(target: &ResumeTarget) -> bool { - target.provider == ProviderId::Claude - && target - .transcript_path - .as_ref() - .is_some_and(|path| path.is_file()) -} - -fn valid_session_id(raw: &str) -> Option<&str> { - let id = raw.trim(); - if id.is_empty() - || id.len() > MAX_SESSION_ID_LEN - || id.starts_with("pid:") - || !id - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':')) - { - return None; - } - Some(id) -} - async fn resume_captured_session( app: &tauri::AppHandle, target: ResumeTarget, @@ -793,57 +644,6 @@ fn resume_generation_is_current(state: &AppState, provider: ProviderId, generati == generation } -fn launch_resume(target: &ResumeTarget) -> Result<(), String> { - let executable = match target.provider { - ProviderId::Codex => codexbar::codex_cli::locate_codex_binary(), - ProviderId::Claude => codexbar::providers::claude::locate_claude_binary(), - _ => None, - } - .ok_or_else(|| format!("{} CLI was not found", target.provider.display_name()))?; - - let command = build_resume_command(target, executable)?; - // This runs after a background refresh, not a user action: the console - // starts minimized and inactive, so it waits on the taskbar instead of - // taking focus from the app the user is working in. - codexbar::host::console_launch::spawn_minimized_console( - &command.program, - &command.args, - &command.cwd, - ) - .map_err(|error| { - format!( - "failed to launch {} CLI: {error}", - target.provider.display_name() - ) - }) -} - -fn build_resume_command( - target: &ResumeTarget, - executable: PathBuf, -) -> Result { - let session_id = valid_session_id(&target.session_id) - .ok_or_else(|| "captured session id is invalid".to_string())?; - if !target.cwd.is_absolute() { - return Err("captured session workspace must be absolute".to_string()); - } - - let cli_args = match target.provider { - ProviderId::Codex => vec!["resume".to_string(), session_id.to_string()], - ProviderId::Claude => vec!["--resume".to_string(), session_id.to_string()], - _ => return Err("provider does not support auto-resume".to_string()), - }; - - // Batch shims such as npm's `claude.cmd` stay the program here; - // `spawn_minimized_console` runs them through cmd.exe with quoting cmd.exe - // parses. - Ok(ResumeCommand { - program: executable, - args: cli_args, - cwd: target.cwd.clone(), - }) -} - #[cfg(test)] #[path = "auto_resume_tests.rs"] mod auto_resume_tests; diff --git a/apps/desktop-tauri/src-tauri/src/auto_resume/session_target.rs b/apps/desktop-tauri/src-tauri/src/auto_resume/session_target.rs new file mode 100644 index 00000000000..56afde13f4b --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/auto_resume/session_target.rs @@ -0,0 +1,204 @@ +use super::*; + +pub(super) async fn capture_session( + provider: ProviderId, + token_account_id: Option, + quota_source: ResumeSource, +) -> Option { + // AgentSession has no account identity. A managed token account therefore + // cannot be positively correlated with a discovered process; do not arm + // a session that could belong to another account. + if token_account_id.is_some() { + return None; + } + let result = LocalAgentSessionScanner::default().scan().await; + if result.error.is_some() { + return None; + } + capture_target_from_sessions(provider, &result.sessions, token_account_id, quota_source) +} + +pub(super) fn capture_target_from_sessions( + provider: ProviderId, + sessions: &[AgentSession], + token_account_id: Option, + quota_source: ResumeSource, +) -> Option { + if token_account_id.is_some() { + return None; + } + let mut candidates = sessions + .iter() + .filter_map(|session| target_from_active_session(provider, session, None, quota_source)); + let target = candidates.next()?; + // Scanner ordering is an activity/display concern, not an account + // correlation. Multiple live candidates are ambiguous, so fail closed. + candidates.next().is_none().then_some(target) +} + +fn target_from_active_session( + provider: ProviderId, + session: &AgentSession, + token_account_id: Option, + quota_source: ResumeSource, +) -> Option { + (session.state == AgentSessionState::Active).then_some(())?; + session.pid?; + target_from_session(provider, session, token_account_id, quota_source) +} + +fn target_from_session( + provider: ProviderId, + session: &AgentSession, + token_account_id: Option, + quota_source: ResumeSource, +) -> Option { + let expected = match provider { + ProviderId::Codex => AgentSessionProvider::Codex, + ProviderId::Claude => AgentSessionProvider::Claude, + _ => return None, + }; + if session.provider != expected + || session.source != AgentSessionSource::Cli + || !is_local_host(&session.host) + { + return None; + } + let session_id = valid_session_id(&session.id)?.to_string(); + let cwd = canonical_workspace(session.workspace.cwd.as_deref())?; + let transcript_path = session + .transcript_path + .as_deref() + .and_then(canonical_transcript); + Some(ResumeTarget { + provider, + quota_source, + session_id, + cwd, + transcript_path, + token_account_id, + }) +} + +pub(super) fn matching_session<'a>( + target: &ResumeTarget, + sessions: &'a [AgentSession], +) -> Option<&'a AgentSession> { + sessions.iter().find(|session| { + target_from_session( + target.provider, + session, + target.token_account_id, + target.quota_source, + ) + .is_some_and(|candidate| { + candidate.provider == target.provider + && candidate.session_id == target.session_id + && candidate.cwd == target.cwd + }) + }) +} + +fn is_local_host(host: &str) -> bool { + let host = host.trim(); + if host.eq_ignore_ascii_case("localhost") { + return true; + } + std::env::var("COMPUTERNAME") + .ok() + .is_some_and(|local| !local.trim().is_empty() && local.trim().eq_ignore_ascii_case(host)) +} + +fn canonical_workspace(raw: Option<&str>) -> Option { + let raw = raw?.trim(); + if raw.is_empty() { + return None; + } + let path = Path::new(raw); + if !path.is_absolute() { + return None; + } + std::fs::canonicalize(path) + .ok() + .filter(|path| path.is_dir()) +} + +fn canonical_transcript(raw: &str) -> Option { + let path = Path::new(raw.trim()); + path.is_file() + .then(|| std::fs::canonicalize(path).ok()) + .flatten() +} + +pub(super) fn transcript_is_still_present(target: &ResumeTarget) -> bool { + target.provider == ProviderId::Claude + && target + .transcript_path + .as_ref() + .is_some_and(|path| path.is_file()) +} + +fn valid_session_id(raw: &str) -> Option<&str> { + let id = raw.trim(); + if id.is_empty() + || id.len() > MAX_SESSION_ID_LEN + || id.starts_with("pid:") + || !id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':')) + { + return None; + } + Some(id) +} + +pub(super) fn launch_resume(target: &ResumeTarget) -> Result<(), String> { + let executable = match target.provider { + ProviderId::Codex => codexbar::codex_cli::locate_codex_binary(), + ProviderId::Claude => codexbar::providers::claude::locate_claude_binary(), + _ => None, + } + .ok_or_else(|| format!("{} CLI was not found", target.provider.display_name()))?; + + let command = build_resume_command(target, executable)?; + // This runs after a background refresh, not a user action: the console + // starts minimized and inactive, so it waits on the taskbar instead of + // taking focus from the app the user is working in. + codexbar::host::console_launch::spawn_minimized_console( + &command.program, + &command.args, + &command.cwd, + ) + .map_err(|error| { + format!( + "failed to launch {} CLI: {error}", + target.provider.display_name() + ) + }) +} + +pub(super) fn build_resume_command( + target: &ResumeTarget, + executable: PathBuf, +) -> Result { + let session_id = valid_session_id(&target.session_id) + .ok_or_else(|| "captured session id is invalid".to_string())?; + if !target.cwd.is_absolute() { + return Err("captured session workspace must be absolute".to_string()); + } + + let cli_args = match target.provider { + ProviderId::Codex => vec!["resume".to_string(), session_id.to_string()], + ProviderId::Claude => vec!["--resume".to_string(), session_id.to_string()], + _ => return Err("provider does not support auto-resume".to_string()), + }; + + // Batch shims such as npm's `claude.cmd` stay the program here; + // `spawn_minimized_console` runs them through cmd.exe with quoting cmd.exe + // parses. + Ok(ResumeCommand { + program: executable, + args: cli_args, + cwd: target.cwd.clone(), + }) +} diff --git a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs index 63f318d0997..6967729e71d 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs @@ -35,18 +35,9 @@ mod tests { remaining_percent: 100.0 - used_percent, window_minutes: Some(300), resets_at: Some(Utc::now().to_rfc3339()), - reset_description: None, is_exhausted: exhausted, is_informational: informational, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - monthly_limit_block: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, + ..Default::default() } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index cc81c51959b..197368ce373 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -6,6 +6,7 @@ mod openai_usage; mod openai_usage_tests; pub(crate) mod pace; mod quota_block; +mod settings_snapshot; mod status; mod throughput; #[cfg(test)] @@ -13,6 +14,7 @@ mod throughput_tests; pub(crate) use openai_live::{ModelRateLimitSnapshot, OrgRateLimitsSnapshot}; pub(crate) use openai_usage::OpenAiApiUsageSnapshot; pub use quota_block::MonthlyLimitBlockSnapshot; +pub use settings_snapshot::*; pub(crate) use status::{compact_tray_status_label, friendly_provider_error}; pub(crate) use throughput::ProviderThroughputSnapshot; @@ -69,7 +71,33 @@ fn default_full_remaining() -> f64 { 100.0 } +impl Default for RateWindowSnapshot { + fn default() -> Self { + Self::from_rate_window(&RateWindow::new(0.0)) + } +} + +/// Parse an RFC 3339 bridge timestamp into UTC. +pub(crate) fn parse_utc(value: &str) -> Option> { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .map(|dt| dt.with_timezone(&chrono::Utc)) +} + impl RateWindowSnapshot { + pub(crate) fn resets_at_utc(&self) -> Option> { + self.resets_at.as_deref().and_then(parse_utc) + } + + pub(crate) fn to_rate_window(&self) -> RateWindow { + RateWindow::with_details( + self.used_percent, + self.window_minutes, + self.resets_at_utc(), + self.reset_description.clone(), + ) + } + pub(super) fn from_rate_window(rw: &RateWindow) -> Self { Self { used_percent: rw.used_percent, @@ -91,22 +119,6 @@ impl RateWindowSnapshot { } } - /// The core window this snapshot was built from, as far as pace needs it: - /// usage, length, reset time and reset description. - pub(crate) fn to_rate_window(&self) -> RateWindow { - let resets_at = self - .resets_at - .as_deref() - .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&chrono::Utc)); - RateWindow::with_details( - self.used_percent, - self.window_minutes, - resets_at, - self.reset_description.clone(), - ) - } - /// Enrich with raw reserve info derived from pace analysis. /// delta_percent = actual - expected; negative means ahead (in reserve). /// Only meaningful for longer windows (weekly); skip if reserve rounds to 0. @@ -400,6 +412,46 @@ pub struct ProviderUsageSnapshot { pub quota_burndown: Option, } +impl Default for ProviderUsageSnapshot { + fn default() -> Self { + Self { + tertiary_label: None, + provider_id: String::new(), + display_name: String::new(), + primary: RateWindowSnapshot::default(), + primary_label: None, + secondary: None, + secondary_label: None, + secondary_label_key: None, + model_specific: None, + tertiary: None, + extra_rate_windows: Vec::new(), + inventory: Vec::new(), + display_details: Vec::new(), + cost: None, + plan_name: None, + account_email: None, + account_label: None, + subscription: None, + source_label: String::new(), + has_successful_claude_cli_quota: false, + updated_at: String::new(), + error: None, + error_state: default_error_state(), + pace: None, + pace_authoritative: default_pace_authoritative(), + account_organization: None, + tray_status_label: None, + fetch_duration_ms: None, + wayfinder_usage: None, + open_ai_api_usage: None, + throughput: None, + session_equivalent_forecast: None, + quota_burndown: None, + } + } +} + fn default_display_name() -> String { "Codex".to_string() } @@ -740,54 +792,11 @@ impl ProviderUsageSnapshot { Self { provider_id: id.cli_name().to_string(), display_name: id.display_name().to_string(), - primary: RateWindowSnapshot { - used_percent: 0.0, - remaining_percent: 100.0, - window_minutes: None, - resets_at: None, - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - monthly_limit_block: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, - }, primary_label: Some(metadata.session_label.to_string()), - secondary: None, - secondary_label: None, - secondary_label_key: None, - model_specific: None, - tertiary: None, - tertiary_label: None, - extra_rate_windows: Vec::new(), - inventory: Vec::new(), - display_details: Vec::new(), - cost: None, - plan_name: None, - account_email: None, - account_label: None, - subscription: None, - source_label: String::new(), - has_successful_claude_cli_quota: false, updated_at: chrono::Utc::now().to_rfc3339(), error: Some(error), error_state: state_kind, - pace: None, - pace_authoritative: true, - account_organization: None, - tray_status_label: None, - fetch_duration_ms: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - session_equivalent_forecast: None, - quota_burndown: None, + ..Default::default() } } } @@ -928,698 +937,5 @@ fn series_entries( .unwrap_or_default() } -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BootstrapState { - pub(crate) contract_version: &'static str, - pub(crate) providers: Vec, - pub(crate) settings: SettingsSnapshot, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CurrentSurfaceState { - pub mode: String, - pub target: SurfaceTarget, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProviderCatalogEntry { - pub(crate) id: String, - pub(crate) display_name: String, - pub(crate) cookie_domain: Option, - /// CLI aliases the Settings search also matches (see `ProviderId::cli_aliases`). - pub(crate) cli_aliases: Vec, -} - -/// One provider's service status for the sidebar dot and the tray panel. -/// `status` is `None` until the first successful poll. -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProviderStatusEntry { - pub provider_id: String, - pub status: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProviderServiceStatus { - /// Upstream raw indicator: none, minor, major, critical, maintenance, unknown. - pub indicator: &'static str, - pub description: Option, - /// Unix milliseconds of the feed's last update. - pub updated_at: Option, -} - -impl From<&codexbar::status::ProviderStatus> for ProviderServiceStatus { - fn from(status: &codexbar::status::ProviderStatus) -> Self { - Self { - indicator: status.indicator.as_raw(), - description: status.description.clone(), - updated_at: status.updated_at.map(|at| at.timestamp_millis()), - } - } -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SettingsSnapshot { - preferred_currency_code: String, - enabled_providers: Vec, - provider_order: Vec, - refresh_interval_secs: u64, - adaptive_refresh: bool, - refresh_all_providers_on_menu_open: bool, - status_checks_enabled: bool, - low_power_mode: bool, - low_power_mode_preference: &'static str, - start_at_login: bool, - start_minimized: bool, - show_notifications: bool, - sound_enabled: bool, - notification_sound_theme: codexbar::settings::NotificationSoundTheme, - notification_sound_paths: codexbar::settings::NotificationSoundPaths, - high_usage_threshold: f64, - critical_usage_threshold: f64, - /// Per-provider quota warnings keyed by CLI name; Global providers are absent. - provider_quota_warnings: - std::collections::HashMap, - predictive_pace_warning_enabled: bool, - limit_reset_notifications_enabled: bool, - credential_expiry_notifications_enabled: bool, - session_quota_notifications_enabled: bool, - show_pace: bool, - tray_icon_mode: &'static str, - stacked_tray_top_provider: Option, - stacked_tray_bottom_provider: Option, - switcher_shows_icons: bool, - menu_bar_shows_highest_usage: bool, - menu_bar_shows_percent: bool, - menu_bar_icon_style: codexbar::settings::MenuBarIconStyle, - merged_overview_selected_providers: Vec, - quota_warning_markers_visible: bool, - highlight_menu_cards_on_hover: bool, - workday_tick_style: codexbar::settings::WorkdayTickStyle, - menu_bar_color_pace: bool, - menu_bar_color_by_provider: bool, - menu_bar_pace_reserve_positive: bool, - providers_sorted_alphabetically: bool, - providers_enabled_only: bool, - show_as_used: bool, - show_all_token_accounts_in_menu: bool, - enable_animations: bool, - reset_time_relative: bool, - show_reset_when_exhausted: bool, - menu_bar_display_mode: String, - overview_layout: String, - hide_personal_info: bool, - update_channel: &'static str, - auto_download_updates: bool, - install_updates_on_quit: bool, - global_shortcut: String, - switcher_shortcuts: std::collections::BTreeMap, - codex_custom_sessions_dirs: Vec, - antigravity_additional_profile_homes: Vec, - agent_sessions_enabled: bool, - stay_awake_enabled: bool, - agent_session_ssh_hosts: Vec, - hooks_enabled: bool, - http_proxy_enabled: bool, - http_proxy_url: String, - http_proxy_username: String, - http_proxy_password: String, - ui_language: &'static str, - ui_language_follows_system: bool, - theme: &'static str, - window_scale_percent: u16, - tray_scale_percent: u16, - tray_panel_always_on_top: bool, - powertoys_status_pipe_enabled: bool, - claude_avoid_keychain_prompts: bool, - claude_swap_enabled: bool, - claude_swap_executable_path: String, - codex_spark_usage_visible: bool, - codex_openai_web_extras: bool, - codex_historical_tracking: bool, - codex_external_oauth_sources_allowed: bool, - codex_local_session_cost_ledger_enabled: bool, - codex_openai_web_battery_saver_enabled: bool, - /// OpenAI API Live Usage (upstream #4495): fetch Tokens/min on each refresh. - #[serde(rename = "openAiLiveUsageEnabled")] - openai_live_usage_enabled: bool, - /// Manual per-model organization limits, read by the card's limit line and the editor. - #[serde(rename = "openAiModelRateLimits")] - openai_model_rate_limits: Vec, - disable_keychain_access: bool, - wayfinder_gateway_url: String, - provider_metrics: std::collections::HashMap, - float_bar_enabled: bool, - float_bar_opacity: u8, - float_bar_scale: u8, - float_bar_orientation: String, - float_bar_style: String, - float_bar_click_through: bool, - float_bar_provider_ids: Vec, - float_bar_dark_text: bool, - float_bar_show_reset_inline: bool, - float_bar_show_cost: bool, - promote_tray_icon: bool, - claude_daily_routines_usage_visible: bool, - claude_allow_reading_claude_code_credentials: bool, - alibaba_token_plan_region: String, - copilot_seat_credit_entitlement: Option, - weekly_progress_work_days: Option, - cost_summary_display_style: &'static str, - open_codex_usage_logs_enabled: bool, - hide_native_codex_cost_when_open_codex_present: bool, - /// History window as its persisted raw form (`rolling:N`, - /// `month-to-date`, `all`). - cost_reporting_period: String, - cost_usage_enabled: bool, - /// Usage & Spend time range, same raw form as `cost_reporting_period`. - spend_dashboard_period: String, - spend_dashboard_hidden_source_ids: Vec, - provider_accent_colors: std::collections::HashMap, -} - -#[tauri::command] -pub fn get_bootstrap_state() -> BootstrapState { - bootstrap_state_for(Settings::load()) -} - -pub(crate) fn bootstrap_state_for(settings: Settings) -> BootstrapState { - BootstrapState { - contract_version: "v1", - providers: provider_catalog_for(&settings), - settings: SettingsSnapshot::from(settings), - } -} - -#[tauri::command] -pub fn get_provider_catalog() -> Vec { - provider_catalog_for(&Settings::load()) -} - -#[tauri::command] -pub fn get_settings_snapshot() -> SettingsSnapshot { - SettingsSnapshot::from(Settings::load()) -} - -impl From for SettingsSnapshot { - fn from(settings: Settings) -> Self { - let avoid_keychain_prompts = settings.claude_avoid_keychain_prompts(); - let claude_swap_enabled = settings.claude_swap_enabled(); - let claude_swap_executable_path = settings.claude_swap_executable_path().to_string(); - let codex_spark_usage_visible = settings.codex_spark_usage_visible(); - let openai_live_usage_enabled = settings.live_usage_enabled(ProviderId::OpenAIApi); - let openai_model_rate_limits = settings - .model_rate_limits(ProviderId::OpenAIApi) - .iter() - .map(ModelRateLimitSnapshot::from) - .collect(); - let codex_openai_web_extras = settings.codex_openai_web_extras(); - let codex_historical_tracking = settings.codex_historical_tracking(); - let codex_local_session_cost_ledger_enabled = - settings.local_session_cost_ledger_enabled(ProviderId::Codex); - let codex_openai_web_battery_saver_enabled = - settings.openai_web_battery_saver_enabled(ProviderId::Codex); - let provider_quota_warnings = settings - .provider_configs - .iter() - .filter(|(_, config)| !config.quota_warnings.is_empty()) - .map(|(id, config)| (id.cli_name().to_string(), config.quota_warnings)) - .collect(); - let wayfinder_gateway_url = settings.gateway_url(ProviderId::Wayfinder).to_string(); - - let provider_order = settings.provider_display_order_names(); - let enabled_providers = provider_order - .iter() - .filter(|provider_id| settings.enabled_providers.contains(*provider_id)) - .cloned() - .collect(); - - let copilot_seat_credit_entitlement = settings.seat_credit_entitlement(ProviderId::Copilot); - - let provider_metrics = settings - .provider_metrics - .into_iter() - .map(|(k, v)| (k, metric_preference_label(v))) - .collect(); - - Self { - preferred_currency_code: settings.preferred_currency_code, - enabled_providers, - provider_order, - refresh_interval_secs: settings.refresh_interval_secs, - adaptive_refresh: settings.adaptive_refresh, - refresh_all_providers_on_menu_open: settings.refresh_all_providers_on_menu_open, - status_checks_enabled: settings.status_checks_enabled, - low_power_mode: settings.low_power_mode_preference - == codexbar::settings::LowPowerModePreference::On, - low_power_mode_preference: settings.low_power_mode_preference.as_str(), - start_at_login: settings.start_at_login, - start_minimized: settings.start_minimized, - show_notifications: settings.show_notifications, - sound_enabled: settings.sound_enabled, - notification_sound_theme: settings.notification_sound_theme, - notification_sound_paths: settings.notification_sound_paths, - high_usage_threshold: settings.high_usage_threshold, - critical_usage_threshold: settings.critical_usage_threshold, - provider_quota_warnings, - predictive_pace_warning_enabled: settings.predictive_pace_warning_enabled, - limit_reset_notifications_enabled: settings.limit_reset_notifications_enabled, - credential_expiry_notifications_enabled: settings - .credential_expiry_notifications_enabled, - session_quota_notifications_enabled: settings.session_quota_notifications_enabled, - show_pace: settings.show_pace, - tray_icon_mode: tray_icon_mode_label(settings.tray_icon_mode), - stacked_tray_top_provider: settings.stacked_tray_top_provider, - stacked_tray_bottom_provider: settings.stacked_tray_bottom_provider, - switcher_shows_icons: settings.switcher_shows_icons, - menu_bar_shows_highest_usage: settings.menu_bar_shows_highest_usage, - menu_bar_shows_percent: settings.menu_bar_shows_percent, - menu_bar_icon_style: settings.menu_bar_icon_style, - merged_overview_selected_providers: settings.merged_overview_selected_providers, - quota_warning_markers_visible: settings.quota_warning_markers_visible, - highlight_menu_cards_on_hover: settings.highlight_menu_cards_on_hover, - workday_tick_style: settings.workday_tick_style, - menu_bar_color_pace: settings.menu_bar_color_pace, - menu_bar_color_by_provider: settings.menu_bar_color_by_provider, - menu_bar_pace_reserve_positive: settings.menu_bar_pace_reserve_positive, - providers_sorted_alphabetically: settings.providers_sorted_alphabetically, - providers_enabled_only: settings.providers_enabled_only, - show_as_used: settings.show_as_used, - show_all_token_accounts_in_menu: settings.show_all_token_accounts_in_menu, - enable_animations: settings.enable_animations, - reset_time_relative: settings.reset_time_relative, - show_reset_when_exhausted: settings.show_reset_when_exhausted, - menu_bar_display_mode: settings.menu_bar_display_mode, - overview_layout: settings.overview_layout, - hide_personal_info: settings.hide_personal_info, - update_channel: update_channel_label(settings.update_channel), - auto_download_updates: settings.auto_download_updates, - install_updates_on_quit: settings.install_updates_on_quit, - switcher_shortcuts: codexbar::switcher_shortcuts::resolve_or_default( - &settings.switcher_shortcuts, - ), - global_shortcut: settings.global_shortcut, - codex_custom_sessions_dirs: settings.codex_custom_sessions_dirs, - antigravity_additional_profile_homes: settings.antigravity_additional_profile_homes, - agent_sessions_enabled: settings.agent_sessions_enabled, - stay_awake_enabled: settings.stay_awake_enabled, - agent_session_ssh_hosts: settings.agent_session_ssh_hosts, - hooks_enabled: settings.hooks_enabled, - openai_live_usage_enabled, - openai_model_rate_limits, - http_proxy_enabled: settings.http_proxy_enabled, - http_proxy_url: settings.http_proxy_url, - http_proxy_username: settings.http_proxy_username, - http_proxy_password: settings.http_proxy_password, - ui_language: language_label(settings.ui_language), - ui_language_follows_system: settings.ui_language_follows_system, - theme: theme_label(settings.theme), - window_scale_percent: settings.window_scale_percent, - tray_scale_percent: settings.tray_scale_percent, - tray_panel_always_on_top: settings.tray_panel_always_on_top, - powertoys_status_pipe_enabled: settings.powertoys_status_pipe_enabled, - claude_avoid_keychain_prompts: avoid_keychain_prompts, - claude_swap_enabled, - claude_swap_executable_path, - codex_spark_usage_visible, - codex_openai_web_extras, - codex_historical_tracking, - codex_external_oauth_sources_allowed: settings.codex_external_oauth_sources_allowed, - codex_local_session_cost_ledger_enabled, - codex_openai_web_battery_saver_enabled, - disable_keychain_access: settings.disable_keychain_access, - wayfinder_gateway_url, - provider_metrics, - float_bar_enabled: settings.float_bar_enabled, - float_bar_opacity: settings.float_bar_opacity, - float_bar_scale: settings.float_bar_scale, - float_bar_orientation: settings.float_bar_orientation, - float_bar_style: settings.float_bar_style, - float_bar_click_through: settings.float_bar_click_through, - float_bar_provider_ids: settings.float_bar_provider_ids, - float_bar_dark_text: settings.float_bar_dark_text, - float_bar_show_reset_inline: settings.float_bar_show_reset_inline, - float_bar_show_cost: settings.float_bar_show_cost, - promote_tray_icon: settings.promote_tray_icon, - claude_daily_routines_usage_visible: settings.claude_daily_routines_usage_visible, - claude_allow_reading_claude_code_credentials: settings - .claude_allow_reading_claude_code_credentials, - alibaba_token_plan_region: settings.alibaba_token_plan_region, - copilot_seat_credit_entitlement, - weekly_progress_work_days: settings.weekly_progress_work_days, - cost_summary_display_style: cost_summary_display_style_label( - settings.cost_summary_display_style, - ), - open_codex_usage_logs_enabled: settings.open_codex_usage_logs_enabled, - hide_native_codex_cost_when_open_codex_present: settings - .hide_native_codex_cost_when_open_codex_present, - cost_reporting_period: settings.cost_reporting_period.raw(), - cost_usage_enabled: settings.cost_usage_enabled, - spend_dashboard_period: settings.spend_dashboard_period.raw(), - spend_dashboard_hidden_source_ids: settings.spend_dashboard_hidden_source_ids, - provider_accent_colors: settings - .provider_configs - .iter() - .filter_map(|(id, config)| { - config - .accent_color - .as_ref() - .map(|color| (id.cli_name().to_string(), color.clone())) - }) - .collect(), - } - } -} - -pub(crate) fn provider_catalog_for(settings: &Settings) -> Vec { - // Soft-removed providers (upstream #2254) stay hidden in Settings unless already enabled. - settings - .provider_display_order() - .into_iter() - .filter(|provider| settings.is_provider_listed(*provider)) - .map(|provider| ProviderCatalogEntry { - id: provider.cli_name().to_string(), - display_name: provider.display_name().to_string(), - cookie_domain: provider.cookie_domain().map(ToString::to_string), - cli_aliases: provider - .cli_aliases() - .iter() - .map(ToString::to_string) - .collect(), - }) - .collect() -} - -fn tray_icon_mode_label(mode: TrayIconMode) -> &'static str { - match mode { - TrayIconMode::Single => "single", - TrayIconMode::PerProvider => "perProvider", - TrayIconMode::Stacked => "stacked", - } -} - -pub(super) fn update_channel_label(channel: UpdateChannel) -> &'static str { - match channel { - UpdateChannel::Stable => "stable", - UpdateChannel::Beta => "beta", - } -} - -pub(super) fn language_label(language: Language) -> &'static str { - language.label() -} - -fn theme_label(theme: ThemePreference) -> &'static str { - match theme { - ThemePreference::Auto => "auto", - ThemePreference::Light => "light", - ThemePreference::Dark => "dark", - } -} - -fn cost_summary_display_style_label( - style: codexbar::settings::CostSummaryDisplayStyle, -) -> &'static str { - match style { - codexbar::settings::CostSummaryDisplayStyle::Compact => "compact", - codexbar::settings::CostSummaryDisplayStyle::Detailed => "detailed", - codexbar::settings::CostSummaryDisplayStyle::Hidden => "hidden", - } -} - -pub(crate) fn parse_cost_summary_display_style( - s: &str, -) -> Option { - use codexbar::settings::CostSummaryDisplayStyle; - match s { - "compact" => Some(CostSummaryDisplayStyle::Compact), - "detailed" => Some(CostSummaryDisplayStyle::Detailed), - "hidden" => Some(CostSummaryDisplayStyle::Hidden), - _ => None, - } -} - -pub(super) fn parse_theme(s: &str) -> Option { - match s { - "auto" => Some(ThemePreference::Auto), - "light" => Some(ThemePreference::Light), - "dark" => Some(ThemePreference::Dark), - _ => None, - } -} - -fn metric_preference_label(pref: MetricPreference) -> &'static str { - match pref { - MetricPreference::Automatic => "automatic", - MetricPreference::Session => "session", - MetricPreference::Weekly => "weekly", - MetricPreference::Model => "model", - MetricPreference::Tertiary => "tertiary", - MetricPreference::Credits => "credits", - MetricPreference::ExtraUsage => "extraUsage", - MetricPreference::MonthlyPlan => "monthlyPlan", - MetricPreference::Average => "average", - } -} - -pub(super) fn parse_metric_preference(s: &str) -> Option { - match s { - "automatic" => Some(MetricPreference::Automatic), - "session" => Some(MetricPreference::Session), - "weekly" => Some(MetricPreference::Weekly), - "model" => Some(MetricPreference::Model), - "tertiary" => Some(MetricPreference::Tertiary), - "credits" => Some(MetricPreference::Credits), - "extraUsage" | "extrausage" => Some(MetricPreference::ExtraUsage), - "monthlyPlan" | "monthlyplan" => Some(MetricPreference::MonthlyPlan), - "average" => Some(MetricPreference::Average), - _ => None, - } -} - #[cfg(test)] -mod tests { - use super::*; - - fn snapshot_window_with( - used_percent: f64, - window_minutes: Option, - resets_at: Option>, - reset_description: Option, - ) -> RateWindowSnapshot { - RateWindowSnapshot { - used_percent, - remaining_percent: 100.0 - used_percent, - window_minutes, - resets_at: resets_at.map(|dt| dt.to_rfc3339()), - reset_description, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - monthly_limit_block: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, - } - } - - /// Notion names its billing lane with the "Monthly" app string - /// (upstream `NotionProviderDescriptor.swift:32`); a provider that only - /// sets the raw "Monthly" label sends no key. - #[test] - fn secondary_label_key_crosses_the_bridge_only_when_the_provider_sets_it() { - let now = chrono::Utc::now(); - let monthly = || { - RateWindow::with_details( - 25.0, - Some(43_200), - Some(now + chrono::Duration::days(9)), - None, - ) - }; - let bridge = |id: ProviderId, usage: codexbar::core::UsageSnapshot| { - let metadata = codexbar::core::instantiate_provider(id).metadata().clone(); - let result = ProviderFetchResult::new(usage, "web"); - serde_json::to_value(ProviderUsageSnapshot::from_fetch_result( - id, &metadata, &result, None, None, - )) - .unwrap() - }; - - let notion = bridge( - ProviderId::Notion, - codexbar::core::UsageSnapshot::new(monthly()) - .with_secondary(monthly()) - .with_secondary_label_key(codexbar::locale::LocaleKey::ProviderLabelMonthly), - ); - assert_eq!(notion["secondaryLabelKey"], "ProviderLabelMonthly"); - assert_eq!(notion["secondaryLabel"], "Monthly"); - - let augment = bridge( - ProviderId::Augment, - codexbar::core::UsageSnapshot::new(monthly()).with_secondary(monthly()), - ); - assert!(augment.get("secondaryLabelKey").is_none()); - assert_eq!(augment["secondaryLabel"], "Monthly"); - } - - #[test] - fn tray_status_prefers_relative_reset_countdown() { - let window = snapshot_window_with( - 13.0, - Some(300), - Some(chrono::Utc::now() + chrono::Duration::minutes(125)), - Some("Jun 10 at 3:00PM".to_string()), - ); - - let label = compact_tray_status_label(&window, Language::English); - - assert!(label.starts_with("13% • Resets in 2h ")); - assert!(label.ends_with('m')); - assert!(!label.contains("Jun 10")); - } - - #[test] - fn tray_status_normalizes_fallback_reset_description() { - let window = snapshot_window_with(8.0, Some(300), None, Some("2h 05m".to_string())); - - assert_eq!( - compact_tray_status_label(&window, Language::English), - "8% • Resets in 2h 05m" - ); - } - - #[test] - fn credit_balance_detail_crosses_the_bridge_and_is_not_a_reset_phrase() { - let rw = RateWindow::with_details(25.0, None, None, Some("750 / 1000 credits left".into())) - .with_description_as_detail(); - let window = RateWindowSnapshot::from_rate_window(&rw); - - assert!(window.description_is_detail); - assert_eq!( - window.reset_description.as_deref(), - Some("750 / 1000 credits left") - ); - let json = serde_json::to_value(&window).unwrap(); - assert_eq!(json["descriptionIsDetail"], true); - assert_eq!(compact_tray_status_label(&window, Language::English), "25%"); - - let plain = RateWindowSnapshot::from_rate_window(&RateWindow::new(10.0)); - let json = serde_json::to_value(&plain).unwrap(); - assert_eq!(json["descriptionIsDetail"], false); - } - - #[test] - fn japanese_tray_status_label_has_no_english_reset_text() { - use codexbar::settings::Language; - - let window = snapshot_window_with( - 13.0, - Some(300), - Some(chrono::Utc::now() + chrono::Duration::minutes(125)), - None, - ); - - let label = compact_tray_status_label(&window, Language::Japanese); - - assert!(label.contains("リセットまで"), "{label}"); - assert!(!label.to_ascii_lowercase().contains("resets in"), "{label}"); - assert!(label.contains("13%"), "{label}"); - } - - #[test] - fn japanese_tray_status_strips_english_fallback_reset_prefix() { - use codexbar::settings::Language; - - let window = - snapshot_window_with(8.0, Some(300), None, Some("Resets in 2h 05m".to_string())); - - let label = compact_tray_status_label(&window, Language::Japanese); - - assert!(label.contains("リセットまで"), "{label}"); - assert!(!label.to_ascii_lowercase().contains("resets in"), "{label}"); - assert!(label.contains("2時間 05分"), "{label}"); - } - - #[test] - fn tray_status_label_relocalizes_without_refetch() { - let window = snapshot_window_with( - 13.0, - Some(300), - Some(chrono::Utc::now() + chrono::Duration::minutes(125)), - None, - ); - - let english = compact_tray_status_label(&window, Language::English); - let japanese = compact_tray_status_label(&window, Language::Japanese); - - assert!(english.contains("Resets in"), "{english}"); - assert!(japanese.contains("リセットまで"), "{japanese}"); - assert!( - !japanese.to_ascii_lowercase().contains("resets in"), - "{japanese}" - ); - } - - /// Provider-declared card wording (Synthetic's regen countdown, LithosAI's - /// card-hidden headline and cost titles) reaches the card. - #[test] - fn provider_card_wording_crosses_the_bridge() { - let regen = - RateWindowSnapshot::from_rate_window(&RateWindow::new(10.0).with_regenerating_reset()); - let hidden = RateWindowSnapshot::from_rate_window( - &RateWindow::informational("Balance: $1.00").hidden_on_card(), - ); - let regen_json = serde_json::to_value(®en).unwrap(); - let hidden_json = serde_json::to_value(&hidden).unwrap(); - assert_eq!(regen_json["resetRegenerates"], true); - assert!(regen_json.get("hiddenOnCard").is_none()); - assert_eq!(hidden_json["hiddenOnCard"], true); - assert!(hidden_json.get("resetRegenerates").is_none()); - - let metadata = codexbar::core::instantiate_provider(ProviderId::LithosAI) - .metadata() - .clone(); - let cost = codexbar::core::CostSnapshot::new(0.0, "USD", "Prepaid credits") - .with_balance(4.0) - .with_card_title(codexbar::locale::LocaleKey::CreditsTitle); - let result = codexbar::core::ProviderFetchResult::new( - codexbar::core::UsageSnapshot::new(RateWindow::new(0.0)), - "web", - ) - .with_cost(cost); - let snapshot = ProviderUsageSnapshot::from_fetch_result( - ProviderId::LithosAI, - &metadata, - &result, - None, - None, - ); - let json = serde_json::to_value(&snapshot).unwrap(); - assert_eq!(json["cost"]["titleKey"], "CreditsTitle"); - - let plain = codexbar::core::ProviderFetchResult::new( - codexbar::core::UsageSnapshot::new(RateWindow::new(0.0)), - "web", - ) - .with_cost(codexbar::core::CostSnapshot::new(1.0, "USD", "This month")); - let json = serde_json::to_value(ProviderUsageSnapshot::from_fetch_result( - ProviderId::LithosAI, - &metadata, - &plain, - None, - None, - )) - .unwrap(); - assert!(json["cost"].get("titleKey").is_none()); - } -} +mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/settings_snapshot.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/settings_snapshot.rs new file mode 100644 index 00000000000..e8e804aa721 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/settings_snapshot.rs @@ -0,0 +1,475 @@ +use super::*; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BootstrapState { + pub(crate) contract_version: &'static str, + pub(crate) providers: Vec, + pub(crate) settings: SettingsSnapshot, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CurrentSurfaceState { + pub mode: String, + pub target: SurfaceTarget, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderCatalogEntry { + pub(crate) id: String, + pub(crate) display_name: String, + pub(crate) cookie_domain: Option, + /// CLI aliases the Settings search also matches (see `ProviderId::cli_aliases`). + pub(crate) cli_aliases: Vec, +} + +/// One provider's service status for the sidebar dot and the tray panel. +/// `status` is `None` until the first successful poll. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderStatusEntry { + pub provider_id: String, + pub status: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderServiceStatus { + /// Upstream raw indicator: none, minor, major, critical, maintenance, unknown. + pub indicator: &'static str, + pub description: Option, + /// Unix milliseconds of the feed's last update. + pub updated_at: Option, +} + +impl From<&codexbar::status::ProviderStatus> for ProviderServiceStatus { + fn from(status: &codexbar::status::ProviderStatus) -> Self { + Self { + indicator: status.indicator.as_raw(), + description: status.description.clone(), + updated_at: status.updated_at.map(|at| at.timestamp_millis()), + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingsSnapshot { + preferred_currency_code: String, + enabled_providers: Vec, + provider_order: Vec, + refresh_interval_secs: u64, + adaptive_refresh: bool, + refresh_all_providers_on_menu_open: bool, + status_checks_enabled: bool, + low_power_mode: bool, + low_power_mode_preference: &'static str, + start_at_login: bool, + start_minimized: bool, + show_notifications: bool, + sound_enabled: bool, + notification_sound_theme: codexbar::settings::NotificationSoundTheme, + notification_sound_paths: codexbar::settings::NotificationSoundPaths, + high_usage_threshold: f64, + critical_usage_threshold: f64, + /// Per-provider quota warnings keyed by CLI name; Global providers are absent. + provider_quota_warnings: + std::collections::HashMap, + predictive_pace_warning_enabled: bool, + limit_reset_notifications_enabled: bool, + credential_expiry_notifications_enabled: bool, + session_quota_notifications_enabled: bool, + show_pace: bool, + tray_icon_mode: &'static str, + stacked_tray_top_provider: Option, + stacked_tray_bottom_provider: Option, + switcher_shows_icons: bool, + menu_bar_shows_highest_usage: bool, + menu_bar_shows_percent: bool, + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle, + merged_overview_selected_providers: Vec, + quota_warning_markers_visible: bool, + highlight_menu_cards_on_hover: bool, + workday_tick_style: codexbar::settings::WorkdayTickStyle, + menu_bar_color_pace: bool, + menu_bar_color_by_provider: bool, + menu_bar_pace_reserve_positive: bool, + providers_sorted_alphabetically: bool, + providers_enabled_only: bool, + show_as_used: bool, + show_all_token_accounts_in_menu: bool, + enable_animations: bool, + reset_time_relative: bool, + show_reset_when_exhausted: bool, + menu_bar_display_mode: String, + overview_layout: String, + hide_personal_info: bool, + update_channel: &'static str, + auto_download_updates: bool, + install_updates_on_quit: bool, + global_shortcut: String, + switcher_shortcuts: std::collections::BTreeMap, + codex_custom_sessions_dirs: Vec, + antigravity_additional_profile_homes: Vec, + agent_sessions_enabled: bool, + stay_awake_enabled: bool, + agent_session_ssh_hosts: Vec, + hooks_enabled: bool, + http_proxy_enabled: bool, + http_proxy_url: String, + http_proxy_username: String, + http_proxy_password: String, + ui_language: &'static str, + ui_language_follows_system: bool, + theme: &'static str, + window_scale_percent: u16, + tray_scale_percent: u16, + tray_panel_always_on_top: bool, + powertoys_status_pipe_enabled: bool, + claude_avoid_keychain_prompts: bool, + claude_swap_enabled: bool, + claude_swap_executable_path: String, + codex_spark_usage_visible: bool, + codex_openai_web_extras: bool, + codex_historical_tracking: bool, + codex_external_oauth_sources_allowed: bool, + codex_local_session_cost_ledger_enabled: bool, + codex_openai_web_battery_saver_enabled: bool, + /// OpenAI API Live Usage (upstream #4495): fetch Tokens/min on each refresh. + #[serde(rename = "openAiLiveUsageEnabled")] + openai_live_usage_enabled: bool, + /// Manual per-model organization limits, read by the card's limit line and the editor. + #[serde(rename = "openAiModelRateLimits")] + openai_model_rate_limits: Vec, + disable_keychain_access: bool, + wayfinder_gateway_url: String, + provider_metrics: std::collections::HashMap, + float_bar_enabled: bool, + float_bar_opacity: u8, + float_bar_scale: u8, + float_bar_orientation: String, + float_bar_style: String, + float_bar_click_through: bool, + float_bar_provider_ids: Vec, + float_bar_dark_text: bool, + float_bar_show_reset_inline: bool, + float_bar_show_cost: bool, + promote_tray_icon: bool, + claude_daily_routines_usage_visible: bool, + claude_allow_reading_claude_code_credentials: bool, + alibaba_token_plan_region: String, + copilot_seat_credit_entitlement: Option, + weekly_progress_work_days: Option, + cost_summary_display_style: &'static str, + open_codex_usage_logs_enabled: bool, + hide_native_codex_cost_when_open_codex_present: bool, + /// History window as its persisted raw form (`rolling:N`, + /// `month-to-date`, `all`). + cost_reporting_period: String, + cost_usage_enabled: bool, + /// Usage & Spend time range, same raw form as `cost_reporting_period`. + spend_dashboard_period: String, + spend_dashboard_hidden_source_ids: Vec, + provider_accent_colors: std::collections::HashMap, +} + +#[tauri::command] +pub fn get_bootstrap_state() -> BootstrapState { + bootstrap_state_for(Settings::load()) +} + +pub(crate) fn bootstrap_state_for(settings: Settings) -> BootstrapState { + BootstrapState { + contract_version: "v1", + providers: provider_catalog_for(&settings), + settings: SettingsSnapshot::from(settings), + } +} + +#[tauri::command] +pub fn get_provider_catalog() -> Vec { + provider_catalog_for(&Settings::load()) +} + +#[tauri::command] +pub fn get_settings_snapshot() -> SettingsSnapshot { + SettingsSnapshot::from(Settings::load()) +} + +impl From for SettingsSnapshot { + fn from(settings: Settings) -> Self { + let avoid_keychain_prompts = settings.claude_avoid_keychain_prompts(); + let claude_swap_enabled = settings.claude_swap_enabled(); + let claude_swap_executable_path = settings.claude_swap_executable_path().to_string(); + let codex_spark_usage_visible = settings.codex_spark_usage_visible(); + let openai_live_usage_enabled = settings.live_usage_enabled(ProviderId::OpenAIApi); + let openai_model_rate_limits = settings + .model_rate_limits(ProviderId::OpenAIApi) + .iter() + .map(ModelRateLimitSnapshot::from) + .collect(); + let codex_openai_web_extras = settings.codex_openai_web_extras(); + let codex_historical_tracking = settings.codex_historical_tracking(); + let codex_local_session_cost_ledger_enabled = + settings.local_session_cost_ledger_enabled(ProviderId::Codex); + let codex_openai_web_battery_saver_enabled = + settings.openai_web_battery_saver_enabled(ProviderId::Codex); + let provider_quota_warnings = settings + .provider_configs + .iter() + .filter(|(_, config)| !config.quota_warnings.is_empty()) + .map(|(id, config)| (id.cli_name().to_string(), config.quota_warnings)) + .collect(); + let wayfinder_gateway_url = settings.gateway_url(ProviderId::Wayfinder).to_string(); + + let provider_order = settings.provider_display_order_names(); + let enabled_providers = provider_order + .iter() + .filter(|provider_id| settings.enabled_providers.contains(*provider_id)) + .cloned() + .collect(); + + let copilot_seat_credit_entitlement = settings.seat_credit_entitlement(ProviderId::Copilot); + + let provider_metrics = settings + .provider_metrics + .into_iter() + .map(|(k, v)| (k, metric_preference_label(v))) + .collect(); + + Self { + preferred_currency_code: settings.preferred_currency_code, + enabled_providers, + provider_order, + refresh_interval_secs: settings.refresh_interval_secs, + adaptive_refresh: settings.adaptive_refresh, + refresh_all_providers_on_menu_open: settings.refresh_all_providers_on_menu_open, + status_checks_enabled: settings.status_checks_enabled, + low_power_mode: settings.low_power_mode_preference + == codexbar::settings::LowPowerModePreference::On, + low_power_mode_preference: settings.low_power_mode_preference.as_str(), + start_at_login: settings.start_at_login, + start_minimized: settings.start_minimized, + show_notifications: settings.show_notifications, + sound_enabled: settings.sound_enabled, + notification_sound_theme: settings.notification_sound_theme, + notification_sound_paths: settings.notification_sound_paths, + high_usage_threshold: settings.high_usage_threshold, + critical_usage_threshold: settings.critical_usage_threshold, + provider_quota_warnings, + predictive_pace_warning_enabled: settings.predictive_pace_warning_enabled, + limit_reset_notifications_enabled: settings.limit_reset_notifications_enabled, + credential_expiry_notifications_enabled: settings + .credential_expiry_notifications_enabled, + session_quota_notifications_enabled: settings.session_quota_notifications_enabled, + show_pace: settings.show_pace, + tray_icon_mode: tray_icon_mode_label(settings.tray_icon_mode), + stacked_tray_top_provider: settings.stacked_tray_top_provider, + stacked_tray_bottom_provider: settings.stacked_tray_bottom_provider, + switcher_shows_icons: settings.switcher_shows_icons, + menu_bar_shows_highest_usage: settings.menu_bar_shows_highest_usage, + menu_bar_shows_percent: settings.menu_bar_shows_percent, + menu_bar_icon_style: settings.menu_bar_icon_style, + merged_overview_selected_providers: settings.merged_overview_selected_providers, + quota_warning_markers_visible: settings.quota_warning_markers_visible, + highlight_menu_cards_on_hover: settings.highlight_menu_cards_on_hover, + workday_tick_style: settings.workday_tick_style, + menu_bar_color_pace: settings.menu_bar_color_pace, + menu_bar_color_by_provider: settings.menu_bar_color_by_provider, + menu_bar_pace_reserve_positive: settings.menu_bar_pace_reserve_positive, + providers_sorted_alphabetically: settings.providers_sorted_alphabetically, + providers_enabled_only: settings.providers_enabled_only, + show_as_used: settings.show_as_used, + show_all_token_accounts_in_menu: settings.show_all_token_accounts_in_menu, + enable_animations: settings.enable_animations, + reset_time_relative: settings.reset_time_relative, + show_reset_when_exhausted: settings.show_reset_when_exhausted, + menu_bar_display_mode: settings.menu_bar_display_mode, + overview_layout: settings.overview_layout, + hide_personal_info: settings.hide_personal_info, + update_channel: update_channel_label(settings.update_channel), + auto_download_updates: settings.auto_download_updates, + install_updates_on_quit: settings.install_updates_on_quit, + switcher_shortcuts: codexbar::switcher_shortcuts::resolve_or_default( + &settings.switcher_shortcuts, + ), + global_shortcut: settings.global_shortcut, + codex_custom_sessions_dirs: settings.codex_custom_sessions_dirs, + antigravity_additional_profile_homes: settings.antigravity_additional_profile_homes, + agent_sessions_enabled: settings.agent_sessions_enabled, + stay_awake_enabled: settings.stay_awake_enabled, + agent_session_ssh_hosts: settings.agent_session_ssh_hosts, + hooks_enabled: settings.hooks_enabled, + openai_live_usage_enabled, + openai_model_rate_limits, + http_proxy_enabled: settings.http_proxy_enabled, + http_proxy_url: settings.http_proxy_url, + http_proxy_username: settings.http_proxy_username, + http_proxy_password: settings.http_proxy_password, + ui_language: language_label(settings.ui_language), + ui_language_follows_system: settings.ui_language_follows_system, + theme: theme_label(settings.theme), + window_scale_percent: settings.window_scale_percent, + tray_scale_percent: settings.tray_scale_percent, + tray_panel_always_on_top: settings.tray_panel_always_on_top, + powertoys_status_pipe_enabled: settings.powertoys_status_pipe_enabled, + claude_avoid_keychain_prompts: avoid_keychain_prompts, + claude_swap_enabled, + claude_swap_executable_path, + codex_spark_usage_visible, + codex_openai_web_extras, + codex_historical_tracking, + codex_external_oauth_sources_allowed: settings.codex_external_oauth_sources_allowed, + codex_local_session_cost_ledger_enabled, + codex_openai_web_battery_saver_enabled, + disable_keychain_access: settings.disable_keychain_access, + wayfinder_gateway_url, + provider_metrics, + float_bar_enabled: settings.float_bar_enabled, + float_bar_opacity: settings.float_bar_opacity, + float_bar_scale: settings.float_bar_scale, + float_bar_orientation: settings.float_bar_orientation, + float_bar_style: settings.float_bar_style, + float_bar_click_through: settings.float_bar_click_through, + float_bar_provider_ids: settings.float_bar_provider_ids, + float_bar_dark_text: settings.float_bar_dark_text, + float_bar_show_reset_inline: settings.float_bar_show_reset_inline, + float_bar_show_cost: settings.float_bar_show_cost, + promote_tray_icon: settings.promote_tray_icon, + claude_daily_routines_usage_visible: settings.claude_daily_routines_usage_visible, + claude_allow_reading_claude_code_credentials: settings + .claude_allow_reading_claude_code_credentials, + alibaba_token_plan_region: settings.alibaba_token_plan_region, + copilot_seat_credit_entitlement, + weekly_progress_work_days: settings.weekly_progress_work_days, + cost_summary_display_style: cost_summary_display_style_label( + settings.cost_summary_display_style, + ), + open_codex_usage_logs_enabled: settings.open_codex_usage_logs_enabled, + hide_native_codex_cost_when_open_codex_present: settings + .hide_native_codex_cost_when_open_codex_present, + cost_reporting_period: settings.cost_reporting_period.raw(), + cost_usage_enabled: settings.cost_usage_enabled, + spend_dashboard_period: settings.spend_dashboard_period.raw(), + spend_dashboard_hidden_source_ids: settings.spend_dashboard_hidden_source_ids, + provider_accent_colors: settings + .provider_configs + .iter() + .filter_map(|(id, config)| { + config + .accent_color + .as_ref() + .map(|color| (id.cli_name().to_string(), color.clone())) + }) + .collect(), + } + } +} + +pub(crate) fn provider_catalog_for(settings: &Settings) -> Vec { + // Soft-removed providers (upstream #2254) stay hidden in Settings unless already enabled. + settings + .provider_display_order() + .into_iter() + .filter(|provider| settings.is_provider_listed(*provider)) + .map(|provider| ProviderCatalogEntry { + id: provider.cli_name().to_string(), + display_name: provider.display_name().to_string(), + cookie_domain: provider.cookie_domain().map(ToString::to_string), + cli_aliases: provider + .cli_aliases() + .iter() + .map(ToString::to_string) + .collect(), + }) + .collect() +} + +fn tray_icon_mode_label(mode: TrayIconMode) -> &'static str { + match mode { + TrayIconMode::Single => "single", + TrayIconMode::PerProvider => "perProvider", + TrayIconMode::Stacked => "stacked", + } +} + +pub(in crate::commands) fn update_channel_label(channel: UpdateChannel) -> &'static str { + match channel { + UpdateChannel::Stable => "stable", + UpdateChannel::Beta => "beta", + } +} + +pub(in crate::commands) fn language_label(language: Language) -> &'static str { + language.label() +} + +fn theme_label(theme: ThemePreference) -> &'static str { + match theme { + ThemePreference::Auto => "auto", + ThemePreference::Light => "light", + ThemePreference::Dark => "dark", + } +} + +fn cost_summary_display_style_label( + style: codexbar::settings::CostSummaryDisplayStyle, +) -> &'static str { + match style { + codexbar::settings::CostSummaryDisplayStyle::Compact => "compact", + codexbar::settings::CostSummaryDisplayStyle::Detailed => "detailed", + codexbar::settings::CostSummaryDisplayStyle::Hidden => "hidden", + } +} + +pub(crate) fn parse_cost_summary_display_style( + s: &str, +) -> Option { + use codexbar::settings::CostSummaryDisplayStyle; + match s { + "compact" => Some(CostSummaryDisplayStyle::Compact), + "detailed" => Some(CostSummaryDisplayStyle::Detailed), + "hidden" => Some(CostSummaryDisplayStyle::Hidden), + _ => None, + } +} + +pub(in crate::commands) fn parse_theme(s: &str) -> Option { + match s { + "auto" => Some(ThemePreference::Auto), + "light" => Some(ThemePreference::Light), + "dark" => Some(ThemePreference::Dark), + _ => None, + } +} + +fn metric_preference_label(pref: MetricPreference) -> &'static str { + match pref { + MetricPreference::Automatic => "automatic", + MetricPreference::Session => "session", + MetricPreference::Weekly => "weekly", + MetricPreference::Model => "model", + MetricPreference::Tertiary => "tertiary", + MetricPreference::Credits => "credits", + MetricPreference::ExtraUsage => "extraUsage", + MetricPreference::MonthlyPlan => "monthlyPlan", + MetricPreference::Average => "average", + } +} + +pub(in crate::commands) fn parse_metric_preference(s: &str) -> Option { + match s { + "automatic" => Some(MetricPreference::Automatic), + "session" => Some(MetricPreference::Session), + "weekly" => Some(MetricPreference::Weekly), + "model" => Some(MetricPreference::Model), + "tertiary" => Some(MetricPreference::Tertiary), + "credits" => Some(MetricPreference::Credits), + "extraUsage" | "extrausage" => Some(MetricPreference::ExtraUsage), + "monthlyPlan" | "monthlyplan" => Some(MetricPreference::MonthlyPlan), + "average" => Some(MetricPreference::Average), + _ => None, + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs index 130c2762ad8..9ffa2bfbc33 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/status.rs @@ -26,9 +26,7 @@ fn compact_reset_description( lang: codexbar::settings::Language, ) -> Option { if let Some(ref resets_at) = window.resets_at { - let dt = chrono::DateTime::parse_from_rfc3339(resets_at) - .ok() - .map(|dt| dt.with_timezone(&chrono::Utc))?; + let dt = super::parse_utc(resets_at)?; return Some(format_compact_reset_countdown(dt, lang)); } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/tests.rs new file mode 100644 index 00000000000..1ed744bcc14 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/tests.rs @@ -0,0 +1,202 @@ +use super::*; + +fn snapshot_window_with( + used_percent: f64, + window_minutes: Option, + resets_at: Option>, + reset_description: Option, +) -> RateWindowSnapshot { + RateWindowSnapshot { + used_percent, + remaining_percent: 100.0 - used_percent, + window_minutes, + resets_at: resets_at.map(|dt| dt.to_rfc3339()), + reset_description, + ..Default::default() + } +} + +#[test] +fn tray_status_prefers_relative_reset_countdown() { + let window = snapshot_window_with( + 13.0, + Some(300), + Some(chrono::Utc::now() + chrono::Duration::minutes(125)), + Some("Jun 10 at 3:00PM".to_string()), + ); + + let label = compact_tray_status_label(&window, Language::English); + + assert!(label.starts_with("13% • Resets in 2h ")); + assert!(label.ends_with('m')); + assert!(!label.contains("Jun 10")); +} + +#[test] +fn tray_status_normalizes_fallback_reset_description() { + let window = snapshot_window_with(8.0, Some(300), None, Some("2h 05m".to_string())); + + assert_eq!( + compact_tray_status_label(&window, Language::English), + "8% • Resets in 2h 05m" + ); +} + +#[test] +fn credit_balance_detail_crosses_the_bridge_and_is_not_a_reset_phrase() { + let rw = RateWindow::with_details(25.0, None, None, Some("750 / 1000 credits left".into())) + .with_description_as_detail(); + let window = RateWindowSnapshot::from_rate_window(&rw); + + assert!(window.description_is_detail); + assert_eq!( + window.reset_description.as_deref(), + Some("750 / 1000 credits left") + ); + let json = serde_json::to_value(&window).unwrap(); + assert_eq!(json["descriptionIsDetail"], true); + assert_eq!(compact_tray_status_label(&window, Language::English), "25%"); + + let plain = RateWindowSnapshot::from_rate_window(&RateWindow::new(10.0)); + let json = serde_json::to_value(&plain).unwrap(); + assert_eq!(json["descriptionIsDetail"], false); +} + +#[test] +fn japanese_tray_status_label_has_no_english_reset_text() { + use codexbar::settings::Language; + + let window = snapshot_window_with( + 13.0, + Some(300), + Some(chrono::Utc::now() + chrono::Duration::minutes(125)), + None, + ); + + let label = compact_tray_status_label(&window, Language::Japanese); + + assert!(label.contains("リセットまで"), "{label}"); + assert!(!label.to_ascii_lowercase().contains("resets in"), "{label}"); + assert!(label.contains("13%"), "{label}"); +} + +#[test] +fn japanese_tray_status_strips_english_fallback_reset_prefix() { + use codexbar::settings::Language; + + let window = snapshot_window_with(8.0, Some(300), None, Some("Resets in 2h 05m".to_string())); + + let label = compact_tray_status_label(&window, Language::Japanese); + + assert!(label.contains("リセットまで"), "{label}"); + assert!(!label.to_ascii_lowercase().contains("resets in"), "{label}"); + assert!(label.contains("2時間 05分"), "{label}"); +} + +#[test] +fn tray_status_label_relocalizes_without_refetch() { + let window = snapshot_window_with( + 13.0, + Some(300), + Some(chrono::Utc::now() + chrono::Duration::minutes(125)), + None, + ); + + let english = compact_tray_status_label(&window, Language::English); + let japanese = compact_tray_status_label(&window, Language::Japanese); + + assert!(english.contains("Resets in"), "{english}"); + assert!(japanese.contains("リセットまで"), "{japanese}"); + assert!( + !japanese.to_ascii_lowercase().contains("resets in"), + "{japanese}" + ); +} + +#[test] +fn secondary_label_key_crosses_the_bridge_only_when_the_provider_sets_it() { + let now = chrono::Utc::now(); + let monthly = || { + RateWindow::with_details( + 25.0, + Some(43_200), + Some(now + chrono::Duration::days(9)), + None, + ) + }; + let bridge = |id: ProviderId, usage: codexbar::core::UsageSnapshot| { + let metadata = codexbar::core::instantiate_provider(id).metadata().clone(); + let result = ProviderFetchResult::new(usage, "web"); + serde_json::to_value(ProviderUsageSnapshot::from_fetch_result( + id, &metadata, &result, None, None, + )) + .unwrap() + }; + + let notion = bridge( + ProviderId::Notion, + codexbar::core::UsageSnapshot::new(monthly()) + .with_secondary(monthly()) + .with_secondary_label_key(codexbar::locale::LocaleKey::ProviderLabelMonthly), + ); + assert_eq!(notion["secondaryLabelKey"], "ProviderLabelMonthly"); + assert_eq!(notion["secondaryLabel"], "Monthly"); + + let augment = bridge( + ProviderId::Augment, + codexbar::core::UsageSnapshot::new(monthly()).with_secondary(monthly()), + ); + assert!(augment.get("secondaryLabelKey").is_none()); + assert_eq!(augment["secondaryLabel"], "Monthly"); +} + +#[test] +fn provider_card_wording_crosses_the_bridge() { + let regen = + RateWindowSnapshot::from_rate_window(&RateWindow::new(10.0).with_regenerating_reset()); + let hidden = RateWindowSnapshot::from_rate_window( + &RateWindow::informational("Balance: $1.00").hidden_on_card(), + ); + let regen_json = serde_json::to_value(®en).unwrap(); + let hidden_json = serde_json::to_value(&hidden).unwrap(); + assert_eq!(regen_json["resetRegenerates"], true); + assert!(regen_json.get("hiddenOnCard").is_none()); + assert_eq!(hidden_json["hiddenOnCard"], true); + assert!(hidden_json.get("resetRegenerates").is_none()); + + let metadata = codexbar::core::instantiate_provider(ProviderId::LithosAI) + .metadata() + .clone(); + let cost = codexbar::core::CostSnapshot::new(0.0, "USD", "Prepaid credits") + .with_balance(4.0) + .with_card_title(codexbar::locale::LocaleKey::CreditsTitle); + let result = codexbar::core::ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(RateWindow::new(0.0)), + "web", + ) + .with_cost(cost); + let snapshot = ProviderUsageSnapshot::from_fetch_result( + ProviderId::LithosAI, + &metadata, + &result, + None, + None, + ); + let json = serde_json::to_value(&snapshot).unwrap(); + assert_eq!(json["cost"]["titleKey"], "CreditsTitle"); + + let plain = codexbar::core::ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(RateWindow::new(0.0)), + "web", + ) + .with_cost(codexbar::core::CostSnapshot::new(1.0, "USD", "This month")); + let json = serde_json::to_value(ProviderUsageSnapshot::from_fetch_result( + ProviderId::LithosAI, + &metadata, + &plain, + None, + None, + )) + .unwrap(); + assert!(json["cost"].get("titleKey").is_none()); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index ebd184a4a54..05a4cb7f8cc 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -23,6 +23,9 @@ use std::sync::{ }; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +mod local_usage_cache; +pub(crate) use local_usage_cache::*; + const LOCAL_USAGE_TTL: Duration = Duration::from_secs(30); /// A single (date, value) point for cost or credits history charts. @@ -603,238 +606,6 @@ fn muse_local_usage_summary( }) } -pub(crate) fn load_provider_local_usage_summary( - provider_id: &str, -) -> Option { - load_local_usage_summary_cached(provider_id, None) -} - -struct CachedLocalUsage { - loaded_at: Instant, - /// `CostReportingPeriod::identity` the entry was built for; a period or - /// month change makes the entry stale before its TTL. - period_identity: String, - summary: Option, -} - -fn local_usage_period_identity() -> String { - current_reporting_period().identity(Utc::now(), CostTimeZone::Local) -} - -/// Cache a completed scan under the reporting window it actually used. The -/// summary timestamp preserves the month for month-to-date identities. -fn local_usage_summary_period_identity(summary: &ProviderLocalUsageSummary) -> Option { - let period = CostReportingPeriod::parse(&summary.reporting_period)?; - let scanned_at = DateTime::::from_timestamp_millis(summary.token_cost_updated_at_ms)?; - Some(period.identity(scanned_at, CostTimeZone::Local)) -} - -fn local_usage_cache() -> &'static Mutex> { - static CACHE: OnceLock>> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(HashMap::new())) -} - -pub(crate) fn clear_provider_local_usage_cache() { - if let Ok(mut guard) = local_usage_cache().lock() { - guard.clear(); - } -} - -pub(crate) fn cached_provider_local_usage_summary( - provider_id: &str, -) -> Option { - let Ok(guard) = local_usage_cache().lock() else { - return None; - }; - guard - .get(provider_id) - .and_then(|entry| entry.summary.clone()) -} - -pub(crate) async fn refresh_provider_local_usage_cache(provider_ids: Vec) { - if provider_ids.is_empty() { - return; - } - - let failure_provider_ids = provider_ids.clone(); - let scans = match tauri::async_runtime::spawn_blocking(move || { - provider_ids - .into_iter() - .map(|provider_id| { - let (summary, unknown_models) = - load_local_usage_summary_with_unknown_models(&provider_id, None); - (provider_id, summary, unknown_models) - }) - .collect::>() - }) - .await - { - Ok(scans) => scans, - Err(err) => { - tracing::warn!("Provider local usage refresh worker failed: {err}"); - for provider_id in failure_provider_ids { - record_local_usage_fetch_failure(&provider_id, CostFetchFailure::Failed); - } - return; - } - }; - - for (provider_id, mut summary, unknown_models) in scans { - let pricing_provider = match provider_id.as_str() { - "codex" => Some("openai"), - "claude" => Some("anthropic"), - _ => None, - }; - if let Some(pricing_provider) = pricing_provider - && codexbar::core::refresh_unknown_models_if_needed(pricing_provider, &unknown_models) - .await - { - let rescan_provider = provider_id.clone(); - summary = tauri::async_runtime::spawn_blocking(move || { - load_local_usage_summary(&rescan_provider, None) - }) - .await - .unwrap_or(summary); - } - store_local_usage_summary_if_tracking(&provider_id, summary); - } -} - -#[cfg(test)] -pub(crate) fn cache_provider_local_usage_summary_for_test( - provider_id: &str, - summary: Option, -) { - store_local_usage_summary(provider_id, summary); -} - -fn load_local_usage_summary_cached( - provider_id: &str, - cancel: Option<&AtomicBool>, -) -> Option { - // Checked before the cache so an entry from before "Track costs" was - // turned off is never served. - if !cost_tracking_enabled() { - return None; - } - let cache = local_usage_cache(); - let period_identity = local_usage_period_identity(); - if let Ok(guard) = cache.lock() - && let Some(entry) = guard.get(provider_id) - && entry.period_identity == period_identity - && token_cost_cache_is_fresh(Some(entry.loaded_at), Instant::now(), LOCAL_USAGE_TTL) - { - return entry.summary.clone(); - } - - if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) { - return None; - } - - let summary = load_local_usage_summary(provider_id, cancel); - if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) { - return None; - } - - store_local_usage_summary_if_tracking(provider_id, summary.clone()); - summary -} - -/// A scan can outlive a "Track costs" off switch, which clears the cache; drop -/// its result rather than refill the cache that the PowerToys pipe reads. -fn store_local_usage_summary_if_tracking( - provider_id: &str, - summary: Option, -) { - let tracking = summary.is_none() || cost_tracking_enabled(); - store_scanned_local_usage_summary(provider_id, summary, tracking); -} - -fn store_scanned_local_usage_summary( - provider_id: &str, - summary: Option, - cost_tracking_enabled: bool, -) { - if summary.is_some() && !cost_tracking_enabled { - return; - } - store_local_usage_summary(provider_id, summary); -} - -fn store_local_usage_summary(provider_id: &str, summary: Option) { - let period_identity = match summary.as_ref() { - Some(summary) => { - let Some(period_identity) = local_usage_summary_period_identity(summary) else { - tracing::warn!("Skipping local usage cache entry with invalid period metadata"); - return; - }; - period_identity - } - None => local_usage_period_identity(), - }; - if let Ok(mut guard) = local_usage_cache().lock() { - guard.insert( - provider_id.to_string(), - CachedLocalUsage { - loaded_at: Instant::now(), - period_identity, - summary, - }, - ); - } -} - -fn record_local_usage_fetch_failure(provider_id: &str, failure: CostFetchFailure) { - let loaded_at = if cost_fetch_failure_allows_early_retry(failure) { - Instant::now() - LOCAL_USAGE_TTL - Duration::from_secs(1) - } else { - Instant::now() - }; - let period_identity = local_usage_period_identity(); - if let Ok(mut guard) = local_usage_cache().lock() { - guard.insert( - provider_id.to_string(), - CachedLocalUsage { - loaded_at, - period_identity, - summary: None, - }, - ); - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow( - dead_code, - reason = "chart command helper reserved for future dashboard integration" -)] -pub(crate) enum CostFetchFailure { - Failed, - TimedOut, -} - -pub(crate) fn token_cost_cache_is_fresh( - loaded_at: Option, - now: Instant, - ttl: Duration, -) -> bool { - loaded_at - .and_then(|loaded| now.checked_duration_since(loaded)) - .map(|age| age <= ttl) - .unwrap_or(false) -} - -pub(crate) fn cost_fetch_failure_allows_early_retry(failure: CostFetchFailure) -> bool { - !matches!(failure, CostFetchFailure::TimedOut) -} - -fn current_unix_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis().min(i64::MAX as u128) as i64) - .unwrap_or(0) -} - fn localized_estimate_note(provider_id: &str, lang: codexbar::settings::Language) -> String { match provider_id { "claude" => locale::get_text(lang, LocaleKey::PanelEstimatedFromLocalLogsClaude), diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart/local_usage_cache.rs b/apps/desktop-tauri/src-tauri/src/commands/chart/local_usage_cache.rs new file mode 100644 index 00000000000..7de505eb6a0 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/chart/local_usage_cache.rs @@ -0,0 +1,238 @@ +use super::*; + +pub(crate) fn load_provider_local_usage_summary( + provider_id: &str, +) -> Option { + load_local_usage_summary_cached(provider_id, None) +} + +pub(super) struct CachedLocalUsage { + loaded_at: Instant, + /// `CostReportingPeriod::identity` the entry was built for; a period or + /// month change makes the entry stale before its TTL. + period_identity: String, + summary: Option, +} + +fn local_usage_period_identity() -> String { + current_reporting_period().identity(Utc::now(), CostTimeZone::Local) +} + +/// Cache a completed scan under the reporting window it actually used. The +/// summary timestamp preserves the month for month-to-date identities. +pub(super) fn local_usage_summary_period_identity( + summary: &ProviderLocalUsageSummary, +) -> Option { + let period = CostReportingPeriod::parse(&summary.reporting_period)?; + let scanned_at = DateTime::::from_timestamp_millis(summary.token_cost_updated_at_ms)?; + Some(period.identity(scanned_at, CostTimeZone::Local)) +} + +pub(super) fn local_usage_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub(crate) fn clear_provider_local_usage_cache() { + if let Ok(mut guard) = local_usage_cache().lock() { + guard.clear(); + } +} + +pub(crate) fn cached_provider_local_usage_summary( + provider_id: &str, +) -> Option { + let Ok(guard) = local_usage_cache().lock() else { + return None; + }; + guard + .get(provider_id) + .and_then(|entry| entry.summary.clone()) +} + +pub(crate) async fn refresh_provider_local_usage_cache(provider_ids: Vec) { + if provider_ids.is_empty() { + return; + } + + let failure_provider_ids = provider_ids.clone(); + let scans = match tauri::async_runtime::spawn_blocking(move || { + provider_ids + .into_iter() + .map(|provider_id| { + let (summary, unknown_models) = + load_local_usage_summary_with_unknown_models(&provider_id, None); + (provider_id, summary, unknown_models) + }) + .collect::>() + }) + .await + { + Ok(scans) => scans, + Err(err) => { + tracing::warn!("Provider local usage refresh worker failed: {err}"); + for provider_id in failure_provider_ids { + record_local_usage_fetch_failure(&provider_id, CostFetchFailure::Failed); + } + return; + } + }; + + for (provider_id, mut summary, unknown_models) in scans { + let pricing_provider = match provider_id.as_str() { + "codex" => Some("openai"), + "claude" => Some("anthropic"), + _ => None, + }; + if let Some(pricing_provider) = pricing_provider + && codexbar::core::refresh_unknown_models_if_needed(pricing_provider, &unknown_models) + .await + { + let rescan_provider = provider_id.clone(); + summary = tauri::async_runtime::spawn_blocking(move || { + load_local_usage_summary(&rescan_provider, None) + }) + .await + .unwrap_or(summary); + } + store_local_usage_summary_if_tracking(&provider_id, summary); + } +} + +#[cfg(test)] +pub(crate) fn cache_provider_local_usage_summary_for_test( + provider_id: &str, + summary: Option, +) { + store_local_usage_summary(provider_id, summary); +} + +pub(super) fn load_local_usage_summary_cached( + provider_id: &str, + cancel: Option<&AtomicBool>, +) -> Option { + // Checked before the cache so an entry from before "Track costs" was + // turned off is never served. + if !cost_tracking_enabled() { + return None; + } + let cache = local_usage_cache(); + let period_identity = local_usage_period_identity(); + if let Ok(guard) = cache.lock() + && let Some(entry) = guard.get(provider_id) + && entry.period_identity == period_identity + && token_cost_cache_is_fresh(Some(entry.loaded_at), Instant::now(), LOCAL_USAGE_TTL) + { + return entry.summary.clone(); + } + + if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) { + return None; + } + + let summary = load_local_usage_summary(provider_id, cancel); + if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) { + return None; + } + + store_local_usage_summary_if_tracking(provider_id, summary.clone()); + summary +} + +/// A scan can outlive a "Track costs" off switch, which clears the cache; drop +/// its result rather than refill the cache that the PowerToys pipe reads. +fn store_local_usage_summary_if_tracking( + provider_id: &str, + summary: Option, +) { + let tracking = summary.is_none() || cost_tracking_enabled(); + store_scanned_local_usage_summary(provider_id, summary, tracking); +} + +pub(super) fn store_scanned_local_usage_summary( + provider_id: &str, + summary: Option, + cost_tracking_enabled: bool, +) { + if summary.is_some() && !cost_tracking_enabled { + return; + } + store_local_usage_summary(provider_id, summary); +} + +pub(super) fn store_local_usage_summary( + provider_id: &str, + summary: Option, +) { + let period_identity = match summary.as_ref() { + Some(summary) => { + let Some(period_identity) = local_usage_summary_period_identity(summary) else { + tracing::warn!("Skipping local usage cache entry with invalid period metadata"); + return; + }; + period_identity + } + None => local_usage_period_identity(), + }; + if let Ok(mut guard) = local_usage_cache().lock() { + guard.insert( + provider_id.to_string(), + CachedLocalUsage { + loaded_at: Instant::now(), + period_identity, + summary, + }, + ); + } +} + +pub(super) fn record_local_usage_fetch_failure(provider_id: &str, failure: CostFetchFailure) { + let loaded_at = if cost_fetch_failure_allows_early_retry(failure) { + Instant::now() - LOCAL_USAGE_TTL - Duration::from_secs(1) + } else { + Instant::now() + }; + let period_identity = local_usage_period_identity(); + if let Ok(mut guard) = local_usage_cache().lock() { + guard.insert( + provider_id.to_string(), + CachedLocalUsage { + loaded_at, + period_identity, + summary: None, + }, + ); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow( + dead_code, + reason = "chart command helper reserved for future dashboard integration" +)] +pub(crate) enum CostFetchFailure { + Failed, + TimedOut, +} + +pub(crate) fn token_cost_cache_is_fresh( + loaded_at: Option, + now: Instant, + ttl: Duration, +) -> bool { + loaded_at + .and_then(|loaded| now.checked_duration_since(loaded)) + .map(|age| age <= ttl) + .unwrap_or(false) +} + +pub(crate) fn cost_fetch_failure_allows_early_retry(failure: CostFetchFailure) -> bool { + !matches!(failure, CostFetchFailure::TimedOut) +} + +pub(super) fn current_unix_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(0) +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs index f5835557002..5f6cdb0b10d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs @@ -230,7 +230,9 @@ fn local_usage_today_hides_known_zero_and_unknown_cost_but_keeps_tokens() { #[test] fn a_scan_that_finishes_after_track_costs_went_off_is_not_cached() { - use super::{cached_provider_local_usage_summary, store_scanned_local_usage_summary}; + use super::local_usage_cache::{ + cached_provider_local_usage_summary, store_scanned_local_usage_summary, + }; let summary = || ProviderLocalUsageSummary { today_cost: Some(1.25), thirty_day_cost: Some(12.5), diff --git a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs index 8de0b21a223..a86606b96d8 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/claude_accounts.rs @@ -15,7 +15,6 @@ use tauri::Manager; static MUTATION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); const AMBIENT_RECONCILIATION_GRACE: Duration = Duration::from_secs(5); -#[tauri::command] pub fn claude_accounts_list() -> Result, String> { AccountManager::new() .and_then(|m| m.list()) diff --git a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs index 66d3fe9d319..d568cdc2461 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/codex_accounts.rs @@ -14,7 +14,7 @@ use codexbar::settings::Settings; use crate::state::AppState; use super::credential_alerts::FetchAttempt; -use super::providers::quota_notification_account_identity_for; +use super::warning_identity::WarningIdentity; use super::*; // ── Codex multi-account (ADR 0003, milestone 2) ────────────────────── @@ -252,13 +252,14 @@ fn observe_codex_account_lane_outcome( return; } - let account_scope = quota_notification_account_identity_for( + let account_scope = WarningIdentity::new( ProviderId::Codex, "", account.email_hint.as_deref(), None, Some(account.id), - ); + ) + .threshold_key(); match attempt { FetchAttempt::Succeeded => { manager.observe_credential_recovery(ProviderId::Codex, &account_scope); diff --git a/apps/desktop-tauri/src-tauri/src/commands/fetch_context_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/fetch_context_tests.rs new file mode 100644 index 00000000000..0012614296f --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/fetch_context_tests.rs @@ -0,0 +1,858 @@ +//! `build_fetch_context` resolution: one table for the plain input/output +//! cases, named tests for the cases with bespoke asserts. + +use std::collections::HashMap; + +use codexbar::core::{ + FetchContext, ProviderAccountData, ProviderId, SourceMode, TokenAccount, TokenAccountKind, + instantiate_provider, +}; +use codexbar::settings::{ApiKeys, ManualCookies, Settings}; + +/// Settings, stored secrets and token accounts for one provider. Unset fields +/// keep the defaults a fresh install has. +#[derive(Clone, Copy, Default)] +pub(super) struct CtxInput<'a> { + pub cookie_source: Option<&'a str>, + pub usage_source: Option<&'a str>, + pub region: Option<&'a str>, + pub manual_cookie: Option<&'a str>, + pub api_key: Option<&'a str>, + /// `(label, token)` pairs added in order. + pub accounts: &'a [(&'a str, &'a str)], + pub active: Option, +} + +pub(super) fn fetch_ctx(id: ProviderId, input: CtxInput<'_>) -> FetchContext { + let mut settings = Settings::default(); + if let Some(source) = input.cookie_source { + settings.set_cookie_source(id, source); + } + if let Some(source) = input.usage_source { + settings.set_usage_source(id, source); + } + if let Some(region) = input.region { + settings.set_api_region(id, region); + } + let mut cookies = ManualCookies::default(); + if let Some(header) = input.manual_cookie { + cookies.set(id.cli_name(), header); + } + let mut api_keys = ApiKeys::default(); + if let Some(key) = input.api_key { + api_keys.set(id.cli_name(), key, None); + } + let mut token_accounts = HashMap::new(); + if !input.accounts.is_empty() { + let mut data = ProviderAccountData::new(); + for &(label, token) in input.accounts { + data.add_account(TokenAccount::new(label, token)); + } + if let Some(index) = input.active { + data.set_active(index); + } + token_accounts.insert(id, data); + } + super::build_fetch_context(id, &settings, &cookies, &api_keys, &token_accounts) +} + +/// Expected context fields; `None` leaves a field unchecked. +#[derive(Default)] +struct Expect<'a> { + source: Option, + cookie: Option>, + api_key: Option>, + missing: Option, + region: Option>, + browser_import: Option, +} + +#[test] +fn fetch_context_resolves_each_case() { + use SourceMode::{Auto, Cli, OAuth, Web}; + let cases: &[(&str, ProviderId, CtxInput, Expect)] = &[ + ( + // Cursor does not support Cli; empty manual cookie remaps to Web (browser attempt). + "fetch_context_defaults_to_manual_cookies_without_browser_import", + ProviderId::Cursor, + CtxInput::default(), + Expect { + source: Some(Web), + ..Default::default() + }, + ), + ( + // Explicit cookie-off keeps Cli (no browser scrape). + "fetch_context_cursor_cookie_off_stays_cli", + ProviderId::Cursor, + CtxInput { + cookie_source: Some("off"), + ..Default::default() + }, + Expect { + source: Some(Cli), + cookie: Some(None), + ..Default::default() + }, + ), + ( + "fetch_context_grok_cookie_off_preserves_auto_oauth", + ProviderId::Grok, + CtxInput { + cookie_source: Some("off"), + usage_source: Some("auto"), + ..Default::default() + }, + Expect { + source: Some(OAuth), + cookie: Some(None), + ..Default::default() + }, + ), + ( + "fetch_context_grok_cookie_off_preserves_explicit_oauth", + ProviderId::Grok, + CtxInput { + cookie_source: Some("off"), + usage_source: Some("oauth"), + ..Default::default() + }, + Expect { + source: Some(OAuth), + cookie: Some(None), + ..Default::default() + }, + ), + ( + "fetch_context_grok_cookie_off_keeps_explicit_cli", + ProviderId::Grok, + CtxInput { + cookie_source: Some("off"), + usage_source: Some("cli"), + ..Default::default() + }, + Expect { + source: Some(Cli), + cookie: Some(None), + ..Default::default() + }, + ), + ( + "fetch_context_grok_empty_manual_preserves_auto_without_browser_import", + ProviderId::Grok, + CtxInput { + cookie_source: Some("manual"), + usage_source: Some("auto"), + ..Default::default() + }, + Expect { + source: Some(Auto), + cookie: Some(None), + ..Default::default() + }, + ), + ( + "fetch_context_grok_manual_cookie_keeps_auto_for_switched_login", + ProviderId::Grok, + CtxInput { + cookie_source: Some("manual"), + usage_source: Some("auto"), + manual_cookie: Some("sso=other-account"), + ..Default::default() + }, + Expect { + source: Some(Auto), + cookie: Some(Some("sso=other-account")), + ..Default::default() + }, + ), + ( + "fetch_context_grok_explicit_web_still_uses_manual_cookie", + ProviderId::Grok, + CtxInput { + cookie_source: Some("manual"), + usage_source: Some("web"), + manual_cookie: Some("sso=browser-account"), + ..Default::default() + }, + Expect { + source: Some(Web), + cookie: Some(Some("sso=browser-account")), + ..Default::default() + }, + ), + ( + // Zed browser billing is opt-in: neither the default manual cookie source + // nor a stored cookie may turn Auto into Web. + "fetch_context_zed_default_and_stored_cookie_keep_editor_credential_lane (default)", + ProviderId::Zed, + CtxInput::default(), + Expect { + source: Some(Auto), + cookie: Some(None), + missing: Some(false), + ..Default::default() + }, + ), + ( + "fetch_context_zed_default_and_stored_cookie_keep_editor_credential_lane (stored cookie)", + ProviderId::Zed, + CtxInput { + manual_cookie: Some("zed.session=stored"), + ..Default::default() + }, + Expect { + source: Some(Auto), + cookie: Some(None), + ..Default::default() + }, + ), + ( + "fetch_context_zed_explicit_web_uses_stored_cookie", + ProviderId::Zed, + CtxInput { + usage_source: Some("web"), + manual_cookie: Some("zed.session=stored"), + ..Default::default() + }, + Expect { + source: Some(Web), + cookie: Some(Some("zed.session=stored")), + ..Default::default() + }, + ), + ( + "fetch_context_opencode_empty_manual_remaps_to_web", + ProviderId::OpenCode, + CtxInput::default(), + Expect { + source: Some(Web), + ..Default::default() + }, + ), + ( + "fetch_context_replicate_empty_manual_fails_closed_without_browser_import", + ProviderId::Replicate, + CtxInput::default(), + Expect { + source: Some(Web), + cookie: Some(None), + missing: Some(true), + ..Default::default() + }, + ), + ( + // Manual with no stored header fails closed instead of using a browser. + "fetch_context_raycast_cookie_sources_never_import_in_the_shell (empty manual)", + ProviderId::Raycast, + CtxInput { + cookie_source: Some("manual"), + ..Default::default() + }, + Expect { + source: Some(Web), + cookie: Some(None), + missing: Some(true), + ..Default::default() + }, + ), + ( + // Off maps to the source the provider refuses, and never carries a header. + "fetch_context_raycast_cookie_sources_never_import_in_the_shell (off)", + ProviderId::Raycast, + CtxInput { + cookie_source: Some("off"), + manual_cookie: Some("__raycast_session=stored"), + ..Default::default() + }, + Expect { + source: Some(Cli), + cookie: Some(None), + ..Default::default() + }, + ), + ( + // Auto leaves browser resolution to the provider (Chrome only). + "fetch_context_raycast_cookie_sources_never_import_in_the_shell (auto)", + ProviderId::Raycast, + CtxInput { + cookie_source: Some("auto"), + ..Default::default() + }, + Expect { + source: Some(Auto), + cookie: Some(None), + missing: Some(false), + ..Default::default() + }, + ), + ( + "fetch_context_raycast_cookie_sources_never_import_in_the_shell (manual)", + ProviderId::Raycast, + CtxInput { + cookie_source: Some("manual"), + manual_cookie: Some("__raycast_session=stored"), + ..Default::default() + }, + Expect { + source: Some(Web), + cookie: Some(Some("__raycast_session=stored")), + ..Default::default() + }, + ), + ( + "fetch_context_ollama_empty_manual_fails_closed_without_browser_import", + ProviderId::Ollama, + CtxInput::default(), + Expect { + source: Some(Web), + cookie: Some(None), + missing: Some(true), + ..Default::default() + }, + ), + ( + "fetch_context_ollama_blank_manual_header_counts_as_missing", + ProviderId::Ollama, + CtxInput { + manual_cookie: Some(" "), + ..Default::default() + }, + Expect { + missing: Some(true), + ..Default::default() + }, + ), + ( + "fetch_context_ollama_pasted_header_is_not_reported_missing", + ProviderId::Ollama, + CtxInput { + manual_cookie: Some("__Secure-session=abc"), + ..Default::default() + }, + Expect { + source: Some(Web), + cookie: Some(Some("__Secure-session=abc")), + missing: Some(false), + ..Default::default() + }, + ), + ( + "fetch_context_ollama_auto_source_never_reports_manual_cookie_missing", + ProviderId::Ollama, + CtxInput { + cookie_source: Some("auto"), + ..Default::default() + }, + Expect { + missing: Some(false), + ..Default::default() + }, + ), + ( + "fetch_context_codex_manual_cookie_keeps_explicit_supported_source", + ProviderId::Codex, + CtxInput { + usage_source: Some("oauth"), + manual_cookie: Some("oai-did=abc"), + ..Default::default() + }, + Expect { + source: Some(OAuth), + ..Default::default() + }, + ), + ( + "fetch_context_claude_uses_oauth_without_manual_cookie", + ProviderId::Claude, + CtxInput::default(), + Expect { + source: Some(OAuth), + cookie: Some(None), + ..Default::default() + }, + ), + ( + "fetch_context_claude_web_source_defers_cookie_resolution_to_provider", + ProviderId::Claude, + CtxInput { + cookie_source: Some("browser"), + usage_source: Some("web"), + ..Default::default() + }, + Expect { + source: Some(Web), + cookie: Some(None), + ..Default::default() + }, + ), + ( + "fetch_context_claude_explicit_cli_source_still_uses_cli", + ProviderId::Claude, + CtxInput { + usage_source: Some("cli"), + ..Default::default() + }, + Expect { + source: Some(Cli), + cookie: Some(None), + ..Default::default() + }, + ), + ( + "fetch_context_manual_cookie_uses_web_without_browser_import", + ProviderId::Cursor, + CtxInput { + manual_cookie: Some("session=abc123"), + ..Default::default() + }, + Expect { + source: Some(Web), + cookie: Some(Some("session=abc123")), + ..Default::default() + }, + ), + ( + "fetch_context_api_key_provider_uses_auto_without_cookie_import", + ProviderId::DeepSeek, + CtxInput { + api_key: Some("sk-test"), + ..Default::default() + }, + Expect { + source: Some(Auto), + cookie: Some(None), + api_key: Some(Some("sk-test")), + ..Default::default() + }, + ), + ( + "fetch_context_kimi_api_key_preserves_auto_for_web_fallback", + ProviderId::Kimi, + CtxInput { + api_key: Some("sk-kimi-test"), + ..Default::default() + }, + Expect { + source: Some(Auto), + cookie: Some(None), + api_key: Some(Some("sk-kimi-test")), + ..Default::default() + }, + ), + ( + "fetch_context_opencodego_api_key_preserves_auto_for_api_overlay", + ProviderId::OpenCodeGo, + CtxInput { + api_key: Some("go-test"), + ..Default::default() + }, + Expect { + source: Some(Auto), + cookie: Some(None), + api_key: Some(Some("go-test")), + ..Default::default() + }, + ), + ( + "fetch_context_includes_minimax_region", + ProviderId::MiniMax, + CtxInput { + region: Some("cn"), + ..Default::default() + }, + Expect { + region: Some(Some("cn")), + ..Default::default() + }, + ), + ( + "fetch_context_token_account_uses_web_cookie_header", + ProviderId::Ollama, + CtxInput { + accounts: &[("Work", "abc123")], + ..Default::default() + }, + Expect { + source: Some(Web), + cookie: Some(Some("__Secure-session=abc123")), + ..Default::default() + }, + ), + ( + "fetch_context_claude_manual_cookie_beats_active_oauth_token_account", + ProviderId::Claude, + CtxInput { + cookie_source: Some("manual"), + usage_source: Some("auto"), + manual_cookie: Some("sessionKey=manual-session"), + accounts: &[("Claude OAuth", "[REDACTED_SECRET]")], + ..Default::default() + }, + Expect { + source: Some(Web), + cookie: Some(Some("sessionKey=manual-session")), + ..Default::default() + }, + ), + ( + "fetch_context_claude_oauth_token_account_uses_oauth", + ProviderId::Claude, + CtxInput { + accounts: &[("Claude OAuth", "sk-ant-oat01-abc123")], + ..Default::default() + }, + Expect { + source: Some(OAuth), + cookie: Some(None), + api_key: Some(Some("sk-ant-oat01-abc123")), + ..Default::default() + }, + ), + ( + "fetch_context_copilot_token_account_uses_oauth_api_key", + ProviderId::Copilot, + CtxInput { + accounts: &[("GitHub", "gho_testtoken")], + ..Default::default() + }, + Expect { + source: Some(OAuth), + cookie: Some(None), + api_key: Some(Some("gho_testtoken")), + ..Default::default() + }, + ), + ( + "fetch_context_claude_session_token_account_uses_web_cookie", + ProviderId::Claude, + CtxInput { + accounts: &[("Claude Web", "sessionKey=abc123")], + ..Default::default() + }, + Expect { + source: Some(Web), + cookie: Some(Some("sessionKey=abc123")), + api_key: Some(None), + ..Default::default() + }, + ), + ( + "fetch_context_token_account_takes_precedence_over_manual_cookie", + ProviderId::Cursor, + CtxInput { + manual_cookie: Some("manual=old"), + accounts: &[("Work", "WorkosCursorSessionToken=new")], + ..Default::default() + }, + Expect { + source: Some(Web), + cookie: Some(Some("WorkosCursorSessionToken=new")), + ..Default::default() + }, + ), + ( + "fetch_context_openrouter_token_account_overrides_stored_api_key", + ProviderId::OpenRouter, + CtxInput { + api_key: Some("sk-or-v1-stored-decoy"), + accounts: &[("Personal", "sk-or-v1-personal"), ("Work", "sk-or-v1-work")], + active: Some(1), + ..Default::default() + }, + Expect { + source: Some(OAuth), + cookie: Some(None), + api_key: Some(Some("sk-or-v1-work")), + ..Default::default() + }, + ), + ( + "fetch_context_openrouter_falls_back_to_stored_api_key_without_token_accounts", + ProviderId::OpenRouter, + CtxInput { + api_key: Some("sk-or-v1-stored"), + ..Default::default() + }, + Expect { + api_key: Some(Some("sk-or-v1-stored")), + ..Default::default() + }, + ), + ( + "muse_default_cookie_source_reads_no_browser_and_keeps_the_login_source", + ProviderId::Muse, + CtxInput::default(), + Expect { + source: Some(Auto), + cookie: Some(None), + browser_import: Some(false), + ..Default::default() + }, + ), + ( + "muse_cookie_source_off_never_reads_or_forwards_a_cookie", + ProviderId::Muse, + CtxInput { + cookie_source: Some("off"), + manual_cookie: Some("llama_dev_sess=abc"), + ..Default::default() + }, + Expect { + cookie: Some(None), + browser_import: Some(false), + ..Default::default() + }, + ), + ( + "muse_automatic_cookie_source_requests_the_browser_import", + ProviderId::Muse, + CtxInput { + cookie_source: Some("auto"), + manual_cookie: Some("llama_dev_sess=abc"), + ..Default::default() + }, + Expect { + source: Some(Auto), + cookie: Some(None), + browser_import: Some(true), + ..Default::default() + }, + ), + ( + "muse_manual_cookie_source_forwards_only_the_pasted_header", + ProviderId::Muse, + CtxInput { + cookie_source: Some("manual"), + manual_cookie: Some("llama_dev_sess=abc"), + ..Default::default() + }, + Expect { + cookie: Some(Some("llama_dev_sess=abc")), + browser_import: Some(false), + ..Default::default() + }, + ), + ( + "other_providers_never_request_the_muse_browser_import", + ProviderId::Cursor, + CtxInput::default(), + Expect { + browser_import: Some(false), + ..Default::default() + }, + ), + ]; + + for (name, id, input, expect) in cases { + let ctx = fetch_ctx(*id, *input); + if let Some(source) = expect.source { + assert_eq!(ctx.source_mode, source, "{name}: source_mode"); + } + if let Some(cookie) = expect.cookie { + assert_eq!( + ctx.manual_cookie_header.as_deref(), + cookie, + "{name}: manual_cookie_header" + ); + } + if let Some(api_key) = expect.api_key { + assert_eq!(ctx.api_key.as_deref(), api_key, "{name}: api_key"); + } + if let Some(missing) = expect.missing { + assert_eq!(ctx.manual_cookie_missing, missing, "{name}: missing"); + } + if let Some(region) = expect.region { + assert_eq!(ctx.api_region.as_deref(), region, "{name}: api_region"); + } + if let Some(browser_import) = expect.browser_import { + assert_eq!( + ctx.browser_cookie_import, browser_import, + "{name}: browser_cookie_import" + ); + } + } +} + +#[test] +fn fetch_context_carries_the_optional_details_opt_in_for_its_own_provider() { + let mut settings = Settings::default(); + settings.set_optional_details_enabled(ProviderId::LiteLLM, true); + let build = |id| { + super::build_fetch_context( + id, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &HashMap::new(), + ) + }; + + assert!(build(ProviderId::LiteLLM).optional_details_enabled); + assert!(!build(ProviderId::Codex).optional_details_enabled); +} + +#[test] +fn fetch_context_codex_manual_cookie_never_forces_unsupported_web() { + // Default cookie source is "manual". Pasting a chatgpt.com cookie used to flip + // Codex into SourceMode::Web, which CodexProvider rejects with + // "Source mode 'Web' not supported for this provider" on every refresh. + let ctx = fetch_ctx( + ProviderId::Codex, + CtxInput { + manual_cookie: Some("oai-did=abc; __Secure-next-auth.session-token=xyz"), + ..Default::default() + }, + ); + + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert!( + instantiate_provider(ProviderId::Codex) + .available_sources() + .contains(&ctx.source_mode) + ); +} + +#[test] +fn kimi_selected_account_forces_web_and_keeps_saved_region() { + let mut settings = Settings::default(); + settings.set_usage_source(ProviderId::Kimi, "oauth"); + settings.set_api_region(ProviderId::Kimi, "international"); + let mut accounts = HashMap::new(); + let mut data = ProviderAccountData::new(); + data.add_account(TokenAccount::new("Work", "selected-kimi-session")); + accounts.insert(ProviderId::Kimi, data); + + let ctx = super::build_fetch_context( + ProviderId::Kimi, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &accounts, + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("kimi-auth=selected-kimi-session") + ); + assert_eq!(ctx.api_key, None); + assert_eq!(ctx.api_region.as_deref(), Some("international")); + assert!(ctx.token_account_isolated); + assert_eq!(settings.usage_source(ProviderId::Kimi), "oauth"); + assert_eq!(settings.api_region(ProviderId::Kimi), "international"); +} + +#[test] +fn doubao_selected_account_forces_ark_api_and_ignores_saved_source() { + let ctx = fetch_ctx( + ProviderId::Doubao, + CtxInput { + usage_source: Some("cli"), + accounts: &[("Work", "selected-ark-key")], + ..Default::default() + }, + ); + + assert_eq!(ctx.source_mode, SourceMode::OAuth); + assert_eq!(ctx.api_key.as_deref(), Some("selected-ark-key")); + assert!(ctx.token_account_isolated); +} + +#[test] +fn opencodego_selected_api_account_overrides_global_key_without_changing_explicit_source() { + let build = |cookie_source, usage_source| { + fetch_ctx( + ProviderId::OpenCodeGo, + CtxInput { + cookie_source, + usage_source: Some(usage_source), + api_key: Some("global-key"), + accounts: &[("Work", "selected-account-key")], + ..Default::default() + }, + ) + }; + + let ctx = build(None, "auto"); + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert_eq!(ctx.api_key.as_deref(), Some("selected-account-key")); + assert!(!ctx.auto_prefer_web); + assert!(ctx.token_account_isolated); + + for cookie_source in ["off", "manual"] { + let auto_ctx = build(Some(cookie_source), "auto"); + assert_eq!(auto_ctx.source_mode, SourceMode::Auto); + assert_eq!(auto_ctx.api_key.as_deref(), Some("selected-account-key")); + assert!(auto_ctx.manual_cookie_header.is_none()); + } + + for (saved_source, expected_source) in [("web", SourceMode::Web), ("cli", SourceMode::Cli)] { + let explicit_ctx = build(Some("off"), saved_source); + assert_eq!(explicit_ctx.source_mode, expected_source); + } +} + +#[test] +fn opencodego_selected_cookie_account_uses_web_route() { + let ctx = fetch_ctx( + ProviderId::OpenCodeGo, + CtxInput { + accounts: &[("Web", "Cookie: session=selected-session")], + ..Default::default() + }, + ); + + assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some("Cookie: session=selected-session") + ); + assert_eq!(ctx.token_account_kind, Some(TokenAccountKind::Cookie)); + assert!(ctx.token_account_isolated); +} + +#[test] +fn opencodego_cookie_source_off_forbids_provider_browser_discovery() { + let mut settings = Settings::default(); + settings.set_cookie_source(ProviderId::OpenCodeGo, "off"); + settings.set_usage_source(ProviderId::OpenCodeGo, "auto"); + let no_accounts = HashMap::new(); + + let without_key = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &ApiKeys::default(), + &no_accounts, + ); + assert_eq!(without_key.source_mode, SourceMode::Cli); + assert!(without_key.manual_cookie_header.is_none()); + assert!(without_key.manual_cookie_missing); + + let mut keys = ApiKeys::default(); + keys.set("opencodego", "global-key", None); + let with_key = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &no_accounts, + ); + assert_eq!(with_key.source_mode, SourceMode::Auto); + assert!(with_key.manual_cookie_missing); + + // Automatic leaves discovery to the provider, which reads a browser only + // when the local estimate needs a session. + settings.set_cookie_source(ProviderId::OpenCodeGo, "auto"); + let auto = super::build_fetch_context( + ProviderId::OpenCodeGo, + &settings, + &ManualCookies::default(), + &keys, + &no_accounts, + ); + assert_eq!(auto.source_mode, SourceMode::Auto); + assert!(auto.manual_cookie_header.is_none()); + assert!(!auto.manual_cookie_missing); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index 404715e240d..fd8497389ef 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -88,6 +88,8 @@ pub(crate) use usage_items::*; #[cfg(test)] mod credential_lane_tests; #[cfg(test)] +mod fetch_context_tests; +#[cfg(test)] mod last_good_owner_tests; #[cfg(test)] mod openai_live_tests; diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 0b26e35a217..2075139bdc0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -11,8 +11,18 @@ use codexbar::notifications::{LimitResetObservation, LimitResetSample, WarningSc use serde::Serialize; use std::sync::Arc; +mod fetch_context; +mod notify; mod reset_backfill; +#[cfg(test)] +pub(crate) use fetch_context::provider_cookie_domain; +pub(crate) use fetch_context::{build_fetch_context, provider_fetch_timeout}; +#[cfg(test)] +pub(super) use notify::limit_reset_sample; +pub(super) use notify::quota_notification_account_identity; +use notify::update_tray_and_notifications; + const MAX_CONCURRENT_PROVIDER_FETCHES: usize = 8; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -65,489 +75,6 @@ pub(crate) fn invalidate_account_usage( pending } -// ── Provider refresh commands ──────────────────────────────────────── - -/// Build a `FetchContext` for a provider using persisted cookies/keys. -pub(crate) fn build_fetch_context( - id: ProviderId, - settings: &Settings, - cookies: &ManualCookies, - api_keys: &ApiKeys, - token_accounts: &HashMap, -) -> FetchContext { - let provider = instantiate_provider(id); - let cookie_source = settings.cookie_source(id); - let stored_cookie = cookies.get(id.cli_name()).map(|s| s.to_string()); - let stored_api_key = api_keys.get(id.cli_name()).map(|s| s.to_string()); - let token_override = token_accounts - .get(&id) - .and_then(|data| data.active_account()) - .cloned() - .map(|account| TokenAccountOverride::from_account(id, account)); - let active_token_cookie = token_override - .as_ref() - .and_then(|override_data| override_data.cookie_header.clone()); - let defer_provider_browser_cookie_lookup = provider.owns_browser_cookie_resolution() - && active_token_cookie.is_none() - && stored_cookie.is_none(); - let active_token_env = token_override - .as_ref() - .and_then(|override_data| override_data.env_override.as_ref()); - let active_token_api_key = active_token_env.and_then(|env| env.values().next().cloned()); - let usage_source = SourceMode::parse(settings.usage_source(id)).unwrap_or_default(); - let token_account_kind = token_override.as_ref().map(|account| account.kind); - // Selected token-account key overrides a stored provider apiKey (upstream #2271 / #1183). - let api_key = active_token_api_key.clone().or(stored_api_key); - let has_kimi_code_api_key = - id == ProviderId::Kimi && api_key.as_deref().is_some_and(|key| !key.trim().is_empty()); - let has_opencodego_api_key = id == ProviderId::OpenCodeGo - && api_key.as_deref().is_some_and(|key| !key.trim().is_empty()); - - // Providers whose cookies only enrich an API result keep the configured - // usage source. Off and the default manual-without-cookie state never read - // a browser; only Automatic imports one (`browser_cookie_import`). - let cookies_only_enrich_usage = provider.cookies_only_enrich_usage(); - let browser_cookie_import = - cookies_only_enrich_usage && matches!(cookie_source, "auto" | "browser" | "web"); - let (mut source_mode, mut cookie_header, fails_closed_without_cookie) = if id - .cookie_domain() - .is_none() - { - // The #725 wallet rule (#725 keeps Auto for wallet providers) folds into - // the account-source decision; other no-cookie providers still honor - // cookie_source below (the account-source pack's fix for the #619 merge). - let keeps_auto = - usage_source == SourceMode::Auto && provider.token_account_preserves_auto_source(); - // A wallet provider keeps its saved usage source even with a selected - // token account: rewriting it to OAuth would skip the wallet read - // (#725 / upstream base-source resolver). - if keeps_auto || active_token_env.is_none() { - // A wallet provider keeps its saved usage source even with a - // selected token account: rewriting it to OAuth would skip the - // wallet read (#725 / upstream base-source resolver). - if active_token_env.is_some() { - (usage_source, None, false) - } else { - match cookie_source { - // #433: an explicitly selected, non-empty Claude manual cookie is - // authoritative. Do not let an active OAuth token account silently - // replace it; this keeps tray refresh behavior aligned with diagnose, - // whose Claude Auto path tries the supplied Web cookie before OAuth. - "manual" - if provider.manual_cookie_precedes_token_account() - && stored_cookie - .as_deref() - .is_some_and(|cookie| !cookie.trim().is_empty()) => - { - (SourceMode::Web, stored_cookie.clone(), false) - } - "auto" | "browser" | "web" => (usage_source, None, false), - _ => (usage_source, None, false), - } - } - } else { - match cookie_source { - // Opt-in web providers keep their default credential lane - // unless the usage source is explicitly Web; a stored or - // browser cookie must not turn Auto into Web. - _ if provider.web_is_opt_in() && usage_source != SourceMode::Web => { - (usage_source, None, false) - } - // #433: an explicitly selected, non-empty Claude manual cookie is - // authoritative. Do not let an active OAuth token account silently - // replace it; this keeps tray refresh behavior aligned with diagnose, - // whose Claude Auto path tries the supplied Web cookie before OAuth. - "manual" - if provider.manual_cookie_precedes_token_account() - && stored_cookie - .as_deref() - .is_some_and(|cookie| !cookie.trim().is_empty()) => - { - (SourceMode::Web, stored_cookie.clone(), false) - } - _ if active_token_env.is_some() => (SourceMode::OAuth, None, false), - // Charm Hyper: the cookie source only picks the session, and - // the usage source keeps routing. Off and an empty Manual - // source never import a browser session, while Auto keeps its - // API-key fallback. - "off" | "manual" if provider.cookie_source_scopes_session_only() => { - let cookie_header = if cookie_source == "manual" { - active_token_cookie.clone().or(stored_cookie) - } else { - None - }; - let source_mode = if provider.available_sources().contains(&usage_source) { - usage_source - } else { - SourceMode::Auto - }; - let cookie_missing = cookie_header.is_none(); - (source_mode, cookie_header, cookie_missing) - } - "off" if provider_uses_oauth_without_cookies(id, usage_source) => { - (SourceMode::OAuth, None, false) - } - "off" - if (has_kimi_code_api_key || has_opencodego_api_key) - && usage_source == SourceMode::Auto => - { - (SourceMode::Auto, None, false) - } - // Droid/Factory: cookie-off must never scrape browser cookies. Map to - // Cli (API-only in the provider) so Auto does not fall through to web. - "off" if id == ProviderId::Factory => (SourceMode::Cli, None, false), - "off" => (SourceMode::Cli, None, false), - "manual" => { - let cookie_header = active_token_cookie.clone().or(stored_cookie); - let fails_closed_without_cookie = cookie_header - .as_deref() - .is_none_or(|header| header.trim().is_empty()) - && provider.manual_empty_cookie_policy() - == ManualEmptyCookiePolicy::FailClosedWeb; - let source_mode = if (has_kimi_code_api_key || has_opencodego_api_key) - && usage_source == SourceMode::Auto - { - SourceMode::Auto - } else if let Some(mode) = grok_source_mode_for_manual_cookie(id, usage_source) - { - // Grok Switch writes ~/.grok/auth.json. Leftover grok.com - // cookies must not force Web, or Weekly/notifications keep - // showing the previous browser account. - mode - } else if cookie_header.is_some() { - SourceMode::Web - } else if fails_closed_without_cookie { - // The provider owns this policy; Web with no header means - // it fails closed instead of importing a browser account - // the user did not select. - SourceMode::Web - } else if provider_uses_oauth_without_cookies(id, usage_source) { - SourceMode::OAuth - } else { - SourceMode::Cli - }; - (source_mode, cookie_header, fails_closed_without_cookie) - } - // `browser` is accepted as a legacy alias from older settings. - "auto" | "browser" | "web" => { - // Claude resolves its cached cookie and browser fallback inside - // the provider; other providers retain the shell fallback. - let cookie_header = - active_token_cookie.clone().or(stored_cookie).or_else(|| { - if defer_provider_browser_cookie_lookup { - None - } else { - provider_cookie_domain(id, settings).and_then(|domain| { - codexbar::browser::cookies::get_cookie_header(domain) - .ok() - .filter(|h| !h.is_empty()) - }) - } - }); - (usage_source, cookie_header, false) - } - _ => (usage_source, stored_cookie, false), - } - } - } else if cookies_only_enrich_usage { - let cookie_header = (cookie_source == "manual") - .then(|| active_token_cookie.clone().or(stored_cookie)) - .flatten(); - (usage_source, cookie_header, false) - } else { - match cookie_source { - // #433: an explicitly selected, non-empty Claude manual cookie is - // authoritative. Do not let an active OAuth token account silently - // replace it; this keeps tray refresh behavior aligned with diagnose, - // whose Claude Auto path tries the supplied Web cookie before OAuth. - "manual" - if provider.manual_cookie_precedes_token_account() - && stored_cookie - .as_deref() - .is_some_and(|cookie| !cookie.trim().is_empty()) => - { - (SourceMode::Web, stored_cookie.clone(), false) - } - _ if active_token_env.is_some() => (SourceMode::OAuth, None, false), - // Providers that pick their own API-key or cookie lane (Command - // Code) keep Auto and API for every cookie source, so a configured - // key is used without touching a browser. `off` marks the cookie - // lane unavailable; `manual`/`auto` pass a stored header through. - _ if provider.owns_credential_lane_routing() - && matches!(usage_source, SourceMode::Auto | SourceMode::OAuth) => - { - let cookie_off = cookie_source == "off"; - let cookie_header = if cookie_off { - None - } else { - active_token_cookie.clone().or(stored_cookie) - }; - (usage_source, cookie_header, cookie_off) - } - // Opt-in web providers keep their default credential lane - // unless the usage source is explicitly Web; a stored or - // browser cookie must not turn Auto into Web. - _ if provider.web_is_opt_in() && usage_source != SourceMode::Web => { - (usage_source, None, false) - } - // Charm Hyper: the cookie source only picks the session, and - // the usage source keeps routing. Off and an empty Manual - // source never import a browser session, while Auto keeps its - // API-key fallback. - "off" | "manual" if provider.cookie_source_scopes_session_only() => { - let cookie_header = if cookie_source == "manual" { - active_token_cookie.clone().or(stored_cookie) - } else { - None - }; - let source_mode = if provider.available_sources().contains(&usage_source) { - usage_source - } else { - SourceMode::Auto - }; - let cookie_missing = cookie_header.is_none(); - (source_mode, cookie_header, cookie_missing) - } - "off" if provider_uses_oauth_without_cookies(id, usage_source) => { - (SourceMode::OAuth, None, false) - } - // OpenCode Go discovers browser sessions inside the provider; the - // missing-cookie flag tells it that Off forbids that discovery. - "off" if id == ProviderId::OpenCodeGo => { - let source_mode = if has_opencodego_api_key && usage_source == SourceMode::Auto { - SourceMode::Auto - } else { - SourceMode::Cli - }; - (source_mode, None, true) - } - "off" if has_kimi_code_api_key && usage_source == SourceMode::Auto => { - (SourceMode::Auto, None, false) - } - // Droid/Factory: cookie-off must never scrape browser cookies. Map to - // Cli (API-only in the provider) so Auto does not fall through to web. - "off" if id == ProviderId::Factory => (SourceMode::Cli, None, false), - "off" => (SourceMode::Cli, None, false), - "manual" => { - let cookie_header = active_token_cookie.clone().or(stored_cookie); - let fails_closed_without_cookie = cookie_header - .as_deref() - .is_none_or(|header| header.trim().is_empty()) - && provider.manual_empty_cookie_policy() - == ManualEmptyCookiePolicy::FailClosedWeb; - let source_mode = if (has_kimi_code_api_key || has_opencodego_api_key) - && usage_source == SourceMode::Auto - { - SourceMode::Auto - } else if let Some(mode) = grok_source_mode_for_manual_cookie(id, usage_source) { - // Grok Switch writes ~/.grok/auth.json. Leftover grok.com - // cookies must not force Web, or Weekly/notifications keep - // showing the previous browser account. - mode - } else if cookie_header.is_some() { - SourceMode::Web - } else if fails_closed_without_cookie { - // The provider owns this policy; Web with no header means - // it fails closed instead of importing a browser account - // the user did not select. - SourceMode::Web - } else if provider_uses_oauth_without_cookies(id, usage_source) { - SourceMode::OAuth - } else { - SourceMode::Cli - }; - (source_mode, cookie_header, fails_closed_without_cookie) - } - // `browser` is accepted as a legacy alias from older settings. - "auto" | "browser" | "web" => { - // Claude resolves its cached cookie and browser fallback inside - // the provider; other providers retain the shell fallback. - let cookie_header = active_token_cookie.clone().or(stored_cookie).or_else(|| { - if defer_provider_browser_cookie_lookup { - None - } else { - provider_cookie_domain(id, settings).and_then(|domain| { - codexbar::browser::cookies::get_cookie_header(domain) - .ok() - .filter(|h| !h.is_empty()) - }) - } - }); - (usage_source, cookie_header, false) - } - _ => (usage_source, stored_cookie, false), - } - }; - - // Cookie-web providers (Cursor, OpenCode, …) reject SourceMode::Cli. The shell - // historically mapped "manual + no cookie" to Cli, which surfaces as - // "Source mode 'Cli' not supported". Remap to Web and try browser cookies - // unless the user explicitly disabled cookies ("off"). Providers whose - // cookie source only scopes the session (Charm Hyper) or only enriches an - // API result (Muse browser team quota) own this contract in the provider, - // so the shell must not remap their source mode. - if source_mode == SourceMode::Cli - && cookie_source != "off" - && !provider.supports_cli() - && !provider.cookie_source_scopes_session_only() - && !cookies_only_enrich_usage - { - // Providers that own browser cookie resolution try each browser's - // session themselves; a pre-resolved first-browser header would pin - // them to that browser. - if !defer_provider_browser_cookie_lookup - && cookie_header - .as_deref() - .map(str::trim) - .is_none_or(|s| s.is_empty()) - { - cookie_header = provider_cookie_domain(id, settings).and_then(|domain| { - codexbar::browser::cookies::get_cookie_header(domain) - .ok() - .filter(|h| !h.is_empty()) - }); - } - source_mode = SourceMode::Web; - } - - // The inverse case: some providers never fetch over the web and reject - // SourceMode::Web outright (Codex since the 0.54 port only does OAuth/PAT/CLI). - // The default cookie source is "manual", so a pasted chatgpt.com cookie flipped - // Codex into Web and every refresh failed with - // "Source mode 'Web' not supported for this provider". Fall back to the - // configured usage source (or Auto) instead of handing the provider a mode it - // advertises as unsupported. - let available_sources = provider.available_sources(); - if source_mode == SourceMode::Web && !available_sources.contains(&SourceMode::Web) { - source_mode = if available_sources.contains(&usage_source) { - usage_source - } else { - SourceMode::Auto - }; - } - - let workspace_id = settings.workspace_id(id).trim().to_string(); - let api_region = settings.api_region(id).trim().to_string(); - // Every gateway-style provider (Wayfinder, Bifrost, Aixy) stores its base - // URL here; providers without one report an empty string. - let gateway_url = Some(settings.gateway_url(id)) - .filter(|url| !url.is_empty()) - .map(str::to_owned); - // Local-first Auto providers (OpenCode Go) flip to web-first when a - // token account or manual cookie source scopes the session to web creds. - let auto_prefer_web = token_override.is_some() || cookie_source == "manual"; - - // These upstream account types are explicit identity selections. Keep the - // provider's saved region/source settings intact, but project the selected - // credential into the route required by that account. - let (cookie_header, api_key) = match (id, token_account_kind, usage_source) { - (ProviderId::Kimi, Some(_), _) => (active_token_cookie.clone(), None), - (ProviderId::Doubao, Some(_), _) => (None, active_token_api_key.clone()), - ( - ProviderId::OpenCodeGo, - Some(codexbar::core::TokenAccountKind::ApiKey), - SourceMode::Auto, - ) => (None, active_token_api_key.clone()), - (ProviderId::OpenCodeGo, Some(codexbar::core::TokenAccountKind::ApiKey), _) => { - (cookie_header, api_key) - } - ( - ProviderId::OpenCodeGo, - Some(codexbar::core::TokenAccountKind::Cookie), - SourceMode::Auto, - ) => (active_token_cookie.clone(), api_key), - _ => (cookie_header, api_key), - }; - let source_mode = token_override - .as_ref() - .and_then(|account| account.effective_source_mode(usage_source)) - .unwrap_or(source_mode); - let token_account_isolated = token_override.is_some() - && matches!( - id, - ProviderId::Kimi | ProviderId::Doubao | ProviderId::OpenCodeGo - ); - - FetchContext { - source_mode, - manual_cookie_header: cookie_header, - manual_cookie_missing: fails_closed_without_cookie, - api_key, - token_account_kind, - token_account_isolated, - workspace_id: (!workspace_id.is_empty()).then_some(workspace_id), - seat_credit_entitlement: settings.seat_credit_entitlement(id), - api_region: (!api_region.is_empty()).then_some(api_region), - gateway_url, - auto_prefer_web: auto_prefer_web - && !(id == ProviderId::OpenCodeGo - && token_account_kind == Some(codexbar::core::TokenAccountKind::ApiKey)), - browser_cookie_import, - optional_details_enabled: settings.optional_details_enabled(id), - live_usage_enabled: settings.live_usage_enabled(id), - ..FetchContext::default() - } -} - -fn provider_uses_oauth_without_cookies(id: ProviderId, usage_source: SourceMode) -> bool { - match id { - ProviderId::Claude => usage_source != SourceMode::Cli, - ProviderId::Grok => matches!(usage_source, SourceMode::Auto | SourceMode::OAuth), - _ => false, - } -} - -/// Keep Grok Auto/OAuth/Cli on the switched login instead of rewriting a -/// leftover manual cookie as Web. Auto remains Auto even when its manual -/// cookie is empty; the provider uses that context to skip browser refresh. -fn grok_source_mode_for_manual_cookie( - id: ProviderId, - usage_source: SourceMode, -) -> Option { - if id != ProviderId::Grok { - return None; - } - match usage_source { - SourceMode::Cli => Some(SourceMode::Cli), - SourceMode::Auto => Some(SourceMode::Auto), - SourceMode::OAuth => Some(SourceMode::OAuth), - _ => None, - } -} - -pub(crate) fn provider_cookie_domain(id: ProviderId, settings: &Settings) -> Option<&'static str> { - if id == ProviderId::MiniMax { - return Some( - codexbar::providers::MiniMaxProvider::cookie_domain_for_region(Some( - settings.api_region(id), - )), - ); - } - if id == ProviderId::Alibaba { - return Some( - codexbar::providers::AlibabaProvider::cookie_domain_for_region(Some( - settings.api_region(id), - )), - ); - } - id.cookie_domain() -} - -const DEFAULT_PROVIDER_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(35); -const SLOW_PROVIDER_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(75); -const OPTIONAL_LITELLM_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(40); -const MAX_CONTEXT_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(65); - -pub(crate) fn provider_fetch_timeout(id: ProviderId, ctx: &FetchContext) -> std::time::Duration { - let provider_timeout = match id { - ProviderId::Claude | ProviderId::Codex | ProviderId::Copilot => SLOW_PROVIDER_FETCH_TIMEOUT, - ProviderId::LiteLLM if ctx.optional_details_enabled => OPTIONAL_LITELLM_FETCH_TIMEOUT, - _ => DEFAULT_PROVIDER_FETCH_TIMEOUT, - }; - let context_timeout = std::time::Duration::from_secs(ctx.web_timeout.saturating_add(5)); - provider_timeout.max(context_timeout.min(MAX_CONTEXT_FETCH_TIMEOUT)) -} - pub(crate) fn upsert_provider_cache( cache: &mut Vec, snapshot: ProviderUsageSnapshot, @@ -577,7 +104,7 @@ pub(crate) fn prune_provider_cache_to_enabled( /// Invalidate in-flight publish work and remove disabled providers from cache. /// /// Also clears the refresh lock so a follow-up force refresh can start immediately -/// (otherwise `begin_provider_refresh` no-ops while a superseded batch still holds +/// (otherwise `reserve_provider_refresh` no-ops while a superseded batch still holds /// `is_refreshing`, and newly enabled providers never get a replacement run). pub(crate) fn invalidate_provider_refresh_and_prune_disabled( state: &tauri::State<'_, Mutex>, @@ -659,8 +186,13 @@ async fn do_refresh_providers_with_policy( } let inputs = ProviderRefreshInputs::load(settings, enabled_ids); - let generation = match begin_provider_refresh(&state, force, &refresh_ids, expected_generation)? - { + let reservation = reserve_provider_refresh( + &mut *state.lock().map_err(|e| e.to_string())?, + force, + &refresh_ids, + expected_generation, + ); + let generation = match reservation { ProviderRefreshReservation::Reserved { generation } => generation, ProviderRefreshReservation::Skipped(reason) => { // Settings or account identity changed while inputs were loading, @@ -696,9 +228,13 @@ async fn do_refresh_providers_with_policy( generation, scope.refresh_account_lanes(), ); - await_provider_refreshes(handles).await; + for handle in handles { + let _ = handle.await; + } - let error_count = match finish_provider_refresh(&state, generation)? { + let completion = + complete_provider_refresh(&mut *state.lock().map_err(|e| e.to_string())?, generation); + let error_count = match completion { ProviderRefreshCompletion::Published { error_count } => error_count, ProviderRefreshCompletion::Superseded { current_generation } => { // Superseded by a newer generation (or invalidate). Do not clear UI @@ -717,21 +253,6 @@ async fn do_refresh_providers_with_policy( Ok(ProviderRefreshOutcome::Published { generation }) } -fn begin_provider_refresh( - state: &tauri::State<'_, Mutex>, - force: bool, - provider_ids: &[ProviderId], - expected_generation: u64, -) -> Result { - let mut guard = state.lock().map_err(|e| e.to_string())?; - Ok(reserve_provider_refresh( - &mut guard, - force, - provider_ids, - expected_generation, - )) -} - struct ProviderRefreshInputs { settings: Settings, enabled_ids: Vec, @@ -1041,19 +562,15 @@ fn preserve_last_good_transient_failure_with_policy( policy: Option, ownership: &codexbar::core::FailureOwnership, ) -> ProviderUsageSnapshot { - let Some(error) = snapshot.error.as_deref() else { - guard.transient_provider_failure_counts.remove(&id); - return snapshot; - }; + use codexbar::core::LastGoodFailurePolicy as Policy; - let policy = policy.unwrap_or(codexbar::core::LastGoodFailurePolicy::Replace); - if policy == codexbar::core::LastGoodFailurePolicy::Replace { - guard.transient_provider_failure_counts.remove(&id); - return snapshot; - } + let policy = policy.unwrap_or(Policy::Replace); // A failure tied to a live session may keep only a snapshot that the same // session produced. Anything else shows the error. - if !ownership.allows_retention(guard.last_good_owners.get(&id)) { + if snapshot.error.is_none() + || policy == Policy::Replace + || !ownership.allows_retention(guard.last_good_owners.get(&id)) + { guard.transient_provider_failure_counts.remove(&id); return snapshot; } @@ -1074,47 +591,33 @@ fn preserve_last_good_transient_failure_with_policy( .transient_provider_failure_counts .entry(id) .or_insert(0); - match policy { - codexbar::core::LastGoodFailurePolicy::Preserve => { - tracing::warn!( - provider = id.cli_name(), - error, - "preserving last good provider snapshot after transient failure" - ); - previous - } - codexbar::core::LastGoodFailurePolicy::PreserveOnce if *count == 0 => { + let preserve = match policy { + Policy::Preserve => true, + Policy::PreserveOnce | Policy::PreserveOnceThenSurface if *count == 0 => { *count = 1; - tracing::warn!( - provider = id.cli_name(), - error, - "preserving last good provider snapshot after transient failure" - ); - previous - } - codexbar::core::LastGoodFailurePolicy::PreserveOnce => { - *count = count.saturating_add(1); - snapshot - } - codexbar::core::LastGoodFailurePolicy::PreserveOnceThenSurface if *count == 0 => { - *count = 1; - tracing::warn!( - provider = id.cli_name(), - error, - "preserving last good provider snapshot after transient failure" - ); - previous + true } - codexbar::core::LastGoodFailurePolicy::PreserveOnceThenSurface => { + Policy::PreserveOnce | Policy::PreserveOnceThenSurface => { *count = count.saturating_add(1); - let mut surfaced = previous; - surfaced.error = snapshot.error; - surfaced.error_state = snapshot.error_state; - surfaced.fetch_duration_ms = snapshot.fetch_duration_ms; - surfaced + false } - codexbar::core::LastGoodFailurePolicy::Replace => snapshot, + Policy::Replace => false, + }; + if preserve { + tracing::warn!( + provider = id.cli_name(), + error = snapshot.error.as_deref().unwrap_or_default(), + "preserving last good provider snapshot after transient failure" + ); + return previous; } + if policy == Policy::PreserveOnceThenSurface { + previous.error = snapshot.error; + previous.error_state = snapshot.error_state; + previous.fetch_duration_ms = snapshot.fetch_duration_ms; + return previous; + } + snapshot } /// What a refresh tells the shell about keeping or replacing the last good @@ -1139,63 +642,43 @@ async fn fetch_provider_snapshot( let metadata = provider.metadata().clone(); let started = std::time::Instant::now(); - let (mut snapshot, account_identity, retention) = - match tokio::time::timeout(provider_fetch_timeout(id, &ctx), provider.fetch_usage(&ctx)) + let outcome = + tokio::time::timeout(provider_fetch_timeout(id, &ctx), provider.fetch_usage(&ctx)) .await - { - Ok(Ok(result)) => { - let account_identity = result.account_identity().map(ToOwned::to_owned); - ( - ProviderUsageSnapshot::from_fetch_result( - id, - &metadata, - &result, - token_account_id, - pace_work_days, - ), - account_identity, - RefreshRetention { - fresh_owner: result.last_good_owner.clone(), - ..RefreshRetention::default() - }, - ) - } - Ok(Err(e)) => { - let policy = provider.last_good_failure_policy_for_error(&e); - ( - ProviderUsageSnapshot::from_error( - id, - &metadata, - codexbar::logging::safe_error_message(&e), - provider.error_state_kind(&e), - ), - None, - RefreshRetention { - policy: Some(policy), - failure_ownership: e.failure_ownership(), - fresh_owner: None, - }, - ) - } - Err(_) => { - let error = codexbar::core::ProviderError::Timeout; - let policy = provider.last_good_failure_policy_for_error(&error); - ( - ProviderUsageSnapshot::from_error( - id, - &metadata, - "Timeout".to_string(), - provider.error_state_kind(&error), - ), - None, - RefreshRetention { - policy: Some(policy), - failure_ownership: error.failure_ownership(), - fresh_owner: None, - }, - ) - } - }; + .unwrap_or_else(|_| Err(codexbar::core::ProviderError::Timeout)); + let (mut snapshot, account_identity, retention) = match outcome { + Ok(result) => { + let account_identity = result.account_identity().map(ToOwned::to_owned); + ( + ProviderUsageSnapshot::from_fetch_result( + id, + &metadata, + &result, + token_account_id, + pace_work_days, + ), + account_identity, + RefreshRetention { + fresh_owner: result.last_good_owner.clone(), + ..RefreshRetention::default() + }, + ) + } + Err(e) => ( + ProviderUsageSnapshot::from_error( + id, + &metadata, + codexbar::logging::safe_error_message(&e), + provider.error_state_kind(&e), + ), + None, + RefreshRetention { + policy: Some(provider.last_good_failure_policy_for_error(&e)), + failure_ownership: e.failure_ownership(), + fresh_owner: None, + }, + ), + }; record_provider_fetch_duration(id, &mut snapshot, started); (snapshot, account_identity, retention) @@ -1217,371 +700,6 @@ fn record_provider_fetch_duration( } } -async fn await_provider_refreshes(handles: Vec>) { - for handle in handles { - let _ = handle.await; - } -} - -/// Finish a refresh batch. Returns `None` when `generation` was superseded -/// (do not emit complete / tray updates for dead work). Returns `Some(error_count)` -/// when this batch still owns the generation and the lock was released. -fn finish_provider_refresh( - state: &tauri::State<'_, Mutex>, - generation: u64, -) -> Result { - let mut guard = state.lock().map_err(|e| e.to_string())?; - Ok(complete_provider_refresh(&mut guard, generation)) -} - -fn update_tray_and_notifications( - app: &tauri::AppHandle, - state: &tauri::State<'_, Mutex>, - settings: &Settings, - token_accounts: &HashMap, -) -> Result<(), String> { - let cached = { - let guard = state.lock().map_err(|e| e.to_string())?; - guard.provider_cache.clone() - }; - crate::tray_bridge::update_tray_status_items(app, &cached); - crate::tray_bridge::update_tray_icon_and_tooltip(app, &cached); - notify_usage_thresholds(state, settings, token_accounts, &cached); - Ok(()) -} - -fn notify_usage_thresholds( - state: &tauri::State<'_, Mutex>, - settings: &Settings, - token_accounts: &HashMap, - cached: &[ProviderUsageSnapshot], -) { - let cli_map = codexbar::core::cli_name_map(); - let persistence = if let Ok(mut guard) = state.lock() { - for snapshot in cached { - if snapshot.error.is_none() - && let Some(&provider) = cli_map.get(snapshot.provider_id.as_str()) - { - let token_account_id = token_accounts - .get(&provider) - .and_then(ProviderAccountData::active_account) - .map(|account| account.id); - let warning_identity = WarningIdentity::new( - provider, - &snapshot.source_label, - snapshot.account_email.as_deref(), - snapshot.account_organization.as_deref(), - token_account_id, - ); - // Hooks keep their own per-source baselines (edge-triggered, first sample - // never fires), so they stay on the source key; only toast dedupe below - // bridges account-identity gaps. - let account = warning_identity.threshold_key(); - let scope = warning_identity.gap_scope(); - // Skip all session consumers for synthetic/no-session - // placeholders (e.g. Claude OAuth five_hour: null). - let session_account = resolve_toast_account( - &mut guard.notification_manager, - provider, - &scope, - "session", - &snapshot.primary, - settings, - ); - if guard.notification_manager.check_session_lane( - provider, - &session_account, - snapshot.primary.used_percent, - snapshot.primary.is_informational, - settings, - ) { - dispatch_quota_hooks( - settings, - provider, - &account, - "session", - snapshot.primary.used_percent, - ); - } - if let Some(weekly) = &snapshot.secondary - && !weekly.is_informational - { - let weekly_account = resolve_toast_account( - &mut guard.notification_manager, - provider, - &scope, - "weekly", - weekly, - settings, - ); - guard.notification_manager.check_and_notify( - provider, - &weekly_account, - "weekly", - weekly.used_percent, - settings, - ); - dispatch_quota_hooks( - settings, - provider, - &account, - "weekly", - weekly.used_percent, - ); - } - let token_account_label = token_accounts - .get(&provider) - .and_then(ProviderAccountData::active_account) - .map(|account| account.label.as_str()); - guard.notification_manager.observe_limit_reset( - limit_reset_sample( - provider, - snapshot, - &warning_identity, - token_account_label, - &session_account, - settings, - ), - settings, - ); - notify_predictive_pace( - &mut guard.notification_manager, - provider, - snapshot, - token_accounts, - settings, - ); - } - } - guard - .notification_manager - .finish_limit_reset_batch(settings, chrono::Utc::now()) - } else { - None - }; - if let Some(persistence) = persistence { - persistence.apply(); - } -} - -/// Reset-detector input for one cached snapshot. Only real session lanes -/// (1..=360 minutes) and the 7-day weekly lane are observed. -pub(super) fn limit_reset_sample<'a>( - provider: ProviderId, - snapshot: &ProviderUsageSnapshot, - identity: &WarningIdentity, - token_account_label: Option<&str>, - session_transition_account: &'a str, - settings: &Settings, -) -> LimitResetSample<'a> { - let label = token_account_label - .filter(|label| !label.trim().is_empty()) - .or(snapshot.account_email.as_deref()) - .filter(|label| !label.trim().is_empty()) - .or(snapshot.account_organization.as_deref()); - let account = identity.limit_reset_account(label, settings.hide_personal_info); - let observed_at = chrono::DateTime::parse_from_rfc3339(&snapshot.updated_at) - .ok() - .map(|date| date.with_timezone(&chrono::Utc)); - let observe = |lane: &RateWindowSnapshot| { - Some(LimitResetObservation { - used_percent: lane.used_percent, - observed_at: observed_at?, - reset_boundary: lane - .resets_at - .as_deref() - .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) - .map(|date| date.with_timezone(&chrono::Utc)), - }) - }; - let session = Some(&snapshot.primary) - .filter(|lane| { - !lane.is_informational && lane.window_minutes.is_some_and(|m| (1..=360).contains(&m)) - }) - .and_then(observe); - let weekly = snapshot - .secondary - .as_ref() - .filter(|lane| !lane.is_informational && lane.window_minutes == Some(10080)) - .and_then(observe); - LimitResetSample { - provider, - account, - session_transition_account, - session, - weekly, - } -} - -/// Account key a toast lane is deduped under (see `NotificationManager::resolve_warning_account`). -/// Informational placeholders are not observed, matching `check_session_lane`. -fn resolve_toast_account( - manager: &mut codexbar::notifications::NotificationManager, - provider: ProviderId, - scope: &WarningScope, - window: &str, - lane: &RateWindowSnapshot, - settings: &Settings, -) -> String { - if lane.is_informational { - return scope.key().to_string(); - } - let resets_at = lane - .resets_at - .as_deref() - .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) - .map(|date| date.with_timezone(&chrono::Utc)); - manager.resolve_warning_account( - provider, - scope, - window, - lane.used_percent, - resets_at, - settings, - ) -} - -fn dispatch_quota_hooks( - settings: &Settings, - provider: ProviderId, - account: &str, - window: &str, - used_percent: f64, -) { - if !settings.hooks_enabled { - return; - } - let thresholds = settings.usage_thresholds(provider, window); - let account = if settings.hide_personal_info || account.is_empty() { - None - } else { - Some(account) - }; - codexbar::core::emit_quota_threshold_hooks( - true, - provider.cli_name(), - window, - used_percent, - thresholds.high, - thresholds.critical, - account, - ); -} - -/// Stable account discriminator for threshold/session toast dedupe. -/// Prefer token-account id, then email, org, plan; empty for single-account lanes. -pub(super) fn quota_notification_account_identity( - snapshot: &ProviderUsageSnapshot, - token_account_id: Option, -) -> String { - ProviderId::from_cli_name(&snapshot.provider_id) - .map(|provider| { - quota_notification_account_identity_for( - provider, - &snapshot.source_label, - snapshot.account_email.as_deref(), - snapshot.account_organization.as_deref(), - token_account_id, - ) - }) - .unwrap_or_default() -} - -pub(super) fn quota_notification_account_identity_for( - provider: ProviderId, - source_label: &str, - account_email: Option<&str>, - account_organization: Option<&str>, - token_account_id: Option, -) -> String { - WarningIdentity::new( - provider, - source_label, - account_email, - account_organization, - token_account_id, - ) - .threshold_key() -} - -fn notify_predictive_pace( - manager: &mut codexbar::notifications::NotificationManager, - provider: ProviderId, - snapshot: &ProviderUsageSnapshot, - token_accounts: &HashMap, - settings: &Settings, -) { - let enabled = settings.show_notifications && settings.predictive_pace_warning_enabled; - manager.set_predictive_warnings_enabled(provider, enabled); - if !enabled || !matches!(provider, ProviderId::Claude | ProviderId::Codex) { - return; - } - - let token_account_id = token_accounts - .get(&provider) - .and_then(ProviderAccountData::active_account) - .map(|account| account.id); - let warning_identity = WarningIdentity::new( - provider, - &snapshot.source_label, - snapshot.account_email.as_deref(), - None, - token_account_id, - ); - let Some(identity) = warning_identity.predictive_key() else { - return; - }; - let observed_at = chrono::DateTime::parse_from_rfc3339(&snapshot.updated_at) - .ok() - .map(|date| date.with_timezone(&chrono::Utc)); - - for (warning_window, window, default_window_minutes) in [ - ( - codexbar::notifications::PredictiveWarningWindow::Session, - Some(&snapshot.primary), - 300, - ), - ( - codexbar::notifications::PredictiveWarningWindow::Weekly, - snapshot.secondary.as_ref(), - 10080, - ), - ] { - let Some(window) = window else { - continue; - }; - if window.is_informational { - continue; - } - let rate_window = RateWindow::with_details( - window.used_percent, - window.window_minutes, - window - .resets_at - .as_deref() - .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) - .map(|date| date.with_timezone(&chrono::Utc)), - window.reset_description.clone(), - ); - let Some(pace) = codexbar::core::UsagePace::weekly( - &rate_window, - observed_at, - default_window_minutes, - settings.weekly_progress_work_days, - ) else { - continue; - }; - manager.check_predictive_pace( - provider, - &identity, - warning_window, - &rate_window, - &pace, - settings, - ); - } -} - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DeepSeekPricingStatus { @@ -1661,262 +779,5 @@ mod refresh_scope_tests { assert!(!RefreshScope::AutoResume.polls_status()); } } - #[cfg(test)] -mod predictive_warning_tests { - use super::*; - - fn empty_snapshot() -> ProviderUsageSnapshot { - let metadata = codexbar::core::instantiate_provider(ProviderId::Claude) - .metadata() - .clone(); - ProviderUsageSnapshot::from_error( - ProviderId::Claude, - &metadata, - "unused".to_string(), - codexbar::core::ProviderStateKind::Unknown, - ) - } - - #[test] - fn quota_notification_account_identity_prefers_token_then_email() { - let account_id = uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(); - let mut snapshot = empty_snapshot(); - snapshot.account_email = Some("Person@Example.com".to_string()); - snapshot.account_organization = Some("Acme Org".to_string()); - snapshot.plan_name = Some("Pro".to_string()); - - assert_eq!( - quota_notification_account_identity(&snapshot, Some(account_id)), - "token-account:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - ); - assert_eq!( - quota_notification_account_identity(&snapshot, None), - "person@example.com" - ); - - snapshot.account_email = None; - assert_eq!( - quota_notification_account_identity(&snapshot, None), - "org:acme org" - ); - - snapshot.account_organization = None; - snapshot.source_label = "oauth".to_string(); - assert_eq!( - quota_notification_account_identity(&snapshot, None), - "claude:oauth:unknown" - ); - - snapshot.plan_name = None; - snapshot.source_label = "cli (reduced fidelity)".to_string(); - assert_eq!( - quota_notification_account_identity(&snapshot, None), - "claude:cli:unknown" - ); - } - - /// The forecast scope key and the notification identity must never disagree. - /// If they did, one account would be seen as two identities and its burn history - /// would be split, silently halving the sample count behind every forecast. - #[test] - fn forecast_account_key_matches_notification_identity() { - use crate::commands::bridge::forecast_account_key; - - let token = uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(); - let mut usage = codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(1.0)); - let mut snapshot = empty_snapshot(); - - for (email, org) in [ - (Some("Person@Example.com"), Some("Acme Org")), - (Some("Person@Example.com"), None), - (None, Some("Acme Org")), - (None, None), - ] { - usage.account_email = email.map(str::to_string); - usage.account_organization = org.map(str::to_string); - snapshot.account_email = usage.account_email.clone(); - snapshot.account_organization = usage.account_organization.clone(); - - for tok in [Some(token), None] { - assert_eq!( - forecast_account_key(&usage, tok).unwrap_or_default(), - quota_notification_account_identity(&snapshot, tok), - "identity drift for email={email:?} org={org:?} token={tok:?}" - ); - } - } - } - - /// Codex OAuth now publishes the auth.json email. Ambient Codex moves from - /// the unscoped key (process-local history only) to the lowercased email; - /// a managed token account still wins, and the header-only token-account - /// label never feeds any scoping key. - #[test] - fn codex_email_scopes_forecast_and_warnings_but_label_does_not() { - use crate::commands::bridge::forecast_account_key; - - let token = uuid::Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").unwrap(); - let mut usage = codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(1.0)); - let mut snapshot = empty_snapshot(); - snapshot.provider_id = "codex".to_string(); - - assert_eq!(forecast_account_key(&usage, None), None); - assert_eq!(quota_notification_account_identity(&snapshot, None), ""); - - snapshot.account_label = Some("Work account".to_string()); - assert_eq!(quota_notification_account_identity(&snapshot, None), ""); - - usage.account_email = Some("Parity.User@Example.com".to_string()); - snapshot.account_email = usage.account_email.clone(); - assert_eq!( - forecast_account_key(&usage, None).as_deref(), - Some("parity.user@example.com") - ); - assert_eq!( - quota_notification_account_identity(&snapshot, None), - "parity.user@example.com" - ); - assert_eq!( - forecast_account_key(&usage, Some(token)).as_deref(), - Some("token-account:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") - ); - } - - #[test] - fn header_label_trims_and_drops_blank_labels() { - assert_eq!( - header_label(" parity-github "), - Some("parity-github".to_string()) - ); - assert_eq!(header_label(" "), None); - } -} -#[cfg(test)] -mod reset_backfill_tests { - use super::*; - use crate::commands::bridge::{ProviderUsageSnapshot, RateWindowSnapshot}; - fn win(used: f64, resets_at: Option<&str>) -> RateWindowSnapshot { - RateWindowSnapshot { - used_percent: used, - remaining_percent: 100.0 - used, - window_minutes: Some(300), - resets_at: resets_at.map(String::from), - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, - monthly_limit_block: None, - } - } - fn codex_snapshot(primary: RateWindowSnapshot) -> ProviderUsageSnapshot { - ProviderUsageSnapshot { - provider_id: "codex".into(), - display_name: "Codex".into(), - primary, - primary_label: None, - secondary: None, - secondary_label: None, - secondary_label_key: None, - model_specific: None, - tertiary: None, - tertiary_label: None, - extra_rate_windows: Vec::new(), - inventory: Vec::new(), - display_details: Vec::new(), - cost: None, - plan_name: None, - account_email: None, - account_label: None, - subscription: None, - source_label: String::new(), - has_successful_claude_cli_quota: false, - updated_at: "2026-01-01T00:00:00Z".into(), - error: None, - error_state: codexbar::core::ProviderStateKind::Ready, - pace: None, - pace_authoritative: true, - account_organization: None, - tray_status_label: None, - fetch_duration_ms: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - session_equivalent_forecast: None, - quota_burndown: None, - } - } - #[test] - fn f6_backfills_future_cached_reset() { - // Cached has a future resets_at; fresh has none → backfilled. - let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); - let cached = codex_snapshot(win(50.0, Some(&future))); - let mut fresh = codex_snapshot(win(30.0, None)); - reset_backfill::codex_reset_backfill(&mut fresh, Some(&cached)); - assert_eq!(fresh.primary.resets_at.as_deref(), Some(future.as_str())); - // used_percent is NOT overwritten. - assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); - } - #[test] - fn f6_does_not_backfill_stale_cached_reset() { - // Cached reset is in the past → not backfilled. - let past = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339(); - let cached = codex_snapshot(win(50.0, Some(&past))); - let mut fresh = codex_snapshot(win(30.0, None)); - reset_backfill::codex_reset_backfill(&mut fresh, Some(&cached)); - assert!( - fresh.primary.resets_at.is_none(), - "stale reset not backfilled" - ); - } - #[test] - fn f6_does_not_overwrite_existing_resets_at() { - // Fresh already has resets_at → cached not applied. - let future1 = (chrono::Utc::now() + chrono::Duration::hours(3)).to_rfc3339(); - let future2 = (chrono::Utc::now() + chrono::Duration::hours(5)).to_rfc3339(); - let cached = codex_snapshot(win(50.0, Some(&future2))); - let mut fresh = codex_snapshot(win(30.0, Some(&future1))); - reset_backfill::codex_reset_backfill(&mut fresh, Some(&cached)); - assert_eq!(fresh.primary.resets_at.as_deref(), Some(future1.as_str())); - } - #[test] - fn f6_skips_non_codex_provider() { - let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); - let mut cached = codex_snapshot(win(50.0, Some(&future))); - cached.provider_id = "claude".into(); - let mut fresh = codex_snapshot(win(30.0, None)); - fresh.provider_id = "claude".into(); - reset_backfill::codex_reset_backfill(&mut fresh, Some(&cached)); - assert!(fresh.primary.resets_at.is_none(), "non-codex skip"); - } - #[test] - fn zai_five_hour_backfill_rejects_impossible_cached_reset() { - for (offset, should_backfill) in [ - (chrono::Duration::hours(1), true), - (chrono::Duration::hours(10), false), - ] { - let future = (chrono::Utc::now() + offset).to_rfc3339(); - let mut cached = codex_snapshot(win(50.0, Some(&future))); - cached.provider_id = "zai".into(); - let mut fresh = codex_snapshot(win(30.0, None)); - fresh.provider_id = "zai".into(); - fresh.primary.reset_description = Some("5-hour".into()); - reset_backfill::codex_reset_backfill(&mut fresh, Some(&cached)); - assert_eq!(fresh.primary.resets_at.is_some(), should_backfill); - assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); - } - } - #[test] - fn f6_skips_when_no_cached_snapshot() { - let mut fresh = codex_snapshot(win(30.0, None)); - reset_backfill::codex_reset_backfill(&mut fresh, None); - assert!(fresh.primary.resets_at.is_none()); - } -} +mod predictive_warning_tests; diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers/fetch_context.rs b/apps/desktop-tauri/src-tauri/src/commands/providers/fetch_context.rs new file mode 100644 index 00000000000..2b4abce5fd2 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/providers/fetch_context.rs @@ -0,0 +1,365 @@ +use super::*; + +/// Build a `FetchContext` for a provider using persisted cookies/keys. +pub(crate) fn build_fetch_context( + id: ProviderId, + settings: &Settings, + cookies: &ManualCookies, + api_keys: &ApiKeys, + token_accounts: &HashMap, +) -> FetchContext { + let provider = instantiate_provider(id); + let cookie_source = settings.cookie_source(id); + let stored_cookie = cookies.get(id.cli_name()).map(|s| s.to_string()); + let stored_api_key = api_keys.get(id.cli_name()).map(|s| s.to_string()); + let token_override = token_accounts + .get(&id) + .and_then(|data| data.active_account()) + .cloned() + .map(|account| TokenAccountOverride::from_account(id, account)); + let active_token_cookie = token_override + .as_ref() + .and_then(|override_data| override_data.cookie_header.clone()); + let defer_provider_browser_cookie_lookup = provider.owns_browser_cookie_resolution() + && active_token_cookie.is_none() + && stored_cookie.is_none(); + let active_token_env = token_override + .as_ref() + .and_then(|override_data| override_data.env_override.as_ref()); + let active_token_api_key = active_token_env.and_then(|env| env.values().next().cloned()); + let usage_source = SourceMode::parse(settings.usage_source(id)).unwrap_or_default(); + let token_account_kind = token_override.as_ref().map(|account| account.kind); + // Selected token-account key overrides a stored provider apiKey (upstream #2271 / #1183). + let api_key = active_token_api_key.clone().or(stored_api_key); + let has_kimi_code_api_key = + id == ProviderId::Kimi && api_key.as_deref().is_some_and(|key| !key.trim().is_empty()); + let has_opencodego_api_key = id == ProviderId::OpenCodeGo + && api_key.as_deref().is_some_and(|key| !key.trim().is_empty()); + + // Providers whose cookies only enrich an API result keep the configured + // usage source. Off and the default manual-without-cookie state never read + // a browser; only Automatic imports one (`browser_cookie_import`). + let cookies_only_enrich_usage = provider.cookies_only_enrich_usage(); + let browser_cookie_import = + cookies_only_enrich_usage && matches!(cookie_source, "auto" | "browser" | "web"); + // #433: an explicitly selected, non-empty Claude manual cookie is + // authoritative. Do not let an active OAuth token account silently + // replace it; this keeps tray refresh behavior aligned with diagnose, + // whose Claude Auto path tries the supplied Web cookie before OAuth. + let manual_cookie_wins = cookie_source == "manual" + && provider.manual_cookie_precedes_token_account() + && stored_cookie + .as_deref() + .is_some_and(|cookie| !cookie.trim().is_empty()); + let (mut source_mode, mut cookie_header, fails_closed_without_cookie) = if id + .cookie_domain() + .is_none() + { + // The #725 wallet rule (#725 keeps Auto for wallet providers) folds into + // the account-source decision; other no-cookie providers still honor + // cookie_source below (the account-source pack's fix for the #619 merge). + // A wallet provider keeps its saved usage source even with a selected + // token account: rewriting it to OAuth would skip the wallet read + // (#725 / upstream base-source resolver). + let keeps_auto = + usage_source == SourceMode::Auto && provider.token_account_preserves_auto_source(); + if active_token_env.is_some() && keeps_auto { + (usage_source, None, false) + } else if active_token_env.is_some() + && provider.web_is_opt_in() + && usage_source != SourceMode::Web + { + // Opt-in web providers keep their default credential lane + // unless the usage source is explicitly Web. + (usage_source, None, false) + } else if manual_cookie_wins { + (SourceMode::Web, stored_cookie.clone(), false) + } else if active_token_env.is_some() { + (SourceMode::OAuth, None, false) + } else { + (usage_source, None, false) + } + } else if cookies_only_enrich_usage { + let cookie_header = (cookie_source == "manual") + .then(|| active_token_cookie.clone().or(stored_cookie)) + .flatten(); + (usage_source, cookie_header, false) + } else { + match cookie_source { + _ if manual_cookie_wins => (SourceMode::Web, stored_cookie.clone(), false), + _ if active_token_env.is_some() => (SourceMode::OAuth, None, false), + // Providers that pick their own API-key or cookie lane (Command + // Code) keep Auto and API for every cookie source, so a configured + // key is used without touching a browser. `off` marks the cookie + // lane unavailable; `manual`/`auto` pass a stored header through. + _ if provider.owns_credential_lane_routing() + && matches!(usage_source, SourceMode::Auto | SourceMode::OAuth) => + { + let cookie_off = cookie_source == "off"; + let cookie_header = if cookie_off { + None + } else { + active_token_cookie.clone().or(stored_cookie) + }; + (usage_source, cookie_header, cookie_off) + } + // Opt-in web providers keep their default credential lane + // unless the usage source is explicitly Web; a stored or + // browser cookie must not turn Auto into Web. + _ if provider.web_is_opt_in() && usage_source != SourceMode::Web => { + (usage_source, None, false) + } + // Charm Hyper: the cookie source only picks the session, and + // the usage source keeps routing. Off and an empty Manual + // source never import a browser session, while Auto keeps its + // API-key fallback. + "off" | "manual" if provider.cookie_source_scopes_session_only() => { + let cookie_header = if cookie_source == "manual" { + active_token_cookie.clone().or(stored_cookie) + } else { + None + }; + let source_mode = if provider.available_sources().contains(&usage_source) { + usage_source + } else { + SourceMode::Auto + }; + let cookie_missing = cookie_header.is_none(); + (source_mode, cookie_header, cookie_missing) + } + "off" if provider_uses_oauth_without_cookies(id, usage_source) => { + (SourceMode::OAuth, None, false) + } + // OpenCode Go discovers browser sessions inside the provider; the + // missing-cookie flag tells it that Off forbids that discovery. + "off" if id == ProviderId::OpenCodeGo => { + let source_mode = if has_opencodego_api_key && usage_source == SourceMode::Auto { + SourceMode::Auto + } else { + SourceMode::Cli + }; + (source_mode, None, true) + } + "off" if has_kimi_code_api_key && usage_source == SourceMode::Auto => { + (SourceMode::Auto, None, false) + } + // Cookie-off must never scrape browser cookies (Droid/Factory). Map to + // Cli (API-only in the provider) so Auto does not fall through to web. + "off" => (SourceMode::Cli, None, false), + "manual" => { + let cookie_header = active_token_cookie.clone().or(stored_cookie); + let fails_closed_without_cookie = cookie_header + .as_deref() + .is_none_or(|header| header.trim().is_empty()) + && provider.manual_empty_cookie_policy() + == ManualEmptyCookiePolicy::FailClosedWeb; + let source_mode = if (has_kimi_code_api_key || has_opencodego_api_key) + && usage_source == SourceMode::Auto + { + SourceMode::Auto + } else if let Some(mode) = grok_source_mode_for_manual_cookie(id, usage_source) { + // Grok Switch writes ~/.grok/auth.json. Leftover grok.com + // cookies must not force Web, or Weekly/notifications keep + // showing the previous browser account. + mode + } else if cookie_header.is_some() { + SourceMode::Web + } else if fails_closed_without_cookie { + // The provider owns this policy; Web with no header means + // it fails closed instead of importing a browser account + // the user did not select. + SourceMode::Web + } else if provider_uses_oauth_without_cookies(id, usage_source) { + SourceMode::OAuth + } else { + SourceMode::Cli + }; + (source_mode, cookie_header, fails_closed_without_cookie) + } + // `browser` is accepted as a legacy alias from older settings. + "auto" | "browser" | "web" => { + // Claude resolves its cached cookie and browser fallback inside + // the provider; other providers retain the shell fallback. + let cookie_header = active_token_cookie.clone().or(stored_cookie).or_else(|| { + (!defer_provider_browser_cookie_lookup) + .then(|| browser_cookie_header(id, settings)) + .flatten() + }); + (usage_source, cookie_header, false) + } + _ => (usage_source, stored_cookie, false), + } + }; + + // Cookie-web providers (Cursor, OpenCode, …) reject SourceMode::Cli. The shell + // historically mapped "manual + no cookie" to Cli, which surfaces as + // "Source mode 'Cli' not supported". Remap to Web and try browser cookies + // unless the user explicitly disabled cookies ("off"). Providers whose + // cookie source only scopes the session (Charm Hyper) or only enriches an + // API result (Muse browser team quota) own this contract in the provider, + // so the shell must not remap their source mode. + if source_mode == SourceMode::Cli + && cookie_source != "off" + && !provider.supports_cli() + && !provider.cookie_source_scopes_session_only() + && !cookies_only_enrich_usage + { + // Providers that own browser cookie resolution try each browser's + // session themselves; a pre-resolved first-browser header would pin + // them to that browser. + if !defer_provider_browser_cookie_lookup + && cookie_header + .as_deref() + .map(str::trim) + .is_none_or(|s| s.is_empty()) + { + cookie_header = browser_cookie_header(id, settings); + } + source_mode = SourceMode::Web; + } + + // The inverse case: some providers never fetch over the web and reject + // SourceMode::Web outright (Codex since the 0.54 port only does OAuth/PAT/CLI). + // The default cookie source is "manual", so a pasted chatgpt.com cookie flipped + // Codex into Web and every refresh failed with + // "Source mode 'Web' not supported for this provider". Fall back to the + // configured usage source (or Auto) instead of handing the provider a mode it + // advertises as unsupported. + let available_sources = provider.available_sources(); + if source_mode == SourceMode::Web && !available_sources.contains(&SourceMode::Web) { + source_mode = if available_sources.contains(&usage_source) { + usage_source + } else { + SourceMode::Auto + }; + } + + let workspace_id = settings.workspace_id(id).trim().to_string(); + let api_region = settings.api_region(id).trim().to_string(); + // Every gateway-style provider (Wayfinder, Bifrost, Aixy) stores its base + // URL here; providers without one report an empty string. + let gateway_url = Some(settings.gateway_url(id)) + .filter(|url| !url.is_empty()) + .map(str::to_owned); + // Local-first Auto providers (OpenCode Go) flip to web-first when a + // token account or manual cookie source scopes the session to web creds. + let auto_prefer_web = token_override.is_some() || cookie_source == "manual"; + + // These upstream account types are explicit identity selections. Keep the + // provider's saved region/source settings intact, but project the selected + // credential into the route required by that account. + let (cookie_header, api_key) = match (id, token_account_kind, usage_source) { + (ProviderId::Kimi, Some(_), _) => (active_token_cookie.clone(), None), + (ProviderId::Doubao, Some(_), _) => (None, active_token_api_key.clone()), + ( + ProviderId::OpenCodeGo, + Some(codexbar::core::TokenAccountKind::ApiKey), + SourceMode::Auto, + ) => (None, active_token_api_key.clone()), + (ProviderId::OpenCodeGo, Some(codexbar::core::TokenAccountKind::ApiKey), _) => { + (cookie_header, api_key) + } + ( + ProviderId::OpenCodeGo, + Some(codexbar::core::TokenAccountKind::Cookie), + SourceMode::Auto, + ) => (active_token_cookie.clone(), api_key), + _ => (cookie_header, api_key), + }; + let source_mode = token_override + .as_ref() + .and_then(|account| account.effective_source_mode(usage_source)) + .unwrap_or(source_mode); + let token_account_isolated = token_override.is_some() + && matches!( + id, + ProviderId::Kimi | ProviderId::Doubao | ProviderId::OpenCodeGo + ); + + FetchContext { + source_mode, + manual_cookie_header: cookie_header, + manual_cookie_missing: fails_closed_without_cookie, + api_key, + token_account_kind, + token_account_isolated, + workspace_id: (!workspace_id.is_empty()).then_some(workspace_id), + seat_credit_entitlement: settings.seat_credit_entitlement(id), + api_region: (!api_region.is_empty()).then_some(api_region), + gateway_url, + auto_prefer_web: auto_prefer_web + && !(id == ProviderId::OpenCodeGo + && token_account_kind == Some(codexbar::core::TokenAccountKind::ApiKey)), + browser_cookie_import, + optional_details_enabled: settings.optional_details_enabled(id), + live_usage_enabled: settings.live_usage_enabled(id), + ..FetchContext::default() + } +} + +fn browser_cookie_header(id: ProviderId, settings: &Settings) -> Option { + provider_cookie_domain(id, settings).and_then(|domain| { + codexbar::browser::cookies::get_cookie_header(domain) + .ok() + .filter(|h| !h.is_empty()) + }) +} + +fn provider_uses_oauth_without_cookies(id: ProviderId, usage_source: SourceMode) -> bool { + match id { + ProviderId::Claude => usage_source != SourceMode::Cli, + ProviderId::Grok => matches!(usage_source, SourceMode::Auto | SourceMode::OAuth), + _ => false, + } +} + +/// Keep Grok Auto/OAuth/Cli on the switched login instead of rewriting a +/// leftover manual cookie as Web. Auto remains Auto even when its manual +/// cookie is empty; the provider uses that context to skip browser refresh. +fn grok_source_mode_for_manual_cookie( + id: ProviderId, + usage_source: SourceMode, +) -> Option { + if id != ProviderId::Grok { + return None; + } + match usage_source { + SourceMode::Cli => Some(SourceMode::Cli), + SourceMode::Auto => Some(SourceMode::Auto), + SourceMode::OAuth => Some(SourceMode::OAuth), + _ => None, + } +} + +pub(crate) fn provider_cookie_domain(id: ProviderId, settings: &Settings) -> Option<&'static str> { + if id == ProviderId::MiniMax { + return Some( + codexbar::providers::MiniMaxProvider::cookie_domain_for_region(Some( + settings.api_region(id), + )), + ); + } + if id == ProviderId::Alibaba { + return Some( + codexbar::providers::AlibabaProvider::cookie_domain_for_region(Some( + settings.api_region(id), + )), + ); + } + id.cookie_domain() +} + +const DEFAULT_PROVIDER_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(35); +const SLOW_PROVIDER_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(75); +const OPTIONAL_LITELLM_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(40); +const MAX_CONTEXT_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(65); + +pub(crate) fn provider_fetch_timeout(id: ProviderId, ctx: &FetchContext) -> std::time::Duration { + let provider_timeout = match id { + ProviderId::Claude | ProviderId::Codex | ProviderId::Copilot => SLOW_PROVIDER_FETCH_TIMEOUT, + ProviderId::LiteLLM if ctx.optional_details_enabled => OPTIONAL_LITELLM_FETCH_TIMEOUT, + _ => DEFAULT_PROVIDER_FETCH_TIMEOUT, + }; + let context_timeout = std::time::Duration::from_secs(ctx.web_timeout.saturating_add(5)); + provider_timeout.max(context_timeout.min(MAX_CONTEXT_FETCH_TIMEOUT)) +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers/notify.rs b/apps/desktop-tauri/src-tauri/src/commands/providers/notify.rs new file mode 100644 index 00000000000..0acd99ab9f1 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/providers/notify.rs @@ -0,0 +1,318 @@ +use super::*; + +pub(super) fn update_tray_and_notifications( + app: &tauri::AppHandle, + state: &tauri::State<'_, Mutex>, + settings: &Settings, + token_accounts: &HashMap, +) -> Result<(), String> { + let cached = { + let guard = state.lock().map_err(|e| e.to_string())?; + guard.provider_cache.clone() + }; + crate::tray_bridge::update_tray_status_items(app, &cached); + crate::tray_bridge::update_tray_icon_and_tooltip(app, &cached); + notify_usage_thresholds(state, settings, token_accounts, &cached); + Ok(()) +} + +fn notify_usage_thresholds( + state: &tauri::State<'_, Mutex>, + settings: &Settings, + token_accounts: &HashMap, + cached: &[ProviderUsageSnapshot], +) { + let cli_map = codexbar::core::cli_name_map(); + let persistence = if let Ok(mut guard) = state.lock() { + for snapshot in cached { + if snapshot.error.is_none() + && let Some(&provider) = cli_map.get(snapshot.provider_id.as_str()) + { + let token_account_id = token_accounts + .get(&provider) + .and_then(ProviderAccountData::active_account) + .map(|account| account.id); + let warning_identity = WarningIdentity::new( + provider, + &snapshot.source_label, + snapshot.account_email.as_deref(), + snapshot.account_organization.as_deref(), + token_account_id, + ); + // Hooks keep their own per-source baselines (edge-triggered, first sample + // never fires), so they stay on the source key; only toast dedupe below + // bridges account-identity gaps. + let account = warning_identity.threshold_key(); + let scope = warning_identity.gap_scope(); + // Skip all session consumers for synthetic/no-session + // placeholders (e.g. Claude OAuth five_hour: null). + let session_account = resolve_toast_account( + &mut guard.notification_manager, + provider, + &scope, + "session", + &snapshot.primary, + settings, + ); + if guard.notification_manager.check_session_lane( + provider, + &session_account, + snapshot.primary.used_percent, + snapshot.primary.is_informational, + settings, + ) { + dispatch_quota_hooks( + settings, + provider, + &account, + "session", + snapshot.primary.used_percent, + ); + } + if let Some(weekly) = &snapshot.secondary + && !weekly.is_informational + { + let weekly_account = resolve_toast_account( + &mut guard.notification_manager, + provider, + &scope, + "weekly", + weekly, + settings, + ); + guard.notification_manager.check_and_notify( + provider, + &weekly_account, + "weekly", + weekly.used_percent, + settings, + ); + dispatch_quota_hooks( + settings, + provider, + &account, + "weekly", + weekly.used_percent, + ); + } + let token_account_label = token_accounts + .get(&provider) + .and_then(ProviderAccountData::active_account) + .map(|account| account.label.as_str()); + guard.notification_manager.observe_limit_reset( + limit_reset_sample( + provider, + snapshot, + &warning_identity, + token_account_label, + &session_account, + settings, + ), + settings, + ); + notify_predictive_pace( + &mut guard.notification_manager, + provider, + snapshot, + token_accounts, + settings, + ); + } + } + guard + .notification_manager + .finish_limit_reset_batch(settings, chrono::Utc::now()) + } else { + None + }; + if let Some(persistence) = persistence { + persistence.apply(); + } +} + +/// Reset-detector input for one cached snapshot. Only real session lanes +/// (1..=360 minutes) and the 7-day weekly lane are observed. +pub(in crate::commands) fn limit_reset_sample<'a>( + provider: ProviderId, + snapshot: &ProviderUsageSnapshot, + identity: &WarningIdentity, + token_account_label: Option<&str>, + session_transition_account: &'a str, + settings: &Settings, +) -> LimitResetSample<'a> { + let label = token_account_label + .filter(|label| !label.trim().is_empty()) + .or(snapshot.account_email.as_deref()) + .filter(|label| !label.trim().is_empty()) + .or(snapshot.account_organization.as_deref()); + let account = identity.limit_reset_account(label, settings.hide_personal_info); + let observed_at = chrono::DateTime::parse_from_rfc3339(&snapshot.updated_at) + .ok() + .map(|date| date.with_timezone(&chrono::Utc)); + let observe = |lane: &RateWindowSnapshot| { + Some(LimitResetObservation { + used_percent: lane.used_percent, + observed_at: observed_at?, + reset_boundary: lane + .resets_at + .as_deref() + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) + .map(|date| date.with_timezone(&chrono::Utc)), + }) + }; + let session = Some(&snapshot.primary) + .filter(|lane| { + !lane.is_informational && lane.window_minutes.is_some_and(|m| (1..=360).contains(&m)) + }) + .and_then(observe); + let weekly = snapshot + .secondary + .as_ref() + .filter(|lane| !lane.is_informational && lane.window_minutes == Some(10080)) + .and_then(observe); + LimitResetSample { + provider, + account, + session_transition_account, + session, + weekly, + } +} + +/// Account key a toast lane is deduped under (see `NotificationManager::resolve_warning_account`). +/// Informational placeholders are not observed, matching `check_session_lane`. +fn resolve_toast_account( + manager: &mut codexbar::notifications::NotificationManager, + provider: ProviderId, + scope: &WarningScope, + window: &str, + lane: &RateWindowSnapshot, + settings: &Settings, +) -> String { + if lane.is_informational { + return scope.key().to_string(); + } + let resets_at = lane.resets_at_utc(); + manager.resolve_warning_account( + provider, + scope, + window, + lane.used_percent, + resets_at, + settings, + ) +} + +fn dispatch_quota_hooks( + settings: &Settings, + provider: ProviderId, + account: &str, + window: &str, + used_percent: f64, +) { + if !settings.hooks_enabled { + return; + } + let thresholds = settings.usage_thresholds(provider, window); + let account = if settings.hide_personal_info || account.is_empty() { + None + } else { + Some(account) + }; + codexbar::core::emit_quota_threshold_hooks( + true, + provider.cli_name(), + window, + used_percent, + thresholds.high, + thresholds.critical, + account, + ); +} + +/// Stable account discriminator for threshold/session toast dedupe. +/// Prefer token-account id, then email, org, plan; empty for single-account lanes. +pub(in crate::commands) fn quota_notification_account_identity( + snapshot: &ProviderUsageSnapshot, + token_account_id: Option, +) -> String { + ProviderId::from_cli_name(&snapshot.provider_id) + .map(|provider| { + WarningIdentity::new( + provider, + &snapshot.source_label, + snapshot.account_email.as_deref(), + snapshot.account_organization.as_deref(), + token_account_id, + ) + .threshold_key() + }) + .unwrap_or_default() +} + +fn notify_predictive_pace( + manager: &mut codexbar::notifications::NotificationManager, + provider: ProviderId, + snapshot: &ProviderUsageSnapshot, + token_accounts: &HashMap, + settings: &Settings, +) { + let enabled = settings.show_notifications && settings.predictive_pace_warning_enabled; + manager.set_predictive_warnings_enabled(provider, enabled); + if !enabled || !matches!(provider, ProviderId::Claude | ProviderId::Codex) { + return; + } + + let token_account_id = token_accounts + .get(&provider) + .and_then(ProviderAccountData::active_account) + .map(|account| account.id); + let warning_identity = WarningIdentity::new( + provider, + &snapshot.source_label, + snapshot.account_email.as_deref(), + None, + token_account_id, + ); + let Some(identity) = warning_identity.predictive_key() else { + return; + }; + let observed_at = parse_utc(&snapshot.updated_at); + + for (warning_window, window, default_window_minutes) in [ + ( + codexbar::notifications::PredictiveWarningWindow::Session, + Some(&snapshot.primary), + 300, + ), + ( + codexbar::notifications::PredictiveWarningWindow::Weekly, + snapshot.secondary.as_ref(), + 10080, + ), + ] { + let Some(window) = window else { + continue; + }; + if window.is_informational { + continue; + } + let rate_window = window.to_rate_window(); + let Some(pace) = codexbar::core::UsagePace::weekly( + &rate_window, + observed_at, + default_window_minutes, + settings.weekly_progress_work_days, + ) else { + continue; + }; + manager.check_predictive_pace( + provider, + &identity, + warning_window, + &rate_window, + &pace, + settings, + ); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers/predictive_warning_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/providers/predictive_warning_tests.rs new file mode 100644 index 00000000000..0c18485b3a7 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/providers/predictive_warning_tests.rs @@ -0,0 +1,275 @@ +use super::*; + +fn limit_reset_snapshot() -> ProviderUsageSnapshot { + let metadata = codexbar::core::instantiate_provider(ProviderId::Codex) + .metadata() + .clone(); + let result = codexbar::core::ProviderFetchResult { + usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), + cost: None, + wayfinder_usage: None, + open_ai_api_usage: None, + throughput: None, + inventory: Vec::new(), + reset_credits: None, + display_details: Vec::new(), + source_label: "OAuth".to_string(), + has_successful_claude_cli_quota: false, + pace_authoritative: true, + account_identity: None, + last_good_owner: None, + }; + let mut snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); + snapshot.updated_at = "2026-05-01T12:00:00Z".to_string(); + snapshot.account_email = Some("owner@example.com".to_string()); + snapshot.primary.window_minutes = Some(300); + snapshot.primary.resets_at = Some("2026-05-01T15:00:00Z".to_string()); + let mut weekly = snapshot.primary.clone(); + weekly.used_percent = 40.0; + weekly.window_minutes = Some(10080); + weekly.resets_at = Some("2026-05-07T00:00:00Z".to_string()); + snapshot.secondary = Some(weekly); + snapshot +} + +fn limit_reset_identity( + snapshot: &ProviderUsageSnapshot, + token_account_id: Option, +) -> super::super::warning_identity::WarningIdentity { + super::super::warning_identity::WarningIdentity::new( + ProviderId::Codex, + &snapshot.source_label, + snapshot.account_email.as_deref(), + snapshot.account_organization.as_deref(), + token_account_id, + ) +} + +fn empty_snapshot() -> ProviderUsageSnapshot { + let metadata = codexbar::core::instantiate_provider(ProviderId::Claude) + .metadata() + .clone(); + ProviderUsageSnapshot::from_error( + ProviderId::Claude, + &metadata, + "unused".to_string(), + codexbar::core::ProviderStateKind::Unknown, + ) +} + +#[test] +fn quota_notification_account_identity_prefers_token_then_email() { + let account_id = uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(); + let mut snapshot = empty_snapshot(); + snapshot.account_email = Some("Person@Example.com".to_string()); + snapshot.account_organization = Some("Acme Org".to_string()); + snapshot.plan_name = Some("Pro".to_string()); + + assert_eq!( + quota_notification_account_identity(&snapshot, Some(account_id)), + "token-account:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + ); + assert_eq!( + quota_notification_account_identity(&snapshot, None), + "person@example.com" + ); + + snapshot.account_email = None; + assert_eq!( + quota_notification_account_identity(&snapshot, None), + "org:acme org" + ); + + snapshot.account_organization = None; + snapshot.source_label = "oauth".to_string(); + assert_eq!( + quota_notification_account_identity(&snapshot, None), + "claude:oauth:unknown" + ); + + snapshot.plan_name = None; + snapshot.source_label = "cli (reduced fidelity)".to_string(); + assert_eq!( + quota_notification_account_identity(&snapshot, None), + "claude:cli:unknown" + ); +} + +/// The forecast scope key and the notification identity must never disagree. +/// If they did, one account would be seen as two identities and its burn history +/// would be split, silently halving the sample count behind every forecast. +#[test] +fn forecast_account_key_matches_notification_identity() { + use crate::commands::bridge::forecast_account_key; + + let token = uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(); + let mut usage = codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(1.0)); + let mut snapshot = empty_snapshot(); + + for (email, org) in [ + (Some("Person@Example.com"), Some("Acme Org")), + (Some("Person@Example.com"), None), + (None, Some("Acme Org")), + (None, None), + ] { + usage.account_email = email.map(str::to_string); + usage.account_organization = org.map(str::to_string); + snapshot.account_email = usage.account_email.clone(); + snapshot.account_organization = usage.account_organization.clone(); + + for tok in [Some(token), None] { + assert_eq!( + forecast_account_key(&usage, tok).unwrap_or_default(), + quota_notification_account_identity(&snapshot, tok), + "identity drift for email={email:?} org={org:?} token={tok:?}" + ); + } + } +} + +#[test] +fn provider_timeout_error_message_is_plain_timeout() { + // The elapsed-timeout path reuses the provider-error path, which + // redacts the message; the user must still see "Timeout". + assert_eq!( + codexbar::logging::safe_error_message(codexbar::core::ProviderError::Timeout), + "Timeout" + ); +} + +/// Codex OAuth now publishes the auth.json email. Ambient Codex moves from +/// the unscoped key (process-local history only) to the lowercased email; +/// a managed token account still wins, and the header-only token-account +/// label never feeds any scoping key. +#[test] +fn codex_email_scopes_forecast_and_warnings_but_label_does_not() { + use crate::commands::bridge::forecast_account_key; + + let token = uuid::Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").unwrap(); + let mut usage = codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(1.0)); + let mut snapshot = empty_snapshot(); + snapshot.provider_id = "codex".to_string(); + + assert_eq!(forecast_account_key(&usage, None), None); + assert_eq!(quota_notification_account_identity(&snapshot, None), ""); + + snapshot.account_label = Some("Work account".to_string()); + assert_eq!(quota_notification_account_identity(&snapshot, None), ""); + + usage.account_email = Some("Parity.User@Example.com".to_string()); + snapshot.account_email = usage.account_email.clone(); + assert_eq!( + forecast_account_key(&usage, None).as_deref(), + Some("parity.user@example.com") + ); + assert_eq!( + quota_notification_account_identity(&snapshot, None), + "parity.user@example.com" + ); + assert_eq!( + forecast_account_key(&usage, Some(token)).as_deref(), + Some("token-account:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") + ); +} + +#[test] +fn header_label_trims_and_drops_blank_labels() { + assert_eq!( + header_label(" parity-github "), + Some("parity-github".to_string()) + ); + assert_eq!(header_label(" "), None); +} + +#[test] +fn limit_reset_sample_observes_session_and_weekly_lanes() { + let snapshot = limit_reset_snapshot(); + let identity = limit_reset_identity(&snapshot, None); + let sample = super::providers::limit_reset_sample( + ProviderId::Codex, + &snapshot, + &identity, + None, + "owner@example.com", + &Settings::default(), + ); + + let observed_at = chrono::DateTime::parse_from_rfc3339("2026-05-01T12:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc); + let session = sample.session.expect("session observed"); + assert_eq!(session.used_percent, 10.0); + assert_eq!(session.observed_at, observed_at); + assert_eq!( + session.reset_boundary, + Some( + chrono::DateTime::parse_from_rfc3339("2026-05-01T15:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc) + ) + ); + assert_eq!(sample.weekly.expect("weekly observed").used_percent, 40.0); + assert_eq!(sample.session_transition_account, "owner@example.com"); + let account = sample.account.expect("owner account"); + assert_eq!(account.key, "owner@example.com"); + assert_eq!(account.label.as_deref(), Some("owner@example.com")); +} + +#[test] +fn limit_reset_sample_prefers_token_label_and_hides_personal_info() { + let snapshot = limit_reset_snapshot(); + let identity = limit_reset_identity(&snapshot, Some(uuid::Uuid::nil())); + let label = |settings: &Settings| { + super::providers::limit_reset_sample( + ProviderId::Codex, + &snapshot, + &identity, + Some("Work"), + "", + settings, + ) + .account + .and_then(|account| account.label) + }; + assert_eq!(label(&Settings::default()).as_deref(), Some("Work")); + let hidden = Settings { + hide_personal_info: true, + ..Settings::default() + }; + assert_eq!(label(&hidden), None); +} + +#[test] +fn limit_reset_sample_skips_non_reset_lanes() { + let mut snapshot = limit_reset_snapshot(); + snapshot.primary.window_minutes = Some(1440); + if let Some(weekly) = snapshot.secondary.as_mut() { + weekly.is_informational = true; + } + let identity = limit_reset_identity(&snapshot, None); + let sample = super::providers::limit_reset_sample( + ProviderId::Codex, + &snapshot, + &identity, + None, + "", + &Settings::default(), + ); + assert!(sample.session.is_none()); + assert!(sample.weekly.is_none()); + + let mut snapshot = limit_reset_snapshot(); + snapshot.updated_at = "not a date".to_string(); + let identity = limit_reset_identity(&snapshot, None); + let sample = super::providers::limit_reset_sample( + ProviderId::Codex, + &snapshot, + &identity, + None, + "", + &Settings::default(), + ); + assert!(sample.session.is_none()); + assert!(sample.weekly.is_none()); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs index 77dd2b77a8b..22046f038e9 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs @@ -70,7 +70,7 @@ fn backfill_slot_window( return; }; // A stale reset is worse than a missing one. - let Ok(cached_dt) = chrono::DateTime::parse_from_rfc3339(cached_reset) else { + let Some(cached_dt) = crate::commands::parse_utc(cached_reset) else { return; }; let now = chrono::Utc::now(); @@ -103,18 +103,7 @@ mod tests { remaining_percent: 100.0 - used, window_minutes: Some(300), resets_at: resets_at.map(String::from), - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - monthly_limit_block: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, + ..Default::default() } } @@ -123,36 +112,8 @@ mod tests { provider_id: "codex".into(), display_name: "Codex".into(), primary, - primary_label: None, - secondary: None, - secondary_label: None, - secondary_label_key: None, - model_specific: None, - tertiary: None, - tertiary_label: None, - extra_rate_windows: Vec::new(), - inventory: Vec::new(), - display_details: Vec::new(), - cost: None, - plan_name: None, - account_email: None, - account_label: None, - subscription: None, - source_label: String::new(), - has_successful_claude_cli_quota: false, updated_at: "2026-01-01T00:00:00Z".into(), - error: None, - error_state: codexbar::core::ProviderStateKind::Ready, - pace: None, - pace_authoritative: true, - account_organization: None, - tray_status_label: None, - fetch_duration_ms: None, - wayfinder_usage: None, - quota_burndown: None, - open_ai_api_usage: None, - throughput: None, - session_equivalent_forecast: None, + ..Default::default() } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index abc32e8b02d..626b77fcced 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -845,751 +845,4 @@ pub async fn update_settings( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn preferred_currency_patch_accepts_supported_codes_and_rejects_unknown_codes() { - let mut settings = Settings::default(); - SettingsUpdate { - preferred_currency_code: Some("try".to_string()), - ..SettingsUpdate::default() - } - .apply_to(&mut settings) - .expect("TRY is supported"); - assert_eq!(settings.preferred_currency_code, "TRY"); - - let result = SettingsUpdate { - preferred_currency_code: Some("BTC".to_string()), - ..SettingsUpdate::default() - } - .apply_to(&mut settings); - assert!(matches!(result, Err(error) if error.contains("Unsupported preferred currency"))); - } - - #[test] - fn only_data_affecting_settings_refresh_providers() { - assert!( - SettingsUpdate { - enabled_providers: Some(vec!["codex".to_string()]), - ..Default::default() - } - .refreshes_provider_data() - ); - assert!( - SettingsUpdate { - claude_allow_reading_claude_code_credentials: Some(true), - ..Default::default() - } - .refreshes_provider_data() - ); - assert!( - !SettingsUpdate { - provider_metrics: Some(Default::default()), - tray_icon_mode: Some("single".to_string()), - ..Default::default() - } - .refreshes_provider_data() - ); - assert!( - !SettingsUpdate { - provider_hidden_usage_item_ids: Some( - [("codex".to_string(), Vec::new())].into_iter().collect(), - ), - ..Default::default() - } - .refreshes_provider_data() - ); - assert!( - !SettingsUpdate { - claude_daily_routines_usage_visible: Some(false), - ..Default::default() - } - .refreshes_provider_data() - ); - } - - #[test] - fn codex_options_and_quota_warnings_round_trip_through_the_bridge() { - let patch: SettingsUpdate = serde_json::from_value(serde_json::json!({ - "codexOpenaiWebExtras": false, - "codexHistoricalTracking": true, - "codexExternalOauthSourcesAllowed": true, - "codexLocalSessionCostLedgerEnabled": true, - "codexOpenaiWebBatterySaverEnabled": true, - "providerQuotaWarnings": { - "codex": { - "session": { "high": 60.0, "critical": 85.0 }, - "weekly": { "enabled": false } - }, - "not-a-provider": { "session": { "high": 10.0 } } - } - })) - .expect("bridge patch deserializes"); - assert!(patch.notifies_float_bar()); - assert!(patch.refreshes_tray_presentation()); - assert!(patch.refreshes_provider_data()); - - let mut settings = Settings::default(); - assert!( - settings.codex_openai_web_extras(), - "Windows default stays on" - ); - patch.apply_to(&mut settings).expect("apply"); - - let snapshot = serde_json::to_value(crate::commands::bridge::SettingsSnapshot::from( - settings.clone(), - )) - .expect("snapshot serializes"); - assert_eq!(snapshot["codexOpenaiWebExtras"], false); - assert_eq!(snapshot["codexHistoricalTracking"], true); - assert_eq!(snapshot["codexExternalOauthSourcesAllowed"], true); - assert_eq!(snapshot["codexLocalSessionCostLedgerEnabled"], true); - assert_eq!(snapshot["codexOpenaiWebBatterySaverEnabled"], true); - assert_eq!( - snapshot["providerQuotaWarnings"], - serde_json::json!({ - "codex": { - "session": { "high": 60.0, "critical": 85.0 }, - "weekly": { "enabled": false } - } - }) - ); - assert!(snapshot.get("providerUsageThresholds").is_none()); - assert!(!settings.quota_warning_enabled(codexbar::core::ProviderId::Codex, "weekly")); - - // An empty object returns the provider to Global. - let reset: SettingsUpdate = serde_json::from_value(serde_json::json!({ - "providerQuotaWarnings": { "codex": {} } - })) - .expect("reset patch"); - reset.apply_to(&mut settings).expect("apply reset"); - assert!( - settings - .quota_warnings(codexbar::core::ProviderId::Codex) - .is_empty() - ); - } - - #[test] - fn apply_advanced_settings_sets_claude_code_credentials_consent() { - let mut settings = Settings::default(); - assert!(!settings.claude_allow_reading_claude_code_credentials); - - SettingsUpdate { - claude_allow_reading_claude_code_credentials: Some(true), - ..Default::default() - } - .apply_advanced_settings(&mut settings); - assert!(settings.claude_allow_reading_claude_code_credentials); - - SettingsUpdate { - claude_allow_reading_claude_code_credentials: Some(false), - ..Default::default() - } - .apply_advanced_settings(&mut settings); - assert!(!settings.claude_allow_reading_claude_code_credentials); - } - - #[test] - fn apply_provider_usage_item_visibility_persists() { - let mut settings = Settings::default(); - SettingsUpdate { - provider_hidden_usage_item_ids: Some( - [("codex".to_string(), vec!["metric:secondary".to_string()])] - .into_iter() - .collect(), - ), - ..Default::default() - } - .apply_provider_settings(&mut settings); - - assert_eq!( - settings.hidden_usage_item_ids(ProviderId::Codex), - vec!["metric:secondary".to_string()] - ); - assert!(settings.codex_spark_usage_visible()); - } - - #[test] - fn copilot_seat_credit_update_distinguishes_missing_clear_and_value() { - let missing: SettingsUpdate = serde_json::from_str("{}").unwrap(); - assert_eq!(missing.copilot_seat_credit_entitlement, None); - - let clear: SettingsUpdate = - serde_json::from_str(r#"{"copilotSeatCreditEntitlement":null}"#).unwrap(); - assert_eq!(clear.copilot_seat_credit_entitlement, Some(None)); - - let value: SettingsUpdate = - serde_json::from_str(r#"{"copilotSeatCreditEntitlement":300}"#).unwrap(); - assert_eq!(value.copilot_seat_credit_entitlement, Some(Some(300.0))); - } - - #[test] - fn invalid_copilot_seat_credit_update_is_rejected_by_the_settings_setter() { - let mut settings = Settings::default(); - - let update: SettingsUpdate = - serde_json::from_str(r#"{"copilotSeatCreditEntitlement":-5}"#).unwrap(); - let error = update - .apply_to(&mut settings) - .expect_err("invalid allowance must be rejected"); - assert_eq!( - error, - "Copilot seat AI-credit allowance must be a finite number greater than zero" - ); - assert_eq!(settings.seat_credit_entitlement(ProviderId::Copilot), None); - } - - #[test] - fn cost_reporting_period_update_is_validated_and_applied() { - use codexbar::cost_reporting_period::CostReportingPeriod; - - let mut settings = Settings::default(); - let bad: SettingsUpdate = - serde_json::from_str(r#"{"costReportingPeriod":"rolling:0"}"#).unwrap(); - let error = bad - .apply_to(&mut settings) - .expect_err("zero-day window must be rejected"); - assert_eq!(error, "Invalid cost reporting period: rolling:0"); - - let good: SettingsUpdate = - serde_json::from_str(r#"{"costReportingPeriod":"month-to-date"}"#).unwrap(); - good.apply_to(&mut settings).expect("valid period applies"); - assert_eq!( - settings.cost_reporting_period, - CostReportingPeriod::MonthToDate - ); - } - - #[test] - fn spend_dashboard_updates_are_validated_and_applied() { - use codexbar::cost_reporting_period::CostReportingPeriod; - - let mut settings = Settings::default(); - let bad: SettingsUpdate = - serde_json::from_str(r#"{"spendDashboardPeriod":"fortnight"}"#).unwrap(); - let error = bad - .apply_to(&mut settings) - .expect_err("unknown dashboard period must be rejected"); - assert_eq!(error, "Invalid spend dashboard period: fortnight"); - - let good: SettingsUpdate = serde_json::from_str( - r#"{"spendDashboardPeriod":"rolling:90","spendDashboardHiddenSourceIds":["pi"," Codex","pi"]}"#, - ) - .unwrap(); - good.apply_to(&mut settings) - .expect("valid dashboard patch applies"); - assert_eq!( - settings.spend_dashboard_period, - CostReportingPeriod::Rolling(90) - ); - // The menu / float-bar period is a separate key. - assert_eq!( - settings.cost_reporting_period, - CostReportingPeriod::Rolling(30) - ); - assert_eq!(settings.spend_dashboard_hidden_source_ids, ["codex", "pi"]); - } - - #[test] - fn turning_cost_tracking_on_pins_the_bucket_zone_and_off_keeps_it() { - let mut settings = Settings { - cost_usage_enabled: false, - cost_usage_bucket_time_zone: String::new(), - ..Settings::default() - }; - let on: SettingsUpdate = serde_json::from_str(r#"{"costUsageEnabled":true}"#).unwrap(); - assert!(on.notifies_float_bar()); - on.apply_to(&mut settings).expect("enable applies"); - assert!(settings.cost_usage_enabled); - let mut expected = Settings { - cost_usage_bucket_time_zone: String::new(), - ..Settings::default() - }; - expected.pin_cost_usage_bucket_time_zone(); - assert_eq!( - settings.cost_usage_bucket_time_zone, - expected.cost_usage_bucket_time_zone - ); - - let off: SettingsUpdate = serde_json::from_str(r#"{"costUsageEnabled":false}"#).unwrap(); - off.apply_to(&mut settings).expect("disable applies"); - assert!(!settings.cost_usage_enabled); - assert_eq!( - settings.cost_usage_bucket_time_zone, - expected.cost_usage_bucket_time_zone - ); - } - - #[test] - fn display_settings_that_affect_tray_trigger_presentation_refresh() { - assert!( - SettingsUpdate { - switcher_shows_icons: Some(false), - ..Default::default() - } - .refreshes_tray_presentation() - ); - assert!( - SettingsUpdate { - reset_time_relative: Some(false), - ..Default::default() - } - .refreshes_tray_presentation() - ); - assert!( - SettingsUpdate { - menu_bar_color_pace: Some(true), - ..Default::default() - } - .refreshes_tray_presentation() - ); - assert!( - SettingsUpdate { - stacked_tray_top_provider: Some("claude".to_string()), - ..Default::default() - } - .refreshes_tray_presentation() - ); - } - - #[test] - fn apply_display_settings_updates_tray_pace_color() { - let mut settings = Settings::default(); - assert!(!settings.menu_bar_color_pace); - - SettingsUpdate { - menu_bar_color_pace: Some(true), - ..Default::default() - } - .apply_display_settings(&mut settings); - assert!(settings.menu_bar_color_pace); - - SettingsUpdate { - menu_bar_color_pace: Some(false), - ..Default::default() - } - .apply_display_settings(&mut settings); - assert!(!settings.menu_bar_color_pace); - } - - #[test] - fn apply_display_settings_updates_color_by_provider() { - let mut settings = Settings::default(); - assert!(!settings.menu_bar_color_by_provider); - - SettingsUpdate { - menu_bar_color_by_provider: Some(true), - ..Default::default() - } - .apply_display_settings(&mut settings); - assert!(settings.menu_bar_color_by_provider); - - SettingsUpdate { - menu_bar_color_by_provider: Some(false), - ..Default::default() - } - .apply_display_settings(&mut settings); - assert!(!settings.menu_bar_color_by_provider); - } - - #[test] - fn apply_display_settings_updates_pace_sign() { - let mut settings = Settings::default(); - assert!(!settings.menu_bar_pace_reserve_positive); - - let update: SettingsUpdate = - serde_json::from_str(r#"{"menuBarPaceReservePositive":true}"#).unwrap(); - update.apply_display_settings(&mut settings); - assert!(settings.menu_bar_pace_reserve_positive); - - SettingsUpdate { - menu_bar_pace_reserve_positive: Some(false), - ..Default::default() - } - .apply_display_settings(&mut settings); - assert!(!settings.menu_bar_pace_reserve_positive); - } - - #[test] - fn menu_bar_icon_style_update_repaints_tray_and_mirrors_legacy_flag() { - let mut settings = Settings::default(); - let update: SettingsUpdate = - serde_json::from_str(r#"{"menuBarIconStyle":"percent"}"#).unwrap(); - assert!(update.refreshes_tray_presentation()); - update.apply_display_settings(&mut settings); - assert_eq!( - settings.menu_bar_icon_style, - codexbar::settings::MenuBarIconStyle::Percent - ); - assert!(settings.menu_bar_shows_percent); - - let update: SettingsUpdate = - serde_json::from_str(r#"{"menuBarIconStyle":"bars"}"#).unwrap(); - update.apply_display_settings(&mut settings); - assert_eq!( - settings.menu_bar_icon_style, - codexbar::settings::MenuBarIconStyle::Bars - ); - assert!(!settings.menu_bar_shows_percent); - - // Progress bars stacked is not the percent style, so the legacy flag stays off. - let update: SettingsUpdate = - serde_json::from_str(r#"{"menuBarIconStyle":"stackedBars"}"#).unwrap(); - assert!(update.refreshes_tray_presentation()); - update.apply_display_settings(&mut settings); - assert_eq!( - settings.menu_bar_icon_style, - codexbar::settings::MenuBarIconStyle::StackedBars - ); - assert!(!settings.menu_bar_shows_percent); - } - - #[test] - fn menu_pane_keys_apply_from_camel_case_patch() { - let mut settings = Settings::default(); - let update: SettingsUpdate = serde_json::from_str( - r#"{"quotaWarningMarkersVisible":false,"workdayTickStyle":"high_contrast", - "highlightMenuCardsOnHover":false, - "mergedOverviewSelectedProviders":["claude","Codex","claude","gemini","cursor","copilot","zai","kimi"], - "weeklyProgressWorkDays":7}"#, - ) - .unwrap(); - update - .apply_display_settings(&mut settings) - .apply_advanced_settings(&mut settings); - assert!(!settings.quota_warning_markers_visible); - assert!(!settings.highlight_menu_cards_on_hover); - assert_eq!( - settings.workday_tick_style, - codexbar::settings::WorkdayTickStyle::HighContrast - ); - assert_eq!( - settings.merged_overview_selected_providers, - ["claude", "codex", "gemini", "cursor", "copilot", "zai"] - .map(String::from) - .to_vec() - ); - assert_eq!(settings.weekly_progress_work_days, Some(7)); - - let update: SettingsUpdate = - serde_json::from_str(r#"{"weeklyProgressWorkDays":8}"#).unwrap(); - update - .apply_display_settings(&mut settings) - .apply_advanced_settings(&mut settings); - assert_eq!(settings.weekly_progress_work_days, None); - - settings.weekly_progress_work_days = Some(5); - let update: SettingsUpdate = - serde_json::from_str(r#"{"weeklyProgressWorkDays":null}"#).unwrap(); - assert!(update.refreshes_provider_data()); - update - .apply_display_settings(&mut settings) - .apply_advanced_settings(&mut settings); - assert_eq!( - settings.weekly_progress_work_days, None, - "null selects Automatic" - ); - } - - #[test] - fn settings_snapshot_emits_snake_case_style_values() { - let settings = Settings { - menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::Percent, - workday_tick_style: codexbar::settings::WorkdayTickStyle::HighContrast, - ..Settings::default() - }; - let json = serde_json::to_value(crate::commands::SettingsSnapshot::from(settings)).unwrap(); - assert_eq!(json["menuBarIconStyle"], "percent"); - assert_eq!(json["workdayTickStyle"], "high_contrast"); - assert_eq!(json["quotaWarningMarkersVisible"], true); - assert_eq!(json["highlightMenuCardsOnHover"], true); - assert_eq!( - json["mergedOverviewSelectedProviders"], - serde_json::json!([]) - ); - } - - #[test] - fn providers_sidebar_sort_applies_without_touching_order_or_tray() { - let mut settings = Settings::default(); - let order = settings.provider_order.clone(); - let update: SettingsUpdate = - serde_json::from_str(r#"{"providersSortedAlphabetically":true}"#).unwrap(); - assert!(!update.refreshes_tray_presentation()); - update.apply_display_settings(&mut settings); - assert!(settings.providers_sorted_alphabetically); - assert_eq!(settings.provider_order, order); - } - - #[test] - fn stacked_tray_update_accepts_mode_and_clears_automatic_provider() { - let mut settings = Settings { - stacked_tray_top_provider: Some("codex".to_string()), - ..Settings::default() - }; - - SettingsUpdate { - tray_icon_mode: Some("stacked".to_string()), - stacked_tray_top_provider: Some(String::new()), - stacked_tray_bottom_provider: Some("claude".to_string()), - ..Default::default() - } - .apply_provider_settings(&mut settings); - - assert_eq!(settings.tray_icon_mode, TrayIconMode::Stacked); - assert_eq!(settings.stacked_tray_top_provider, None); - assert_eq!( - settings.stacked_tray_bottom_provider.as_deref(), - Some("claude") - ); - } - - #[test] - fn choosing_system_language_follows_the_system() { - let mut settings = Settings { - ui_language: Language::Spanish, - ui_language_follows_system: false, - ..Settings::default() - }; - let patch = SettingsUpdate { - ui_language_follows_system: Some(true), - ..Default::default() - }; - assert!(patch.rebuilds_tray_menu()); - assert!(patch.refreshes_tray_presentation()); - patch.apply_language_with(&mut settings, Language::Korean); - assert!(settings.ui_language_follows_system); - assert_eq!(settings.ui_language, Language::Korean); - } - - #[test] - fn choosing_a_specific_language_stops_following_the_system() { - let mut settings = Settings { - ui_language: Language::Korean, - ui_language_follows_system: true, - ..Settings::default() - }; - SettingsUpdate { - ui_language: Some("japanese".to_string()), - ..Default::default() - } - .apply_language_with(&mut settings, Language::Korean); - assert!(!settings.ui_language_follows_system); - assert_eq!(settings.ui_language, Language::Japanese); - - // An unrelated patch leaves the choice alone. - SettingsUpdate::default().apply_language_with(&mut settings, Language::Korean); - assert!(!settings.ui_language_follows_system); - assert_eq!(settings.ui_language, Language::Japanese); - } - - #[test] - fn ui_language_change_refreshes_tray_presentation() { - assert!( - SettingsUpdate { - ui_language: Some("japanese".to_string()), - ..Default::default() - } - .refreshes_tray_presentation() - ); - } - - #[test] - fn apply_display_settings_clamps_window_scale_percent() { - let mut settings = Settings::default(); - - SettingsUpdate { - window_scale_percent: Some(300), - ..Default::default() - } - .apply_display_settings(&mut settings); - assert_eq!(settings.window_scale_percent, 250); - - SettingsUpdate { - window_scale_percent: Some(50), - ..Default::default() - } - .apply_display_settings(&mut settings); - assert_eq!(settings.window_scale_percent, 100); - } - - #[test] - fn apply_display_settings_clamps_tray_scale_percent() { - let mut settings = Settings::default(); - - SettingsUpdate { - tray_scale_percent: Some(300), - ..Default::default() - } - .apply_display_settings(&mut settings); - assert_eq!(settings.tray_scale_percent, 200); - - SettingsUpdate { - tray_scale_percent: Some(50), - ..Default::default() - } - .apply_display_settings(&mut settings); - assert_eq!(settings.tray_scale_percent, 100); - } - - #[test] - fn apply_display_settings_updates_tray_panel_always_on_top() { - let mut settings = Settings::default(); - assert!(!settings.tray_panel_always_on_top); - - SettingsUpdate { - tray_panel_always_on_top: Some(true), - ..Default::default() - } - .apply_display_settings(&mut settings); - assert!(settings.tray_panel_always_on_top); - - SettingsUpdate { - tray_panel_always_on_top: Some(false), - ..Default::default() - } - .apply_display_settings(&mut settings); - assert!(!settings.tray_panel_always_on_top); - } - - #[test] - fn apply_notification_settings_updates_sound() { - let mut settings = Settings::default(); - - SettingsUpdate { - notification_sound_theme: Some(codexbar::settings::NotificationSoundTheme::CodexBar), - ..Default::default() - } - .apply_notification_settings(&mut settings) - .expect("apply sound theme"); - - assert_eq!( - settings.notification_sound_theme, - codexbar::settings::NotificationSoundTheme::CodexBar - ); - } - - #[test] - fn apply_notification_settings_rejects_invalid_custom_sound() { - let mut settings = Settings::default(); - let result = SettingsUpdate { - notification_sound_paths: Some(codexbar::settings::NotificationSoundPaths { - high_usage: Some("relative.wav".to_string()), - ..Default::default() - }), - ..Default::default() - } - .apply_notification_settings(&mut settings); - - assert!(result.is_err()); - assert_eq!( - settings.notification_sound_paths, - codexbar::settings::NotificationSoundPaths::default() - ); - } - - fn switcher_patch(json: &str) -> SettingsUpdate { - serde_json::from_str(&format!(r#"{{"switcherShortcuts":{json}}}"#)).unwrap() - } - - #[test] - fn switcher_shortcuts_patch_stores_normalized_non_default_overrides() { - let mut settings = Settings::default(); - switcher_patch(r#"{"select2":"Alt+Cmd+2","previous":"left","next":"none"}"#) - .apply_to(&mut settings) - .expect("valid overrides are stored"); - - assert_eq!( - settings.switcher_shortcuts, - std::collections::BTreeMap::from([ - ("select2".to_string(), "ctrl+alt+2".to_string()), - ("next".to_string(), "none".to_string()), - ]) - ); - } - - #[test] - fn switcher_shortcuts_empty_patch_restores_defaults() { - let mut settings = Settings::default(); - switcher_patch(r#"{"next":"shift+right"}"#) - .apply_to(&mut settings) - .unwrap(); - switcher_patch("{}").apply_to(&mut settings).unwrap(); - - assert!(settings.switcher_shortcuts.is_empty()); - } - - #[test] - fn switcher_shortcuts_patch_rejects_invalid_maps_and_keeps_stored_value() { - let mut settings = Settings::default(); - switcher_patch(r#"{"next":"shift+right"}"#) - .apply_to(&mut settings) - .unwrap(); - - for (json, message) in [ - ( - r#"{"bogus":"ctrl+1"}"#, - "Unknown switcher shortcut action: bogus", - ), - ( - r#"{"next":"left"}"#, - "Each switcher shortcut can be assigned to only one action", - ), - ( - r#"{"next":"ctrl+r"}"#, - "ctrl+r is reserved and cannot be used as a switcher shortcut", - ), - (r#"{"next":"f1"}"#, "f1 is not a valid switcher shortcut"), - ] { - let error = switcher_patch(json) - .apply_to(&mut settings) - .expect_err(json); - assert_eq!(error, message); - } - assert_eq!( - settings.switcher_shortcuts.get("next").map(String::as_str), - Some("shift+right") - ); - } - - #[test] - fn switcher_shortcuts_snapshot_exposes_the_fully_resolved_map() { - let settings = Settings { - switcher_shortcuts: std::collections::BTreeMap::from([( - "next".to_string(), - "none".to_string(), - )]), - ..Settings::default() - }; - let value = - serde_json::to_value(super::super::bridge::SettingsSnapshot::from(settings)).unwrap(); - let map = &value["switcherShortcuts"]; - - assert_eq!(map["next"], "none"); - assert_eq!(map["previous"], "left"); - assert_eq!(map["select9"], "ctrl+9"); - assert_eq!(map.as_object().unwrap().len(), 11); - } - - #[test] - fn limit_reset_toggle_travels_through_patch_and_snapshot() { - let snapshot = |settings: &Settings| { - serde_json::to_value(super::super::bridge::SettingsSnapshot::from( - settings.clone(), - )) - .unwrap()["limitResetNotificationsEnabled"] - .clone() - }; - let mut settings = Settings::default(); - assert_eq!(snapshot(&settings), serde_json::Value::Bool(false)); - - serde_json::from_str::(r#"{"limitResetNotificationsEnabled":true}"#) - .unwrap() - .apply_to(&mut settings) - .unwrap(); - assert!(settings.limit_reset_notifications_enabled); - assert_eq!(snapshot(&settings), serde_json::Value::Bool(true)); - } -} +mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/settings/tests.rs new file mode 100644 index 00000000000..36d92cb1b5a --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/settings/tests.rs @@ -0,0 +1,743 @@ +use super::*; + +#[test] +fn preferred_currency_patch_accepts_supported_codes_and_rejects_unknown_codes() { + let mut settings = Settings::default(); + SettingsUpdate { + preferred_currency_code: Some("try".to_string()), + ..SettingsUpdate::default() + } + .apply_to(&mut settings) + .expect("TRY is supported"); + assert_eq!(settings.preferred_currency_code, "TRY"); + + let result = SettingsUpdate { + preferred_currency_code: Some("BTC".to_string()), + ..SettingsUpdate::default() + } + .apply_to(&mut settings); + assert!(matches!(result, Err(error) if error.contains("Unsupported preferred currency"))); +} + +#[test] +fn only_data_affecting_settings_refresh_providers() { + assert!( + SettingsUpdate { + enabled_providers: Some(vec!["codex".to_string()]), + ..Default::default() + } + .refreshes_provider_data() + ); + assert!( + SettingsUpdate { + claude_allow_reading_claude_code_credentials: Some(true), + ..Default::default() + } + .refreshes_provider_data() + ); + assert!( + !SettingsUpdate { + provider_metrics: Some(Default::default()), + tray_icon_mode: Some("single".to_string()), + ..Default::default() + } + .refreshes_provider_data() + ); + assert!( + !SettingsUpdate { + provider_hidden_usage_item_ids: Some( + [("codex".to_string(), Vec::new())].into_iter().collect(), + ), + ..Default::default() + } + .refreshes_provider_data() + ); + assert!( + !SettingsUpdate { + claude_daily_routines_usage_visible: Some(false), + ..Default::default() + } + .refreshes_provider_data() + ); +} + +#[test] +fn codex_options_and_quota_warnings_round_trip_through_the_bridge() { + let patch: SettingsUpdate = serde_json::from_value(serde_json::json!({ + "codexOpenaiWebExtras": false, + "codexHistoricalTracking": true, + "codexExternalOauthSourcesAllowed": true, + "codexLocalSessionCostLedgerEnabled": true, + "codexOpenaiWebBatterySaverEnabled": true, + "providerQuotaWarnings": { + "codex": { + "session": { "high": 60.0, "critical": 85.0 }, + "weekly": { "enabled": false } + }, + "not-a-provider": { "session": { "high": 10.0 } } + } + })) + .expect("bridge patch deserializes"); + assert!(patch.notifies_float_bar()); + assert!(patch.refreshes_tray_presentation()); + assert!(patch.refreshes_provider_data()); + + let mut settings = Settings::default(); + assert!( + settings.codex_openai_web_extras(), + "Windows default stays on" + ); + patch.apply_to(&mut settings).expect("apply"); + + let snapshot = serde_json::to_value(crate::commands::bridge::SettingsSnapshot::from( + settings.clone(), + )) + .expect("snapshot serializes"); + assert_eq!(snapshot["codexOpenaiWebExtras"], false); + assert_eq!(snapshot["codexHistoricalTracking"], true); + assert_eq!(snapshot["codexExternalOauthSourcesAllowed"], true); + assert_eq!(snapshot["codexLocalSessionCostLedgerEnabled"], true); + assert_eq!(snapshot["codexOpenaiWebBatterySaverEnabled"], true); + assert_eq!( + snapshot["providerQuotaWarnings"], + serde_json::json!({ + "codex": { + "session": { "high": 60.0, "critical": 85.0 }, + "weekly": { "enabled": false } + } + }) + ); + assert!(snapshot.get("providerUsageThresholds").is_none()); + assert!(!settings.quota_warning_enabled(codexbar::core::ProviderId::Codex, "weekly")); + + // An empty object returns the provider to Global. + let reset: SettingsUpdate = serde_json::from_value(serde_json::json!({ + "providerQuotaWarnings": { "codex": {} } + })) + .expect("reset patch"); + reset.apply_to(&mut settings).expect("apply reset"); + assert!( + settings + .quota_warnings(codexbar::core::ProviderId::Codex) + .is_empty() + ); +} + +#[test] +fn apply_advanced_settings_sets_claude_code_credentials_consent() { + let mut settings = Settings::default(); + assert!(!settings.claude_allow_reading_claude_code_credentials); + + SettingsUpdate { + claude_allow_reading_claude_code_credentials: Some(true), + ..Default::default() + } + .apply_advanced_settings(&mut settings); + assert!(settings.claude_allow_reading_claude_code_credentials); + + SettingsUpdate { + claude_allow_reading_claude_code_credentials: Some(false), + ..Default::default() + } + .apply_advanced_settings(&mut settings); + assert!(!settings.claude_allow_reading_claude_code_credentials); +} + +#[test] +fn apply_provider_usage_item_visibility_persists() { + let mut settings = Settings::default(); + SettingsUpdate { + provider_hidden_usage_item_ids: Some( + [("codex".to_string(), vec!["metric:secondary".to_string()])] + .into_iter() + .collect(), + ), + ..Default::default() + } + .apply_provider_settings(&mut settings); + + assert_eq!( + settings.hidden_usage_item_ids(ProviderId::Codex), + vec!["metric:secondary".to_string()] + ); + assert!(settings.codex_spark_usage_visible()); +} + +#[test] +fn copilot_seat_credit_update_distinguishes_missing_clear_and_value() { + let missing: SettingsUpdate = serde_json::from_str("{}").unwrap(); + assert_eq!(missing.copilot_seat_credit_entitlement, None); + + let clear: SettingsUpdate = + serde_json::from_str(r#"{"copilotSeatCreditEntitlement":null}"#).unwrap(); + assert_eq!(clear.copilot_seat_credit_entitlement, Some(None)); + + let value: SettingsUpdate = + serde_json::from_str(r#"{"copilotSeatCreditEntitlement":300}"#).unwrap(); + assert_eq!(value.copilot_seat_credit_entitlement, Some(Some(300.0))); +} + +#[test] +fn invalid_copilot_seat_credit_update_is_rejected_by_the_settings_setter() { + let mut settings = Settings::default(); + + let update: SettingsUpdate = + serde_json::from_str(r#"{"copilotSeatCreditEntitlement":-5}"#).unwrap(); + let error = update + .apply_to(&mut settings) + .expect_err("invalid allowance must be rejected"); + assert_eq!( + error, + "Copilot seat AI-credit allowance must be a finite number greater than zero" + ); + assert_eq!(settings.seat_credit_entitlement(ProviderId::Copilot), None); +} + +#[test] +fn cost_reporting_period_update_is_validated_and_applied() { + use codexbar::cost_reporting_period::CostReportingPeriod; + + let mut settings = Settings::default(); + let bad: SettingsUpdate = + serde_json::from_str(r#"{"costReportingPeriod":"rolling:0"}"#).unwrap(); + let error = bad + .apply_to(&mut settings) + .expect_err("zero-day window must be rejected"); + assert_eq!(error, "Invalid cost reporting period: rolling:0"); + + let good: SettingsUpdate = + serde_json::from_str(r#"{"costReportingPeriod":"month-to-date"}"#).unwrap(); + good.apply_to(&mut settings).expect("valid period applies"); + assert_eq!( + settings.cost_reporting_period, + CostReportingPeriod::MonthToDate + ); +} + +#[test] +fn spend_dashboard_updates_are_validated_and_applied() { + use codexbar::cost_reporting_period::CostReportingPeriod; + + let mut settings = Settings::default(); + let bad: SettingsUpdate = + serde_json::from_str(r#"{"spendDashboardPeriod":"fortnight"}"#).unwrap(); + let error = bad + .apply_to(&mut settings) + .expect_err("unknown dashboard period must be rejected"); + assert_eq!(error, "Invalid spend dashboard period: fortnight"); + + let good: SettingsUpdate = serde_json::from_str( + r#"{"spendDashboardPeriod":"rolling:90","spendDashboardHiddenSourceIds":["pi"," Codex","pi"]}"#, + ) + .unwrap(); + good.apply_to(&mut settings) + .expect("valid dashboard patch applies"); + assert_eq!( + settings.spend_dashboard_period, + CostReportingPeriod::Rolling(90) + ); + // The menu / float-bar period is a separate key. + assert_eq!( + settings.cost_reporting_period, + CostReportingPeriod::Rolling(30) + ); + assert_eq!(settings.spend_dashboard_hidden_source_ids, ["codex", "pi"]); +} + +#[test] +fn turning_cost_tracking_on_pins_the_bucket_zone_and_off_keeps_it() { + let mut settings = Settings { + cost_usage_enabled: false, + cost_usage_bucket_time_zone: String::new(), + ..Settings::default() + }; + let on: SettingsUpdate = serde_json::from_str(r#"{"costUsageEnabled":true}"#).unwrap(); + assert!(on.notifies_float_bar()); + on.apply_to(&mut settings).expect("enable applies"); + assert!(settings.cost_usage_enabled); + let mut expected = Settings { + cost_usage_bucket_time_zone: String::new(), + ..Settings::default() + }; + expected.pin_cost_usage_bucket_time_zone(); + assert_eq!( + settings.cost_usage_bucket_time_zone, + expected.cost_usage_bucket_time_zone + ); + + let off: SettingsUpdate = serde_json::from_str(r#"{"costUsageEnabled":false}"#).unwrap(); + off.apply_to(&mut settings).expect("disable applies"); + assert!(!settings.cost_usage_enabled); + assert_eq!( + settings.cost_usage_bucket_time_zone, + expected.cost_usage_bucket_time_zone + ); +} + +#[test] +fn display_settings_that_affect_tray_trigger_presentation_refresh() { + assert!( + SettingsUpdate { + switcher_shows_icons: Some(false), + ..Default::default() + } + .refreshes_tray_presentation() + ); + assert!( + SettingsUpdate { + reset_time_relative: Some(false), + ..Default::default() + } + .refreshes_tray_presentation() + ); + assert!( + SettingsUpdate { + menu_bar_color_pace: Some(true), + ..Default::default() + } + .refreshes_tray_presentation() + ); + assert!( + SettingsUpdate { + stacked_tray_top_provider: Some("claude".to_string()), + ..Default::default() + } + .refreshes_tray_presentation() + ); +} + +#[test] +fn apply_display_settings_updates_tray_pace_color() { + let mut settings = Settings::default(); + assert!(!settings.menu_bar_color_pace); + + SettingsUpdate { + menu_bar_color_pace: Some(true), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert!(settings.menu_bar_color_pace); + + SettingsUpdate { + menu_bar_color_pace: Some(false), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert!(!settings.menu_bar_color_pace); +} + +#[test] +fn apply_display_settings_updates_color_by_provider() { + let mut settings = Settings::default(); + assert!(!settings.menu_bar_color_by_provider); + + SettingsUpdate { + menu_bar_color_by_provider: Some(true), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert!(settings.menu_bar_color_by_provider); + + SettingsUpdate { + menu_bar_color_by_provider: Some(false), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert!(!settings.menu_bar_color_by_provider); +} + +#[test] +fn apply_display_settings_updates_pace_sign() { + let mut settings = Settings::default(); + assert!(!settings.menu_bar_pace_reserve_positive); + + let update: SettingsUpdate = + serde_json::from_str(r#"{"menuBarPaceReservePositive":true}"#).unwrap(); + update.apply_display_settings(&mut settings); + assert!(settings.menu_bar_pace_reserve_positive); + + SettingsUpdate { + menu_bar_pace_reserve_positive: Some(false), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert!(!settings.menu_bar_pace_reserve_positive); +} + +#[test] +fn menu_bar_icon_style_update_repaints_tray_and_mirrors_legacy_flag() { + let mut settings = Settings::default(); + let update: SettingsUpdate = serde_json::from_str(r#"{"menuBarIconStyle":"percent"}"#).unwrap(); + assert!(update.refreshes_tray_presentation()); + update.apply_display_settings(&mut settings); + assert_eq!( + settings.menu_bar_icon_style, + codexbar::settings::MenuBarIconStyle::Percent + ); + assert!(settings.menu_bar_shows_percent); + + let update: SettingsUpdate = serde_json::from_str(r#"{"menuBarIconStyle":"bars"}"#).unwrap(); + update.apply_display_settings(&mut settings); + assert_eq!( + settings.menu_bar_icon_style, + codexbar::settings::MenuBarIconStyle::Bars + ); + assert!(!settings.menu_bar_shows_percent); + + // Progress bars stacked is not the percent style, so the legacy flag stays off. + let update: SettingsUpdate = + serde_json::from_str(r#"{"menuBarIconStyle":"stackedBars"}"#).unwrap(); + assert!(update.refreshes_tray_presentation()); + update.apply_display_settings(&mut settings); + assert_eq!( + settings.menu_bar_icon_style, + codexbar::settings::MenuBarIconStyle::StackedBars + ); + assert!(!settings.menu_bar_shows_percent); +} + +#[test] +fn menu_pane_keys_apply_from_camel_case_patch() { + let mut settings = Settings::default(); + let update: SettingsUpdate = serde_json::from_str( + r#"{"quotaWarningMarkersVisible":false,"workdayTickStyle":"high_contrast", + "highlightMenuCardsOnHover":false, + "mergedOverviewSelectedProviders":["claude","Codex","claude","gemini","cursor","copilot","zai","kimi"], + "weeklyProgressWorkDays":7}"#, + ) + .unwrap(); + update + .apply_display_settings(&mut settings) + .apply_advanced_settings(&mut settings); + assert!(!settings.quota_warning_markers_visible); + assert!(!settings.highlight_menu_cards_on_hover); + assert_eq!( + settings.workday_tick_style, + codexbar::settings::WorkdayTickStyle::HighContrast + ); + assert_eq!( + settings.merged_overview_selected_providers, + ["claude", "codex", "gemini", "cursor", "copilot", "zai"] + .map(String::from) + .to_vec() + ); + assert_eq!(settings.weekly_progress_work_days, Some(7)); + + let update: SettingsUpdate = serde_json::from_str(r#"{"weeklyProgressWorkDays":8}"#).unwrap(); + update + .apply_display_settings(&mut settings) + .apply_advanced_settings(&mut settings); + assert_eq!(settings.weekly_progress_work_days, None); + + settings.weekly_progress_work_days = Some(5); + let update: SettingsUpdate = + serde_json::from_str(r#"{"weeklyProgressWorkDays":null}"#).unwrap(); + assert!(update.refreshes_provider_data()); + update + .apply_display_settings(&mut settings) + .apply_advanced_settings(&mut settings); + assert_eq!( + settings.weekly_progress_work_days, None, + "null selects Automatic" + ); +} + +#[test] +fn settings_snapshot_emits_snake_case_style_values() { + let settings = Settings { + menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::Percent, + workday_tick_style: codexbar::settings::WorkdayTickStyle::HighContrast, + ..Settings::default() + }; + let json = serde_json::to_value(crate::commands::SettingsSnapshot::from(settings)).unwrap(); + assert_eq!(json["menuBarIconStyle"], "percent"); + assert_eq!(json["workdayTickStyle"], "high_contrast"); + assert_eq!(json["quotaWarningMarkersVisible"], true); + assert_eq!(json["highlightMenuCardsOnHover"], true); + assert_eq!( + json["mergedOverviewSelectedProviders"], + serde_json::json!([]) + ); +} + +#[test] +fn providers_sidebar_sort_applies_without_touching_order_or_tray() { + let mut settings = Settings::default(); + let order = settings.provider_order.clone(); + let update: SettingsUpdate = + serde_json::from_str(r#"{"providersSortedAlphabetically":true}"#).unwrap(); + assert!(!update.refreshes_tray_presentation()); + update.apply_display_settings(&mut settings); + assert!(settings.providers_sorted_alphabetically); + assert_eq!(settings.provider_order, order); +} + +#[test] +fn stacked_tray_update_accepts_mode_and_clears_automatic_provider() { + let mut settings = Settings { + stacked_tray_top_provider: Some("codex".to_string()), + ..Settings::default() + }; + + SettingsUpdate { + tray_icon_mode: Some("stacked".to_string()), + stacked_tray_top_provider: Some(String::new()), + stacked_tray_bottom_provider: Some("claude".to_string()), + ..Default::default() + } + .apply_provider_settings(&mut settings); + + assert_eq!(settings.tray_icon_mode, TrayIconMode::Stacked); + assert_eq!(settings.stacked_tray_top_provider, None); + assert_eq!( + settings.stacked_tray_bottom_provider.as_deref(), + Some("claude") + ); +} + +#[test] +fn choosing_system_language_follows_the_system() { + let mut settings = Settings { + ui_language: Language::Spanish, + ui_language_follows_system: false, + ..Settings::default() + }; + let patch = SettingsUpdate { + ui_language_follows_system: Some(true), + ..Default::default() + }; + assert!(patch.rebuilds_tray_menu()); + assert!(patch.refreshes_tray_presentation()); + patch.apply_language_with(&mut settings, Language::Korean); + assert!(settings.ui_language_follows_system); + assert_eq!(settings.ui_language, Language::Korean); +} + +#[test] +fn choosing_a_specific_language_stops_following_the_system() { + let mut settings = Settings { + ui_language: Language::Korean, + ui_language_follows_system: true, + ..Settings::default() + }; + SettingsUpdate { + ui_language: Some("japanese".to_string()), + ..Default::default() + } + .apply_language_with(&mut settings, Language::Korean); + assert!(!settings.ui_language_follows_system); + assert_eq!(settings.ui_language, Language::Japanese); + + // An unrelated patch leaves the choice alone. + SettingsUpdate::default().apply_language_with(&mut settings, Language::Korean); + assert!(!settings.ui_language_follows_system); + assert_eq!(settings.ui_language, Language::Japanese); +} + +#[test] +fn ui_language_change_refreshes_tray_presentation() { + assert!( + SettingsUpdate { + ui_language: Some("japanese".to_string()), + ..Default::default() + } + .refreshes_tray_presentation() + ); +} + +#[test] +fn apply_display_settings_clamps_window_scale_percent() { + let mut settings = Settings::default(); + + SettingsUpdate { + window_scale_percent: Some(300), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert_eq!(settings.window_scale_percent, 250); + + SettingsUpdate { + window_scale_percent: Some(50), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert_eq!(settings.window_scale_percent, 100); +} + +#[test] +fn apply_display_settings_clamps_tray_scale_percent() { + let mut settings = Settings::default(); + + SettingsUpdate { + tray_scale_percent: Some(300), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert_eq!(settings.tray_scale_percent, 200); + + SettingsUpdate { + tray_scale_percent: Some(50), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert_eq!(settings.tray_scale_percent, 100); +} + +#[test] +fn apply_display_settings_updates_tray_panel_always_on_top() { + let mut settings = Settings::default(); + assert!(!settings.tray_panel_always_on_top); + + SettingsUpdate { + tray_panel_always_on_top: Some(true), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert!(settings.tray_panel_always_on_top); + + SettingsUpdate { + tray_panel_always_on_top: Some(false), + ..Default::default() + } + .apply_display_settings(&mut settings); + assert!(!settings.tray_panel_always_on_top); +} + +#[test] +fn apply_notification_settings_updates_sound() { + let mut settings = Settings::default(); + + SettingsUpdate { + notification_sound_theme: Some(codexbar::settings::NotificationSoundTheme::CodexBar), + ..Default::default() + } + .apply_notification_settings(&mut settings) + .expect("apply sound theme"); + + assert_eq!( + settings.notification_sound_theme, + codexbar::settings::NotificationSoundTheme::CodexBar + ); +} + +#[test] +fn apply_notification_settings_rejects_invalid_custom_sound() { + let mut settings = Settings::default(); + let result = SettingsUpdate { + notification_sound_paths: Some(codexbar::settings::NotificationSoundPaths { + high_usage: Some("relative.wav".to_string()), + ..Default::default() + }), + ..Default::default() + } + .apply_notification_settings(&mut settings); + + assert!(result.is_err()); + assert_eq!( + settings.notification_sound_paths, + codexbar::settings::NotificationSoundPaths::default() + ); +} + +fn switcher_patch(json: &str) -> SettingsUpdate { + serde_json::from_str(&format!(r#"{{"switcherShortcuts":{json}}}"#)).unwrap() +} + +#[test] +fn switcher_shortcuts_patch_stores_normalized_non_default_overrides() { + let mut settings = Settings::default(); + switcher_patch(r#"{"select2":"Alt+Cmd+2","previous":"left","next":"none"}"#) + .apply_to(&mut settings) + .expect("valid overrides are stored"); + + assert_eq!( + settings.switcher_shortcuts, + std::collections::BTreeMap::from([ + ("select2".to_string(), "ctrl+alt+2".to_string()), + ("next".to_string(), "none".to_string()), + ]) + ); +} + +#[test] +fn switcher_shortcuts_empty_patch_restores_defaults() { + let mut settings = Settings::default(); + switcher_patch(r#"{"next":"shift+right"}"#) + .apply_to(&mut settings) + .unwrap(); + switcher_patch("{}").apply_to(&mut settings).unwrap(); + + assert!(settings.switcher_shortcuts.is_empty()); +} + +#[test] +fn switcher_shortcuts_patch_rejects_invalid_maps_and_keeps_stored_value() { + let mut settings = Settings::default(); + switcher_patch(r#"{"next":"shift+right"}"#) + .apply_to(&mut settings) + .unwrap(); + + for (json, message) in [ + ( + r#"{"bogus":"ctrl+1"}"#, + "Unknown switcher shortcut action: bogus", + ), + ( + r#"{"next":"left"}"#, + "Each switcher shortcut can be assigned to only one action", + ), + ( + r#"{"next":"ctrl+r"}"#, + "ctrl+r is reserved and cannot be used as a switcher shortcut", + ), + (r#"{"next":"f1"}"#, "f1 is not a valid switcher shortcut"), + ] { + let error = switcher_patch(json) + .apply_to(&mut settings) + .expect_err(json); + assert_eq!(error, message); + } + assert_eq!( + settings.switcher_shortcuts.get("next").map(String::as_str), + Some("shift+right") + ); +} + +#[test] +fn switcher_shortcuts_snapshot_exposes_the_fully_resolved_map() { + let settings = Settings { + switcher_shortcuts: std::collections::BTreeMap::from([( + "next".to_string(), + "none".to_string(), + )]), + ..Settings::default() + }; + let value = + serde_json::to_value(super::super::bridge::SettingsSnapshot::from(settings)).unwrap(); + let map = &value["switcherShortcuts"]; + + assert_eq!(map["next"], "none"); + assert_eq!(map["previous"], "left"); + assert_eq!(map["select9"], "ctrl+9"); + assert_eq!(map.as_object().unwrap().len(), 11); +} + +#[test] +fn limit_reset_toggle_travels_through_patch_and_snapshot() { + let snapshot = |settings: &Settings| { + serde_json::to_value(super::super::bridge::SettingsSnapshot::from( + settings.clone(), + )) + .unwrap()["limitResetNotificationsEnabled"] + .clone() + }; + let mut settings = Settings::default(); + assert_eq!(snapshot(&settings), serde_json::Value::Bool(false)); + + serde_json::from_str::(r#"{"limitResetNotificationsEnabled":true}"#) + .unwrap() + .apply_to(&mut settings) + .unwrap(); + assert!(settings.limit_reset_notifications_enabled); + assert_eq!(snapshot(&settings), serde_json::Value::Bool(true)); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 4dc5385b3de..d91bdcbd163 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use super::{ NamedRateWindowSnapshot, ProviderSummary, ProviderUsageSnapshot, provider_cookie_source_lookup, provider_region_lookup, validate_external_url, validate_surface_target, @@ -8,11 +6,16 @@ use crate::state::AppState; use crate::surface::SurfaceMode; use crate::surface_target::SurfaceTarget; use codexbar::core::{ - FetchContext, ProviderAccountData, ProviderDisplayDetail, ProviderError, ProviderFetchResult, - ProviderId, ProviderInventoryItem, SourceMode, TokenAccount, instantiate_provider, + FetchContext, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, + ProviderInventoryItem, instantiate_provider, }; use codexbar::host::session::launch_block_reason; -use codexbar::settings::{ApiKeys, Language, ManualCookies, Settings}; +use codexbar::settings::{Language, Settings}; + +mod cookie_region_options; +mod provider_cache; +mod provider_detail; +mod usage_items; #[test] fn validate_surface_target_accepts_matching_target() { @@ -341,2461 +344,270 @@ fn provider_cookie_source_set_rejects_unknown_provider() { } #[test] -fn fetch_context_defaults_to_manual_cookies_without_browser_import() { - let settings = Settings::default(); - let cookies = ManualCookies::default(); - let api_keys = ApiKeys::default(); - let token_accounts = HashMap::new(); - - let ctx = super::build_fetch_context( - ProviderId::Cursor, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); +fn provider_dashboard_url_uses_selected_regional_console() { + let mut settings = Settings::default(); + settings.set_api_region(ProviderId::MiniMax, "cn"); + settings.set_api_region(ProviderId::Kimi, "international"); - // Cursor does not support Cli; empty manual cookie remaps to Web (browser attempt). - assert_eq!(ctx.source_mode, SourceMode::Web); + assert_eq!( + super::provider_dashboard_url(ProviderId::MiniMax, &settings).as_deref(), + Some("https://platform.minimaxi.com/user-center/payment/coding-plan?cycle_type=3") + ); + assert_eq!( + super::provider_dashboard_url(ProviderId::Kimi, &settings).as_deref(), + Some("https://www.kimi.ai/code/console") + ); } +/// Provider metadata is the only source of the provider dashboard link; the +/// API-key catalog URL is the key-management link shown next to the key field. +/// A provider that only has a catalog URL must get a metadata URL instead of +/// silently borrowing the key page. #[test] -fn fetch_context_carries_the_optional_details_opt_in_for_its_own_provider() { - let mut settings = Settings::default(); - settings.set_optional_details_enabled(ProviderId::LiteLLM, true); - let build = |id| { - super::build_fetch_context( - id, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - &HashMap::new(), - ) - }; - - assert!(build(ProviderId::LiteLLM).optional_details_enabled); - assert!(!build(ProviderId::Codex).optional_details_enabled); +fn api_key_catalog_providers_have_metadata_dashboard_urls() { + let settings = Settings::default(); + for provider in codexbar::settings::get_api_key_providers() { + if provider.dashboard_url.is_some() { + assert!( + super::provider_dashboard_url(provider.id, &settings).is_some(), + "{:?} has an API-key page but no metadata dashboard URL", + provider.id + ); + } + } } #[test] -fn fetch_context_cursor_cookie_off_stays_cli() { - let mut settings = Settings::default(); - settings.set_cookie_source(ProviderId::Cursor, "off"); - let cookies = ManualCookies::default(); - let api_keys = ApiKeys::default(); - let token_accounts = HashMap::new(); - - let ctx = super::build_fetch_context( - ProviderId::Cursor, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - // Explicit cookie-off keeps Cli (no browser scrape). - assert_eq!(ctx.source_mode, SourceMode::Cli); - assert!(ctx.manual_cookie_header.is_none()); +fn provider_region_set_rejects_non_regional_provider() { + let mut s = Settings::default(); + let err = super::provider_region_set(&mut s, "claude", "global".into()).unwrap_err(); + assert!(err.contains("claude")); } #[test] -fn fetch_context_grok_cookie_off_preserves_auto_oauth() { - let mut settings = Settings::default(); - settings.set_cookie_source(ProviderId::Grok, "off"); - settings.set_usage_source(ProviderId::Grok, "auto"); - let ctx = super::build_fetch_context( - ProviderId::Grok, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - &HashMap::new(), - ); - - assert_eq!(ctx.source_mode, SourceMode::OAuth); - assert!(ctx.manual_cookie_header.is_none()); +fn launch_block_reason_helper_returns_none_when_not_blocked() { + assert!(launch_block_reason(false, false).is_none()); } #[test] -fn fetch_context_grok_cookie_off_preserves_explicit_oauth() { - let mut settings = Settings::default(); - settings.set_cookie_source(ProviderId::Grok, "off"); - settings.set_usage_source(ProviderId::Grok, "oauth"); - let ctx = super::build_fetch_context( - ProviderId::Grok, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - &HashMap::new(), - ); - - assert_eq!(ctx.source_mode, SourceMode::OAuth); - assert!(ctx.manual_cookie_header.is_none()); +fn launch_block_reason_helper_prefers_ssh() { + let msg = launch_block_reason(true, true).unwrap(); + assert!(msg.contains("SSH")); } #[test] -fn fetch_context_grok_cookie_off_keeps_explicit_cli() { - let mut settings = Settings::default(); - settings.set_cookie_source(ProviderId::Grok, "off"); - settings.set_usage_source(ProviderId::Grok, "cli"); - let ctx = super::build_fetch_context( - ProviderId::Grok, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - &HashMap::new(), +fn chart_data_serde_roundtrip_preserves_fields() { + use super::{ + DailyCostPoint, DailyTokenPoint, DailyUsageBreakdown, ProviderChartData, + QuotaWindowHistoryBridge, QuotaWindowHistoryPoint, ServiceUsagePoint, + }; + + let original = ProviderChartData { + provider_id: "codex".into(), + cost_history: vec![ + DailyCostPoint { + date: "2025-01-01".into(), + value: Some(1.25), + incomplete_request_count: None, + }, + DailyCostPoint { + date: "2025-01-02".into(), + value: Some(0.0), + incomplete_request_count: None, + }, + ], + credits_history: vec![DailyCostPoint { + date: "2025-01-01".into(), + value: Some(42.0), + incomplete_request_count: None, + }], + usage_breakdown: vec![DailyUsageBreakdown { + day: "2025-01-01".into(), + services: vec![ + ServiceUsagePoint { + service: "gpt-4o".into(), + credits_used: 10.0, + }, + ServiceUsagePoint { + service: "gpt-4o-mini".into(), + credits_used: 3.5, + }, + ], + total_credits_used: 13.5, + }], + local_usage: None, + tokens_history: vec![DailyTokenPoint { + date: "2025-01-01".into(), + tokens: 123_456, + }], + tokens_incomplete: true, + quota_window_history: Some(QuotaWindowHistoryBridge { + provider_id: "codex".into(), + account_scope: Some("person@example.com".into()), + windows: vec![QuotaWindowHistoryPoint { + offset: 0, + start: "2025-01-01T00:00:00Z".into(), + end: "2025-01-08T00:00:00Z".into(), + total_tokens: Some(123_456), + total_cost_usd: None, + tokens_are_complete: true, + cost_is_complete: false, + entry_count: 2, + boundaries_are_estimated: true, + }], + history_coverage_established: false, + }), + }; + + let json = serde_json::to_string(&original).expect("serialize"); + assert!( + json.contains("\"providerId\":\"codex\""), + "camelCase providerId: {json}" ); + assert!(json.contains("\"costHistory\"")); + assert!(json.contains("\"creditsHistory\"")); + assert!(json.contains("\"usageBreakdown\"")); + assert!(json.contains("\"localUsage\":null")); + assert!(json.contains("\"creditsUsed\":10.0")); + assert!(json.contains("\"totalCreditsUsed\":13.5")); + assert!(json.contains("\"tokensHistory\"")); + assert!(json.contains("\"tokens\":123456")); + assert!(json.contains("\"tokensIncomplete\":true")); + + let back: ProviderChartData = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.provider_id, "codex"); + assert_eq!(back.cost_history.len(), 2); + assert_eq!(back.cost_history[0].date, "2025-01-01"); + assert_eq!(back.credits_history[0].value, Some(42.0)); + assert_eq!(back.usage_breakdown[0].services.len(), 2); + assert_eq!(back.usage_breakdown[0].total_credits_used, 13.5); + assert_eq!(back.tokens_history[0].tokens, 123_456); + assert!(back.tokens_incomplete); + let history = back.quota_window_history.expect("quota history"); + assert_eq!(history.account_scope.as_deref(), Some("person@example.com")); + assert_eq!(history.windows[0].offset, 0); + assert!(history.windows[0].boundaries_are_estimated); + assert!(!history.windows[0].cost_is_complete); + assert!(!history.history_coverage_established); - assert_eq!(ctx.source_mode, SourceMode::Cli); - assert!(ctx.manual_cookie_header.is_none()); + let mut legacy = serde_json::to_value(&original).expect("serialize legacy fixture"); + legacy + .as_object_mut() + .expect("chart object") + .remove("quotaWindowHistory"); + let legacy_back: ProviderChartData = + serde_json::from_value(legacy).expect("legacy chart payload remains readable"); + assert!(legacy_back.quota_window_history.is_none()); } #[test] -fn fetch_context_grok_empty_manual_preserves_auto_without_browser_import() { - let mut settings = Settings::default(); - settings.set_cookie_source(ProviderId::Grok, "manual"); - settings.set_usage_source(ProviderId::Grok, "auto"); - let ctx = super::build_fetch_context( - ProviderId::Grok, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - &HashMap::new(), - ); - - assert_eq!(ctx.source_mode, SourceMode::Auto); - assert!(ctx.manual_cookie_header.is_none()); +fn chart_data_for_unknown_provider_is_empty() { + let data = + super::build_provider_chart_data("this-provider-definitely-does-not-exist".into(), None); + assert_eq!(data.provider_id, "this-provider-definitely-does-not-exist"); + assert!(data.credits_history.is_empty()); + assert!(data.usage_breakdown.is_empty()); } #[test] -fn fetch_context_grok_manual_cookie_keeps_auto_for_switched_login() { - let mut settings = Settings::default(); - settings.set_cookie_source(ProviderId::Grok, "manual"); - settings.set_usage_source(ProviderId::Grok, "auto"); - let mut cookies = ManualCookies::default(); - cookies.set("grok", "sso=other-account"); - let ctx = super::build_fetch_context( - ProviderId::Grok, - &settings, - &cookies, - &ApiKeys::default(), - &HashMap::new(), - ); +fn japanese_provider_snapshot_localizes_weekly_label() { + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let usage = codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)) + .with_secondary(codexbar::core::RateWindow::new(20.0)); + let result = ProviderFetchResult::new(usage, "OAuth"); - assert_eq!(ctx.source_mode, SourceMode::Auto); - assert_eq!( - ctx.manual_cookie_header.as_deref(), - Some("sso=other-account") + let snapshot = ProviderUsageSnapshot::from_fetch_result( + ProviderId::Claude, + &metadata, + &result, + None, + None, ); + + // Secondary label stays raw; localization happens at render time. + assert_eq!(snapshot.secondary_label, Some("Weekly".to_string())); } #[test] -fn fetch_context_grok_explicit_web_still_uses_manual_cookie() { - let mut settings = Settings::default(); - settings.set_cookie_source(ProviderId::Grok, "manual"); - settings.set_usage_source(ProviderId::Grok, "web"); - let mut cookies = ManualCookies::default(); - cookies.set("grok", "sso=browser-account"); - let ctx = super::build_fetch_context( - ProviderId::Grok, - &settings, - &cookies, - &ApiKeys::default(), - &HashMap::new(), +fn japanese_provider_snapshot_localizes_pace_reserve_description() { + use chrono::{Duration, Utc}; + + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let now = Utc::now(); + // 7-day window, half elapsed, 40% used → 10% ahead of pace, will last to reset. + let secondary = codexbar::core::RateWindow::with_details( + 40.0, + Some(7 * 24 * 60), + Some(now + Duration::minutes(7 * 24 * 60 / 2)), + None, ); + let usage = codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)) + .with_secondary(secondary); + let result = ProviderFetchResult::new(usage, "OAuth"); - assert_eq!(ctx.source_mode, SourceMode::Web); - assert_eq!( - ctx.manual_cookie_header.as_deref(), - Some("sso=browser-account") + let snapshot = ProviderUsageSnapshot::from_fetch_result( + ProviderId::Claude, + &metadata, + &result, + None, + None, ); + + // Reserve data stays raw; localization happens at render time. + let secondary = snapshot.secondary.as_ref().expect("secondary window"); + assert!(secondary.reserve_percent.is_some()); + assert!(secondary.reserve_will_last_to_reset); + assert!(secondary.reserve_description.is_none()); } #[test] -fn fetch_context_zed_default_and_stored_cookie_keep_editor_credential_lane() { - // Zed browser billing is opt-in: neither the default manual cookie source - // nor a stored cookie may turn Auto into Web. - let ctx = super::build_fetch_context( - ProviderId::Zed, - &Settings::default(), - &ManualCookies::default(), - &ApiKeys::default(), - &HashMap::new(), - ); - assert_eq!(ctx.source_mode, SourceMode::Auto); - assert!(ctx.manual_cookie_header.is_none()); - assert!(!ctx.manual_cookie_missing); - - let mut cookies = ManualCookies::default(); - cookies.set("zed", "zed.session=stored"); - let ctx = super::build_fetch_context( - ProviderId::Zed, - &Settings::default(), - &cookies, - &ApiKeys::default(), - &HashMap::new(), - ); - assert_eq!(ctx.source_mode, SourceMode::Auto); - assert!(ctx.manual_cookie_header.is_none()); +fn chart_data_requires_account_email_for_codex() { + let (credits_history, usage_breakdown) = + super::chart::load_openai_dashboard_chart_data_for_test("codex", None); + assert!(credits_history.is_empty()); + assert!(usage_breakdown.is_empty()); } -#[test] -fn fetch_context_zed_explicit_web_uses_stored_cookie() { - let mut settings = Settings::default(); - settings.set_usage_source(ProviderId::Zed, "web"); - let mut cookies = ManualCookies::default(); - cookies.set("zed", "zed.session=stored"); - let ctx = super::build_fetch_context( - ProviderId::Zed, - &settings, - &cookies, - &ApiKeys::default(), - &HashMap::new(), - ); +// ── Phase 6d — credential detection UIs ──────────────────────── - assert_eq!(ctx.source_mode, SourceMode::Web); - assert_eq!( - ctx.manual_cookie_header.as_deref(), - Some("zed.session=stored") - ); +#[test] +fn open_path_rejects_empty_path() { + let err = super::open_path(String::new()).unwrap_err(); + assert!(err.to_lowercase().contains("empty")); } #[test] -fn fetch_context_opencode_empty_manual_remaps_to_web() { - let settings = Settings::default(); - let cookies = ManualCookies::default(); - let api_keys = ApiKeys::default(); - let token_accounts = HashMap::new(); - - let ctx = super::build_fetch_context( - ProviderId::OpenCode, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); +fn open_path_rejects_relative_path() { + let err = super::open_path("relative/path".into()).unwrap_err(); + assert!(err.contains("absolute")); +} - assert_eq!(ctx.source_mode, SourceMode::Web); +#[test] +fn open_path_rejects_missing_path() { + let missing = std::env::temp_dir() + .join(format!("codexbar-phase6d-missing-{}", std::process::id())) + .join("does-not-exist"); + let err = super::open_path(missing.to_string_lossy().into_owned()).unwrap_err(); + assert!(err.contains("not found")); } #[test] -fn kimi_selected_account_forces_web_and_keeps_saved_region() { - let mut settings = Settings::default(); - settings.set_usage_source(ProviderId::Kimi, "oauth"); - settings.set_api_region(ProviderId::Kimi, "international"); - let mut accounts = HashMap::new(); - let mut data = ProviderAccountData::new(); - data.add_account(TokenAccount::new("Work", "selected-kimi-session")); - accounts.insert(ProviderId::Kimi, data); - - let ctx = super::build_fetch_context( - ProviderId::Kimi, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - &accounts, +fn external_url_validator_accepts_http_and_https() { + assert_eq!( + super::validate_external_url(" https://github.com/Finesssee/Win-CodexBar "), + Ok("https://github.com/Finesssee/Win-CodexBar") ); - - assert_eq!(ctx.source_mode, SourceMode::Web); assert_eq!( - ctx.manual_cookie_header.as_deref(), - Some("kimi-auth=selected-kimi-session") + super::validate_external_url("http://localhost:1420"), + Ok("http://localhost:1420") ); - assert_eq!(ctx.api_key, None); - assert_eq!(ctx.api_region.as_deref(), Some("international")); - assert!(ctx.token_account_isolated); - assert_eq!(settings.usage_source(ProviderId::Kimi), "oauth"); - assert_eq!(settings.api_region(ProviderId::Kimi), "international"); } #[test] -fn doubao_selected_account_forces_ark_api_and_ignores_saved_source() { - let mut settings = Settings::default(); - settings.set_usage_source(ProviderId::Doubao, "cli"); - let mut accounts = HashMap::new(); - let mut data = ProviderAccountData::new(); - data.add_account(TokenAccount::new("Work", "selected-ark-key")); - accounts.insert(ProviderId::Doubao, data); - - let ctx = super::build_fetch_context( - ProviderId::Doubao, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - &accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::OAuth); - assert_eq!(ctx.api_key.as_deref(), Some("selected-ark-key")); - assert!(ctx.token_account_isolated); -} - -#[test] -fn opencodego_selected_api_account_overrides_global_key_without_changing_explicit_source() { - let mut settings = Settings::default(); - settings.set_usage_source(ProviderId::OpenCodeGo, "auto"); - let mut keys = ApiKeys::default(); - keys.set("opencodego", "global-key", None); - let mut accounts = HashMap::new(); - let mut data = ProviderAccountData::new(); - data.add_account(TokenAccount::new("Work", "selected-account-key")); - accounts.insert(ProviderId::OpenCodeGo, data); - - let ctx = super::build_fetch_context( - ProviderId::OpenCodeGo, - &settings, - &ManualCookies::default(), - &keys, - &accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::Auto); - assert_eq!(ctx.api_key.as_deref(), Some("selected-account-key")); - assert!(!ctx.auto_prefer_web); - assert!(ctx.token_account_isolated); - - for cookie_source in ["off", "manual"] { - settings.set_cookie_source(ProviderId::OpenCodeGo, cookie_source); - settings.set_usage_source(ProviderId::OpenCodeGo, "auto"); - let auto_ctx = super::build_fetch_context( - ProviderId::OpenCodeGo, - &settings, - &ManualCookies::default(), - &keys, - &accounts, - ); - assert_eq!(auto_ctx.source_mode, SourceMode::Auto); - assert_eq!(auto_ctx.api_key.as_deref(), Some("selected-account-key")); - assert!(auto_ctx.manual_cookie_header.is_none()); - } - - for (saved_source, expected_source) in [("web", SourceMode::Web), ("cli", SourceMode::Cli)] { - settings.set_cookie_source(ProviderId::OpenCodeGo, "off"); - settings.set_usage_source(ProviderId::OpenCodeGo, saved_source); - let explicit_ctx = super::build_fetch_context( - ProviderId::OpenCodeGo, - &settings, - &ManualCookies::default(), - &keys, - &accounts, - ); - assert_eq!(explicit_ctx.source_mode, expected_source); - } -} - -#[test] -fn opencodego_cookie_source_off_forbids_provider_browser_discovery() { - let mut settings = Settings::default(); - settings.set_cookie_source(ProviderId::OpenCodeGo, "off"); - settings.set_usage_source(ProviderId::OpenCodeGo, "auto"); - let no_accounts = HashMap::new(); - - let without_key = super::build_fetch_context( - ProviderId::OpenCodeGo, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - &no_accounts, - ); - assert_eq!(without_key.source_mode, SourceMode::Cli); - assert!(without_key.manual_cookie_header.is_none()); - assert!(without_key.manual_cookie_missing); - - let mut keys = ApiKeys::default(); - keys.set("opencodego", "global-key", None); - let with_key = super::build_fetch_context( - ProviderId::OpenCodeGo, - &settings, - &ManualCookies::default(), - &keys, - &no_accounts, - ); - assert_eq!(with_key.source_mode, SourceMode::Auto); - assert!(with_key.manual_cookie_missing); - - // Automatic leaves discovery to the provider, which reads a browser only - // when the local estimate needs a session. - settings.set_cookie_source(ProviderId::OpenCodeGo, "auto"); - let auto = super::build_fetch_context( - ProviderId::OpenCodeGo, - &settings, - &ManualCookies::default(), - &keys, - &no_accounts, - ); - assert_eq!(auto.source_mode, SourceMode::Auto); - assert!(auto.manual_cookie_header.is_none()); - assert!(!auto.manual_cookie_missing); -} - -#[test] -fn opencodego_selected_cookie_account_uses_web_route() { - let settings = Settings::default(); - let mut accounts = HashMap::new(); - let mut data = ProviderAccountData::new(); - data.add_account(TokenAccount::new("Web", "Cookie: session=selected-session")); - accounts.insert(ProviderId::OpenCodeGo, data); - - let ctx = super::build_fetch_context( - ProviderId::OpenCodeGo, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - &accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::Web); - assert_eq!( - ctx.manual_cookie_header.as_deref(), - Some("Cookie: session=selected-session") - ); - assert_eq!( - ctx.token_account_kind, - Some(codexbar::core::TokenAccountKind::Cookie) - ); - assert!(ctx.token_account_isolated); -} - -#[test] -fn fetch_context_replicate_empty_manual_fails_closed_without_browser_import() { - let settings = Settings::default(); - let ctx = super::build_fetch_context( - ProviderId::Replicate, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - &HashMap::new(), - ); - - assert_eq!(ctx.source_mode, SourceMode::Web); - assert!(ctx.manual_cookie_header.is_none()); - assert!(ctx.manual_cookie_missing); -} - -#[test] -fn fetch_context_raycast_cookie_sources_never_import_in_the_shell() { - let build = |source: &str, stored: Option<&str>| { - let mut settings = Settings::default(); - settings.set_cookie_source(ProviderId::Raycast, source); - let mut cookies = ManualCookies::default(); - if let Some(stored) = stored { - cookies.set(ProviderId::Raycast.cli_name(), stored); - } - super::build_fetch_context( - ProviderId::Raycast, - &settings, - &cookies, - &ApiKeys::default(), - &HashMap::new(), - ) - }; - - // Manual with no stored header fails closed instead of using a browser. - let empty_manual = build("manual", None); - assert_eq!(empty_manual.source_mode, SourceMode::Web); - assert!(empty_manual.manual_cookie_header.is_none()); - assert!(empty_manual.manual_cookie_missing); - - // Off maps to the source the provider refuses, and never carries a header. - let off = build("off", Some("__raycast_session=stored")); - assert_eq!(off.source_mode, SourceMode::Cli); - assert!(off.manual_cookie_header.is_none()); - - // Auto leaves browser resolution to the provider (Chrome only). - let auto = build("auto", None); - assert_eq!(auto.source_mode, SourceMode::Auto); - assert!(auto.manual_cookie_header.is_none()); - assert!(!auto.manual_cookie_missing); - - let manual = build("manual", Some("__raycast_session=stored")); - assert_eq!(manual.source_mode, SourceMode::Web); - assert_eq!( - manual.manual_cookie_header.as_deref(), - Some("__raycast_session=stored") - ); -} - -#[test] -fn fetch_context_ollama_empty_manual_fails_closed_without_browser_import() { - let settings = Settings::default(); - let ctx = super::build_fetch_context( - ProviderId::Ollama, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - &HashMap::new(), - ); - - assert_eq!(ctx.source_mode, SourceMode::Web); - assert!(ctx.manual_cookie_header.is_none()); - assert!(ctx.manual_cookie_missing); -} - -#[test] -fn fetch_context_ollama_blank_manual_header_counts_as_missing() { - let mut cookies = ManualCookies::default(); - cookies.set(ProviderId::Ollama.cli_name(), " "); - let ctx = super::build_fetch_context( - ProviderId::Ollama, - &Settings::default(), - &cookies, - &ApiKeys::default(), - &HashMap::new(), - ); - - assert!(ctx.manual_cookie_missing); -} - -#[test] -fn fetch_context_ollama_pasted_header_is_not_reported_missing() { - let mut cookies = ManualCookies::default(); - cookies.set(ProviderId::Ollama.cli_name(), "__Secure-session=abc"); - let ctx = super::build_fetch_context( - ProviderId::Ollama, - &Settings::default(), - &cookies, - &ApiKeys::default(), - &HashMap::new(), - ); - - assert_eq!(ctx.source_mode, SourceMode::Web); - assert_eq!( - ctx.manual_cookie_header.as_deref(), - Some("__Secure-session=abc") - ); - assert!(!ctx.manual_cookie_missing); -} - -#[test] -fn fetch_context_ollama_auto_source_never_reports_manual_cookie_missing() { - let mut settings = Settings::default(); - settings.set_cookie_source(ProviderId::Ollama, "auto"); - let ctx = super::build_fetch_context( - ProviderId::Ollama, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - &HashMap::new(), - ); - - assert!(!ctx.manual_cookie_missing); -} - -#[test] -fn fetch_context_codex_manual_cookie_never_forces_unsupported_web() { - // Default cookie source is "manual". Pasting a chatgpt.com cookie used to flip - // Codex into SourceMode::Web, which CodexProvider rejects with - // "Source mode 'Web' not supported for this provider" on every refresh. - let settings = Settings::default(); - let mut cookies = ManualCookies::default(); - cookies.set( - ProviderId::Codex.cli_name(), - "oai-did=abc; __Secure-next-auth.session-token=xyz", - ); - - let ctx = super::build_fetch_context( - ProviderId::Codex, - &settings, - &cookies, - &ApiKeys::default(), - &HashMap::new(), - ); - - assert_eq!(ctx.source_mode, SourceMode::Auto); - assert!( - instantiate_provider(ProviderId::Codex) - .available_sources() - .contains(&ctx.source_mode) - ); -} - -#[test] -fn fetch_context_codex_manual_cookie_keeps_explicit_supported_source() { - let mut settings = Settings::default(); - settings.set_usage_source(ProviderId::Codex, "oauth"); - let mut cookies = ManualCookies::default(); - cookies.set(ProviderId::Codex.cli_name(), "oai-did=abc"); - - let ctx = super::build_fetch_context( - ProviderId::Codex, - &settings, - &cookies, - &ApiKeys::default(), - &HashMap::new(), - ); - - assert_eq!(ctx.source_mode, SourceMode::OAuth); -} - -#[test] -fn fetch_context_claude_uses_oauth_without_manual_cookie() { - let settings = Settings::default(); - let cookies = ManualCookies::default(); - let api_keys = ApiKeys::default(); - let token_accounts = HashMap::new(); - - let ctx = super::build_fetch_context( - ProviderId::Claude, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::OAuth); - assert!(ctx.manual_cookie_header.is_none()); -} - -#[test] -fn fetch_context_claude_web_source_defers_cookie_resolution_to_provider() { - let mut settings = Settings::default(); - settings.set_cookie_source(ProviderId::Claude, "browser"); - settings.set_usage_source(ProviderId::Claude, "web"); - - let ctx = super::build_fetch_context( - ProviderId::Claude, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - &HashMap::new(), - ); - - assert_eq!(ctx.source_mode, SourceMode::Web); - assert!(ctx.manual_cookie_header.is_none()); -} - -#[test] -fn fetch_context_claude_explicit_cli_source_still_uses_cli() { - let mut settings = Settings::default(); - settings.set_usage_source(ProviderId::Claude, "cli"); - let cookies = ManualCookies::default(); - let api_keys = ApiKeys::default(); - let token_accounts = HashMap::new(); - - let ctx = super::build_fetch_context( - ProviderId::Claude, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::Cli); - assert!(ctx.manual_cookie_header.is_none()); -} - -#[test] -fn fetch_context_manual_cookie_uses_web_without_browser_import() { - let settings = Settings::default(); - let mut cookies = ManualCookies::default(); - cookies.set("cursor", "session=abc123"); - let api_keys = ApiKeys::default(); - let token_accounts = HashMap::new(); - - let ctx = super::build_fetch_context( - ProviderId::Cursor, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::Web); - assert_eq!(ctx.manual_cookie_header.as_deref(), Some("session=abc123")); -} - -#[test] -fn fetch_context_api_key_provider_uses_auto_without_cookie_import() { - let settings = Settings::default(); - let cookies = ManualCookies::default(); - let mut api_keys = ApiKeys::default(); - api_keys.set("deepseek", "sk-test", None); - let token_accounts = HashMap::new(); - - let ctx = super::build_fetch_context( - ProviderId::DeepSeek, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::Auto); - assert!(ctx.manual_cookie_header.is_none()); - assert_eq!(ctx.api_key.as_deref(), Some("sk-test")); -} - -#[test] -fn fetch_context_kimi_api_key_preserves_auto_for_web_fallback() { - let settings = Settings::default(); - let cookies = ManualCookies::default(); - let mut api_keys = ApiKeys::default(); - api_keys.set("kimi", "sk-kimi-test", None); - let token_accounts = HashMap::new(); - - let ctx = super::build_fetch_context( - ProviderId::Kimi, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::Auto); - assert!(ctx.manual_cookie_header.is_none()); - assert_eq!(ctx.api_key.as_deref(), Some("sk-kimi-test")); -} - -#[test] -fn fetch_context_opencodego_api_key_preserves_auto_for_api_overlay() { - let settings = Settings::default(); - let cookies = ManualCookies::default(); - let mut api_keys = ApiKeys::default(); - api_keys.set("opencodego", "go-test", None); - let token_accounts = HashMap::new(); - - let ctx = super::build_fetch_context( - ProviderId::OpenCodeGo, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::Auto); - assert!(ctx.manual_cookie_header.is_none()); - assert_eq!(ctx.api_key.as_deref(), Some("go-test")); -} - -#[test] -fn fetch_context_includes_minimax_region() { - let mut settings = Settings::default(); - settings.set_api_region(ProviderId::MiniMax, "cn"); - let cookies = ManualCookies::default(); - let api_keys = ApiKeys::default(); - let token_accounts = HashMap::new(); - - let ctx = super::build_fetch_context( - ProviderId::MiniMax, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.api_region.as_deref(), Some("cn")); -} - -#[test] -fn provider_dashboard_url_uses_selected_regional_console() { - let mut settings = Settings::default(); - settings.set_api_region(ProviderId::MiniMax, "cn"); - settings.set_api_region(ProviderId::Kimi, "international"); - - assert_eq!( - super::provider_dashboard_url(ProviderId::MiniMax, &settings).as_deref(), - Some("https://platform.minimaxi.com/user-center/payment/coding-plan?cycle_type=3") - ); - assert_eq!( - super::provider_dashboard_url(ProviderId::Kimi, &settings).as_deref(), - Some("https://www.kimi.ai/code/console") - ); -} - -/// Provider metadata is the only source of the provider dashboard link; the -/// API-key catalog URL is the key-management link shown next to the key field. -/// A provider that only has a catalog URL must get a metadata URL instead of -/// silently borrowing the key page. -#[test] -fn api_key_catalog_providers_have_metadata_dashboard_urls() { - let settings = Settings::default(); - for provider in codexbar::settings::get_api_key_providers() { - if provider.dashboard_url.is_some() { - assert!( - super::provider_dashboard_url(provider.id, &settings).is_some(), - "{:?} has an API-key page but no metadata dashboard URL", - provider.id - ); - } - } -} - -#[test] -fn fetch_context_token_account_uses_web_cookie_header() { - let settings = Settings::default(); - let cookies = ManualCookies::default(); - let api_keys = ApiKeys::default(); - let mut token_accounts = HashMap::new(); - let mut data = ProviderAccountData::new(); - data.add_account(TokenAccount::new("Work", "abc123")); - token_accounts.insert(ProviderId::Ollama, data); - - let ctx = super::build_fetch_context( - ProviderId::Ollama, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::Web); - assert_eq!( - ctx.manual_cookie_header.as_deref(), - Some("__Secure-session=abc123") - ); -} - -#[test] -fn fetch_context_claude_manual_cookie_beats_active_oauth_token_account() { - let mut settings = Settings::default(); - settings.set_cookie_source(ProviderId::Claude, "manual"); - settings.set_usage_source(ProviderId::Claude, "auto"); - let mut cookies = ManualCookies::default(); - cookies.set("claude", "sessionKey=manual-session"); - let api_keys = ApiKeys::default(); - let mut token_accounts = HashMap::new(); - let mut data = ProviderAccountData::new(); - data.add_account(TokenAccount::new("Claude OAuth", "[REDACTED_SECRET]")); - token_accounts.insert(ProviderId::Claude, data); - - let ctx = super::build_fetch_context( - ProviderId::Claude, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::Web); - assert_eq!( - ctx.manual_cookie_header.as_deref(), - Some("sessionKey=manual-session") - ); -} - -#[test] -fn fetch_context_claude_oauth_token_account_uses_oauth() { - let settings = Settings::default(); - let cookies = ManualCookies::default(); - let api_keys = ApiKeys::default(); - let mut token_accounts = HashMap::new(); - let mut data = ProviderAccountData::new(); - data.add_account(TokenAccount::new("Claude OAuth", "sk-ant-oat01-abc123")); - token_accounts.insert(ProviderId::Claude, data); - - let ctx = super::build_fetch_context( - ProviderId::Claude, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::OAuth); - assert!(ctx.manual_cookie_header.is_none()); - assert_eq!(ctx.api_key.as_deref(), Some("sk-ant-oat01-abc123")); -} - -#[test] -fn fetch_context_copilot_token_account_uses_oauth_api_key() { - let settings = Settings::default(); - let cookies = ManualCookies::default(); - let api_keys = ApiKeys::default(); - let mut token_accounts = HashMap::new(); - let mut data = ProviderAccountData::new(); - data.add_account(TokenAccount::new("GitHub", "gho_testtoken")); - token_accounts.insert(ProviderId::Copilot, data); - - let ctx = super::build_fetch_context( - ProviderId::Copilot, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::OAuth); - assert!(ctx.manual_cookie_header.is_none()); - assert_eq!(ctx.api_key.as_deref(), Some("gho_testtoken")); -} - -#[test] -fn fetch_context_claude_session_token_account_uses_web_cookie() { - let settings = Settings::default(); - let cookies = ManualCookies::default(); - let api_keys = ApiKeys::default(); - let mut token_accounts = HashMap::new(); - let mut data = ProviderAccountData::new(); - data.add_account(TokenAccount::new("Claude Web", "sessionKey=abc123")); - token_accounts.insert(ProviderId::Claude, data); - - let ctx = super::build_fetch_context( - ProviderId::Claude, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::Web); - assert_eq!( - ctx.manual_cookie_header.as_deref(), - Some("sessionKey=abc123") - ); - assert!(ctx.api_key.is_none()); -} - -#[test] -fn fetch_context_token_account_takes_precedence_over_manual_cookie() { - let settings = Settings::default(); - let mut cookies = ManualCookies::default(); - cookies.set("cursor", "manual=old"); - let api_keys = ApiKeys::default(); - let mut token_accounts = HashMap::new(); - let mut data = ProviderAccountData::new(); - data.add_account(TokenAccount::new("Work", "WorkosCursorSessionToken=new")); - token_accounts.insert(ProviderId::Cursor, data); - - let ctx = super::build_fetch_context( - ProviderId::Cursor, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::Web); - assert_eq!( - ctx.manual_cookie_header.as_deref(), - Some("WorkosCursorSessionToken=new") - ); -} - -#[test] -fn fetch_context_openrouter_token_account_overrides_stored_api_key() { - let settings = Settings::default(); - let cookies = ManualCookies::default(); - let mut api_keys = ApiKeys::default(); - api_keys.set("openrouter", "sk-or-v1-stored-decoy", None); - let mut token_accounts = HashMap::new(); - let mut data = ProviderAccountData::new(); - data.add_account(TokenAccount::new("Personal", "sk-or-v1-personal")); - data.add_account(TokenAccount::new("Work", "sk-or-v1-work")); - data.set_active(1); - token_accounts.insert(ProviderId::OpenRouter, data); - - let ctx = super::build_fetch_context( - ProviderId::OpenRouter, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.source_mode, SourceMode::OAuth); - assert!(ctx.manual_cookie_header.is_none()); - assert_eq!(ctx.api_key.as_deref(), Some("sk-or-v1-work")); -} - -#[test] -fn fetch_context_openrouter_falls_back_to_stored_api_key_without_token_accounts() { - let settings = Settings::default(); - let cookies = ManualCookies::default(); - let mut api_keys = ApiKeys::default(); - api_keys.set("openrouter", "sk-or-v1-stored", None); - let token_accounts = HashMap::new(); - - let ctx = super::build_fetch_context( - ProviderId::OpenRouter, - &settings, - &cookies, - &api_keys, - &token_accounts, - ); - - assert_eq!(ctx.api_key.as_deref(), Some("sk-or-v1-stored")); -} - -#[test] -fn provider_region_set_rejects_non_regional_provider() { - let mut s = Settings::default(); - let err = super::provider_region_set(&mut s, "claude", "global".into()).unwrap_err(); - assert!(err.contains("claude")); -} - -#[test] -fn launch_block_reason_helper_returns_none_when_not_blocked() { - assert!(launch_block_reason(false, false).is_none()); -} - -#[test] -fn launch_block_reason_helper_prefers_ssh() { - let msg = launch_block_reason(true, true).unwrap(); - assert!(msg.contains("SSH")); -} - -// ── Phase 6b — provider detail pane ──────────────────────────── - -#[test] -fn build_provider_detail_populates_identity_urls() { - let (detail, _settings, _id) = super::build_provider_detail("claude").expect("known provider"); - assert_eq!(detail.id, "claude"); - assert_eq!(detail.display_name, "Claude"); - // Claude advertises a status page URL in its metadata. - assert!(detail.status_page_url.is_some()); - // No snapshot yet — empty usage bars and no error. - assert!(detail.session.is_none()); - assert!(detail.last_error.is_none()); - assert!(!detail.has_snapshot); -} - -#[test] -fn build_provider_detail_rejects_unknown_provider() { - let err = super::build_provider_detail("not-a-provider").unwrap_err(); - assert!(err.contains("not-a-provider")); -} - -#[test] -fn provider_detail_roundtrips_through_serde() { - let (detail, _settings, _id) = super::build_provider_detail("codex").expect("known provider"); - let json = serde_json::to_string(&detail).expect("serialize"); - // camelCase rename survives the round-trip. - assert!(json.contains("\"displayName\"")); - assert!(json.contains("\"hasSnapshot\"")); - assert!(json.contains("\"statusPageUrl\"")); -} - -#[test] -fn provider_detail_carries_openai_daily_usage_only_when_present() { - let (mut detail, _settings, _id) = - super::build_provider_detail("openaiapi").expect("known provider"); - assert!(detail.open_ai_api_usage.is_none()); - let json = serde_json::to_string(&detail).expect("serialize"); - assert!(!json.contains("openAiApiUsage")); - - detail.open_ai_api_usage = Some(super::OpenAiApiUsageSnapshot { - history_days: 30, - project_id: None, - daily: Vec::new(), - }); - let json = serde_json::to_string(&detail).expect("serialize"); - assert!(json.contains("\"openAiApiUsage\":{\"historyDays\":30")); -} - -#[test] -fn usage_item_descriptors_keep_raw_ids_and_redact_titles() { - let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), - cost: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: "OAuth".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - }; - let mut snapshot = - ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); - snapshot.primary_label = Some("Account owner@example.com".to_string()); - snapshot.extra_rate_windows = vec![NamedRateWindowSnapshot { - id: "credits".to_string(), - title: "Credits owner@example.com".to_string(), - window: snapshot.primary.clone(), - fallback_lane: false, - icon_fallback: None, - }]; - - let mut settings = Settings { - hide_personal_info: true, - ..Settings::default() - }; - settings.set_hidden_usage_item_ids(ProviderId::Codex, vec!["metric:extra-missing".to_string()]); - - let items = super::usage_item_descriptors(Some(&snapshot), &settings, ProviderId::Codex); - - assert_eq!(items[0].id, "metric:primary"); - assert_eq!(items[0].title, "Account"); - assert_eq!(items[1].id, "metric:extra-credits"); - assert_eq!(items[1].title, "Credits"); - assert_eq!(items[2].id, "metric:extra-missing"); - assert!(!items[2].available); -} - -#[test] -fn usage_item_descriptors_include_detail_sections() { - let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), - cost: None, - wayfinder_usage: None, - inventory: Vec::new(), - reset_credits: None, - display_details: vec![ - codexbar::core::ProviderDisplayDetail::new("d1", "Rate limits", "5") - .expect("valid detail"), - codexbar::core::ProviderDisplayDetail::new("d2", "Rate limits", "6") - .expect("valid detail"), - codexbar::core::ProviderDisplayDetail::new("d3", "owner@example.com quota", "1") - .expect("valid detail"), - ], - source_label: "OAuth".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - open_ai_api_usage: None, - throughput: None, - last_good_owner: None, - }; - let snapshot = - ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); - - let mut settings = Settings { - hide_personal_info: true, - ..Settings::default() - }; - settings.set_hidden_usage_item_ids( - ProviderId::Codex, - vec!["detailSection:owner@example.com quota".to_string()], - ); - - let items = super::usage_item_descriptors(Some(&snapshot), &settings, ProviderId::Codex); - - // First three items are the distinct detail sections, redacted titles. - assert_eq!(items[0].id, "detailSection:Rate limits"); - assert_eq!(items[0].title, "Rate limits"); - assert!(items[0].available); - // Duplicate title is deduped to one descriptor; the email title is redacted. - assert!( - !items[1..] - .iter() - .any(|item| item.title.contains("owner@example.com")) - ); - // The hidden detail section is a placeholder, redacted in the stored ID. - let hidden = items - .iter() - .find(|item| item.id.starts_with("detailSection:") && !item.available) - .expect("hidden detail placeholder"); - assert!(hidden.id.contains("owner@example.com quota")); - assert_eq!(hidden.title, "quota"); -} - -#[test] -fn usage_item_descriptors_skip_card_hidden_windows() { - let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new( - codexbar::core::RateWindow::informational("$1.00").hidden_on_card(), - ) - .with_secondary(codexbar::core::RateWindow::new(20.0)), - cost: None, - wayfinder_usage: None, - inventory: Vec::new(), - reset_credits: None, - display_details: vec![ - codexbar::core::ProviderDisplayDetail::new("spend", "Spend", "$1.00") - .expect("valid detail"), - ], - source_label: "api".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - open_ai_api_usage: None, - throughput: None, - last_good_owner: None, - }; - let mut snapshot = - ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); - let mut hidden_extra = snapshot.primary.clone(); - hidden_extra.hidden_on_card = true; - snapshot.extra_rate_windows = vec![NamedRateWindowSnapshot { - id: "hidden".to_string(), - title: "Hidden".to_string(), - window: hidden_extra, - fallback_lane: false, - icon_fallback: None, - }]; - - let items = - super::usage_item_descriptors(Some(&snapshot), &Settings::default(), ProviderId::Codex); - let ids: Vec<&str> = items.iter().map(|item| item.id.as_str()).collect(); - - assert_eq!(ids, ["detailSection:Spend", "metric:secondary"]); - - // A saved hidden ID for a card-hidden window gets no placeholder, while - // one for a window the snapshot no longer reports still does. - let mut settings = Settings::default(); - settings.set_hidden_usage_item_ids( - ProviderId::Codex, - vec![ - "metric:primary".to_string(), - "metric:extra-hidden".to_string(), - "metric:tertiary".to_string(), - ], - ); - let items = super::usage_item_descriptors(Some(&snapshot), &settings, ProviderId::Codex); - let ids: Vec<(&str, bool)> = items - .iter() - .map(|item| (item.id.as_str(), item.available)) - .collect(); - assert_eq!( - ids, - [ - ("detailSection:Spend", true), - ("metric:secondary", true), - ("metric:tertiary", false), - ] - ); -} - -fn claude_snapshot_with_sections(source_label: &str) -> ProviderUsageSnapshot { - let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), - cost: Some(codexbar::core::CostSnapshot::new(0.0, "USD", "Extra usage").with_balance(5.0)), - wayfinder_usage: None, - open_ai_api_usage: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: source_label.to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - throughput: None, - }; - let mut snapshot = ProviderUsageSnapshot::from_fetch_result( - ProviderId::Claude, - &metadata, - &result, - None, - None, - ); - snapshot.inventory = vec![super::bridge::ProviderInventoryItemSnapshot { - id: "reset-credits".to_string(), - title: "Limit Reset Credits".to_string(), - available_count: 2, - next_expires_at: None, - }]; - snapshot.subscription = Some(super::bridge::SubscriptionMetadataSnapshot { - starts_at: None, - expires_at: None, - renews_at: Some("2026-12-01T00:00:00+00:00".to_string()), - renews_at_is_date_only: true, - expires_at_is_date_only: false, - }); - snapshot -} - -#[test] -fn usage_item_descriptors_offer_independent_claude_sections() { - let snapshot = claude_snapshot_with_sections("web"); - let items = - super::usage_item_descriptors(Some(&snapshot), &Settings::default(), ProviderId::Claude); - let sections: Vec<(&str, &str, bool, bool)> = items - .iter() - .filter(|item| item.id.starts_with("section:")) - .map(|item| { - ( - item.id.as_str(), - item.title.as_str(), - item.available, - item.requires_cost_summary, - ) - }) - .collect(); - assert_eq!( - sections, - [ - ("section:subscription", "Plan renewal / expiry", true, false), - ( - "section:claude-reset-credits", - "Limit reset credits", - true, - false - ), - ("section:claude-extra-usage", "Extra usage", true, true), - ] - ); - - // Admin API spend is not Extra usage and has no Extra usage choice. - let admin = claude_snapshot_with_sections("admin-api"); - let items = - super::usage_item_descriptors(Some(&admin), &Settings::default(), ProviderId::Claude); - assert!( - !items - .iter() - .any(|item| item.id == "section:claude-extra-usage") - ); - - // The same sections are never offered for another provider. - let items = - super::usage_item_descriptors(Some(&snapshot), &Settings::default(), ProviderId::Codex); - assert!(!items.iter().any(|item| item.id.starts_with("section:"))); -} - -#[test] -fn usage_item_descriptors_keep_hidden_claude_sections_restorable() { - let mut settings = Settings::default(); - settings.set_hidden_usage_item_ids( - ProviderId::Claude, - vec![ - "section:claude-reset-credits".to_string(), - "section:unknown".to_string(), - ], - ); - let items = super::usage_item_descriptors(None, &settings, ProviderId::Claude); - let ids: Vec<(&str, &str, bool)> = items - .iter() - .map(|item| (item.id.as_str(), item.title.as_str(), item.available)) - .collect(); - assert_eq!( - ids, - [( - "section:claude-reset-credits", - "Limit reset credits (unavailable)", - false - )] - ); - // Section IDs never become "metric:section:..." placeholders. - assert!(!items.iter().any(|item| item.id.starts_with("metric:"))); -} - -#[test] -fn pace_stage_serializes_to_snake_case_string() { - use codexbar::core::PaceStage; - assert_eq!( - super::bridge::pace::stage_str(PaceStage::OnTrack), - "on_track" - ); - assert_eq!( - super::bridge::pace::stage_str(PaceStage::SlightlyAhead), - "slightly_ahead" - ); - assert_eq!( - super::bridge::pace::stage_str(PaceStage::FarAhead), - "far_ahead" - ); - assert_eq!( - super::bridge::pace::stage_str(PaceStage::SlightlyBehind), - "slightly_behind" - ); - assert_eq!(super::bridge::pace::stage_str(PaceStage::Behind), "behind"); - assert_eq!( - super::bridge::pace::stage_str(PaceStage::FarBehind), - "far_behind" - ); -} - -#[test] -fn local_opencodego_estimates_keep_quota_windows_but_drop_derived_pace() { - let now = chrono::Utc::now(); - let usage = codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::with_details( - 12.0, - Some(300), - Some(now + chrono::Duration::hours(2)), - None, - )) - .with_secondary(codexbar::core::RateWindow::with_details( - 23.0, - Some(10080), - Some(now + chrono::Duration::days(3)), - None, - )) - .with_tertiary(codexbar::core::RateWindow::with_details( - 34.0, - Some(43200), - Some(now + chrono::Duration::days(10)), - None, - )); - let result = ProviderFetchResult::new( - usage, - codexbar::providers::opencodego::LOCAL_ESTIMATE_SOURCE_LABEL, - ) - .with_non_authoritative_pace(); - let metadata = instantiate_provider(ProviderId::OpenCodeGo) - .metadata() - .clone(); - let snapshot = ProviderUsageSnapshot::from_fetch_result( - ProviderId::OpenCodeGo, - &metadata, - &result, - None, - None, - ); - - assert_eq!(snapshot.source_label, "local estimate"); - assert_eq!(snapshot.primary.used_percent, 12.0); - assert_eq!(snapshot.secondary.as_ref().unwrap().used_percent, 23.0); - assert_eq!(snapshot.tertiary.as_ref().unwrap().used_percent, 34.0); - assert!(snapshot.primary.resets_at.is_some()); - assert!(snapshot.secondary.as_ref().unwrap().resets_at.is_some()); - assert!(snapshot.tertiary.as_ref().unwrap().resets_at.is_some()); - assert!(snapshot.pace.is_none()); - assert!( - snapshot - .secondary - .as_ref() - .unwrap() - .reserve_percent - .is_none() - ); -} - -#[test] -fn provider_inventory_maps_to_the_bridge_without_token_ids() { - let expiry = chrono::DateTime::::from_timestamp(1_900_000_000, 0).unwrap(); - let result = ProviderFetchResult::new( - codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(12.0)), - "web", - ) - .with_inventory_item(ProviderInventoryItem { - id: "reset-credits".to_string(), - title: "Limit Reset Credits".to_string(), - available_count: 2, - next_expires_at: Some(expiry), - }) - .with_display_detail( - ProviderDisplayDetail::new("credits", "Used this cycle", "12") - .and_then(|row| row.with_secondary_value("Monthly refill: 100")) - .and_then(|row| row.with_section_title("Credit usage")) - .and_then(|row| row.with_progress(12.0, 100.0)), - ); - let metadata = instantiate_provider(ProviderId::Grok).metadata().clone(); - let snapshot = - ProviderUsageSnapshot::from_fetch_result(ProviderId::Grok, &metadata, &result, None, None); - - assert_eq!(snapshot.inventory.len(), 1); - assert_eq!(snapshot.inventory[0].available_count, 2); - assert_eq!( - snapshot.inventory[0].next_expires_at.as_deref(), - Some("2030-03-17T17:46:40+00:00") - ); - assert_eq!(snapshot.display_details.len(), 1); - assert_eq!(snapshot.display_details[0].value, "12"); - assert_eq!( - snapshot.display_details[0].section_title.as_deref(), - Some("Credit usage") - ); - let snapshot_json = serde_json::to_value(&snapshot).unwrap(); - assert_eq!( - snapshot_json["displayDetails"][0]["sectionTitle"], - "Credit usage" - ); - assert_eq!( - snapshot.display_details[0].secondary_value.as_deref(), - Some("Monthly refill: 100") - ); - let serialized = serde_json::to_string(&snapshot).unwrap(); - assert!(serialized.contains("reset-credits")); - assert!(!serialized.contains("coupon-token-secret")); -} - -#[test] -fn provider_cache_is_fresh_inside_stale_window() { - assert!(super::is_provider_cache_fresh( - Some(std::time::Instant::now()), - std::time::Duration::from_secs(30), - )); -} - -#[test] -fn provider_cache_is_stale_when_missing_timestamp() { - assert!(!super::is_provider_cache_fresh( - None, - std::time::Duration::from_secs(30), - )); -} - -#[test] -fn provider_cache_is_stale_after_window() { - assert!(!super::is_provider_cache_fresh( - Some(std::time::Instant::now() - std::time::Duration::from_secs(31)), - std::time::Duration::from_secs(30), - )); -} - -#[test] -fn provider_fetch_timeout_allows_slower_authenticated_providers() { - let ctx = FetchContext { - web_timeout: 30, - ..FetchContext::default() - }; - assert_eq!( - super::provider_fetch_timeout(ProviderId::Claude, &ctx), - std::time::Duration::from_secs(75) - ); - assert_eq!( - super::provider_fetch_timeout(ProviderId::Codex, &ctx), - std::time::Duration::from_secs(75) - ); - assert_eq!( - super::provider_fetch_timeout(ProviderId::Copilot, &ctx), - std::time::Duration::from_secs(75) - ); - assert_eq!( - super::provider_fetch_timeout(ProviderId::DeepSeek, &ctx), - std::time::Duration::from_secs(35) - ); - - let optional_litellm_ctx = FetchContext { - web_timeout: 30, - optional_details_enabled: true, - ..FetchContext::default() - }; - assert_eq!( - super::provider_fetch_timeout(ProviderId::LiteLLM, &optional_litellm_ctx), - std::time::Duration::from_secs(40) - ); -} - -#[test] -fn provider_fetch_timeout_respects_context_web_timeout_with_cap() { - let ctx = FetchContext { - web_timeout: 60, - ..FetchContext::default() - }; - assert_eq!( - super::provider_fetch_timeout(ProviderId::T3Chat, &ctx), - std::time::Duration::from_secs(65) - ); - - let ctx = FetchContext { - web_timeout: 120, - ..FetchContext::default() - }; - assert_eq!( - super::provider_fetch_timeout(ProviderId::AzureOpenAI, &ctx), - std::time::Duration::from_secs(65) - ); -} - -#[test] -fn provider_cache_upsert_replaces_existing_provider() { - let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), - cost: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: "CLI".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - }; - let mut first = - ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); - let mut second = first.clone(); - first.error = Some("old".to_string()); - second.error = Some("new".to_string()); - - let mut cache = vec![first]; - super::upsert_provider_cache(&mut cache, second); - - assert_eq!(cache.len(), 1); - assert_eq!(cache[0].provider_id, "codex"); - assert_eq!(cache[0].error.as_deref(), Some("new")); -} - -#[test] -fn provider_cache_prunes_disabled_providers() { - let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), - cost: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: "CLI".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - }; - let codex = - ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); - let claude_meta = instantiate_provider(ProviderId::Claude).metadata().clone(); - let claude = ProviderUsageSnapshot::from_fetch_result( - ProviderId::Claude, - &claude_meta, - &result, - None, - None, - ); - - let mut cache = vec![codex, claude]; - super::prune_provider_cache_to_enabled(&mut cache, &[ProviderId::Codex]); - - assert_eq!(cache.len(), 1); - assert_eq!(cache[0].provider_id, "codex"); -} - -#[test] -fn superseded_refresh_generation_is_not_current() { - let mut state = AppState::new(); - state.provider_refresh_generation = 3; - assert!(super::is_current_provider_refresh_generation(&state, 3)); - assert!(!super::is_current_provider_refresh_generation(&state, 2)); -} - -#[test] - -fn claude_transient_auth_failure_preserves_first_last_good_snapshot() { - let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), - cost: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: "OAuth".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - }; - let good = ProviderUsageSnapshot::from_fetch_result( - ProviderId::Claude, - &metadata, - &result, - None, - None, - ); - let snapshot = ProviderUsageSnapshot::from_error( - ProviderId::Claude, - &metadata, - "Unauthorized".to_string(), - codexbar::core::ProviderStateKind::NeedsAuthentication, - ); - let error = ProviderError::AuthRequired; - let mut state = crate::state::AppState::new(); - state.provider_cache.push(good.clone()); - - let preserved = super::providers::preserve_last_good_transient_failure( - &mut state, - ProviderId::Claude, - snapshot, - &error, - ); - - assert_eq!(preserved.error, None); - assert_eq!(preserved.primary.used_percent, 42.0); -} - -#[test] -fn codex_transient_transport_failure_helper_uses_typed_policy() { - let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), - cost: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: "OAuth".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - }; - let good = - ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); - let snapshot = ProviderUsageSnapshot::from_error( - ProviderId::Codex, - &metadata, - "Timeout".to_string(), - codexbar::core::ProviderStateKind::Unknown, - ); - let mut state = crate::state::AppState::new(); - state.provider_cache.push(good); - - let preserved = super::providers::preserve_last_good_transient_failure( - &mut state, - ProviderId::Codex, - snapshot, - &ProviderError::Timeout, - ); - - assert_eq!(preserved.error, None); - assert_eq!(preserved.primary.used_percent, 42.0); -} - -#[test] -fn claude_repeated_auth_failure_surfaces_error() { - let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), - cost: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: "OAuth".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - }; - let good = ProviderUsageSnapshot::from_fetch_result( - ProviderId::Claude, - &metadata, - &result, - None, - None, - ); - let first_error = ProviderUsageSnapshot::from_error( - ProviderId::Claude, - &metadata, - "Unauthorized".to_string(), - codexbar::core::ProviderStateKind::NeedsAuthentication, - ); - let second_error = first_error.clone(); - let failure = ProviderError::AuthRequired; - let mut state = crate::state::AppState::new(); - state.provider_cache.push(good); - - let _ = super::providers::preserve_last_good_transient_failure( - &mut state, - ProviderId::Claude, - first_error, - &failure, - ); - let surfaced = super::providers::preserve_last_good_transient_failure( - &mut state, - ProviderId::Claude, - second_error, - &failure, - ); - - assert!(surfaced.error.is_some()); -} - -#[test] -fn claude_cloudflare_challenge_retains_prior_usage_while_surfaceing_guidance() { - let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), - cost: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: "OAuth".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - }; - let good = ProviderUsageSnapshot::from_fetch_result( - ProviderId::Claude, - &metadata, - &result, - None, - None, - ); - let challenge = codexbar::providers::claude::CLOUDFLARE_CHALLENGE_MESSAGE; - let error = ProviderUsageSnapshot::from_error( - ProviderId::Claude, - &metadata, - challenge.to_string(), - codexbar::core::ProviderStateKind::Unknown, - ); - let failure = ProviderError::Other(challenge.to_string()); - let mut state = crate::state::AppState::new(); - state.provider_cache.push(good); - - let surfaced = super::providers::preserve_last_good_transient_failure( - &mut state, - ProviderId::Claude, - error, - &failure, - ); - - assert_eq!(surfaced.error, None); - assert_eq!(surfaced.primary.used_percent, 42.0); - assert_eq!( - super::providers::preserve_last_good_transient_failure( - &mut state, - ProviderId::Claude, - ProviderUsageSnapshot::from_error( - ProviderId::Claude, - &metadata, - challenge.to_string(), - codexbar::core::ProviderStateKind::Unknown, - ), - &failure, - ) - .error - .as_deref(), - Some(challenge) - ); -} - -#[test] -fn claude_cloudflare_challenge_keeps_prior_usage_when_guidance_surfaces() { - let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), - cost: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: "Web".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - }; - let mut good = ProviderUsageSnapshot::from_fetch_result( - ProviderId::Claude, - &metadata, - &result, - None, - None, - ); - good.updated_at = "2026-09-01T00:00:00Z".to_string(); - let error = ProviderUsageSnapshot::from_error( - ProviderId::Claude, - &metadata, - codexbar::providers::claude::CLOUDFLARE_CHALLENGE_MESSAGE.to_string(), - codexbar::core::ProviderStateKind::Unknown, - ); - let failure = - ProviderError::Other(codexbar::providers::claude::CLOUDFLARE_CHALLENGE_MESSAGE.to_string()); - let mut state = crate::state::AppState::new(); - state.provider_cache.push(good.clone()); - - let first = super::providers::preserve_last_good_transient_failure( - &mut state, - ProviderId::Claude, - error.clone(), - &failure, - ); - let second = super::providers::preserve_last_good_transient_failure( - &mut state, - ProviderId::Claude, - error, - &failure, - ); - - assert_eq!(first.error, None); - assert_eq!(first.primary.used_percent, 42.0); - assert_eq!( - second.error.as_deref(), - Some(codexbar::providers::claude::CLOUDFLARE_CHALLENGE_MESSAGE,) - ); - assert_eq!(second.primary.used_percent, 42.0); - assert_eq!(second.updated_at, good.updated_at); -} - -#[test] -fn claude_cli_parse_failure_keeps_last_good_every_time() { - let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), - cost: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: "CLI".to_string(), - has_successful_claude_cli_quota: true, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - }; - let good = ProviderUsageSnapshot::from_fetch_result( - ProviderId::Claude, - &metadata, - &result, - None, - None, - ); - let err = ProviderUsageSnapshot::from_error( - ProviderId::Claude, - &metadata, - "Parse error: Empty output from Claude CLI".to_string(), - codexbar::core::ProviderStateKind::Unknown, - ); - let failure = ProviderError::Parse("Empty output from Claude CLI".to_string()); - let mut state = crate::state::AppState::new(); - state.provider_cache.push(good.clone()); - - let first = super::providers::preserve_last_good_transient_failure( - &mut state, - ProviderId::Claude, - err.clone(), - &failure, - ); - let second = super::providers::preserve_last_good_transient_failure( - &mut state, - ProviderId::Claude, - err, - &failure, - ); - - assert_eq!(first.error, None); - assert_eq!(first.primary.used_percent, 17.0); - assert!(!first.has_successful_claude_cli_quota); - // Parse failures keep last-good on every refresh (upstream #2247), unlike one-shot auth. - assert_eq!(second.error, None); - assert_eq!(second.primary.used_percent, 17.0); -} - -#[test] -fn claude_hard_credentials_missing_does_not_preserve_stale() { - let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), - cost: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: "OAuth".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - }; - let good = ProviderUsageSnapshot::from_fetch_result( - ProviderId::Claude, - &metadata, - &result, - None, - None, - ); - let err = ProviderUsageSnapshot::from_error( - ProviderId::Claude, - &metadata, - "OAuth error: Claude OAuth credentials not found. Run `claude` to authenticate." - .to_string(), - codexbar::core::ProviderStateKind::NeedsAuthentication, - ); - let failure = ProviderError::OAuth( - "Claude OAuth credentials not found. Run `claude` to authenticate.".to_string(), - ); - let mut state = crate::state::AppState::new(); - state.provider_cache.push(good); - - let out = super::providers::preserve_last_good_transient_failure( - &mut state, - ProviderId::Claude, - err, - &failure, - ); - assert!(out.error.is_some()); - assert_eq!( - out.error_state, - codexbar::core::ProviderStateKind::NeedsAuthentication, - "hard auth failure must carry its classification on the snapshot" - ); -} - -#[test] -fn claude_error_message_removes_upstream_swift_cancellation() { - let message = super::friendly_provider_error( - ProviderId::Claude, - "The operation couldn't be completed. (Swift.CancellationError error 1.)", - ); - - assert!(!message.contains("Swift")); - assert!(message.contains("Claude usage fetch was cancelled")); - assert!(message.contains("Refresh Claude")); -} - -#[test] -fn claude_error_message_explains_missing_sign_in() { - let message = super::friendly_provider_error( - ProviderId::Claude, - "OAuth error: Claude OAuth credentials not found. Run `claude` to authenticate.", - ); - - assert_eq!( - message, - "Claude sign-in was not found. Run `claude` once to authenticate, then refresh Claude in Win-CodexBar." - ); -} - -#[test] -fn claude_cloudflare_error_preserves_distinct_recovery_guidance() { - let challenge = codexbar::providers::claude::CLOUDFLARE_CHALLENGE_MESSAGE; - let message = super::friendly_provider_error(ProviderId::Claude, challenge); - - assert_eq!(message, challenge); - assert!(message.contains("OAuth")); - assert!(message.contains("different network")); -} - -#[test] -fn non_claude_error_message_is_preserved() { - let message = super::friendly_provider_error( - ProviderId::Codex, - "OAuth error: Claude OAuth credentials not found. Run `claude` to authenticate.", - ); - - assert_eq!( - message, - "OAuth error: Claude OAuth credentials not found. Run `claude` to authenticate." - ); -} - -#[test] -fn chart_data_serde_roundtrip_preserves_fields() { - use super::{ - DailyCostPoint, DailyTokenPoint, DailyUsageBreakdown, ProviderChartData, - QuotaWindowHistoryBridge, QuotaWindowHistoryPoint, ServiceUsagePoint, - }; - - let original = ProviderChartData { - provider_id: "codex".into(), - cost_history: vec![ - DailyCostPoint { - date: "2025-01-01".into(), - value: Some(1.25), - incomplete_request_count: None, - }, - DailyCostPoint { - date: "2025-01-02".into(), - value: Some(0.0), - incomplete_request_count: None, - }, - ], - credits_history: vec![DailyCostPoint { - date: "2025-01-01".into(), - value: Some(42.0), - incomplete_request_count: None, - }], - usage_breakdown: vec![DailyUsageBreakdown { - day: "2025-01-01".into(), - services: vec![ - ServiceUsagePoint { - service: "gpt-4o".into(), - credits_used: 10.0, - }, - ServiceUsagePoint { - service: "gpt-4o-mini".into(), - credits_used: 3.5, - }, - ], - total_credits_used: 13.5, - }], - local_usage: None, - tokens_history: vec![DailyTokenPoint { - date: "2025-01-01".into(), - tokens: 123_456, - }], - tokens_incomplete: true, - quota_window_history: Some(QuotaWindowHistoryBridge { - provider_id: "codex".into(), - account_scope: Some("person@example.com".into()), - windows: vec![QuotaWindowHistoryPoint { - offset: 0, - start: "2025-01-01T00:00:00Z".into(), - end: "2025-01-08T00:00:00Z".into(), - total_tokens: Some(123_456), - total_cost_usd: None, - tokens_are_complete: true, - cost_is_complete: false, - entry_count: 2, - boundaries_are_estimated: true, - }], - history_coverage_established: false, - }), - }; - - let json = serde_json::to_string(&original).expect("serialize"); - assert!( - json.contains("\"providerId\":\"codex\""), - "camelCase providerId: {json}" - ); - assert!(json.contains("\"costHistory\"")); - assert!(json.contains("\"creditsHistory\"")); - assert!(json.contains("\"usageBreakdown\"")); - assert!(json.contains("\"localUsage\":null")); - assert!(json.contains("\"creditsUsed\":10.0")); - assert!(json.contains("\"totalCreditsUsed\":13.5")); - assert!(json.contains("\"tokensHistory\"")); - assert!(json.contains("\"tokens\":123456")); - assert!(json.contains("\"tokensIncomplete\":true")); - - let back: ProviderChartData = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(back.provider_id, "codex"); - assert_eq!(back.cost_history.len(), 2); - assert_eq!(back.cost_history[0].date, "2025-01-01"); - assert_eq!(back.credits_history[0].value, Some(42.0)); - assert_eq!(back.usage_breakdown[0].services.len(), 2); - assert_eq!(back.usage_breakdown[0].total_credits_used, 13.5); - assert_eq!(back.tokens_history[0].tokens, 123_456); - assert!(back.tokens_incomplete); - let history = back.quota_window_history.expect("quota history"); - assert_eq!(history.account_scope.as_deref(), Some("person@example.com")); - assert_eq!(history.windows[0].offset, 0); - assert!(history.windows[0].boundaries_are_estimated); - assert!(!history.windows[0].cost_is_complete); - assert!(!history.history_coverage_established); - - let mut legacy = serde_json::to_value(&original).expect("serialize legacy fixture"); - legacy - .as_object_mut() - .expect("chart object") - .remove("quotaWindowHistory"); - let legacy_back: ProviderChartData = - serde_json::from_value(legacy).expect("legacy chart payload remains readable"); - assert!(legacy_back.quota_window_history.is_none()); -} - -#[test] -fn chart_data_for_unknown_provider_is_empty() { - let data = - super::build_provider_chart_data("this-provider-definitely-does-not-exist".into(), None); - assert_eq!(data.provider_id, "this-provider-definitely-does-not-exist"); - assert!(data.credits_history.is_empty()); - assert!(data.usage_breakdown.is_empty()); -} - -#[test] -fn japanese_provider_snapshot_localizes_weekly_label() { - let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); - let usage = codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)) - .with_secondary(codexbar::core::RateWindow::new(20.0)); - let result = ProviderFetchResult { - usage, - cost: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: "OAuth".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - }; - - let snapshot = ProviderUsageSnapshot::from_fetch_result( - ProviderId::Claude, - &metadata, - &result, - None, - None, - ); - - // Secondary label stays raw; localization happens at render time. - assert_eq!(snapshot.secondary_label, Some("Weekly".to_string())); -} - -#[test] -fn japanese_provider_snapshot_localizes_pace_reserve_description() { - use chrono::{Duration, Utc}; - - let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); - let now = Utc::now(); - // 7-day window, half elapsed, 40% used → 10% ahead of pace, will last to reset. - let secondary = codexbar::core::RateWindow::with_details( - 40.0, - Some(7 * 24 * 60), - Some(now + Duration::minutes(7 * 24 * 60 / 2)), - None, - ); - let usage = codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)) - .with_secondary(secondary); - let result = ProviderFetchResult { - usage, - cost: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: "OAuth".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - }; - - let snapshot = ProviderUsageSnapshot::from_fetch_result( - ProviderId::Claude, - &metadata, - &result, - None, - None, - ); - - // Reserve data stays raw; localization happens at render time. - let secondary = snapshot.secondary.as_ref().expect("secondary window"); - assert!(secondary.reserve_percent.is_some()); - assert!(secondary.reserve_will_last_to_reset); - assert!(secondary.reserve_description.is_none()); -} - -#[test] -fn chart_data_requires_account_email_for_codex() { - let (credits_history, usage_breakdown) = - super::chart::load_openai_dashboard_chart_data_for_test("codex", None); - assert!(credits_history.is_empty()); - assert!(usage_breakdown.is_empty()); -} - -#[test] -fn cookie_options_for_cookie_supporting_provider() { - let opts = super::cookie_source_options_for("codex", Language::English); - let values: Vec<_> = opts.iter().map(|o| o.value.as_str()).collect(); - assert_eq!(values, vec!["auto", "manual", "off"]); - assert!(opts.iter().any(|o| o.label == "Automatic")); - assert!(opts.iter().any(|o| o.label == "Manual")); - assert!(opts.iter().any(|o| o.label == "Disabled")); -} - -#[test] -fn replicate_cookie_options_allow_automatic_and_manual_sessions() { - let opts = super::cookie_source_options_for("replicate", Language::English); - let values: Vec<_> = opts.iter().map(|option| option.value.as_str()).collect(); - assert_eq!(values, vec!["auto", "manual"]); -} - -#[test] -fn raycast_cookie_options_include_off_and_a_pinned_manual_session() { - let opts = super::cookie_source_options_for("raycast", Language::English); - let values: Vec<_> = opts.iter().map(|option| option.value.as_str()).collect(); - assert_eq!(values, vec!["auto", "manual", "off"]); -} - -#[test] -fn cookie_options_empty_for_providers_without_picker() { - assert!(super::cookie_source_options_for("anthropic", Language::English).is_empty()); - assert!(super::cookie_source_options_for("unknown", Language::English).is_empty()); -} - -#[test] -fn region_options_for_regional_provider() { - let opts = super::region_options_for("alibaba", Language::English); - let values: Vec<_> = opts.iter().map(|o| o.value.as_str()).collect(); - assert_eq!(values, vec!["singapore", "us", "germany", "hongkong", "cn"]); -} - -#[test] -fn alibaba_token_plan_region_options() { - let opts = super::region_options_for("alibabatokenplan", Language::English); - let values: Vec<_> = opts.iter().map(|o| o.value.as_str()).collect(); - let labels: Vec<_> = opts.iter().map(|o| o.label.as_str()).collect(); - assert_eq!(values, vec!["cn", "intl", "cn-personal", "intl-personal"]); - assert_eq!( - labels, - vec![ - "China Team", - "International Team", - "China Personal/Solo", - "International Personal/Solo" - ] - ); -} - -#[test] -fn minimax_region_options_match_upstream_hosts() { - let opts = super::region_options_for("minimax", Language::English); - let values: Vec<_> = opts.iter().map(|o| o.value.as_str()).collect(); - let labels: Vec<_> = opts.iter().map(|o| o.label.as_str()).collect(); - assert_eq!(values, vec!["global", "cn"]); - assert_eq!( - labels, - vec![ - "Global (platform.minimax.io)", - "China mainland (platform.minimaxi.com)" - ] - ); -} - -#[test] -fn kimi_region_options_match_regional_hosts() { - let opts = super::region_options_for("kimi", Language::English); - let values: Vec<_> = opts.iter().map(|option| option.value.as_str()).collect(); - assert_eq!(values, vec!["china", "international"]); -} - -#[test] -fn cookie_and_region_options_are_localized_for_every_provider() { - // Every description and region label must come from the locale catalog: none may stay - // English in another language, except the labels in ALLOWED_SAME_AS_ENGLISH, which the - // catalogs legitimately keep identical to the English text (proper names). - const ALLOWED_SAME_AS_ENGLISH: &[(Language, &str, &str)] = &[ - (Language::Spanish, "kimi", "china"), - (Language::Spanish, "minimax", "global"), - (Language::Spanish, "zai", "global"), - (Language::PortugueseBrazil, "kimi", "china"), - (Language::PortugueseBrazil, "minimax", "global"), - (Language::PortugueseBrazil, "zai", "global"), - ]; - for &lang in Language::all() { - if lang == Language::English { - continue; - } - for provider in codexbar::core::ProviderId::all() { - let id = provider.cli_name(); - let en = super::cookie_source_options_for(id, Language::English); - let other = super::cookie_source_options_for(id, lang); - assert_eq!(en.len(), other.len(), "{lang:?} {id}"); - for (en, other) in en.iter().zip(&other) { - if let Some(text) = &en.description { - assert_ne!( - Some(text), - other.description.as_ref(), - "{lang:?} {id} {} description", - en.value - ); - } - } - let en = super::region_options_for(id, Language::English); - let other = super::region_options_for(id, lang); - assert_eq!(en.len(), other.len(), "{lang:?} {id} regions"); - for (en, other) in en.iter().zip(&other) { - if ALLOWED_SAME_AS_ENGLISH.contains(&(lang, id, en.value.as_str())) { - continue; - } - assert_ne!(en.label, other.label, "{lang:?} {id} region {}", en.value); - } - } - } -} - -#[test] -fn english_region_labels_match_the_provider_display_names() { - use codexbar::providers::{AlibabaRegion, AlibabaTokenPlanRegion, KimiRegion, MiniMaxRegion}; - let labels = |id| { - super::region_options_for(id, Language::English) - .into_iter() - .map(|option| option.label) - .collect::>() - }; - assert_eq!( - labels("alibaba"), - AlibabaRegion::ALL - .iter() - .map(|r| r.display_name()) - .collect::>() - ); - assert_eq!( - labels("alibabatokenplan"), - AlibabaTokenPlanRegion::ALL - .iter() - .map(|r| r.display_name()) - .collect::>() - ); - assert_eq!( - labels("kimi"), - KimiRegion::ALL - .iter() - .map(|r| r.display_name()) - .collect::>() - ); - assert_eq!( - labels("minimax"), - [MiniMaxRegion::Global, MiniMaxRegion::ChinaMainland].map(|r| r.display_name()) - ); -} - -#[test] -fn region_options_empty_for_non_regional_provider() { - assert!(super::region_options_for("claude", Language::English).is_empty()); - assert!(super::region_options_for("codex", Language::English).is_empty()); -} - -#[test] -fn cookie_source_option_roundtrips_serde() { - let opt = super::CookieSourceOption { - value: "auto".to_string(), - label: "Automatic".to_string(), - description: Some("Imports browser cookies.".to_string()), - }; - let json = serde_json::to_string(&opt).unwrap(); - let back: super::CookieSourceOption = serde_json::from_str(&json).unwrap(); - assert_eq!(opt, back); -} - -#[test] -fn region_option_roundtrips_serde() { - let opt = super::RegionOption { - value: "intl".to_string(), - label: "International".to_string(), - }; - let json = serde_json::to_string(&opt).unwrap(); - let back: super::RegionOption = serde_json::from_str(&json).unwrap(); - assert_eq!(opt, back); -} - -// ── Phase 6d — credential detection UIs ──────────────────────── - -#[test] -fn open_path_rejects_empty_path() { - let err = super::open_path(String::new()).unwrap_err(); - assert!(err.to_lowercase().contains("empty")); -} - -#[test] -fn open_path_rejects_relative_path() { - let err = super::open_path("relative/path".into()).unwrap_err(); - assert!(err.contains("absolute")); -} - -#[test] -fn open_path_rejects_missing_path() { - let missing = std::env::temp_dir() - .join(format!("codexbar-phase6d-missing-{}", std::process::id())) - .join("does-not-exist"); - let err = super::open_path(missing.to_string_lossy().into_owned()).unwrap_err(); - assert!(err.contains("not found")); -} - -#[test] -fn external_url_validator_accepts_http_and_https() { - assert_eq!( - super::validate_external_url(" https://github.com/Finesssee/Win-CodexBar "), - Ok("https://github.com/Finesssee/Win-CodexBar") - ); - assert_eq!( - super::validate_external_url("http://localhost:1420"), - Ok("http://localhost:1420") - ); -} - -#[test] -fn external_url_validator_rejects_non_web_and_control_urls() { - assert!(super::validate_external_url("file:///C:/Windows/win.ini").is_err()); - assert!(super::validate_external_url("javascript:alert(1)").is_err()); - assert!(super::validate_external_url("https://example.com/\nmalicious").is_err()); +fn external_url_validator_rejects_non_web_and_control_urls() { + assert!(super::validate_external_url("file:///C:/Windows/win.ini").is_err()); + assert!(super::validate_external_url("javascript:alert(1)").is_err()); + assert!(super::validate_external_url("https://example.com/\nmalicious").is_err()); } // ── Phase 13 — E2E IPC harness ───────────────────────────────── @@ -2901,207 +713,3 @@ fn bootstrap_catalog_depends_only_on_supplied_settings() { "a deprecated provider that is not enabled stays hidden" ); } - -fn muse_fetch_context(cookie_source: Option<&str>, stored_cookie: Option<&str>) -> FetchContext { - let mut settings = Settings::default(); - if let Some(source) = cookie_source { - settings.set_cookie_source(ProviderId::Muse, source); - } - let mut cookies = ManualCookies::default(); - if let Some(header) = stored_cookie { - cookies.set(ProviderId::Muse.cli_name(), header); - } - super::build_fetch_context( - ProviderId::Muse, - &settings, - &cookies, - &ApiKeys::default(), - &HashMap::new(), - ) -} - -#[test] -fn muse_default_cookie_source_reads_no_browser_and_keeps_the_login_source() { - let ctx = muse_fetch_context(None, None); - - assert!(!ctx.browser_cookie_import); - assert!(ctx.manual_cookie_header.is_none()); - assert_eq!(ctx.source_mode, SourceMode::Auto); -} - -#[test] -fn muse_cookie_source_off_never_reads_or_forwards_a_cookie() { - let ctx = muse_fetch_context(Some("off"), Some("llama_dev_sess=abc")); - - assert!(!ctx.browser_cookie_import); - assert!(ctx.manual_cookie_header.is_none()); -} - -#[test] -fn muse_automatic_cookie_source_requests_the_browser_import() { - let ctx = muse_fetch_context(Some("auto"), Some("llama_dev_sess=abc")); - - assert!(ctx.browser_cookie_import); - assert!(ctx.manual_cookie_header.is_none()); - assert_eq!(ctx.source_mode, SourceMode::Auto); -} - -#[test] -fn muse_manual_cookie_source_forwards_only_the_pasted_header() { - let ctx = muse_fetch_context(Some("manual"), Some("llama_dev_sess=abc")); - - assert!(!ctx.browser_cookie_import); - assert_eq!( - ctx.manual_cookie_header.as_deref(), - Some("llama_dev_sess=abc") - ); -} - -#[test] -fn other_providers_never_request_the_muse_browser_import() { - let ctx = super::build_fetch_context( - ProviderId::Cursor, - &Settings::default(), - &ManualCookies::default(), - &ApiKeys::default(), - &HashMap::new(), - ); - - assert!(!ctx.browser_cookie_import); -} - -fn limit_reset_snapshot() -> ProviderUsageSnapshot { - let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); - let result = ProviderFetchResult { - usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), - cost: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - inventory: Vec::new(), - reset_credits: None, - display_details: Vec::new(), - source_label: "OAuth".to_string(), - has_successful_claude_cli_quota: false, - pace_authoritative: true, - account_identity: None, - last_good_owner: None, - }; - let mut snapshot = - ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); - snapshot.updated_at = "2026-05-01T12:00:00Z".to_string(); - snapshot.account_email = Some("owner@example.com".to_string()); - snapshot.primary.window_minutes = Some(300); - snapshot.primary.resets_at = Some("2026-05-01T15:00:00Z".to_string()); - let mut weekly = snapshot.primary.clone(); - weekly.used_percent = 40.0; - weekly.window_minutes = Some(10080); - weekly.resets_at = Some("2026-05-07T00:00:00Z".to_string()); - snapshot.secondary = Some(weekly); - snapshot -} - -fn limit_reset_identity( - snapshot: &ProviderUsageSnapshot, - token_account_id: Option, -) -> super::warning_identity::WarningIdentity { - super::warning_identity::WarningIdentity::new( - ProviderId::Codex, - &snapshot.source_label, - snapshot.account_email.as_deref(), - snapshot.account_organization.as_deref(), - token_account_id, - ) -} - -#[test] -fn limit_reset_sample_observes_session_and_weekly_lanes() { - let snapshot = limit_reset_snapshot(); - let identity = limit_reset_identity(&snapshot, None); - let sample = super::providers::limit_reset_sample( - ProviderId::Codex, - &snapshot, - &identity, - None, - "owner@example.com", - &Settings::default(), - ); - - let observed_at = chrono::DateTime::parse_from_rfc3339("2026-05-01T12:00:00Z") - .unwrap() - .with_timezone(&chrono::Utc); - let session = sample.session.expect("session observed"); - assert_eq!(session.used_percent, 10.0); - assert_eq!(session.observed_at, observed_at); - assert_eq!( - session.reset_boundary, - Some( - chrono::DateTime::parse_from_rfc3339("2026-05-01T15:00:00Z") - .unwrap() - .with_timezone(&chrono::Utc) - ) - ); - assert_eq!(sample.weekly.expect("weekly observed").used_percent, 40.0); - assert_eq!(sample.session_transition_account, "owner@example.com"); - let account = sample.account.expect("owner account"); - assert_eq!(account.key, "owner@example.com"); - assert_eq!(account.label.as_deref(), Some("owner@example.com")); -} - -#[test] -fn limit_reset_sample_skips_non_reset_lanes() { - let mut snapshot = limit_reset_snapshot(); - snapshot.primary.window_minutes = Some(1440); - if let Some(weekly) = snapshot.secondary.as_mut() { - weekly.is_informational = true; - } - let identity = limit_reset_identity(&snapshot, None); - let sample = super::providers::limit_reset_sample( - ProviderId::Codex, - &snapshot, - &identity, - None, - "", - &Settings::default(), - ); - assert!(sample.session.is_none()); - assert!(sample.weekly.is_none()); - - let mut snapshot = limit_reset_snapshot(); - snapshot.updated_at = "not a date".to_string(); - let identity = limit_reset_identity(&snapshot, None); - let sample = super::providers::limit_reset_sample( - ProviderId::Codex, - &snapshot, - &identity, - None, - "", - &Settings::default(), - ); - assert!(sample.session.is_none()); - assert!(sample.weekly.is_none()); -} - -#[test] -fn limit_reset_sample_prefers_token_label_and_hides_personal_info() { - let snapshot = limit_reset_snapshot(); - let identity = limit_reset_identity(&snapshot, Some(uuid::Uuid::nil())); - let label = |settings: &Settings| { - super::providers::limit_reset_sample( - ProviderId::Codex, - &snapshot, - &identity, - Some("Work"), - "", - settings, - ) - .account - .and_then(|account| account.label) - }; - assert_eq!(label(&Settings::default()).as_deref(), Some("Work")); - let hidden = Settings { - hide_personal_info: true, - ..Settings::default() - }; - assert_eq!(label(&hidden), None); -} diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests/cookie_region_options.rs b/apps/desktop-tauri/src-tauri/src/commands/tests/cookie_region_options.rs new file mode 100644 index 00000000000..f1551bc0b8e --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/tests/cookie_region_options.rs @@ -0,0 +1,187 @@ +use super::*; + +#[test] +fn cookie_options_for_cookie_supporting_provider() { + let opts = crate::commands::cookie_source_options_for("codex", Language::English); + let values: Vec<_> = opts.iter().map(|o| o.value.as_str()).collect(); + assert_eq!(values, vec!["auto", "manual", "off"]); + assert!(opts.iter().any(|o| o.label == "Automatic")); + assert!(opts.iter().any(|o| o.label == "Manual")); + assert!(opts.iter().any(|o| o.label == "Disabled")); +} + +#[test] +fn replicate_cookie_options_allow_automatic_and_manual_sessions() { + let opts = crate::commands::cookie_source_options_for("replicate", Language::English); + let values: Vec<_> = opts.iter().map(|option| option.value.as_str()).collect(); + assert_eq!(values, vec!["auto", "manual"]); +} + +#[test] +fn raycast_cookie_options_include_off_and_a_pinned_manual_session() { + let opts = crate::commands::cookie_source_options_for("raycast", Language::English); + let values: Vec<_> = opts.iter().map(|option| option.value.as_str()).collect(); + assert_eq!(values, vec!["auto", "manual", "off"]); +} + +#[test] +fn cookie_options_empty_for_providers_without_picker() { + assert!(crate::commands::cookie_source_options_for("anthropic", Language::English).is_empty()); + assert!(crate::commands::cookie_source_options_for("unknown", Language::English).is_empty()); +} + +#[test] +fn region_options_for_regional_provider() { + let opts = crate::commands::region_options_for("alibaba", Language::English); + let values: Vec<_> = opts.iter().map(|o| o.value.as_str()).collect(); + assert_eq!(values, vec!["singapore", "us", "germany", "hongkong", "cn"]); +} + +#[test] +fn alibaba_token_plan_region_options() { + let opts = crate::commands::region_options_for("alibabatokenplan", Language::English); + let values: Vec<_> = opts.iter().map(|o| o.value.as_str()).collect(); + let labels: Vec<_> = opts.iter().map(|o| o.label.as_str()).collect(); + assert_eq!(values, vec!["cn", "intl", "cn-personal", "intl-personal"]); + assert_eq!( + labels, + vec![ + "China Team", + "International Team", + "China Personal/Solo", + "International Personal/Solo" + ] + ); +} + +#[test] +fn minimax_region_options_match_upstream_hosts() { + let opts = crate::commands::region_options_for("minimax", Language::English); + let values: Vec<_> = opts.iter().map(|o| o.value.as_str()).collect(); + let labels: Vec<_> = opts.iter().map(|o| o.label.as_str()).collect(); + assert_eq!(values, vec!["global", "cn"]); + assert_eq!( + labels, + vec![ + "Global (platform.minimax.io)", + "China mainland (platform.minimaxi.com)" + ] + ); +} + +#[test] +fn kimi_region_options_match_regional_hosts() { + let opts = crate::commands::region_options_for("kimi", Language::English); + let values: Vec<_> = opts.iter().map(|option| option.value.as_str()).collect(); + assert_eq!(values, vec!["china", "international"]); +} + +#[test] +fn cookie_and_region_options_are_localized_for_every_provider() { + // Every description and region label must come from the locale catalog: none may stay + // English in another language, except the labels in ALLOWED_SAME_AS_ENGLISH, which the + // catalogs legitimately keep identical to the English text (proper names). + const ALLOWED_SAME_AS_ENGLISH: &[(Language, &str, &str)] = &[ + (Language::Spanish, "kimi", "china"), + (Language::Spanish, "minimax", "global"), + (Language::Spanish, "zai", "global"), + (Language::PortugueseBrazil, "kimi", "china"), + (Language::PortugueseBrazil, "minimax", "global"), + (Language::PortugueseBrazil, "zai", "global"), + ]; + for &lang in Language::all() { + if lang == Language::English { + continue; + } + for provider in codexbar::core::ProviderId::all() { + let id = provider.cli_name(); + let en = crate::commands::cookie_source_options_for(id, Language::English); + let other = crate::commands::cookie_source_options_for(id, lang); + assert_eq!(en.len(), other.len(), "{lang:?} {id}"); + for (en, other) in en.iter().zip(&other) { + if let Some(text) = &en.description { + assert_ne!( + Some(text), + other.description.as_ref(), + "{lang:?} {id} {} description", + en.value + ); + } + } + let en = crate::commands::region_options_for(id, Language::English); + let other = crate::commands::region_options_for(id, lang); + assert_eq!(en.len(), other.len(), "{lang:?} {id} regions"); + for (en, other) in en.iter().zip(&other) { + if ALLOWED_SAME_AS_ENGLISH.contains(&(lang, id, en.value.as_str())) { + continue; + } + assert_ne!(en.label, other.label, "{lang:?} {id} region {}", en.value); + } + } + } +} + +#[test] +fn english_region_labels_match_the_provider_display_names() { + use codexbar::providers::{AlibabaRegion, AlibabaTokenPlanRegion, KimiRegion, MiniMaxRegion}; + let labels = |id| { + crate::commands::region_options_for(id, Language::English) + .into_iter() + .map(|option| option.label) + .collect::>() + }; + assert_eq!( + labels("alibaba"), + AlibabaRegion::ALL + .iter() + .map(|r| r.display_name()) + .collect::>() + ); + assert_eq!( + labels("alibabatokenplan"), + AlibabaTokenPlanRegion::ALL + .iter() + .map(|r| r.display_name()) + .collect::>() + ); + assert_eq!( + labels("kimi"), + KimiRegion::ALL + .iter() + .map(|r| r.display_name()) + .collect::>() + ); + assert_eq!( + labels("minimax"), + [MiniMaxRegion::Global, MiniMaxRegion::ChinaMainland].map(|r| r.display_name()) + ); +} + +#[test] +fn region_options_empty_for_non_regional_provider() { + assert!(crate::commands::region_options_for("claude", Language::English).is_empty()); + assert!(crate::commands::region_options_for("codex", Language::English).is_empty()); +} + +#[test] +fn cookie_source_option_roundtrips_serde() { + let opt = crate::commands::CookieSourceOption { + value: "auto".to_string(), + label: "Automatic".to_string(), + description: Some("Imports browser cookies.".to_string()), + }; + let json = serde_json::to_string(&opt).unwrap(); + let back: crate::commands::CookieSourceOption = serde_json::from_str(&json).unwrap(); + assert_eq!(opt, back); +} + +#[test] +fn region_option_roundtrips_serde() { + let opt = crate::commands::RegionOption { + value: "intl".to_string(), + label: "International".to_string(), + }; + let json = serde_json::to_string(&opt).unwrap(); + let back: crate::commands::RegionOption = serde_json::from_str(&json).unwrap(); + assert_eq!(opt, back); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests/provider_cache.rs b/apps/desktop-tauri/src-tauri/src/commands/tests/provider_cache.rs new file mode 100644 index 00000000000..da5fc844515 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/tests/provider_cache.rs @@ -0,0 +1,480 @@ +use super::*; + +#[test] +fn provider_cache_is_fresh_inside_stale_window() { + assert!(crate::commands::is_provider_cache_fresh( + Some(std::time::Instant::now()), + std::time::Duration::from_secs(30), + )); +} + +#[test] +fn provider_cache_is_stale_when_missing_timestamp() { + assert!(!crate::commands::is_provider_cache_fresh( + None, + std::time::Duration::from_secs(30), + )); +} + +#[test] +fn provider_cache_is_stale_after_window() { + assert!(!crate::commands::is_provider_cache_fresh( + Some(std::time::Instant::now() - std::time::Duration::from_secs(31)), + std::time::Duration::from_secs(30), + )); +} + +#[test] +fn provider_fetch_timeout_allows_slower_authenticated_providers() { + let ctx = FetchContext { + web_timeout: 30, + ..FetchContext::default() + }; + assert_eq!( + crate::commands::provider_fetch_timeout(ProviderId::Claude, &ctx), + std::time::Duration::from_secs(75) + ); + assert_eq!( + crate::commands::provider_fetch_timeout(ProviderId::Codex, &ctx), + std::time::Duration::from_secs(75) + ); + assert_eq!( + crate::commands::provider_fetch_timeout(ProviderId::Copilot, &ctx), + std::time::Duration::from_secs(75) + ); + assert_eq!( + crate::commands::provider_fetch_timeout(ProviderId::DeepSeek, &ctx), + std::time::Duration::from_secs(35) + ); + + let optional_litellm_ctx = FetchContext { + web_timeout: 30, + optional_details_enabled: true, + ..FetchContext::default() + }; + assert_eq!( + crate::commands::provider_fetch_timeout(ProviderId::LiteLLM, &optional_litellm_ctx), + std::time::Duration::from_secs(40) + ); +} + +#[test] +fn provider_fetch_timeout_respects_context_web_timeout_with_cap() { + let ctx = FetchContext { + web_timeout: 60, + ..FetchContext::default() + }; + assert_eq!( + crate::commands::provider_fetch_timeout(ProviderId::T3Chat, &ctx), + std::time::Duration::from_secs(65) + ); + + let ctx = FetchContext { + web_timeout: 120, + ..FetchContext::default() + }; + assert_eq!( + crate::commands::provider_fetch_timeout(ProviderId::AzureOpenAI, &ctx), + std::time::Duration::from_secs(65) + ); +} + +#[test] +fn provider_cache_upsert_replaces_existing_provider() { + let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), + "CLI", + ); + let mut first = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); + let mut second = first.clone(); + first.error = Some("old".to_string()); + second.error = Some("new".to_string()); + + let mut cache = vec![first]; + crate::commands::upsert_provider_cache(&mut cache, second); + + assert_eq!(cache.len(), 1); + assert_eq!(cache[0].provider_id, "codex"); + assert_eq!(cache[0].error.as_deref(), Some("new")); +} + +#[test] +fn provider_cache_prunes_disabled_providers() { + let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), + "CLI", + ); + let codex = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); + let claude_meta = instantiate_provider(ProviderId::Claude).metadata().clone(); + let claude = ProviderUsageSnapshot::from_fetch_result( + ProviderId::Claude, + &claude_meta, + &result, + None, + None, + ); + + let mut cache = vec![codex, claude]; + crate::commands::prune_provider_cache_to_enabled(&mut cache, &[ProviderId::Codex]); + + assert_eq!(cache.len(), 1); + assert_eq!(cache[0].provider_id, "codex"); +} + +#[test] +fn superseded_refresh_generation_is_not_current() { + let mut state = AppState::new(); + state.provider_refresh_generation = 3; + assert!(crate::commands::is_current_provider_refresh_generation( + &state, 3 + )); + assert!(!crate::commands::is_current_provider_refresh_generation( + &state, 2 + )); +} + +#[test] + +fn claude_transient_auth_failure_preserves_first_last_good_snapshot() { + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), + "OAuth", + ); + let good = ProviderUsageSnapshot::from_fetch_result( + ProviderId::Claude, + &metadata, + &result, + None, + None, + ); + let snapshot = ProviderUsageSnapshot::from_error( + ProviderId::Claude, + &metadata, + "Unauthorized".to_string(), + codexbar::core::ProviderStateKind::NeedsAuthentication, + ); + let error = ProviderError::AuthRequired; + let mut state = crate::state::AppState::new(); + state.provider_cache.push(good.clone()); + + let preserved = crate::commands::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Claude, + snapshot, + &error, + ); + + assert_eq!(preserved.error, None); + assert_eq!(preserved.primary.used_percent, 42.0); +} + +#[test] +fn codex_transient_transport_failure_helper_uses_typed_policy() { + let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), + "OAuth", + ); + let good = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); + let snapshot = ProviderUsageSnapshot::from_error( + ProviderId::Codex, + &metadata, + "Timeout".to_string(), + codexbar::core::ProviderStateKind::Unknown, + ); + let mut state = crate::state::AppState::new(); + state.provider_cache.push(good); + + let preserved = crate::commands::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Codex, + snapshot, + &ProviderError::Timeout, + ); + + assert_eq!(preserved.error, None); + assert_eq!(preserved.primary.used_percent, 42.0); +} + +#[test] +fn claude_repeated_auth_failure_surfaces_error() { + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), + "OAuth", + ); + let good = ProviderUsageSnapshot::from_fetch_result( + ProviderId::Claude, + &metadata, + &result, + None, + None, + ); + let first_error = ProviderUsageSnapshot::from_error( + ProviderId::Claude, + &metadata, + "Unauthorized".to_string(), + codexbar::core::ProviderStateKind::NeedsAuthentication, + ); + let second_error = first_error.clone(); + let failure = ProviderError::AuthRequired; + let mut state = crate::state::AppState::new(); + state.provider_cache.push(good); + + let _ = crate::commands::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Claude, + first_error, + &failure, + ); + let surfaced = crate::commands::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Claude, + second_error, + &failure, + ); + + assert!(surfaced.error.is_some()); +} + +#[test] +fn claude_cloudflare_challenge_retains_prior_usage_while_surfaceing_guidance() { + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), + "OAuth", + ); + let good = ProviderUsageSnapshot::from_fetch_result( + ProviderId::Claude, + &metadata, + &result, + None, + None, + ); + let challenge = codexbar::providers::claude::CLOUDFLARE_CHALLENGE_MESSAGE; + let error = ProviderUsageSnapshot::from_error( + ProviderId::Claude, + &metadata, + challenge.to_string(), + codexbar::core::ProviderStateKind::Unknown, + ); + let failure = ProviderError::Other(challenge.to_string()); + let mut state = crate::state::AppState::new(); + state.provider_cache.push(good); + + let surfaced = crate::commands::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Claude, + error, + &failure, + ); + + assert_eq!(surfaced.error, None); + assert_eq!(surfaced.primary.used_percent, 42.0); + assert_eq!( + crate::commands::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Claude, + ProviderUsageSnapshot::from_error( + ProviderId::Claude, + &metadata, + challenge.to_string(), + codexbar::core::ProviderStateKind::Unknown, + ), + &failure, + ) + .error + .as_deref(), + Some(challenge) + ); +} + +#[test] +fn claude_cloudflare_challenge_keeps_prior_usage_when_guidance_surfaces() { + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), + "Web", + ); + let mut good = ProviderUsageSnapshot::from_fetch_result( + ProviderId::Claude, + &metadata, + &result, + None, + None, + ); + good.updated_at = "2026-09-01T00:00:00Z".to_string(); + let error = ProviderUsageSnapshot::from_error( + ProviderId::Claude, + &metadata, + codexbar::providers::claude::CLOUDFLARE_CHALLENGE_MESSAGE.to_string(), + codexbar::core::ProviderStateKind::Unknown, + ); + let failure = + ProviderError::Other(codexbar::providers::claude::CLOUDFLARE_CHALLENGE_MESSAGE.to_string()); + let mut state = crate::state::AppState::new(); + state.provider_cache.push(good.clone()); + + let first = crate::commands::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Claude, + error.clone(), + &failure, + ); + let second = crate::commands::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Claude, + error, + &failure, + ); + + assert_eq!(first.error, None); + assert_eq!(first.primary.used_percent, 42.0); + assert_eq!( + second.error.as_deref(), + Some(codexbar::providers::claude::CLOUDFLARE_CHALLENGE_MESSAGE,) + ); + assert_eq!(second.primary.used_percent, 42.0); + assert_eq!(second.updated_at, good.updated_at); +} + +#[test] +fn claude_cli_parse_failure_keeps_last_good_every_time() { + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let mut result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), + "CLI", + ); + result.has_successful_claude_cli_quota = true; + let good = ProviderUsageSnapshot::from_fetch_result( + ProviderId::Claude, + &metadata, + &result, + None, + None, + ); + let err = ProviderUsageSnapshot::from_error( + ProviderId::Claude, + &metadata, + "Parse error: Empty output from Claude CLI".to_string(), + codexbar::core::ProviderStateKind::Unknown, + ); + let failure = ProviderError::Parse("Empty output from Claude CLI".to_string()); + let mut state = crate::state::AppState::new(); + state.provider_cache.push(good.clone()); + + let first = crate::commands::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Claude, + err.clone(), + &failure, + ); + let second = crate::commands::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Claude, + err, + &failure, + ); + + assert_eq!(first.error, None); + assert_eq!(first.primary.used_percent, 17.0); + assert!(!first.has_successful_claude_cli_quota); + // Parse failures keep last-good on every refresh (upstream #2247), unlike one-shot auth. + assert_eq!(second.error, None); + assert_eq!(second.primary.used_percent, 17.0); +} + +#[test] +fn claude_hard_credentials_missing_does_not_preserve_stale() { + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(17.0)), + "OAuth", + ); + let good = ProviderUsageSnapshot::from_fetch_result( + ProviderId::Claude, + &metadata, + &result, + None, + None, + ); + let err = ProviderUsageSnapshot::from_error( + ProviderId::Claude, + &metadata, + "OAuth error: Claude OAuth credentials not found. Run `claude` to authenticate." + .to_string(), + codexbar::core::ProviderStateKind::NeedsAuthentication, + ); + let failure = ProviderError::OAuth( + "Claude OAuth credentials not found. Run `claude` to authenticate.".to_string(), + ); + let mut state = crate::state::AppState::new(); + state.provider_cache.push(good); + + let out = crate::commands::providers::preserve_last_good_transient_failure( + &mut state, + ProviderId::Claude, + err, + &failure, + ); + assert!(out.error.is_some()); + assert_eq!( + out.error_state, + codexbar::core::ProviderStateKind::NeedsAuthentication, + "hard auth failure must carry its classification on the snapshot" + ); +} + +#[test] +fn claude_error_message_removes_upstream_swift_cancellation() { + let message = crate::commands::friendly_provider_error( + ProviderId::Claude, + "The operation couldn't be completed. (Swift.CancellationError error 1.)", + ); + + assert!(!message.contains("Swift")); + assert!(message.contains("Claude usage fetch was cancelled")); + assert!(message.contains("Refresh Claude")); +} + +#[test] +fn claude_error_message_explains_missing_sign_in() { + let message = crate::commands::friendly_provider_error( + ProviderId::Claude, + "OAuth error: Claude OAuth credentials not found. Run `claude` to authenticate.", + ); + + assert_eq!( + message, + "Claude sign-in was not found. Run `claude` once to authenticate, then refresh Claude in Win-CodexBar." + ); +} + +#[test] +fn claude_cloudflare_error_preserves_distinct_recovery_guidance() { + let challenge = codexbar::providers::claude::CLOUDFLARE_CHALLENGE_MESSAGE; + let message = crate::commands::friendly_provider_error(ProviderId::Claude, challenge); + + assert_eq!(message, challenge); + assert!(message.contains("OAuth")); + assert!(message.contains("different network")); +} + +#[test] +fn non_claude_error_message_is_preserved() { + let message = crate::commands::friendly_provider_error( + ProviderId::Codex, + "OAuth error: Claude OAuth credentials not found. Run `claude` to authenticate.", + ); + + assert_eq!( + message, + "OAuth error: Claude OAuth credentials not found. Run `claude` to authenticate." + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/tests/provider_detail.rs new file mode 100644 index 00000000000..48ae0fae0f3 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/tests/provider_detail.rs @@ -0,0 +1,264 @@ +use super::*; + +#[test] +fn build_provider_detail_populates_identity_urls() { + let (detail, _settings, _id) = + crate::commands::build_provider_detail("claude").expect("known provider"); + assert_eq!(detail.id, "claude"); + assert_eq!(detail.display_name, "Claude"); + // Claude advertises a status page URL in its metadata. + assert!(detail.status_page_url.is_some()); + // No snapshot yet — empty usage bars and no error. + assert!(detail.session.is_none()); + assert!(detail.last_error.is_none()); + assert!(!detail.has_snapshot); +} + +#[test] +fn build_provider_detail_rejects_unknown_provider() { + let err = crate::commands::build_provider_detail("not-a-provider").unwrap_err(); + assert!(err.contains("not-a-provider")); +} + +#[test] +fn provider_detail_roundtrips_through_serde() { + let (detail, _settings, _id) = + crate::commands::build_provider_detail("codex").expect("known provider"); + let json = serde_json::to_string(&detail).expect("serialize"); + // camelCase rename survives the round-trip. + assert!(json.contains("\"displayName\"")); + assert!(json.contains("\"hasSnapshot\"")); + assert!(json.contains("\"statusPageUrl\"")); +} + +#[test] +fn provider_detail_carries_openai_daily_usage_only_when_present() { + let (mut detail, _settings, _id) = + crate::commands::build_provider_detail("openaiapi").expect("known provider"); + assert!(detail.open_ai_api_usage.is_none()); + let json = serde_json::to_string(&detail).expect("serialize"); + assert!(!json.contains("openAiApiUsage")); + + detail.open_ai_api_usage = Some(crate::commands::OpenAiApiUsageSnapshot { + history_days: 30, + project_id: None, + daily: Vec::new(), + }); + let json = serde_json::to_string(&detail).expect("serialize"); + assert!(json.contains("\"openAiApiUsage\":{\"historyDays\":30")); +} + +#[test] +fn usage_item_descriptors_keep_raw_ids_and_redact_titles() { + let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), + "OAuth", + ); + let mut snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); + snapshot.primary_label = Some("Account owner@example.com".to_string()); + snapshot.extra_rate_windows = vec![NamedRateWindowSnapshot { + id: "credits".to_string(), + title: "Credits owner@example.com".to_string(), + window: snapshot.primary.clone(), + fallback_lane: false, + icon_fallback: None, + }]; + + let mut settings = Settings { + hide_personal_info: true, + ..Settings::default() + }; + settings.set_hidden_usage_item_ids(ProviderId::Codex, vec!["metric:extra-missing".to_string()]); + + let items = + crate::commands::usage_item_descriptors(Some(&snapshot), &settings, ProviderId::Codex); + + assert_eq!(items[0].id, "metric:primary"); + assert_eq!(items[0].title, "Account"); + assert_eq!(items[1].id, "metric:extra-credits"); + assert_eq!(items[1].title, "Credits"); + assert_eq!(items[2].id, "metric:extra-missing"); + assert!(!items[2].available); +} + +#[test] +fn usage_item_descriptors_include_detail_sections() { + let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); + let mut result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), + "OAuth", + ); + result.display_details = vec![ + codexbar::core::ProviderDisplayDetail::new("d1", "Rate limits", "5").expect("valid detail"), + codexbar::core::ProviderDisplayDetail::new("d2", "Rate limits", "6").expect("valid detail"), + codexbar::core::ProviderDisplayDetail::new("d3", "owner@example.com quota", "1") + .expect("valid detail"), + ]; + let snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); + + let mut settings = Settings { + hide_personal_info: true, + ..Settings::default() + }; + settings.set_hidden_usage_item_ids( + ProviderId::Codex, + vec!["detailSection:owner@example.com quota".to_string()], + ); + + let items = + crate::commands::usage_item_descriptors(Some(&snapshot), &settings, ProviderId::Codex); + + // First three items are the distinct detail sections, redacted titles. + assert_eq!(items[0].id, "detailSection:Rate limits"); + assert_eq!(items[0].title, "Rate limits"); + assert!(items[0].available); + // Duplicate title is deduped to one descriptor; the email title is redacted. + assert!( + !items[1..] + .iter() + .any(|item| item.title.contains("owner@example.com")) + ); + // The hidden detail section is a placeholder, redacted in the stored ID. + let hidden = items + .iter() + .find(|item| item.id.starts_with("detailSection:") && !item.available) + .expect("hidden detail placeholder"); + assert!(hidden.id.contains("owner@example.com quota")); + assert_eq!(hidden.title, "quota"); +} + +#[test] +fn pace_stage_serializes_to_snake_case_string() { + use codexbar::core::PaceStage; + assert_eq!( + crate::commands::bridge::pace::stage_str(PaceStage::OnTrack), + "on_track" + ); + assert_eq!( + crate::commands::bridge::pace::stage_str(PaceStage::SlightlyAhead), + "slightly_ahead" + ); + assert_eq!( + crate::commands::bridge::pace::stage_str(PaceStage::FarAhead), + "far_ahead" + ); + assert_eq!( + crate::commands::bridge::pace::stage_str(PaceStage::SlightlyBehind), + "slightly_behind" + ); + assert_eq!( + crate::commands::bridge::pace::stage_str(PaceStage::Behind), + "behind" + ); + assert_eq!( + crate::commands::bridge::pace::stage_str(PaceStage::FarBehind), + "far_behind" + ); +} + +#[test] +fn local_opencodego_estimates_keep_quota_windows_but_drop_derived_pace() { + let now = chrono::Utc::now(); + let usage = codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::with_details( + 12.0, + Some(300), + Some(now + chrono::Duration::hours(2)), + None, + )) + .with_secondary(codexbar::core::RateWindow::with_details( + 23.0, + Some(10080), + Some(now + chrono::Duration::days(3)), + None, + )) + .with_tertiary(codexbar::core::RateWindow::with_details( + 34.0, + Some(43200), + Some(now + chrono::Duration::days(10)), + None, + )); + let result = ProviderFetchResult::new( + usage, + codexbar::providers::opencodego::LOCAL_ESTIMATE_SOURCE_LABEL, + ) + .with_non_authoritative_pace(); + let metadata = instantiate_provider(ProviderId::OpenCodeGo) + .metadata() + .clone(); + let snapshot = ProviderUsageSnapshot::from_fetch_result( + ProviderId::OpenCodeGo, + &metadata, + &result, + None, + None, + ); + + assert_eq!(snapshot.source_label, "local estimate"); + assert_eq!(snapshot.primary.used_percent, 12.0); + assert_eq!(snapshot.secondary.as_ref().unwrap().used_percent, 23.0); + assert_eq!(snapshot.tertiary.as_ref().unwrap().used_percent, 34.0); + assert!(snapshot.primary.resets_at.is_some()); + assert!(snapshot.secondary.as_ref().unwrap().resets_at.is_some()); + assert!(snapshot.tertiary.as_ref().unwrap().resets_at.is_some()); + assert!(snapshot.pace.is_none()); + assert!( + snapshot + .secondary + .as_ref() + .unwrap() + .reserve_percent + .is_none() + ); +} + +#[test] +fn provider_inventory_maps_to_the_bridge_without_token_ids() { + let expiry = chrono::DateTime::::from_timestamp(1_900_000_000, 0).unwrap(); + let result = ProviderFetchResult::new( + codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(12.0)), + "web", + ) + .with_inventory_item(ProviderInventoryItem { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 2, + next_expires_at: Some(expiry), + }) + .with_display_detail( + ProviderDisplayDetail::new("credits", "Used this cycle", "12") + .and_then(|row| row.with_secondary_value("Monthly refill: 100")) + .and_then(|row| row.with_section_title("Credit usage")) + .and_then(|row| row.with_progress(12.0, 100.0)), + ); + let metadata = instantiate_provider(ProviderId::Grok).metadata().clone(); + let snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Grok, &metadata, &result, None, None); + + assert_eq!(snapshot.inventory.len(), 1); + assert_eq!(snapshot.inventory[0].available_count, 2); + assert_eq!( + snapshot.inventory[0].next_expires_at.as_deref(), + Some("2030-03-17T17:46:40+00:00") + ); + assert_eq!(snapshot.display_details.len(), 1); + assert_eq!(snapshot.display_details[0].value, "12"); + assert_eq!( + snapshot.display_details[0].section_title.as_deref(), + Some("Credit usage") + ); + let snapshot_json = serde_json::to_value(&snapshot).unwrap(); + assert_eq!( + snapshot_json["displayDetails"][0]["sectionTitle"], + "Credit usage" + ); + assert_eq!( + snapshot.display_details[0].secondary_value.as_deref(), + Some("Monthly refill: 100") + ); + let serialized = serde_json::to_string(&snapshot).unwrap(); + assert!(serialized.contains("reset-credits")); + assert!(!serialized.contains("coupon-token-secret")); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests/usage_items.rs b/apps/desktop-tauri/src-tauri/src/commands/tests/usage_items.rs new file mode 100644 index 00000000000..7ab3fdfb8db --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/tests/usage_items.rs @@ -0,0 +1,186 @@ +use codexbar::core::{ProviderFetchResult, ProviderId, instantiate_provider}; +use codexbar::settings::Settings; + +use super::super::ProviderUsageSnapshot; +use super::super::bridge::{ + NamedRateWindowSnapshot, ProviderInventoryItemSnapshot, SubscriptionMetadataSnapshot, +}; +use super::super::usage_items::*; + +fn claude_snapshot_with_sections(source_label: &str) -> ProviderUsageSnapshot { + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let result = ProviderFetchResult { + usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), + cost: Some(codexbar::core::CostSnapshot::new(0.0, "USD", "Extra usage").with_balance(5.0)), + wayfinder_usage: None, + open_ai_api_usage: None, + inventory: Vec::new(), + reset_credits: None, + display_details: Vec::new(), + source_label: source_label.to_string(), + has_successful_claude_cli_quota: false, + pace_authoritative: true, + account_identity: None, + last_good_owner: None, + throughput: None, + }; + let mut snapshot = ProviderUsageSnapshot::from_fetch_result( + ProviderId::Claude, + &metadata, + &result, + None, + None, + ); + snapshot.inventory = vec![ProviderInventoryItemSnapshot { + id: "reset-credits".to_string(), + title: "Limit Reset Credits".to_string(), + available_count: 2, + next_expires_at: None, + }]; + snapshot.subscription = Some(SubscriptionMetadataSnapshot { + starts_at: None, + expires_at: None, + renews_at: Some("2026-12-01T00:00:00+00:00".to_string()), + renews_at_is_date_only: true, + expires_at_is_date_only: false, + }); + snapshot +} + +#[test] +fn usage_item_descriptors_keep_hidden_claude_sections_restorable() { + let mut settings = Settings::default(); + settings.set_hidden_usage_item_ids( + ProviderId::Claude, + vec![ + "section:claude-reset-credits".to_string(), + "section:unknown".to_string(), + ], + ); + let items = usage_item_descriptors(None, &settings, ProviderId::Claude); + let ids: Vec<(&str, &str, bool)> = items + .iter() + .map(|item| (item.id.as_str(), item.title.as_str(), item.available)) + .collect(); + assert_eq!( + ids, + [( + "section:claude-reset-credits", + "Limit reset credits (unavailable)", + false + )] + ); + // Section IDs never become "metric:section:..." placeholders. + assert!(!items.iter().any(|item| item.id.starts_with("metric:"))); +} + +#[test] +fn usage_item_descriptors_offer_independent_claude_sections() { + let snapshot = claude_snapshot_with_sections("web"); + let items = usage_item_descriptors(Some(&snapshot), &Settings::default(), ProviderId::Claude); + let sections: Vec<(&str, &str, bool, bool)> = items + .iter() + .filter(|item| item.id.starts_with("section:")) + .map(|item| { + ( + item.id.as_str(), + item.title.as_str(), + item.available, + item.requires_cost_summary, + ) + }) + .collect(); + assert_eq!( + sections, + [ + ("section:subscription", "Plan renewal / expiry", true, false), + ( + "section:claude-reset-credits", + "Limit reset credits", + true, + false + ), + ("section:claude-extra-usage", "Extra usage", true, true), + ] + ); + + // Admin API spend is not Extra usage and has no Extra usage choice. + let admin = claude_snapshot_with_sections("admin-api"); + let items = usage_item_descriptors(Some(&admin), &Settings::default(), ProviderId::Claude); + assert!( + !items + .iter() + .any(|item| item.id == "section:claude-extra-usage") + ); + + // The same sections are never offered for another provider. + let items = usage_item_descriptors(Some(&snapshot), &Settings::default(), ProviderId::Codex); + assert!(!items.iter().any(|item| item.id.starts_with("section:"))); +} + +#[test] +fn usage_item_descriptors_skip_card_hidden_windows() { + let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); + let result = ProviderFetchResult { + usage: codexbar::core::UsageSnapshot::new( + codexbar::core::RateWindow::informational("$1.00").hidden_on_card(), + ) + .with_secondary(codexbar::core::RateWindow::new(20.0)), + cost: None, + wayfinder_usage: None, + inventory: Vec::new(), + reset_credits: None, + display_details: vec![ + codexbar::core::ProviderDisplayDetail::new("spend", "Spend", "$1.00") + .expect("valid detail"), + ], + source_label: "api".to_string(), + has_successful_claude_cli_quota: false, + pace_authoritative: true, + account_identity: None, + open_ai_api_usage: None, + throughput: None, + last_good_owner: None, + }; + let mut snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); + let mut hidden_extra = snapshot.primary.clone(); + hidden_extra.hidden_on_card = true; + snapshot.extra_rate_windows = vec![NamedRateWindowSnapshot { + id: "hidden".to_string(), + title: "Hidden".to_string(), + window: hidden_extra, + fallback_lane: false, + icon_fallback: None, + }]; + + let items = usage_item_descriptors(Some(&snapshot), &Settings::default(), ProviderId::Codex); + let ids: Vec<&str> = items.iter().map(|item| item.id.as_str()).collect(); + + assert_eq!(ids, ["detailSection:Spend", "metric:secondary"]); + + // A saved hidden ID for a card-hidden window gets no placeholder, while + // one for a window the snapshot no longer reports still does. + let mut settings = Settings::default(); + settings.set_hidden_usage_item_ids( + ProviderId::Codex, + vec![ + "metric:primary".to_string(), + "metric:extra-hidden".to_string(), + "metric:tertiary".to_string(), + ], + ); + let items = usage_item_descriptors(Some(&snapshot), &settings, ProviderId::Codex); + let ids: Vec<(&str, bool)> = items + .iter() + .map(|item| (item.id.as_str(), item.available)) + .collect(); + assert_eq!( + ids, + [ + ("detailSection:Spend", true), + ("metric:secondary", true), + ("metric:tertiary", false), + ] + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/token_account_source_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/token_account_source_tests.rs index ee1cee114e7..099a3297e9e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/token_account_source_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/token_account_source_tests.rs @@ -1,38 +1,28 @@ //! Source-mode resolution for selected token accounts. -use std::collections::HashMap; - -use codexbar::core::{ - ProviderAccountData, ProviderId, SourceMode, TokenAccount, instantiate_provider, -}; -use codexbar::settings::{ApiKeys, ManualCookies, Settings}; - -fn token_accounts(id: ProviderId, token: &str) -> HashMap { - let mut data = ProviderAccountData::new(); - data.add_account(TokenAccount::new("Work", token)); - HashMap::from([(id, data)]) -} - -fn context_with_usage_source( - id: ProviderId, - usage_source: &str, - accounts: &HashMap, -) -> codexbar::core::FetchContext { - let mut settings = Settings::default(); - settings.set_usage_source(id, usage_source); - super::build_fetch_context( - id, - &settings, - &ManualCookies::default(), - &ApiKeys::default(), - accounts, +use codexbar::core::{FetchContext, ProviderId, SourceMode, instantiate_provider}; + +use super::fetch_context_tests::{CtxInput, fetch_ctx}; + +fn huggingface_context(usage_source: &str, with_account: bool) -> FetchContext { + let accounts: &[(&str, &str)] = if with_account { + &[("Work", "hf_account_token")] + } else { + &[] + }; + fetch_ctx( + ProviderId::HuggingFace, + CtxInput { + usage_source: Some(usage_source), + accounts, + ..Default::default() + }, ) } #[test] fn huggingface_token_account_keeps_auto_so_the_wallet_is_still_read() { - let accounts = token_accounts(ProviderId::HuggingFace, "hf_account_token"); - let ctx = context_with_usage_source(ProviderId::HuggingFace, "auto", &accounts); + let ctx = huggingface_context("auto", true); assert_eq!(ctx.source_mode, SourceMode::Auto); assert_eq!(ctx.api_key.as_deref(), Some("hf_account_token")); @@ -40,8 +30,7 @@ fn huggingface_token_account_keeps_auto_so_the_wallet_is_still_read() { #[test] fn huggingface_token_account_with_explicit_api_source_stays_api_only() { - let accounts = token_accounts(ProviderId::HuggingFace, "hf_account_token"); - let ctx = context_with_usage_source(ProviderId::HuggingFace, "oauth", &accounts); + let ctx = huggingface_context("oauth", true); assert_eq!(ctx.source_mode, SourceMode::OAuth); assert_eq!(ctx.api_key.as_deref(), Some("hf_account_token")); @@ -49,16 +38,15 @@ fn huggingface_token_account_with_explicit_api_source_stays_api_only() { #[test] fn huggingface_token_account_with_an_unsupported_stored_source_falls_back_to_api() { - let accounts = token_accounts(ProviderId::HuggingFace, "hf_account_token"); for stale in ["web", "cli"] { - let ctx = context_with_usage_source(ProviderId::HuggingFace, stale, &accounts); + let ctx = huggingface_context(stale, true); assert_eq!(ctx.source_mode, SourceMode::OAuth, "{stale}"); } } #[test] fn huggingface_without_a_token_account_follows_the_usage_source() { - let ctx = context_with_usage_source(ProviderId::HuggingFace, "auto", &HashMap::new()); + let ctx = huggingface_context("auto", false); assert_eq!(ctx.source_mode, SourceMode::Auto); } diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs index de35a80b31c..cb93e7d3065 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -19,6 +19,14 @@ use crate::state::AppState; use std::collections::{BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock}; +mod build; +#[cfg(test)] +use build::{ + antigravity_spend_values, cached_spend, include_in_shared_overview, period_cost_from_daily, + selected_period_scan, +}; +use build::{build_usage_spend_summary, cost_tracking_off_summary}; + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct UsageSpendDailyPoint { @@ -448,1019 +456,7 @@ fn usage_spend_cache_key_with_privacy( ) } -fn build_usage_spend_summary( - cached: &[ProviderUsageSnapshot], - period: CostReportingPeriod, - settings: &codexbar::settings::Settings, - force_refresh: bool, -) -> UsageSpendSummary { - let now = Utc::now(); - let period_scan_days = period.scan_days(now); - let include_opencodex = settings.open_codex_usage_logs_enabled; - let hide_native = settings.hide_native_codex_cost_when_open_codex_present; - let pi_selected = settings.enabled_providers.iter().any(|id| id == "pi") - || cached.iter().any(|snapshot| snapshot.provider_id == "pi"); - let include_pi_in_native = !pi_selected; - - // Upstream 0.55.0 #3105: independent provider baselines load in parallel. - // Keep each provider's 7d/30d scans serial so they can safely share that - // provider's incremental cache, while Codex and Claude run concurrently. - let codex_scan_options = if force_refresh { - codexbar::core::CostScanOptions::app_driven() - } else { - codexbar::core::CostScanOptions::default() - }; - let mut codex_scan_options = codex_scan_options; - codex_scan_options.include_pi_sessions = include_pi_in_native; - // Any period other than 7d/30d costs one extra scan per provider. - let ( - (codex_7_summary, codex_30_summary, codex_period_summary, codex_activity), - (claude_7_summary, claude_30_summary, claude_period_summary, claude_activity), - (pi_7_summary, pi_30_summary, pi_period_summary), - ) = std::thread::scope(|scope| { - let codex = scope.spawn(move || { - let seven = CostScanner::new(7) - .with_options(codex_scan_options) - .scan_codex(); - let thirty = CostScanner::new(30) - .with_options(codex_scan_options) - .scan_codex(); - let selected = selected_period_scan(period, &seven, &thirty, || { - CostScanner::for_period(period) - .with_options(codex_scan_options) - .scan_codex() - }); - let activity = daily_token_activity("codex", TOKEN_ACTIVITY_DAYS); - (seven, thirty, selected, activity) - }); - let claude = scope.spawn(|| { - let seven = CostScanner::new(7) - .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native); - let thirty = CostScanner::new(30) - .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native); - let selected = selected_period_scan(period, &seven, &thirty, || { - CostScanner::for_period(period) - .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native) - }); - let activity = daily_token_activity("claude", TOKEN_ACTIVITY_DAYS); - (seven, thirty, selected, activity) - }); - let pi = scope.spawn(|| { - let seven = CostScanner::new(7).scan_pi(); - let thirty = CostScanner::new(30).scan_pi(); - let selected = selected_period_scan(period, &seven, &thirty, || { - CostScanner::for_period(period).scan_pi() - }); - (seven, thirty, selected) - }); - ( - codex.join().expect("Codex spend scan worker panicked"), - claude.join().expect("Claude spend scan worker panicked"), - pi.join().expect("Pi spend scan worker panicked"), - ) - }); - - let codex_stale = !codex_30_summary.history_coverage_established; - let codex_stale_updated_at = codex_stale - .then(|| { - codexbar::core::JsonlScanner::load_cache_status(codexbar::core::ProviderId::Codex, None) - .previous_report - .and_then(|report| report.updated_at) - }) - .flatten(); - - let codex_7_contract = build_local_spend_contract_from_summary( - "codex", - 7, - include_opencodex, - hide_native, - settings.hide_personal_info, - codex_7_summary.clone(), - ); - let codex_30_contract = build_local_spend_contract_from_summary( - "codex", - 30, - include_opencodex, - hide_native, - settings.hide_personal_info, - codex_30_summary.clone(), - ); - let codex_period_contract = build_contract_from_period_summary( - "codex", - period, - include_opencodex, - hide_native, - settings.hide_personal_info, - codex_period_summary, - ); - let pi_period_contract = build_contract_from_period_summary( - "pi", - period, - false, - false, - settings.hide_personal_info, - pi_period_summary, - ); - let pi_7_contract = build_local_spend_contract_from_summary( - "pi", - 7, - false, - false, - settings.hide_personal_info, - pi_7_summary.clone(), - ); - let pi_30_contract = build_local_spend_contract_from_summary( - "pi", - 30, - false, - false, - settings.hide_personal_info, - pi_30_summary.clone(), - ); - - let mut provider_ids: BTreeSet = settings.enabled_providers.iter().cloned().collect(); - provider_ids.extend(cached.iter().map(|snapshot| snapshot.provider_id.clone())); - if include_opencodex { - // OpenCodex is an enrichment source, never a standalone provider row. - // Publish routed subscriptions even when no live provider snapshot exists. - for id in ["codex", "opencodego", "kimi", "deepseek", "nous"] { - let contract = match id { - "codex" => None, - _ => Some(build_local_spend_contract(id, 30, true)), - }; - if contract - .as_ref() - .is_some_and(|contract| !contract.imports.is_empty()) - { - provider_ids.insert(id.to_string()); - } - } - } - - let token_activity = token_activity_sources(&provider_ids, codex_activity, claude_activity); - - let cached_by_id: HashMap<&str, &ProviderUsageSnapshot> = cached - .iter() - .map(|snapshot| (snapshot.provider_id.as_str(), snapshot)) - .collect(); - - let mut rows = Vec::new(); - for provider_id in provider_ids { - let cached_snapshot = cached_by_id.get(provider_id.as_str()).copied(); - let display_name = cached_snapshot - .map(|snapshot| snapshot.display_name.trim()) - .filter(|name| !name.is_empty()) - .map(str::to_string) - .or_else(|| { - codexbar::core::ProviderId::from_cli_name(&provider_id).map(|id| { - codexbar::core::instantiate_provider(id) - .metadata() - .display_name - .to_string() - }) - }) - .unwrap_or_else(|| provider_id.clone()); - - let mut local_cost_estimates = None; - let mut token_lower_bounds = (false, false); - let spend = match provider_id.as_str() { - "codex" => SpendValues { - seven_day: codex_7_contract.known_cost_usd, - thirty_day: codex_30_contract.known_cost_usd, - seven_day_tokens: codex_7_contract.token_total, - thirty_day_tokens: codex_30_contract.token_total, - period_cost: codex_period_contract.known_cost_usd, - period_tokens: codex_period_contract.token_total, - source: if include_opencodex && !codex_30_contract.imports.is_empty() { - "local logs + OpenCodex".to_string() - } else { - "local logs".to_string() - }, - refreshing: codex_stale, - stale_updated_at: codex_stale_updated_at.clone(), - }, - "claude" => SpendValues { - seven_day: Some(claude_7_summary.total_cost_usd), - thirty_day: Some(claude_30_summary.total_cost_usd), - seven_day_tokens: Some(claude_7_summary.total_tokens_for_provider("claude")), - thirty_day_tokens: Some(claude_30_summary.total_tokens_for_provider("claude")), - period_cost: Some(claude_period_summary.total_cost_usd), - period_tokens: Some(claude_period_summary.total_tokens_for_provider("claude")), - source: "local logs".to_string(), - refreshing: false, - stale_updated_at: None, - }, - "pi" => SpendValues { - seven_day: pi_7_contract.known_cost_usd, - thirty_day: pi_30_contract.known_cost_usd, - seven_day_tokens: pi_7_contract.token_total, - thirty_day_tokens: pi_30_contract.token_total, - period_cost: pi_period_contract.known_cost_usd, - period_tokens: pi_period_contract.token_total, - source: "local Pi/OMP history".to_string(), - refreshing: !pi_30_summary.history_coverage_established, - stale_updated_at: None, - }, - "opencodego" | "kimi" | "deepseek" | "nous" if include_opencodex => { - let seven = build_local_spend_contract(&provider_id, 7, true); - let thirty = build_local_spend_contract(&provider_id, 30, true); - if !thirty.imports.is_empty() { - let selected = - build_local_spend_contract_for_period(&provider_id, period, true); - SpendValues { - seven_day: seven.known_cost_usd, - thirty_day: thirty.known_cost_usd, - seven_day_tokens: seven.token_total, - thirty_day_tokens: thirty.token_total, - period_cost: selected.known_cost_usd, - period_tokens: selected.token_total, - source: if provider_id == "opencodego" { - "local logs + OpenCodex".to_string() - } else { - "OpenCodex".to_string() - }, - refreshing: false, - stale_updated_at: None, - } - } else { - cached_spend(cached_snapshot, period, now) - } - } - "opencodego" => { - // Cost stays the provider's; recorded local tokens fill the token columns. - let mut spend = cached_spend(cached_snapshot, period, now); - let seven = CostScanner::new(7).scan_opencodego_usage_tokens_with_cancel(None); - let thirty = CostScanner::new(30).scan_opencodego_usage_tokens_with_cancel(None); - spend.period_tokens = selected_period_scan(period, &seven, &thirty, || { - CostScanner::for_period(period).scan_opencodego_usage_tokens_with_cancel(None) - }); - spend.seven_day_tokens = seven; - spend.thirty_day_tokens = thirty; - spend - } - "cursor" => { - let seven = codexbar::providers::cursor::local_csv::summarize(7); - let thirty = codexbar::providers::cursor::local_csv::summarize(30); - if thirty.row_count > 0 { - let selected = - codexbar::providers::cursor::local_csv::summarize(period_scan_days); - SpendValues { - seven_day: (seven.row_count > 0).then_some(seven.total_cost_usd), - thirty_day: Some(thirty.total_cost_usd), - seven_day_tokens: (seven.row_count > 0).then_some(seven.total_tokens), - thirty_day_tokens: Some(thirty.total_tokens), - period_cost: (selected.row_count > 0).then_some(selected.total_cost_usd), - period_tokens: (selected.row_count > 0).then_some(selected.total_tokens), - source: "local Cursor tokscale cache".to_string(), - refreshing: false, - stale_updated_at: None, - } - } else { - cached_spend(cached_snapshot, period, now) - } - } - "grok" => { - let seven = codexbar::providers::grok::local_sessions::summarize(7); - let thirty = codexbar::providers::grok::local_sessions::summarize(30); - let selected = - codexbar::providers::grok::local_sessions::summarize(period_scan_days); - let mut spend = cached_spend(cached_snapshot, period, now); - spend.seven_day_tokens = (seven.session_count > 0).then_some(seven.total_tokens); - spend.thirty_day_tokens = (thirty.session_count > 0).then_some(thirty.total_tokens); - spend.period_tokens = (selected.session_count > 0).then_some(selected.total_tokens); - if thirty.session_count > 0 { - spend.source = "local Grok sessions".to_string(); - } - spend - } - "antigravity" => { - use codexbar::providers::antigravity::local_sessions; - let seven = local_sessions::summarize(7); - let thirty = local_sessions::summarize(30); - let selected = local_sessions::summarize(period_scan_days); - let mut spend = cached_spend(cached_snapshot, period, now); - // Upstream 0.64: the app refreshes unknown-model pricing in the - // background; a later read (provider refresh or Refresh) reprices. - if let Some(refresh) = local_sessions::background_pricing_refresh(&thirty) { - tauri::async_runtime::spawn(refresh); - } - { - use codexbar::providers::antigravity::local_sessions::LocalHistoryCoverage; - spend.seven_day_tokens = - matches!(seven.coverage, LocalHistoryCoverage::Complete) - .then_some(seven.total_tokens); - spend.thirty_day_tokens = - matches!(thirty.coverage, LocalHistoryCoverage::Complete) - .then_some(thirty.total_tokens); - spend.period_tokens = - matches!(selected.coverage, LocalHistoryCoverage::Complete) - .then_some(selected.total_tokens); - if matches!(thirty.coverage, LocalHistoryCoverage::Complete) { - spend.source = "local Antigravity history".to_string(); - } - } - let spend = antigravity_spend_values(spend, &seven, &thirty); - token_lower_bounds = (seven.lower_bound, thirty.lower_bound); - local_cost_estimates = Some((seven.cost_estimate, thirty.cost_estimate)); - spend - } - _ => cached_spend(cached_snapshot, period, now), - }; - - let currency = cached_snapshot - .and_then(|snapshot| snapshot.cost.as_ref()) - .map(|cost| cost.currency_code.clone()) - .unwrap_or_else(|| "USD".to_string()); - let daily = cached_snapshot - .and_then(|snapshot| snapshot.cost.as_ref()) - .map(|cost| { - cost.daily - .iter() - .map(|point| UsageSpendDailyPoint { - day: point.day.clone(), - amount: point.amount, - }) - .collect() - }) - .unwrap_or_default(); - let (seven_day_estimate, thirty_day_estimate) = local_cost_estimates - .map(|(seven, thirty)| (Some(seven), Some(thirty))) - .unwrap_or((None, None)); - rows.push(UsageSpendRow { - provider_id: provider_id.clone(), - display_name, - seven_day: spend.seven_day, - thirty_day: spend.thirty_day, - seven_day_estimate, - thirty_day_estimate, - seven_day_tokens: spend.seven_day_tokens, - thirty_day_tokens: spend.thirty_day_tokens, - seven_day_tokens_lower_bound: token_lower_bounds.0, - thirty_day_tokens_lower_bound: token_lower_bounds.1, - period_cost: spend.period_cost, - period_tokens: spend.period_tokens, - currency, - source: spend.source, - included_in_overview: include_in_shared_overview( - &provider_id, - settings.enabled_providers.contains(&provider_id), - cached_snapshot.is_some(), - ), - daily, - refreshing: spend.refreshing, - stale_updated_at: spend.stale_updated_at, - }); - } - - let contract = codex_period_contract; - let claude_trend = rows.iter().any(|row| row.provider_id == "claude").then(|| { - let (daily, hourly) = claude_trend_history(period.sidecar_days(now), now); - (claude_period_summary.known_zero, daily, hourly) - }); - let trend = build_spend_trend(&rows, &contract, claude_trend, period, now); - let reporting_day = last_included_reporting_day(&contract); - let dashboard_timezone = codexbar::core::local_timezone_name(); - UsageSpendSummary { - rows, - contract, - reporting_period: period.raw(), - reporting_day, - dashboard_timezone, - cost_tracking_enabled: true, - token_activity, - trend: Some(trend), - } -} - -/// The summary while "Track costs" is off: no rows, no scan. -fn cost_tracking_off_summary(period: CostReportingPeriod) -> UsageSpendSummary { - UsageSpendSummary { - rows: Vec::new(), - contract: SpendContract::empty("codex", period), - reporting_period: period.raw(), - reporting_day: chrono::Local::now() - .date_naive() - .format("%Y-%m-%d") - .to_string(), - dashboard_timezone: codexbar::core::local_timezone_name(), - cost_tracking_enabled: false, - token_activity: Vec::new(), - trend: None, - } -} - -type DailyCosts = Vec<(String, Option)>; -type HourlyCosts = Vec; - -/// Window length, local reporting day, and cost bucket zone of one Claude -/// trend walk. -type ClaudeTrendKey = (u32, chrono::NaiveDate, String); - -struct ClaudeTrendMemo { - key: ClaudeTrendKey, - stored_at: std::time::Instant, - daily: DailyCosts, - hourly: HourlyCosts, -} - -/// The Claude trend walk reads every transcript in the window with no -/// incremental cache, so a complete result is reused for the cost scan -/// debounce interval instead of re-walking on every summary rebuild. -fn claude_trend_history(days: u32, now: chrono::DateTime) -> (DailyCosts, HourlyCosts) { - static MEMO: Mutex> = Mutex::new(None); - let zone = codexbar::cost_reporting_period::cost_bucket_zone(); - let key: ClaudeTrendKey = (days, zone.date(now), zone.identifier()); - let ttl = std::time::Duration::from_secs( - codexbar::core::CostScanOptions::default().refresh_min_interval_secs, - ); - if let Some(memo) = MEMO - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .as_ref() - .filter(|memo| memo.key == key && memo.stored_at.elapsed() <= ttl) - { - return (memo.daily.clone(), memo.hourly.clone()); - } - let (daily, hourly, complete) = - codexbar::cost_scanner::get_claude_daily_and_hourly_cost_history(days); - // A partial walk is never reused, so the next rebuild retries it. - if complete { - *MEMO - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(ClaudeTrendMemo { - key, - stored_at: std::time::Instant::now(), - daily: daily.clone(), - hourly: hourly.clone(), - }); - } - (daily, hourly) -} -/// Claude's window known-zero flag with its daily and hourly cost series. -type ClaudeTrendSeries = (bool, DailyCosts, HourlyCosts); - -/// Recorded daily (and, where the source keeps timestamps, hourly) spend per -/// source over the selected window. Local-log providers use their own scans; -/// every other USD row contributes its provider-reported daily costs. -fn build_spend_trend( - rows: &[UsageSpendRow], - codex: &SpendContract, - claude: Option, - period: CostReportingPeriod, - now: chrono::DateTime, -) -> SpendTrend { - use codexbar::spend_trend::{SpendTrendBuilder, SpendTrendSource}; - let mut series: Vec<(SpendTrendSource, DailyCosts, HourlyCosts)> = Vec::new(); - for row in rows { - let source = |known_zero: bool| SpendTrendSource { - id: row.provider_id.clone(), - provider_id: row.provider_id.clone(), - display_name: row.display_name.clone(), - known_zero, - }; - match row.provider_id.as_str() { - "codex" => series.push(( - source(codex.known_zero), - codex - .daily - .iter() - .map(|point| (point.day.clone(), point.cost_usd)) - .collect(), - Vec::new(), - )), - "claude" => { - if let Some((known_zero, daily, hourly)) = claude.clone() { - series.push((source(known_zero), daily, hourly)); - } - } - _ if row.included_in_overview - && row.currency.eq_ignore_ascii_case("USD") - && !row.daily.is_empty() => - { - series.push(( - source(false), - row.daily - .iter() - .map(|point| (point.day.clone(), Some(point.amount))) - .collect(), - Vec::new(), - )); - } - _ => {} - } - } - // All available history opens at the first recorded spend, not at 1970. - let earliest = series - .iter() - .flat_map(|(_, daily, _)| daily.iter()) - .filter(|(_, cost)| cost.is_some_and(|cost| cost > 0.0)) - .filter_map(|(day, _)| chrono::NaiveDate::parse_from_str(day, "%Y-%m-%d").ok()) - .min(); - let zone = codexbar::cost_reporting_period::cost_bucket_zone(); - let bounds = period.bounds(now, zone, earliest.or(Some(zone.date(now)))); - let mut builder = SpendTrendBuilder::new(bounds, zone); - for (source, daily, hourly) in series { - builder.add_source(source, daily, &hourly); - } - builder.finish() -} - -/// Reuse the fixed scan when it already covers the selected period. -fn selected_period_scan( - period: CostReportingPeriod, - seven: &T, - thirty: &T, - scan: impl FnOnce() -> T, -) -> T { - match period { - CostReportingPeriod::Rolling(7) => seven.clone(), - CostReportingPeriod::Rolling(30) => thirty.clone(), - _ => scan(), - } -} - -/// Pi is an alternate local-history view over rows that may already be -/// projected into Codex or Claude. Keep it out of the shared denominator so -/// enabling Pi cannot double-count the same physical usage. -fn include_in_shared_overview(provider_id: &str, enabled: bool, cached: bool) -> bool { - provider_id != "pi" && (enabled || cached) -} - -fn last_included_reporting_day(contract: &SpendContract) -> String { - contract - .daily - .iter() - .filter_map(|point| chrono::NaiveDate::parse_from_str(&point.day, "%Y-%m-%d").ok()) - .max() - .unwrap_or_else(|| chrono::Local::now().date_naive()) - .format("%Y-%m-%d") - .to_string() -} - -fn antigravity_spend_values( - mut spend: SpendValues, - seven: &codexbar::spend_contract::LocalTokenHistorySummary, - thirty: &codexbar::spend_contract::LocalTokenHistorySummary, -) -> SpendValues { - use codexbar::spend_contract::LocalHistoryCoverage; - - spend.seven_day = seven.total_usd(); - spend.thirty_day = thirty.total_usd(); - // Exact for a complete scan, a floor for a lower bound, unknown otherwise. - spend.seven_day_tokens = seven.published_tokens(); - spend.thirty_day_tokens = thirty.published_tokens(); - if spend.thirty_day.is_some() { - spend.source = "local Antigravity history · API list-price estimate".to_string(); - } else if thirty.cost_estimate.known_subtotal_usd.is_some() { - spend.source = "local Antigravity history · known API list-price subtotal".to_string(); - } else if thirty.coverage == LocalHistoryCoverage::Complete { - spend.source = "local Antigravity history · unpriced".to_string(); - } - spend -} - -/// Provider-reported daily costs bucket by UTC day, so the selected period is -/// resolved in UTC here. `None` when no day falls inside the window. -fn period_cost_from_daily( - daily: &[super::bridge::CostDailyPointBridge], - period: CostReportingPeriod, - now: chrono::DateTime, -) -> Option { - let earliest = daily - .iter() - .filter_map(|point| chrono::NaiveDate::parse_from_str(&point.day, "%Y-%m-%d").ok()) - .min(); - let bounds = period.bounds(now, CostTimeZone::UTC, earliest); - let mut total = 0.0; - let mut saw = false; - for point in daily { - if bounds.contains_day_key(&point.day) { - total += point.amount; - saw = true; - } - } - saw.then_some(total) -} - -fn cached_spend( - snapshot: Option<&ProviderUsageSnapshot>, - reporting_period: CostReportingPeriod, - now: chrono::DateTime, -) -> SpendValues { - let Some(snapshot) = snapshot else { - return SpendValues { - seven_day: None, - thirty_day: None, - seven_day_tokens: None, - thirty_day_tokens: None, - period_cost: None, - period_tokens: None, - source: "unavailable".to_string(), - refreshing: false, - stale_updated_at: None, - }; - }; - let Some(cost) = snapshot.cost.as_ref() else { - return SpendValues { - seven_day: None, - thirty_day: None, - seven_day_tokens: None, - thirty_day_tokens: None, - period_cost: None, - period_tokens: None, - source: if snapshot.error.is_some() { - "unavailable".to_string() - } else { - snapshot.source_label.clone() - }, - refreshing: false, - stale_updated_at: None, - }; - }; - let period = cost.period.trim(); - let period_lower = period.to_ascii_lowercase(); - let (seven_day, thirty_day) = if cost.daily.is_empty() { - ( - None, - (period_lower.contains("30 day") || period_lower.contains("30-day")) - .then_some(cost.used), - ) - } else { - let today = chrono::Utc::now().date_naive(); - let seven_cutoff = today - chrono::Duration::days(6); - let thirty_cutoff = today - chrono::Duration::days(29); - let mut seven = 0.0; - let mut thirty = 0.0; - let mut saw_seven = false; - let mut saw_thirty = false; - for point in &cost.daily { - let Ok(day) = chrono::NaiveDate::parse_from_str(&point.day, "%Y-%m-%d") else { - continue; - }; - // Providers with long daily history (Bedrock keeps 14 months) must - // not widen the fixed 30-day column. - if day > today || day < thirty_cutoff { - continue; - } - thirty += point.amount; - saw_thirty = true; - if day >= seven_cutoff { - seven += point.amount; - saw_seven = true; - } - } - (saw_seven.then_some(seven), saw_thirty.then_some(thirty)) - }; - let period_cost = if cost.daily.is_empty() { - // A provider-reported 30-day total only answers a 30-day selection. - (reporting_period == CostReportingPeriod::Rolling(30)) - .then_some(thirty_day) - .flatten() - } else { - period_cost_from_daily(&cost.daily, reporting_period, now) - }; - SpendValues { - seven_day, - thirty_day, - seven_day_tokens: None, - thirty_day_tokens: None, - period_cost, - period_tokens: None, - source: if period.is_empty() { - snapshot.source_label.clone() - } else { - format!("period ({period})") - }, - refreshing: false, - stale_updated_at: None, - } -} - #[cfg(test)] -mod cache_key_tests { - use super::*; - - fn local_history( - total_tokens: u64, - coverage: codexbar::spend_contract::LocalHistoryCoverage, - known_subtotal_usd: Option, - unpriced: u32, - ) -> codexbar::spend_contract::LocalTokenHistorySummary { - codexbar::spend_contract::LocalTokenHistorySummary { - total_tokens, - session_count: if total_tokens > 0 { 1 } else { 0 }, - coverage, - cost_estimate: codexbar::spend_contract::LocalCostEstimate { - known_subtotal_usd, - coverage: codexbar::spend_contract::CostCoverageCounts { - estimated: if known_subtotal_usd.is_some() { 1 } else { 0 }, - unpriced, - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - } - } - - #[test] - fn invalidated_owner_clears_orphaned_indexing_activity() { - let mut coordinator = UsageSpendCoordinator::default(); - let owner = coordinator.begin("account:old".to_string()); - - assert!(coordinator.clear_if_indexing(&owner)); - assert!(!coordinator.is_current(&owner)); - } - - #[test] - fn old_owner_cleanup_cannot_clear_a_replacement() { - let mut coordinator = UsageSpendCoordinator::default(); - let old = coordinator.begin("account:old".to_string()); - let replacement = coordinator.begin("account:new".to_string()); - - assert!(!coordinator.clear_if_indexing(&old)); - assert!(coordinator.is_current(&replacement)); - } - - #[test] - fn settings_replacement_preserves_an_intentional_pause() { - let mut coordinator = UsageSpendCoordinator::default(); - let old = coordinator.begin("settings:old".to_string()); - let replacement = coordinator.begin("settings:new".to_string()); - let status = codexbar::core::CachedCostReadStatus { - codex_scan_pause_reason: Some(codexbar::core::CodexScanPauseReason::NoProgress), - ..Default::default() - }; - mark_refresh_paused_if_codex_scan_paused( - &mut coordinator, - &replacement, - true, - status.codex_scan_pause_reason.as_ref(), - ); - - assert!(!coordinator.clear_if_indexing(&old)); - assert_eq!( - coordinator.current.as_ref().map(|(_, phase)| *phase), - Some(UsageSpendRefreshPhase::Paused) - ); - } - - fn empty_summary() -> UsageSpendSummary { - UsageSpendSummary { - reporting_period: "rolling:30".to_string(), - rows: Vec::new(), - contract: SpendContract { - provider_id: "codex".to_string(), - reporting_period: "rolling:30".to_string(), - history_days: 30, - known_cost_usd: None, - known_zero: false, - provenance: codexbar::spend_contract::CostProvenance::Unknown, - price_coverage: Default::default(), - price_coverage_ratio: None, - history_coverage_established: false, - token_mix: Default::default(), - token_total: None, - conversation_count: 0, - models: Vec::new(), - projects: Vec::new(), - conversations: Vec::new(), - daily: Vec::new(), - hourly_activity: Vec::new(), - project_source_status: None, - custom_pricing_active: false, - imports: Vec::new(), - }, - reporting_day: "2026-10-01".to_string(), - dashboard_timezone: "UTC".to_string(), - cost_tracking_enabled: true, - token_activity: Vec::new(), - trend: None, - } - } - - fn activity_day(day: &str, total_tokens: Option) -> TokenActivityDay { - TokenActivityDay { - day: day.to_string(), - total_tokens, - is_scanned: true, - } - } - - fn trend_row(provider_id: &str, currency: &str, daily: &[(&str, f64)]) -> UsageSpendRow { - UsageSpendRow { - provider_id: provider_id.to_string(), - display_name: provider_id.to_uppercase(), - seven_day: None, - thirty_day: None, - seven_day_estimate: None, - thirty_day_estimate: None, - seven_day_tokens: None, - thirty_day_tokens: None, - seven_day_tokens_lower_bound: false, - thirty_day_tokens_lower_bound: false, - period_cost: None, - period_tokens: None, - currency: currency.to_string(), - source: "fixture".to_string(), - included_in_overview: provider_id != "pi", - daily: daily - .iter() - .map(|(day, amount)| UsageSpendDailyPoint { - day: (*day).to_string(), - amount: *amount, - }) - .collect(), - refreshing: false, - stale_updated_at: None, - } - } - - #[test] - fn token_activity_keeps_listed_local_log_rows_only() { - let listed: BTreeSet = ["codex".to_string(), "pi".to_string()].into(); - let sources = token_activity_sources( - &listed, - vec![activity_day("2026-10-01", Some(5))], - vec![activity_day("2026-10-01", Some(7))], - ); - let ids: Vec<_> = sources - .iter() - .map(|source| source.provider_id.as_str()) - .collect(); - assert_eq!(ids, ["codex"]); - - let both: BTreeSet = ["codex".to_string(), "claude".to_string()].into(); - let sources = token_activity_sources(&both, Vec::new(), vec![activity_day("d", None)]); - let json = serde_json::to_value(&sources).expect("serialize"); - assert_eq!( - json, - serde_json::json!([{ - "providerId": "claude", - "days": [{"day": "d", "totalTokens": null, "isScanned": true}], - }]) - ); - } - - #[test] - fn spend_trend_publishes_each_usd_source_once_inside_the_window() { - let now = chrono::Utc::now(); - let zone = codexbar::cost_reporting_period::cost_bucket_zone(); - let today = zone.date(now); - let key = |offset: i64| { - (today - chrono::Duration::days(offset)) - .format("%Y-%m-%d") - .to_string() - }; - let mut codex = empty_summary().contract; - codex.known_zero = true; - codex.daily = vec![codexbar::spend_contract::SpendDailyPoint { - day: key(1), - cost_usd: Some(2.0), - total_tokens: Some(10), - }]; - let (old, recent) = (key(40), key(2)); - let rows = vec![ - trend_row("codex", "USD", &[(recent.as_str(), 99.0)]), - trend_row("claude", "USD", &[]), - trend_row( - "openrouter", - "USD", - &[(old.as_str(), 5.0), (recent.as_str(), 3.0)], - ), - trend_row("mistral", "EUR", &[(recent.as_str(), 4.0)]), - trend_row("pi", "USD", &[(recent.as_str(), 1.0)]), - ]; - let claude = Some((false, vec![(key(0), Some(1.5))], Vec::new())); - let trend = build_spend_trend(&rows, &codex, claude, CostReportingPeriod::Rolling(30), now); - - let ids: Vec<_> = trend.sources.iter().map(|s| s.id.as_str()).collect(); - assert_eq!(ids, ["claude", "codex", "openrouter"]); - assert!( - trend - .sources - .iter() - .find(|s| s.id == "codex") - .unwrap() - .known_zero - ); - // Codex comes from local logs, never the snapshot's daily series. - let codex_days: Vec<_> = trend - .daily - .iter() - .filter(|p| p.source_id == "codex") - .map(|p| (p.day.clone(), p.cost_usd)) - .collect(); - assert_eq!(codex_days, vec![(key(1), Some(2.0))]); - // The 40-day-old point falls outside a 30-day window. - assert!( - trend - .daily - .iter() - .filter(|p| p.source_id == "openrouter") - .all(|p| p.day == recent) - ); - assert_eq!(trend.start_day, key(29)); - assert_eq!(trend.end_day, key(0)); - } - - #[test] - fn cost_tracking_off_summary_is_empty_and_flagged() { - let summary = cost_tracking_off_summary(CostReportingPeriod::MonthToDate); - assert!(!summary.cost_tracking_enabled); - assert!(summary.rows.is_empty()); - assert!(summary.token_activity.is_empty()); - assert_eq!(summary.reporting_period, "month-to-date"); - assert_eq!(summary.contract.known_cost_usd, None); - assert!(summary.contract.daily.is_empty()); - let json = serde_json::to_value(&summary).expect("serialize"); - assert_eq!(json["costTrackingEnabled"], serde_json::json!(false)); - } - - #[test] - fn only_a_summary_rebuild_may_refresh_opencodex_pricing() { - let mut coordinator = UsageSpendCoordinator::default(); - assert!(coordinator.reusable("day|30", false).is_none()); - coordinator.cache = Some(CachedUsageSpendSummary { - key: "day|30".to_string(), - summary: empty_summary(), - refresh_owner: None, - }); - // A cached read starts no network work; a forced or new key rebuilds. - assert!(coordinator.reusable("day|30", false).is_some()); - assert!(coordinator.reusable("day|30", true).is_none()); - assert!(coordinator.reusable("day|7", false).is_none()); - } - - #[test] - fn privacy_mode_is_part_of_usage_spend_cache_identity() { - let public = usage_spend_cache_key_with_privacy(&[], "rolling:30", false, false, false); - let private = usage_spend_cache_key_with_privacy(&[], "rolling:30", false, false, true); - assert_ne!(public, private); - } - - #[test] - fn pi_history_is_an_alternate_view_not_a_shared_overview_source() { - assert!(!include_in_shared_overview("pi", true, true)); - assert!(include_in_shared_overview("codex", true, false)); - assert!(include_in_shared_overview("claude", false, true)); - assert!(!include_in_shared_overview("codex", false, false)); - } - - #[test] - fn antigravity_partial_history_exposes_only_the_known_subtotal() { - use codexbar::spend_contract::LocalHistoryCoverage; - - let seven = local_history(100, LocalHistoryCoverage::Partial, Some(1.25), 0); - let thirty = local_history(200, LocalHistoryCoverage::Partial, Some(2.50), 0); - let spend = antigravity_spend_values( - cached_spend(None, CostReportingPeriod::Rolling(30), chrono::Utc::now()), - &seven, - &thirty, - ); - - assert_eq!(spend.seven_day, None); - assert_eq!(spend.thirty_day, None); - assert_eq!(spend.seven_day_tokens, None); - assert_eq!(spend.thirty_day_tokens, None); - assert!(spend.source.contains("known API list-price subtotal")); - } - - #[test] - fn antigravity_lower_bound_history_publishes_floors_not_exact_totals() { - use codexbar::spend_contract::LocalHistoryCoverage; - - let mut seven = local_history(100, LocalHistoryCoverage::Partial, Some(1.25), 0); - seven.lower_bound = true; - let withheld = local_history(0, LocalHistoryCoverage::Partial, None, 0); - let spend = antigravity_spend_values( - cached_spend(None, CostReportingPeriod::Rolling(30), chrono::Utc::now()), - &seven, - &withheld, - ); - - assert_eq!(spend.seven_day, None); - assert_eq!(spend.seven_day_tokens, Some(100)); - assert_eq!(spend.thirty_day, None); - assert_eq!(spend.thirty_day_tokens, None); - } - - #[test] - fn antigravity_complete_empty_history_is_a_known_zero() { - use codexbar::spend_contract::LocalHistoryCoverage; - - let seven = local_history(0, LocalHistoryCoverage::Complete, None, 0); - let thirty = local_history(0, LocalHistoryCoverage::Complete, None, 0); - let spend = antigravity_spend_values( - cached_spend(None, CostReportingPeriod::Rolling(30), chrono::Utc::now()), - &seven, - &thirty, - ); - - assert_eq!(spend.seven_day, Some(0.0)); - assert_eq!(spend.thirty_day, Some(0.0)); - assert_eq!(spend.seven_day_tokens, Some(0)); - assert_eq!(spend.thirty_day_tokens, Some(0)); - assert!(spend.source.contains("API list-price estimate")); - } -} +mod cache_key_tests; +#[cfg(test)] +mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend/build.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend/build.rs new file mode 100644 index 00000000000..c9c9be33f75 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend/build.rs @@ -0,0 +1,689 @@ +use super::*; + +pub(super) fn build_usage_spend_summary( + cached: &[ProviderUsageSnapshot], + period: CostReportingPeriod, + settings: &codexbar::settings::Settings, + force_refresh: bool, +) -> UsageSpendSummary { + let now = Utc::now(); + let period_scan_days = period.scan_days(now); + let include_opencodex = settings.open_codex_usage_logs_enabled; + let hide_native = settings.hide_native_codex_cost_when_open_codex_present; + let pi_selected = settings.enabled_providers.iter().any(|id| id == "pi") + || cached.iter().any(|snapshot| snapshot.provider_id == "pi"); + let include_pi_in_native = !pi_selected; + + // Upstream 0.55.0 #3105: independent provider baselines load in parallel. + // Keep each provider's 7d/30d scans serial so they can safely share that + // provider's incremental cache, while Codex and Claude run concurrently. + let codex_scan_options = if force_refresh { + codexbar::core::CostScanOptions::app_driven() + } else { + codexbar::core::CostScanOptions::default() + }; + let mut codex_scan_options = codex_scan_options; + codex_scan_options.include_pi_sessions = include_pi_in_native; + // Any period other than 7d/30d costs one extra scan per provider. + let ( + (codex_7_summary, codex_30_summary, codex_period_summary, codex_activity), + (claude_7_summary, claude_30_summary, claude_period_summary, claude_activity), + (pi_7_summary, pi_30_summary, pi_period_summary), + ) = std::thread::scope(|scope| { + let codex = scope.spawn(move || { + let seven = CostScanner::new(7) + .with_options(codex_scan_options) + .scan_codex(); + let thirty = CostScanner::new(30) + .with_options(codex_scan_options) + .scan_codex(); + let selected = selected_period_scan(period, &seven, &thirty, || { + CostScanner::for_period(period) + .with_options(codex_scan_options) + .scan_codex() + }); + let activity = daily_token_activity("codex", TOKEN_ACTIVITY_DAYS); + (seven, thirty, selected, activity) + }); + let claude = scope.spawn(|| { + let seven = CostScanner::new(7) + .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native); + let thirty = CostScanner::new(30) + .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native); + let selected = selected_period_scan(period, &seven, &thirty, || { + CostScanner::for_period(period) + .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native) + }); + let activity = daily_token_activity("claude", TOKEN_ACTIVITY_DAYS); + (seven, thirty, selected, activity) + }); + let pi = scope.spawn(|| { + let seven = CostScanner::new(7).scan_pi(); + let thirty = CostScanner::new(30).scan_pi(); + let selected = selected_period_scan(period, &seven, &thirty, || { + CostScanner::for_period(period).scan_pi() + }); + (seven, thirty, selected) + }); + ( + codex.join().expect("Codex spend scan worker panicked"), + claude.join().expect("Claude spend scan worker panicked"), + pi.join().expect("Pi spend scan worker panicked"), + ) + }); + + let codex_stale = !codex_30_summary.history_coverage_established; + let codex_stale_updated_at = codex_stale + .then(|| { + codexbar::core::JsonlScanner::load_cache_status(codexbar::core::ProviderId::Codex, None) + .previous_report + .and_then(|report| report.updated_at) + }) + .flatten(); + + let codex_7_contract = build_local_spend_contract_from_summary( + "codex", + 7, + include_opencodex, + hide_native, + settings.hide_personal_info, + codex_7_summary.clone(), + ); + let codex_30_contract = build_local_spend_contract_from_summary( + "codex", + 30, + include_opencodex, + hide_native, + settings.hide_personal_info, + codex_30_summary.clone(), + ); + let codex_period_contract = build_contract_from_period_summary( + "codex", + period, + include_opencodex, + hide_native, + settings.hide_personal_info, + codex_period_summary, + ); + let pi_period_contract = build_contract_from_period_summary( + "pi", + period, + false, + false, + settings.hide_personal_info, + pi_period_summary, + ); + let pi_7_contract = build_local_spend_contract_from_summary( + "pi", + 7, + false, + false, + settings.hide_personal_info, + pi_7_summary.clone(), + ); + let pi_30_contract = build_local_spend_contract_from_summary( + "pi", + 30, + false, + false, + settings.hide_personal_info, + pi_30_summary.clone(), + ); + + let mut provider_ids: BTreeSet = settings.enabled_providers.iter().cloned().collect(); + provider_ids.extend(cached.iter().map(|snapshot| snapshot.provider_id.clone())); + if include_opencodex { + // OpenCodex is an enrichment source, never a standalone provider row. + // Publish routed subscriptions even when no live provider snapshot exists. + for id in ["codex", "opencodego", "kimi", "deepseek", "nous"] { + let contract = match id { + "codex" => None, + _ => Some(build_local_spend_contract(id, 30, true)), + }; + if contract + .as_ref() + .is_some_and(|contract| !contract.imports.is_empty()) + { + provider_ids.insert(id.to_string()); + } + } + } + + let token_activity = token_activity_sources(&provider_ids, codex_activity, claude_activity); + + let cached_by_id: HashMap<&str, &ProviderUsageSnapshot> = cached + .iter() + .map(|snapshot| (snapshot.provider_id.as_str(), snapshot)) + .collect(); + + let mut rows = Vec::new(); + for provider_id in provider_ids { + let cached_snapshot = cached_by_id.get(provider_id.as_str()).copied(); + let display_name = cached_snapshot + .map(|snapshot| snapshot.display_name.trim()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .or_else(|| { + codexbar::core::ProviderId::from_cli_name(&provider_id).map(|id| { + codexbar::core::instantiate_provider(id) + .metadata() + .display_name + .to_string() + }) + }) + .unwrap_or_else(|| provider_id.clone()); + + let mut local_cost_estimates = None; + let mut token_lower_bounds = (false, false); + let spend = match provider_id.as_str() { + "codex" => SpendValues { + seven_day: codex_7_contract.known_cost_usd, + thirty_day: codex_30_contract.known_cost_usd, + seven_day_tokens: codex_7_contract.token_total, + thirty_day_tokens: codex_30_contract.token_total, + period_cost: codex_period_contract.known_cost_usd, + period_tokens: codex_period_contract.token_total, + source: if include_opencodex && !codex_30_contract.imports.is_empty() { + "local logs + OpenCodex".to_string() + } else { + "local logs".to_string() + }, + refreshing: codex_stale, + stale_updated_at: codex_stale_updated_at.clone(), + }, + "claude" => SpendValues { + seven_day: Some(claude_7_summary.total_cost_usd), + thirty_day: Some(claude_30_summary.total_cost_usd), + seven_day_tokens: Some(claude_7_summary.total_tokens_for_provider("claude")), + thirty_day_tokens: Some(claude_30_summary.total_tokens_for_provider("claude")), + period_cost: Some(claude_period_summary.total_cost_usd), + period_tokens: Some(claude_period_summary.total_tokens_for_provider("claude")), + source: "local logs".to_string(), + refreshing: false, + stale_updated_at: None, + }, + "pi" => SpendValues { + seven_day: pi_7_contract.known_cost_usd, + thirty_day: pi_30_contract.known_cost_usd, + seven_day_tokens: pi_7_contract.token_total, + thirty_day_tokens: pi_30_contract.token_total, + period_cost: pi_period_contract.known_cost_usd, + period_tokens: pi_period_contract.token_total, + source: "local Pi/OMP history".to_string(), + refreshing: !pi_30_summary.history_coverage_established, + stale_updated_at: None, + }, + "opencodego" | "kimi" | "deepseek" | "nous" if include_opencodex => { + let seven = build_local_spend_contract(&provider_id, 7, true); + let thirty = build_local_spend_contract(&provider_id, 30, true); + if !thirty.imports.is_empty() { + let selected = + build_local_spend_contract_for_period(&provider_id, period, true); + SpendValues { + seven_day: seven.known_cost_usd, + thirty_day: thirty.known_cost_usd, + seven_day_tokens: seven.token_total, + thirty_day_tokens: thirty.token_total, + period_cost: selected.known_cost_usd, + period_tokens: selected.token_total, + source: if provider_id == "opencodego" { + "local logs + OpenCodex".to_string() + } else { + "OpenCodex".to_string() + }, + refreshing: false, + stale_updated_at: None, + } + } else { + cached_spend(cached_snapshot, period, now) + } + } + "opencodego" => { + // Cost stays the provider's; recorded local tokens fill the token columns. + let mut spend = cached_spend(cached_snapshot, period, now); + let seven = CostScanner::new(7).scan_opencodego_usage_tokens_with_cancel(None); + let thirty = CostScanner::new(30).scan_opencodego_usage_tokens_with_cancel(None); + spend.period_tokens = selected_period_scan(period, &seven, &thirty, || { + CostScanner::for_period(period).scan_opencodego_usage_tokens_with_cancel(None) + }); + spend.seven_day_tokens = seven; + spend.thirty_day_tokens = thirty; + spend + } + "cursor" => { + let seven = codexbar::providers::cursor::local_csv::summarize(7); + let thirty = codexbar::providers::cursor::local_csv::summarize(30); + if thirty.row_count > 0 { + let selected = + codexbar::providers::cursor::local_csv::summarize(period_scan_days); + SpendValues { + seven_day: (seven.row_count > 0).then_some(seven.total_cost_usd), + thirty_day: Some(thirty.total_cost_usd), + seven_day_tokens: (seven.row_count > 0).then_some(seven.total_tokens), + thirty_day_tokens: Some(thirty.total_tokens), + period_cost: (selected.row_count > 0).then_some(selected.total_cost_usd), + period_tokens: (selected.row_count > 0).then_some(selected.total_tokens), + source: "local Cursor tokscale cache".to_string(), + refreshing: false, + stale_updated_at: None, + } + } else { + cached_spend(cached_snapshot, period, now) + } + } + "grok" => { + let seven = codexbar::providers::grok::local_sessions::summarize(7); + let thirty = codexbar::providers::grok::local_sessions::summarize(30); + let selected = + codexbar::providers::grok::local_sessions::summarize(period_scan_days); + let mut spend = cached_spend(cached_snapshot, period, now); + spend.seven_day_tokens = (seven.session_count > 0).then_some(seven.total_tokens); + spend.thirty_day_tokens = (thirty.session_count > 0).then_some(thirty.total_tokens); + spend.period_tokens = (selected.session_count > 0).then_some(selected.total_tokens); + if thirty.session_count > 0 { + spend.source = "local Grok sessions".to_string(); + } + spend + } + "antigravity" => { + use codexbar::providers::antigravity::local_sessions; + let seven = local_sessions::summarize(7); + let thirty = local_sessions::summarize(30); + let selected = local_sessions::summarize(period_scan_days); + let mut spend = cached_spend(cached_snapshot, period, now); + // Upstream 0.64: the app refreshes unknown-model pricing in the + // background; a later read (provider refresh or Refresh) reprices. + if let Some(refresh) = local_sessions::background_pricing_refresh(&thirty) { + tauri::async_runtime::spawn(refresh); + } + { + use codexbar::providers::antigravity::local_sessions::LocalHistoryCoverage; + spend.seven_day_tokens = + matches!(seven.coverage, LocalHistoryCoverage::Complete) + .then_some(seven.total_tokens); + spend.thirty_day_tokens = + matches!(thirty.coverage, LocalHistoryCoverage::Complete) + .then_some(thirty.total_tokens); + spend.period_tokens = + matches!(selected.coverage, LocalHistoryCoverage::Complete) + .then_some(selected.total_tokens); + if matches!(thirty.coverage, LocalHistoryCoverage::Complete) { + spend.source = "local Antigravity history".to_string(); + } + } + let spend = antigravity_spend_values(spend, &seven, &thirty); + token_lower_bounds = (seven.lower_bound, thirty.lower_bound); + local_cost_estimates = Some((seven.cost_estimate, thirty.cost_estimate)); + spend + } + _ => cached_spend(cached_snapshot, period, now), + }; + + let currency = cached_snapshot + .and_then(|snapshot| snapshot.cost.as_ref()) + .map(|cost| cost.currency_code.clone()) + .unwrap_or_else(|| "USD".to_string()); + let daily = cached_snapshot + .and_then(|snapshot| snapshot.cost.as_ref()) + .map(|cost| { + cost.daily + .iter() + .map(|point| UsageSpendDailyPoint { + day: point.day.clone(), + amount: point.amount, + }) + .collect() + }) + .unwrap_or_default(); + let (seven_day_estimate, thirty_day_estimate) = local_cost_estimates + .map(|(seven, thirty)| (Some(seven), Some(thirty))) + .unwrap_or((None, None)); + rows.push(UsageSpendRow { + provider_id: provider_id.clone(), + display_name, + seven_day: spend.seven_day, + thirty_day: spend.thirty_day, + seven_day_estimate, + thirty_day_estimate, + seven_day_tokens: spend.seven_day_tokens, + thirty_day_tokens: spend.thirty_day_tokens, + seven_day_tokens_lower_bound: token_lower_bounds.0, + thirty_day_tokens_lower_bound: token_lower_bounds.1, + period_cost: spend.period_cost, + period_tokens: spend.period_tokens, + currency, + source: spend.source, + included_in_overview: include_in_shared_overview( + &provider_id, + settings.enabled_providers.contains(&provider_id), + cached_snapshot.is_some(), + ), + daily, + refreshing: spend.refreshing, + stale_updated_at: spend.stale_updated_at, + }); + } + + let contract = codex_period_contract; + let claude_trend = rows.iter().any(|row| row.provider_id == "claude").then(|| { + let (daily, hourly) = claude_trend_history(period.sidecar_days(now), now); + (claude_period_summary.known_zero, daily, hourly) + }); + let trend = build_spend_trend(&rows, &contract, claude_trend, period, now); + let reporting_day = last_included_reporting_day(&contract); + let dashboard_timezone = codexbar::core::local_timezone_name(); + UsageSpendSummary { + rows, + contract, + reporting_period: period.raw(), + reporting_day, + dashboard_timezone, + cost_tracking_enabled: true, + token_activity, + trend: Some(trend), + } +} + +/// The summary while "Track costs" is off: no rows, no scan. +pub(super) fn cost_tracking_off_summary(period: CostReportingPeriod) -> UsageSpendSummary { + UsageSpendSummary { + rows: Vec::new(), + contract: SpendContract::empty("codex", period), + reporting_period: period.raw(), + reporting_day: chrono::Local::now() + .date_naive() + .format("%Y-%m-%d") + .to_string(), + dashboard_timezone: codexbar::core::local_timezone_name(), + cost_tracking_enabled: false, + token_activity: Vec::new(), + trend: None, + } +} + +type DailyCosts = Vec<(String, Option)>; +type HourlyCosts = Vec; + +/// Window length, local reporting day, and cost bucket zone of one Claude +/// trend walk. +type ClaudeTrendKey = (u32, chrono::NaiveDate, String); + +struct ClaudeTrendMemo { + key: ClaudeTrendKey, + stored_at: std::time::Instant, + daily: DailyCosts, + hourly: HourlyCosts, +} + +/// The Claude trend walk reads every transcript in the window with no +/// incremental cache, so a complete result is reused for the cost scan +/// debounce interval instead of re-walking on every summary rebuild. +fn claude_trend_history(days: u32, now: chrono::DateTime) -> (DailyCosts, HourlyCosts) { + static MEMO: Mutex> = Mutex::new(None); + let zone = codexbar::cost_reporting_period::cost_bucket_zone(); + let key: ClaudeTrendKey = (days, zone.date(now), zone.identifier()); + let ttl = std::time::Duration::from_secs( + codexbar::core::CostScanOptions::default().refresh_min_interval_secs, + ); + if let Some(memo) = MEMO + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .filter(|memo| memo.key == key && memo.stored_at.elapsed() <= ttl) + { + return (memo.daily.clone(), memo.hourly.clone()); + } + let (daily, hourly, complete) = + codexbar::cost_scanner::get_claude_daily_and_hourly_cost_history(days); + // A partial walk is never reused, so the next rebuild retries it. + if complete { + *MEMO + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(ClaudeTrendMemo { + key, + stored_at: std::time::Instant::now(), + daily: daily.clone(), + hourly: hourly.clone(), + }); + } + (daily, hourly) +} +/// Claude's window known-zero flag with its daily and hourly cost series. +type ClaudeTrendSeries = (bool, DailyCosts, HourlyCosts); + +/// Recorded daily (and, where the source keeps timestamps, hourly) spend per +/// source over the selected window. Local-log providers use their own scans; +/// every other USD row contributes its provider-reported daily costs. +pub(super) fn build_spend_trend( + rows: &[UsageSpendRow], + codex: &SpendContract, + claude: Option, + period: CostReportingPeriod, + now: chrono::DateTime, +) -> SpendTrend { + use codexbar::spend_trend::{SpendTrendBuilder, SpendTrendSource}; + let mut series: Vec<(SpendTrendSource, DailyCosts, HourlyCosts)> = Vec::new(); + for row in rows { + let source = |known_zero: bool| SpendTrendSource { + id: row.provider_id.clone(), + provider_id: row.provider_id.clone(), + display_name: row.display_name.clone(), + known_zero, + }; + match row.provider_id.as_str() { + "codex" => series.push(( + source(codex.known_zero), + codex + .daily + .iter() + .map(|point| (point.day.clone(), point.cost_usd)) + .collect(), + Vec::new(), + )), + "claude" => { + if let Some((known_zero, daily, hourly)) = claude.clone() { + series.push((source(known_zero), daily, hourly)); + } + } + _ if row.included_in_overview + && row.currency.eq_ignore_ascii_case("USD") + && !row.daily.is_empty() => + { + series.push(( + source(false), + row.daily + .iter() + .map(|point| (point.day.clone(), Some(point.amount))) + .collect(), + Vec::new(), + )); + } + _ => {} + } + } + // All available history opens at the first recorded spend, not at 1970. + let earliest = series + .iter() + .flat_map(|(_, daily, _)| daily.iter()) + .filter(|(_, cost)| cost.is_some_and(|cost| cost > 0.0)) + .filter_map(|(day, _)| chrono::NaiveDate::parse_from_str(day, "%Y-%m-%d").ok()) + .min(); + let zone = codexbar::cost_reporting_period::cost_bucket_zone(); + let bounds = period.bounds(now, zone, earliest.or(Some(zone.date(now)))); + let mut builder = SpendTrendBuilder::new(bounds, zone); + for (source, daily, hourly) in series { + builder.add_source(source, daily, &hourly); + } + builder.finish() +} + +/// Reuse the fixed scan when it already covers the selected period. +pub(super) fn selected_period_scan( + period: CostReportingPeriod, + seven: &T, + thirty: &T, + scan: impl FnOnce() -> T, +) -> T { + match period { + CostReportingPeriod::Rolling(7) => seven.clone(), + CostReportingPeriod::Rolling(30) => thirty.clone(), + _ => scan(), + } +} + +/// Pi is an alternate local-history view over rows that may already be +/// projected into Codex or Claude. Keep it out of the shared denominator so +/// enabling Pi cannot double-count the same physical usage. +pub(super) fn include_in_shared_overview(provider_id: &str, enabled: bool, cached: bool) -> bool { + provider_id != "pi" && (enabled || cached) +} + +fn last_included_reporting_day(contract: &SpendContract) -> String { + contract + .daily + .iter() + .filter_map(|point| chrono::NaiveDate::parse_from_str(&point.day, "%Y-%m-%d").ok()) + .max() + .unwrap_or_else(|| chrono::Local::now().date_naive()) + .format("%Y-%m-%d") + .to_string() +} + +pub(super) fn antigravity_spend_values( + mut spend: SpendValues, + seven: &codexbar::spend_contract::LocalTokenHistorySummary, + thirty: &codexbar::spend_contract::LocalTokenHistorySummary, +) -> SpendValues { + use codexbar::spend_contract::LocalHistoryCoverage; + + spend.seven_day = seven.total_usd(); + spend.thirty_day = thirty.total_usd(); + // Exact for a complete scan, a floor for a lower bound, unknown otherwise. + spend.seven_day_tokens = seven.published_tokens(); + spend.thirty_day_tokens = thirty.published_tokens(); + if spend.thirty_day.is_some() { + spend.source = "local Antigravity history · API list-price estimate".to_string(); + } else if thirty.cost_estimate.known_subtotal_usd.is_some() { + spend.source = "local Antigravity history · known API list-price subtotal".to_string(); + } else if thirty.coverage == LocalHistoryCoverage::Complete { + spend.source = "local Antigravity history · unpriced".to_string(); + } + spend +} + +/// Provider-reported daily costs bucket by UTC day, so the selected period is +/// resolved in UTC here. `None` when no day falls inside the window. +pub(super) fn period_cost_from_daily( + daily: &[crate::commands::bridge::CostDailyPointBridge], + period: CostReportingPeriod, + now: chrono::DateTime, +) -> Option { + let earliest = daily + .iter() + .filter_map(|point| chrono::NaiveDate::parse_from_str(&point.day, "%Y-%m-%d").ok()) + .min(); + let bounds = period.bounds(now, CostTimeZone::UTC, earliest); + let mut total = 0.0; + let mut saw = false; + for point in daily { + if bounds.contains_day_key(&point.day) { + total += point.amount; + saw = true; + } + } + saw.then_some(total) +} + +pub(super) fn cached_spend( + snapshot: Option<&ProviderUsageSnapshot>, + reporting_period: CostReportingPeriod, + now: chrono::DateTime, +) -> SpendValues { + let Some(snapshot) = snapshot else { + return SpendValues { + seven_day: None, + thirty_day: None, + seven_day_tokens: None, + thirty_day_tokens: None, + period_cost: None, + period_tokens: None, + source: "unavailable".to_string(), + refreshing: false, + stale_updated_at: None, + }; + }; + let Some(cost) = snapshot.cost.as_ref() else { + return SpendValues { + seven_day: None, + thirty_day: None, + seven_day_tokens: None, + thirty_day_tokens: None, + period_cost: None, + period_tokens: None, + source: if snapshot.error.is_some() { + "unavailable".to_string() + } else { + snapshot.source_label.clone() + }, + refreshing: false, + stale_updated_at: None, + }; + }; + let period = cost.period.trim(); + let period_lower = period.to_ascii_lowercase(); + let (seven_day, thirty_day) = if cost.daily.is_empty() { + ( + None, + (period_lower.contains("30 day") || period_lower.contains("30-day")) + .then_some(cost.used), + ) + } else { + let today = chrono::Utc::now().date_naive(); + let seven_cutoff = today - chrono::Duration::days(6); + let thirty_cutoff = today - chrono::Duration::days(29); + let mut seven = 0.0; + let mut thirty = 0.0; + let mut saw_seven = false; + let mut saw_thirty = false; + for point in &cost.daily { + let Ok(day) = chrono::NaiveDate::parse_from_str(&point.day, "%Y-%m-%d") else { + continue; + }; + // Providers with long daily history (Bedrock keeps 14 months) must + // not widen the fixed 30-day column. + if day > today || day < thirty_cutoff { + continue; + } + thirty += point.amount; + saw_thirty = true; + if day >= seven_cutoff { + seven += point.amount; + saw_seven = true; + } + } + (saw_seven.then_some(seven), saw_thirty.then_some(thirty)) + }; + let period_cost = if cost.daily.is_empty() { + // A provider-reported 30-day total only answers a 30-day selection. + (reporting_period == CostReportingPeriod::Rolling(30)) + .then_some(thirty_day) + .flatten() + } else { + period_cost_from_daily(&cost.daily, reporting_period, now) + }; + SpendValues { + seven_day, + thirty_day, + seven_day_tokens: None, + thirty_day_tokens: None, + period_cost, + period_tokens: None, + source: if period.is_empty() { + snapshot.source_label.clone() + } else { + format!("period ({period})") + }, + refreshing: false, + stale_updated_at: None, + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend/cache_key_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend/cache_key_tests.rs new file mode 100644 index 00000000000..d89d52ba68d --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend/cache_key_tests.rs @@ -0,0 +1,187 @@ +use super::*; + +fn local_history( + total_tokens: u64, + coverage: codexbar::spend_contract::LocalHistoryCoverage, + known_subtotal_usd: Option, + unpriced: u32, +) -> codexbar::spend_contract::LocalTokenHistorySummary { + codexbar::spend_contract::LocalTokenHistorySummary { + total_tokens, + session_count: if total_tokens > 0 { 1 } else { 0 }, + coverage, + cost_estimate: codexbar::spend_contract::LocalCostEstimate { + known_subtotal_usd, + coverage: codexbar::spend_contract::CostCoverageCounts { + estimated: if known_subtotal_usd.is_some() { 1 } else { 0 }, + unpriced, + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + } +} + +#[test] +fn invalidated_owner_clears_orphaned_indexing_activity() { + let mut coordinator = UsageSpendCoordinator::default(); + let owner = coordinator.begin("account:old".to_string()); + + assert!(coordinator.clear_if_indexing(&owner)); + assert!(!coordinator.is_current(&owner)); +} + +#[test] +fn old_owner_cleanup_cannot_clear_a_replacement() { + let mut coordinator = UsageSpendCoordinator::default(); + let old = coordinator.begin("account:old".to_string()); + let replacement = coordinator.begin("account:new".to_string()); + + assert!(!coordinator.clear_if_indexing(&old)); + assert!(coordinator.is_current(&replacement)); +} + +#[test] +fn settings_replacement_preserves_an_intentional_pause() { + let mut coordinator = UsageSpendCoordinator::default(); + let old = coordinator.begin("settings:old".to_string()); + let replacement = coordinator.begin("settings:new".to_string()); + let status = codexbar::core::CachedCostReadStatus { + codex_scan_pause_reason: Some(codexbar::core::CodexScanPauseReason::NoProgress), + ..Default::default() + }; + mark_refresh_paused_if_codex_scan_paused( + &mut coordinator, + &replacement, + true, + status.codex_scan_pause_reason.as_ref(), + ); + + assert!(!coordinator.clear_if_indexing(&old)); + assert_eq!( + coordinator.current.as_ref().map(|(_, phase)| *phase), + Some(UsageSpendRefreshPhase::Paused) + ); +} + +pub(super) fn empty_summary() -> UsageSpendSummary { + UsageSpendSummary { + reporting_period: "rolling:30".to_string(), + rows: Vec::new(), + contract: SpendContract { + provider_id: "codex".to_string(), + reporting_period: "rolling:30".to_string(), + history_days: 30, + known_cost_usd: None, + known_zero: false, + provenance: codexbar::spend_contract::CostProvenance::Unknown, + price_coverage: Default::default(), + price_coverage_ratio: None, + history_coverage_established: false, + token_mix: Default::default(), + token_total: None, + conversation_count: 0, + models: Vec::new(), + projects: Vec::new(), + conversations: Vec::new(), + daily: Vec::new(), + hourly_activity: Vec::new(), + project_source_status: None, + custom_pricing_active: false, + imports: Vec::new(), + }, + reporting_day: "2026-10-01".to_string(), + dashboard_timezone: "UTC".to_string(), + cost_tracking_enabled: true, + token_activity: Vec::new(), + trend: None, + } +} + +#[test] +fn only_a_summary_rebuild_may_refresh_opencodex_pricing() { + let mut coordinator = UsageSpendCoordinator::default(); + assert!(coordinator.reusable("day|30", false).is_none()); + coordinator.cache = Some(CachedUsageSpendSummary { + key: "day|30".to_string(), + summary: empty_summary(), + refresh_owner: None, + }); + // A cached read starts no network work; a forced or new key rebuilds. + assert!(coordinator.reusable("day|30", false).is_some()); + assert!(coordinator.reusable("day|30", true).is_none()); + assert!(coordinator.reusable("day|7", false).is_none()); +} + +#[test] +fn privacy_mode_is_part_of_usage_spend_cache_identity() { + let public = usage_spend_cache_key_with_privacy(&[], "rolling:30", false, false, false); + let private = usage_spend_cache_key_with_privacy(&[], "rolling:30", false, false, true); + assert_ne!(public, private); +} + +#[test] +fn pi_history_is_an_alternate_view_not_a_shared_overview_source() { + assert!(!include_in_shared_overview("pi", true, true)); + assert!(include_in_shared_overview("codex", true, false)); + assert!(include_in_shared_overview("claude", false, true)); + assert!(!include_in_shared_overview("codex", false, false)); +} + +#[test] +fn antigravity_partial_history_exposes_only_the_known_subtotal() { + use codexbar::spend_contract::LocalHistoryCoverage; + + let seven = local_history(100, LocalHistoryCoverage::Partial, Some(1.25), 0); + let thirty = local_history(200, LocalHistoryCoverage::Partial, Some(2.50), 0); + let spend = antigravity_spend_values( + cached_spend(None, CostReportingPeriod::Rolling(30), chrono::Utc::now()), + &seven, + &thirty, + ); + + assert_eq!(spend.seven_day, None); + assert_eq!(spend.thirty_day, None); + assert_eq!(spend.seven_day_tokens, None); + assert_eq!(spend.thirty_day_tokens, None); + assert!(spend.source.contains("known API list-price subtotal")); +} + +#[test] +fn antigravity_lower_bound_history_publishes_floors_not_exact_totals() { + use codexbar::spend_contract::LocalHistoryCoverage; + + let mut seven = local_history(100, LocalHistoryCoverage::Partial, Some(1.25), 0); + seven.lower_bound = true; + let withheld = local_history(0, LocalHistoryCoverage::Partial, None, 0); + let spend = antigravity_spend_values( + cached_spend(None, CostReportingPeriod::Rolling(30), chrono::Utc::now()), + &seven, + &withheld, + ); + + assert_eq!(spend.seven_day, None); + assert_eq!(spend.seven_day_tokens, Some(100)); + assert_eq!(spend.thirty_day, None); + assert_eq!(spend.thirty_day_tokens, None); +} + +#[test] +fn antigravity_complete_empty_history_is_a_known_zero() { + use codexbar::spend_contract::LocalHistoryCoverage; + + let seven = local_history(0, LocalHistoryCoverage::Complete, None, 0); + let thirty = local_history(0, LocalHistoryCoverage::Complete, None, 0); + let spend = antigravity_spend_values( + cached_spend(None, CostReportingPeriod::Rolling(30), chrono::Utc::now()), + &seven, + &thirty, + ); + + assert_eq!(spend.seven_day, Some(0.0)); + assert_eq!(spend.thirty_day, Some(0.0)); + assert_eq!(spend.seven_day_tokens, Some(0)); + assert_eq!(spend.thirty_day_tokens, Some(0)); + assert!(spend.source.contains("API list-price estimate")); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend/tests.rs index 7187b98187a..843da648a60 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend/tests.rs @@ -1,6 +1,45 @@ +use super::build::build_spend_trend; +use super::cache_key_tests::empty_summary; use super::*; use codexbar::cost_scanner::CostSummary; +fn activity_day(day: &str, total_tokens: Option) -> TokenActivityDay { + TokenActivityDay { + day: day.to_string(), + total_tokens, + is_scanned: true, + } +} + +fn trend_row(provider_id: &str, currency: &str, daily: &[(&str, f64)]) -> UsageSpendRow { + UsageSpendRow { + provider_id: provider_id.to_string(), + display_name: provider_id.to_uppercase(), + seven_day: None, + thirty_day: None, + seven_day_estimate: None, + thirty_day_estimate: None, + seven_day_tokens: None, + thirty_day_tokens: None, + seven_day_tokens_lower_bound: false, + thirty_day_tokens_lower_bound: false, + period_cost: None, + period_tokens: None, + currency: currency.to_string(), + source: "fixture".to_string(), + included_in_overview: provider_id != "pi", + daily: daily + .iter() + .map(|(day, amount)| UsageSpendDailyPoint { + day: (*day).to_string(), + amount: *amount, + }) + .collect(), + refreshing: false, + stale_updated_at: None, + } +} + #[test] fn invalidated_owner_clears_orphaned_indexing_activity() { let mut coordinator = UsageSpendCoordinator::default(); @@ -180,3 +219,104 @@ fn pi_history_is_an_alternate_view_not_a_shared_overview_source() { assert!(include_in_shared_overview("claude", false, true)); assert!(!include_in_shared_overview("codex", false, false)); } + +#[test] +fn cost_tracking_off_summary_is_empty_and_flagged() { + let summary = cost_tracking_off_summary(CostReportingPeriod::MonthToDate); + assert!(!summary.cost_tracking_enabled); + assert!(summary.rows.is_empty()); + assert!(summary.token_activity.is_empty()); + assert_eq!(summary.reporting_period, "month-to-date"); + assert_eq!(summary.contract.known_cost_usd, None); + assert!(summary.contract.daily.is_empty()); + let json = serde_json::to_value(&summary).expect("serialize"); + assert_eq!(json["costTrackingEnabled"], serde_json::json!(false)); +} + +#[test] +fn spend_trend_publishes_each_usd_source_once_inside_the_window() { + let now = chrono::Utc::now(); + let zone = codexbar::cost_reporting_period::cost_bucket_zone(); + let today = zone.date(now); + let key = |offset: i64| { + (today - chrono::Duration::days(offset)) + .format("%Y-%m-%d") + .to_string() + }; + let mut codex = empty_summary().contract; + codex.known_zero = true; + codex.daily = vec![codexbar::spend_contract::SpendDailyPoint { + day: key(1), + cost_usd: Some(2.0), + total_tokens: Some(10), + }]; + let (old, recent) = (key(40), key(2)); + let rows = vec![ + trend_row("codex", "USD", &[(recent.as_str(), 99.0)]), + trend_row("claude", "USD", &[]), + trend_row( + "openrouter", + "USD", + &[(old.as_str(), 5.0), (recent.as_str(), 3.0)], + ), + trend_row("mistral", "EUR", &[(recent.as_str(), 4.0)]), + trend_row("pi", "USD", &[(recent.as_str(), 1.0)]), + ]; + let claude = Some((false, vec![(key(0), Some(1.5))], Vec::new())); + let trend = build_spend_trend(&rows, &codex, claude, CostReportingPeriod::Rolling(30), now); + + let ids: Vec<_> = trend.sources.iter().map(|s| s.id.as_str()).collect(); + assert_eq!(ids, ["claude", "codex", "openrouter"]); + assert!( + trend + .sources + .iter() + .find(|s| s.id == "codex") + .unwrap() + .known_zero + ); + // Codex comes from local logs, never the snapshot's daily series. + let codex_days: Vec<_> = trend + .daily + .iter() + .filter(|p| p.source_id == "codex") + .map(|p| (p.day.clone(), p.cost_usd)) + .collect(); + assert_eq!(codex_days, vec![(key(1), Some(2.0))]); + // The 40-day-old point falls outside a 30-day window. + assert!( + trend + .daily + .iter() + .filter(|p| p.source_id == "openrouter") + .all(|p| p.day == recent) + ); + assert_eq!(trend.start_day, key(29)); + assert_eq!(trend.end_day, key(0)); +} + +#[test] +fn token_activity_keeps_listed_local_log_rows_only() { + let listed: BTreeSet = ["codex".to_string(), "pi".to_string()].into(); + let sources = token_activity_sources( + &listed, + vec![activity_day("2026-10-01", Some(5))], + vec![activity_day("2026-10-01", Some(7))], + ); + let ids: Vec<_> = sources + .iter() + .map(|source| source.provider_id.as_str()) + .collect(); + assert_eq!(ids, ["codex"]); + + let both: BTreeSet = ["codex".to_string(), "claude".to_string()].into(); + let sources = token_activity_sources(&both, Vec::new(), vec![activity_day("d", None)]); + let json = serde_json::to_value(&sources).expect("serialize"); + assert_eq!( + json, + serde_json::json!([{ + "providerId": "claude", + "days": [{"day": "d", "totalTokens": null, "isScanned": true}], + }]) + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/commands.rs b/apps/desktop-tauri/src-tauri/src/floatbar/commands.rs index 887045c5ddc..5a9f5043ef7 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/commands.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/commands.rs @@ -1,66 +1,13 @@ //! Tauri commands that drive the floating-bar window. //! -//! These are thin wrappers around the local `window` module. User-initiated -//! changes keep persisted settings in sync, while the resize command applies a -//! new size and the native interaction state together. +//! The resize command applies a new size and the native interaction state +//! together. -use codexbar::settings::{Settings, clamp_float_bar_opacity, normalize_float_bar_orientation}; +use codexbar::settings::Settings; use tauri::{AppHandle, Manager}; use super::window as floatbar_window; -#[tauri::command] -pub async fn show_float_bar(app: AppHandle) -> Result<(), String> { - let mut settings = Settings::load(); - settings.float_bar_enabled = true; - settings.save().map_err(|e| e.to_string())?; - - floatbar_window::show( - &app, - settings.float_bar_opacity, - &settings.float_bar_orientation, - &settings.float_bar_style, - settings.float_bar_click_through, - ) -} - -#[tauri::command] -pub fn hide_float_bar(app: AppHandle) -> Result<(), String> { - let mut settings = Settings::load(); - settings.float_bar_enabled = false; - settings.save().map_err(|e| e.to_string())?; - floatbar_window::hide(&app) -} - -#[tauri::command] -pub fn set_float_bar_opacity(app: AppHandle, opacity: u8) -> Result<(), String> { - let opacity = clamp_float_bar_opacity(opacity); - let mut settings = Settings::load(); - settings.float_bar_opacity = opacity; - settings.save().map_err(|e| e.to_string())?; - - if let Some(window) = app.get_webview_window(floatbar_window::FLOATBAR_LABEL) { - floatbar_window::apply_no_activate(&window); - floatbar_window::apply_opacity(&window, opacity); - floatbar_window::apply_always_on_top(&window); - } - Ok(()) -} - -#[tauri::command] -pub fn set_float_bar_click_through(app: AppHandle, enabled: bool) -> Result<(), String> { - let mut settings = Settings::load(); - settings.float_bar_click_through = enabled; - settings.save().map_err(|e| e.to_string())?; - - if let Some(window) = app.get_webview_window(floatbar_window::FLOATBAR_LABEL) { - floatbar_window::apply_no_activate(&window); - floatbar_window::apply_click_through(&window, enabled); - floatbar_window::apply_always_on_top(&window); - } - Ok(()) -} - #[tauri::command] pub fn resize_float_bar(app: AppHandle, width: f64, height: f64) -> Result<(), String> { let settings = Settings::load(); @@ -71,19 +18,3 @@ pub fn resize_float_bar(app: AppHandle, width: f64, height: f64) -> Result<(), S } Ok(()) } - -#[tauri::command] -pub fn set_float_bar_orientation(app: AppHandle, orientation: String) -> Result<(), String> { - let orientation = normalize_float_bar_orientation(&orientation); - let mut settings = Settings::load(); - settings.float_bar_orientation = orientation; - settings.save().map_err(|e| e.to_string())?; - - // The webview re-reads orientation from settings on every config - // change — emit the live-update event so it re-lays-out without us - // needing to destroy/recreate the window (which races with Tauri's - // async window lifecycle and can crash on Windows). - use tauri::Emitter; - let _ = app.emit(super::FLOAT_BAR_CONFIG_CHANGED_EVENT, ()); - Ok(()) -} diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs b/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs index 5e2c1f79086..22f8ce94a12 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs @@ -28,13 +28,7 @@ pub fn install(app: &tauri::AppHandle) { if persisted.float_bar_enabled { // Best-effort floatbar show on startup; a show failure is logged // by the window layer and is non-fatal to app setup. - let _show = window::show( - app, - persisted.float_bar_opacity, - &persisted.float_bar_orientation, - &persisted.float_bar_style, - persisted.float_bar_click_through, - ); + let _show = window::show(app, &persisted); } } @@ -85,13 +79,7 @@ pub fn toggle(app: &tauri::AppHandle) { if settings.float_bar_enabled { // Best-effort show; window::show logs its own failures and the // toggle is fire-and-forget from the tray. - let _show = window::show( - app, - settings.float_bar_opacity, - &settings.float_bar_orientation, - &settings.float_bar_style, - settings.float_bar_click_through, - ); + let _show = window::show(app, &settings); } else { // Best-effort hide; a hide failure leaves the bar visible, which // the next toggle corrects — non-fatal. @@ -105,13 +93,7 @@ pub fn toggle(app: &tauri::AppHandle) { pub fn apply_state(app: &tauri::AppHandle, settings: &Settings) { let open = app.get_webview_window(FLOATBAR_LABEL).is_some(); if settings.float_bar_enabled && !open { - let _show = window::show( - app, - settings.float_bar_opacity, - &settings.float_bar_orientation, - &settings.float_bar_style, - settings.float_bar_click_through, - ); + let _show = window::show(app, settings); } else if !settings.float_bar_enabled && open { let _hide = window::hide(app); } else if let Some(w) = app.get_webview_window(FLOATBAR_LABEL) { diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/placement.rs b/apps/desktop-tauri/src-tauri/src/floatbar/placement.rs index fe3f1a8dc28..e6c10344451 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/placement.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/placement.rs @@ -64,11 +64,7 @@ impl MonitorLayout { } fn scale_milli(scale_factor: f64) -> u32 { - if scale_factor.is_finite() && scale_factor > 0.0 { - (scale_factor * 1000.0).round() as u32 - } else { - 1000 - } + (crate::window_positioner::safe_scale(scale_factor) * 1000.0).round() as u32 } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/window.rs b/apps/desktop-tauri/src-tauri/src/floatbar/window.rs index 1c3289f6e1c..e5981028404 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/window.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/window.rs @@ -92,11 +92,7 @@ fn fallback_physical_position( scale_factor: f64, style: &str, ) -> PhysicalPosition { - let scale_factor = if scale_factor.is_finite() && scale_factor > 0.0 { - scale_factor - } else { - 1.0 - }; + let scale_factor = crate::window_positioner::safe_scale(scale_factor); let mon_x = work_area_position.x as f64 / scale_factor; let mon_y = work_area_position.y as f64 / scale_factor; let mon_w = work_area_size.width as f64 / scale_factor; @@ -234,24 +230,17 @@ pub fn opacity_to_alpha(opacity: u8) -> u8 { /// already open. The bar never takes focus. Position is restored from the /// geometry store keyed by `floatbar`; on first launch the window is /// centered horizontally near the top of the primary monitor. -pub fn show( - app: &tauri::AppHandle, - opacity: u8, - orientation: &str, - style: &str, - click_through: bool, -) -> Result<(), String> { +pub fn show(app: &tauri::AppHandle, settings: &codexbar::settings::Settings) -> Result<(), String> { + let opacity = settings.float_bar_opacity; + let orientation = settings.float_bar_orientation.as_str(); + let style = settings.float_bar_style.as_str(); + let click_through = settings.float_bar_click_through; if let Some(window) = app.get_webview_window(FLOATBAR_LABEL) { apply_opacity(&window, opacity); apply_click_through(&window, click_through); let _ = ensure_visible_on_active_monitor(&window, style); // Re-assert after possible unminimize/relocate so focus stays off. - apply_no_activate(&window); - apply_always_on_top(&window); - window.show().map_err(|e| e.to_string())?; - apply_always_on_top(&window); - super::topmost_guard::set_active(true); - return Ok(()); + return present(&window); } let (w, h) = initial_size(orientation); @@ -319,10 +308,15 @@ pub fn show( apply_opacity(&win, opacity); apply_click_through(&win, click_through); - apply_no_activate(&win); - apply_always_on_top(&win); - win.show().map_err(|e| e.to_string())?; - apply_always_on_top(&win); + present(&win) +} + +/// Show the bar without activating it and keep it above the taskbar. +fn present(window: &tauri::WebviewWindow) -> Result<(), String> { + apply_no_activate(window); + apply_always_on_top(window); + window.show().map_err(|e| e.to_string())?; + apply_always_on_top(window); super::topmost_guard::set_active(true); Ok(()) } @@ -458,70 +452,46 @@ pub trait WindowGeometry { fn set_physical_position(&self, position: PhysicalPosition) -> tauri::Result<()>; } -impl WindowGeometry for tauri::WebviewWindow { - fn outer_position(&self) -> tauri::Result> { - tauri::WebviewWindow::outer_position(self) - } - fn outer_size(&self) -> tauri::Result> { - tauri::WebviewWindow::outer_size(self) - } - fn scale_factor(&self) -> tauri::Result { - tauri::WebviewWindow::scale_factor(self) - } - fn is_minimized(&self) -> tauri::Result { - tauri::WebviewWindow::is_minimized(self) - } - fn primary_monitor(&self) -> tauri::Result> { - tauri::WebviewWindow::primary_monitor(self) - } - fn available_monitors(&self) -> tauri::Result> { - tauri::WebviewWindow::available_monitors(self) - } - fn restore_without_activation(&self) -> tauri::Result<()> { - // Queued on the main thread ahead of the calls below, so the restore - // lands before `unminimize` re-reads the state and before the caller - // moves the window. - let window = self.clone(); - tauri::WebviewWindow::run_on_main_thread(self, move || { - crate::shell::activation::restore_minimized_without_activation(&window); - })?; - tauri::WebviewWindow::unminimize(self) - } - fn set_physical_position(&self, position: PhysicalPosition) -> tauri::Result<()> { - tauri::WebviewWindow::set_position(self, position) - } +macro_rules! impl_window_geometry { + ($ty:ident) => { + impl WindowGeometry for tauri::$ty { + fn outer_position(&self) -> tauri::Result> { + tauri::$ty::outer_position(self) + } + fn outer_size(&self) -> tauri::Result> { + tauri::$ty::outer_size(self) + } + fn scale_factor(&self) -> tauri::Result { + tauri::$ty::scale_factor(self) + } + fn is_minimized(&self) -> tauri::Result { + tauri::$ty::is_minimized(self) + } + fn primary_monitor(&self) -> tauri::Result> { + tauri::$ty::primary_monitor(self) + } + fn available_monitors(&self) -> tauri::Result> { + tauri::$ty::available_monitors(self) + } + fn restore_without_activation(&self) -> tauri::Result<()> { + // Queued on the main thread ahead of the calls below, so the + // restore lands before `unminimize` re-reads the state and + // before the caller moves the window. + let window = self.clone(); + tauri::$ty::run_on_main_thread(self, move || { + crate::shell::activation::restore_minimized_without_activation(&window); + })?; + tauri::$ty::unminimize(self) + } + fn set_physical_position(&self, position: PhysicalPosition) -> tauri::Result<()> { + tauri::$ty::set_position(self, position) + } + } + }; } -impl WindowGeometry for tauri::Window { - fn outer_position(&self) -> tauri::Result> { - tauri::Window::outer_position(self) - } - fn outer_size(&self) -> tauri::Result> { - tauri::Window::outer_size(self) - } - fn scale_factor(&self) -> tauri::Result { - tauri::Window::scale_factor(self) - } - fn is_minimized(&self) -> tauri::Result { - tauri::Window::is_minimized(self) - } - fn primary_monitor(&self) -> tauri::Result> { - tauri::Window::primary_monitor(self) - } - fn available_monitors(&self) -> tauri::Result> { - tauri::Window::available_monitors(self) - } - fn restore_without_activation(&self) -> tauri::Result<()> { - let window = self.clone(); - tauri::Window::run_on_main_thread(self, move || { - crate::shell::activation::restore_minimized_without_activation(&window); - })?; - tauri::Window::unminimize(self) - } - fn set_physical_position(&self, position: PhysicalPosition) -> tauri::Result<()> { - tauri::Window::set_position(self, position) - } -} +impl_window_geometry!(WebviewWindow); +impl_window_geometry!(Window); /// Resize the floatbar to the given logical dimensions and re-assert the /// native interaction invariants in the same step. @@ -698,178 +668,4 @@ unsafe extern "system" { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn opacity_to_alpha_clamps_low_values() { - assert_eq!(opacity_to_alpha(0), opacity_to_alpha(30)); - assert_eq!(opacity_to_alpha(10), opacity_to_alpha(30)); - } - - #[test] - fn opacity_to_alpha_full_is_255() { - assert_eq!(opacity_to_alpha(100), 255); - } - - #[test] - fn opacity_to_alpha_is_monotonic() { - let a = opacity_to_alpha(30); - let b = opacity_to_alpha(60); - let c = opacity_to_alpha(100); - assert!(a < b); - assert!(b < c); - } - - #[test] - fn opacity_to_alpha_midpoint() { - // 50% should be roughly half of 255. - let alpha = opacity_to_alpha(50); - assert!((125..=130).contains(&alpha), "got {alpha}"); - } - - #[test] - fn initial_size_picks_orientation() { - assert_eq!( - initial_size("horizontal"), - (FLOATBAR_DEFAULT_WIDTH_H, FLOATBAR_DEFAULT_HEIGHT_H) - ); - assert_eq!( - initial_size("vertical"), - (FLOATBAR_DEFAULT_WIDTH_V, FLOATBAR_DEFAULT_HEIGHT_V) - ); - // Unknown values fall through to horizontal so a corrupted setting - // can't yield an unreadable strip. - assert_eq!( - initial_size("diagonal"), - (FLOATBAR_DEFAULT_WIDTH_H, FLOATBAR_DEFAULT_HEIGHT_H) - ); - } - - #[test] - fn windows_minimized_position_is_not_restored() { - assert!(is_windows_minimized_position(-32_000, -32_000)); - } - - #[test] - fn legitimate_negative_monitor_positions_are_preserved() { - assert!(!is_windows_minimized_position(-3_840, 0)); - assert!(!is_windows_minimized_position(-8_000, -8_000)); - assert!(!is_windows_minimized_position(-16_000, -16_000)); - } - - #[test] - fn minimized_or_parked_physical_positions_are_not_remembered() { - assert!(!should_remember_physical_position(true, 100, 100)); - assert!(!should_remember_physical_position(false, -32_000, -32_000)); - assert!(should_remember_physical_position(false, -3_840, 100)); - } - - #[test] - fn physical_multi_monitor_origin_is_remembered() { - assert!(should_remember_physical_position(false, -8_000, -8_000)); - } - - #[test] - fn window_must_intersect_an_active_monitor_to_be_visible() { - let window_size = PhysicalSize::new(211, 40); - let monitor_size = PhysicalSize::new(1_920, 1_032); - - assert!(physical_rects_intersect( - PhysicalPosition::new(780, 8), - window_size, - PhysicalPosition::new(0, 0), - monitor_size, - )); - assert!(!physical_rects_intersect( - PhysicalPosition::new(-32_000, -32_000), - window_size, - PhysicalPosition::new(0, 0), - monitor_size, - )); - assert!(physical_rects_intersect( - PhysicalPosition::new(-3_840, 8), - window_size, - PhysicalPosition::new(-3_840, 0), - monitor_size, - )); - } - - #[test] - fn taskbar_strip_outside_work_area_still_counts_as_on_monitor() { - // Full monitor 1920x1080; work area is 1920x1032 (48px taskbar strip). - // A taskbar-style bar sitting fully in the strip is on-screen for - // visibility even though it has zero work-area intersection. - let window_pos = PhysicalPosition::new(800, 1_040); - let window_size = PhysicalSize::new(211, 40); - let monitor_pos = PhysicalPosition::new(0, 0); - let monitor_size = PhysicalSize::new(1_920, 1_080); - let work_area_pos = PhysicalPosition::new(0, 0); - let work_area_size = PhysicalSize::new(1_920, 1_032); - - assert!(physical_rects_intersect( - window_pos, - window_size, - monitor_pos, - monitor_size, - )); - assert!(!physical_rects_intersect( - window_pos, - window_size, - work_area_pos, - work_area_size, - )); - } - - #[test] - fn hidpi_parked_logical_geometry_is_rejected() { - assert!(is_unusable_stored_logical_position(-32_000, -32_000)); - // -32000 physical at 1.25x / 1.5x / 2.0x scale factors. - assert!(is_unusable_stored_logical_position(-25_600, -25_600)); - assert!(is_unusable_stored_logical_position(-21_333, -21_333)); - assert!(is_unusable_stored_logical_position(-16_000, -16_000)); - // Legitimate multi-monitor logical origins must still restore. - assert!(!is_unusable_stored_logical_position(-3_840, 0)); - assert!(!is_unusable_stored_logical_position(-8_000, -8_000)); - } - - #[test] - fn disconnected_monitor_position_falls_back_to_primary_work_area() { - let work_area_position = PhysicalPosition::new(0, 0); - let work_area_size = PhysicalSize::new(1_920, 1_032); - let window_size = PhysicalSize::new(211, 40); - - assert_eq!( - fallback_physical_position( - work_area_position, - work_area_size, - window_size, - 1.0, - "floating", - ), - PhysicalPosition::new(855, 8), - ); - assert_eq!( - fallback_physical_position( - work_area_position, - work_area_size, - window_size, - 1.0, - "taskbar", - ), - PhysicalPosition::new(855, 984), - ); - } - - #[test] - fn default_logical_origin_matches_first_open_policy() { - assert_eq!( - default_logical_origin(0.0, 0.0, 1_920.0, 1_080.0, 211.0, 40.0, "floating"), - (854.5, 8.0), - ); - assert_eq!( - default_logical_origin(0.0, 0.0, 1_920.0, 1_080.0, 211.0, 40.0, "taskbar"), - (854.5, 1_032.0), - ); - } -} +mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/window/tests.rs b/apps/desktop-tauri/src-tauri/src/floatbar/window/tests.rs new file mode 100644 index 00000000000..8b3ad8bac54 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/floatbar/window/tests.rs @@ -0,0 +1,173 @@ +use super::*; + +#[test] +fn opacity_to_alpha_clamps_low_values() { + assert_eq!(opacity_to_alpha(0), opacity_to_alpha(30)); + assert_eq!(opacity_to_alpha(10), opacity_to_alpha(30)); +} + +#[test] +fn opacity_to_alpha_full_is_255() { + assert_eq!(opacity_to_alpha(100), 255); +} + +#[test] +fn opacity_to_alpha_is_monotonic() { + let a = opacity_to_alpha(30); + let b = opacity_to_alpha(60); + let c = opacity_to_alpha(100); + assert!(a < b); + assert!(b < c); +} + +#[test] +fn opacity_to_alpha_midpoint() { + // 50% should be roughly half of 255. + let alpha = opacity_to_alpha(50); + assert!((125..=130).contains(&alpha), "got {alpha}"); +} + +#[test] +fn initial_size_picks_orientation() { + assert_eq!( + initial_size("horizontal"), + (FLOATBAR_DEFAULT_WIDTH_H, FLOATBAR_DEFAULT_HEIGHT_H) + ); + assert_eq!( + initial_size("vertical"), + (FLOATBAR_DEFAULT_WIDTH_V, FLOATBAR_DEFAULT_HEIGHT_V) + ); + // Unknown values fall through to horizontal so a corrupted setting + // can't yield an unreadable strip. + assert_eq!( + initial_size("diagonal"), + (FLOATBAR_DEFAULT_WIDTH_H, FLOATBAR_DEFAULT_HEIGHT_H) + ); +} + +#[test] +fn windows_minimized_position_is_not_restored() { + assert!(is_windows_minimized_position(-32_000, -32_000)); +} + +#[test] +fn legitimate_negative_monitor_positions_are_preserved() { + assert!(!is_windows_minimized_position(-3_840, 0)); + assert!(!is_windows_minimized_position(-8_000, -8_000)); + assert!(!is_windows_minimized_position(-16_000, -16_000)); +} + +#[test] +fn minimized_or_parked_physical_positions_are_not_remembered() { + assert!(!should_remember_physical_position(true, 100, 100)); + assert!(!should_remember_physical_position(false, -32_000, -32_000)); + assert!(should_remember_physical_position(false, -3_840, 100)); +} + +#[test] +fn physical_multi_monitor_origin_is_remembered() { + assert!(should_remember_physical_position(false, -8_000, -8_000)); +} + +#[test] +fn window_must_intersect_an_active_monitor_to_be_visible() { + let window_size = PhysicalSize::new(211, 40); + let monitor_size = PhysicalSize::new(1_920, 1_032); + + assert!(physical_rects_intersect( + PhysicalPosition::new(780, 8), + window_size, + PhysicalPosition::new(0, 0), + monitor_size, + )); + assert!(!physical_rects_intersect( + PhysicalPosition::new(-32_000, -32_000), + window_size, + PhysicalPosition::new(0, 0), + monitor_size, + )); + assert!(physical_rects_intersect( + PhysicalPosition::new(-3_840, 8), + window_size, + PhysicalPosition::new(-3_840, 0), + monitor_size, + )); +} + +#[test] +fn taskbar_strip_outside_work_area_still_counts_as_on_monitor() { + // Full monitor 1920x1080; work area is 1920x1032 (48px taskbar strip). + // A taskbar-style bar sitting fully in the strip is on-screen for + // visibility even though it has zero work-area intersection. + let window_pos = PhysicalPosition::new(800, 1_040); + let window_size = PhysicalSize::new(211, 40); + let monitor_pos = PhysicalPosition::new(0, 0); + let monitor_size = PhysicalSize::new(1_920, 1_080); + let work_area_pos = PhysicalPosition::new(0, 0); + let work_area_size = PhysicalSize::new(1_920, 1_032); + + assert!(physical_rects_intersect( + window_pos, + window_size, + monitor_pos, + monitor_size, + )); + assert!(!physical_rects_intersect( + window_pos, + window_size, + work_area_pos, + work_area_size, + )); +} + +#[test] +fn hidpi_parked_logical_geometry_is_rejected() { + assert!(is_unusable_stored_logical_position(-32_000, -32_000)); + // -32000 physical at 1.25x / 1.5x / 2.0x scale factors. + assert!(is_unusable_stored_logical_position(-25_600, -25_600)); + assert!(is_unusable_stored_logical_position(-21_333, -21_333)); + assert!(is_unusable_stored_logical_position(-16_000, -16_000)); + // Legitimate multi-monitor logical origins must still restore. + assert!(!is_unusable_stored_logical_position(-3_840, 0)); + assert!(!is_unusable_stored_logical_position(-8_000, -8_000)); +} + +#[test] +fn disconnected_monitor_position_falls_back_to_primary_work_area() { + let work_area_position = PhysicalPosition::new(0, 0); + let work_area_size = PhysicalSize::new(1_920, 1_032); + let window_size = PhysicalSize::new(211, 40); + + assert_eq!( + fallback_physical_position( + work_area_position, + work_area_size, + window_size, + 1.0, + "floating", + ), + PhysicalPosition::new(855, 8), + ); + assert_eq!( + fallback_physical_position( + work_area_position, + work_area_size, + window_size, + 1.0, + "taskbar", + ), + PhysicalPosition::new(855, 984), + ); +} + +#[test] +fn default_logical_origin_matches_first_open_policy() { + assert_eq!( + default_logical_origin(0.0, 0.0, 1_920.0, 1_080.0, 211.0, 40.0, "floating"), + (854.5, 8.0), + ); + assert_eq!( + default_logical_origin(0.0, 0.0, 1_920.0, 1_080.0, 211.0, 40.0, "taskbar"), + (854.5, 1_032.0), + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 87f5ca01833..e945098821a 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -225,7 +225,6 @@ fn main() { commands::get_cached_providers, commands::get_deepseek_pricing_status, commands::codex_accounts_list, - commands::claude_accounts_list, commands::get_claude_accounts_state, commands::claude_reconciliation_state, commands::claude_account_add, @@ -327,12 +326,7 @@ fn main() { commands::set_ui_language, commands::open_path, tray_visibility::tray_visibility_status, - floatbar::show_float_bar, - floatbar::hide_float_bar, - floatbar::set_float_bar_opacity, - floatbar::set_float_bar_click_through, floatbar::resize_float_bar, - floatbar::set_float_bar_orientation, ]) .setup(move |app| { if let Err(error) = codexbar::providers::claude::accounts::cleanup_abandoned_logins() { @@ -391,14 +385,6 @@ fn main() { ) { return; } - if let Some(st) = window.app_handle().try_state::>() - && st - .lock() - .unwrap() - .take_startup_tray_blur_grace(std::time::Instant::now()) - { - return; - } // Grace period: ignore blur within 500ms of showing the panel. // On Windows, the tray click can cause a spurious blur before // the window fully acquires focus. diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 26e4e1c0357..0efb3b64637 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -173,20 +173,7 @@ mod tests { RateWindowSnapshot { used_percent, remaining_percent: 100.0 - used_percent, - window_minutes: None, - resets_at: None, - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_eta_seconds: None, - pace: None, - monthly_limit_block: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, - reserve_will_last_to_reset: false, + ..Default::default() } } @@ -197,35 +184,12 @@ mod tests { display_name: "Test Provider".to_string(), primary: rate_window(42.0), primary_label: Some("Session".to_string()), - secondary: None, - secondary_label: None, - secondary_label_key: None, - model_specific: None, - tertiary: None, - tertiary_label: None, - extra_rate_windows: Vec::new(), - inventory: Vec::new(), - display_details: Vec::new(), - cost: None, plan_name: Some("Team".to_string()), account_email: Some("dev@example.com".to_string()), - account_label: None, - subscription: None, source_label: "web".to_string(), - has_successful_claude_cli_quota: false, updated_at: "2026-07-09T00:00:00Z".to_string(), - error: None, - error_state: codexbar::core::ProviderStateKind::Ready, - pace: None, - pace_authoritative: true, account_organization: Some("Example Org".to_string()), - tray_status_label: None, - fetch_duration_ms: None, - wayfinder_usage: None, - quota_burndown: None, - open_ai_api_usage: None, - throughput: None, - session_equivalent_forecast: None, + ..Default::default() }); let value = serde_json::to_value(snapshot).unwrap(); @@ -258,35 +222,9 @@ mod tests { display_name: "Test Provider".to_string(), primary: rate_window(42.0), primary_label: Some("Session".to_string()), - secondary: None, - secondary_label: None, - secondary_label_key: None, - model_specific: None, - tertiary: None, - tertiary_label: None, - extra_rate_windows: Vec::new(), - inventory: Vec::new(), - display_details: Vec::new(), - cost: None, - plan_name: None, - account_email: None, - account_label: None, - subscription: None, source_label: "web".to_string(), - has_successful_claude_cli_quota: false, updated_at: "2026-07-09T00:00:00Z".to_string(), - error: None, - error_state: codexbar::core::ProviderStateKind::Ready, - pace: None, - pace_authoritative: true, - account_organization: None, - tray_status_label: None, - fetch_duration_ms: None, - wayfinder_usage: None, - open_ai_api_usage: None, - throughput: None, - session_equivalent_forecast: None, - quota_burndown: None, + ..Default::default() }); let value = serde_json::to_value(snapshot).unwrap(); diff --git a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs index 2fec84cae39..008ca465b20 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/flyout_window.rs @@ -288,10 +288,7 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - return true; }; { - let mut guard = st.lock().unwrap(); - if guard.take_startup_tray_blur_grace(Instant::now()) { - return true; - } + let guard = st.lock().unwrap(); if guard.was_tray_panel_recently_shown(Instant::now(), RECENTLY_SHOWN_GRACE) { return true; } @@ -363,12 +360,12 @@ pub fn reanchor(app: &AppHandle) -> Result<(), String> { let work_area = crate::shell::geometry::monitor_work_area_rect(&monitor); // The target monitor can differ from the window's previous monitor. let scale = monitor.scale_factor().max(1.0); - let monitor_bounds = Rect { - x: monitor.position().x, - y: monitor.position().y, - width: monitor.size().width, - height: monitor.size().height, - }; + let monitor_bounds = Rect::new( + monitor.position().x, + monitor.position().y, + monitor.size().width, + monitor.size().height, + ); let (x, y) = { if let Some(cursor) = cursor { @@ -380,12 +377,7 @@ pub fn reanchor(app: &AppHandle) -> Result<(), String> { ) } else if let Some(a) = anchor { crate::window_positioner::calculate_panel_position( - &Rect { - x: a.x, - y: a.y, - width: a.width, - height: a.height, - }, + &Rect::new(a.x, a.y, a.width, a.height), &monitor_bounds, &work_area, &panel_size, diff --git a/apps/desktop-tauri/src-tauri/src/shell/geometry.rs b/apps/desktop-tauri/src-tauri/src/shell/geometry.rs index 516aaca0611..0aa1967a56a 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/geometry.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/geometry.rs @@ -48,21 +48,16 @@ pub(super) fn monitor_work_area_rect(monitor: &tauri::Monitor) -> Rect { && area.x + area.width <= position.x + size_width && area.y + area.height <= position.y + size_height { - return Rect { - x: area.x, - y: area.y, - width: area.width as u32, - height: area.height as u32, - }; + return Rect::new(area.x, area.y, area.width as u32, area.height as u32); } let work_area = monitor.work_area(); - Rect { - x: work_area.position.x, - y: work_area.position.y, - width: work_area.size.width, - height: work_area.size.height, - } + Rect::new( + work_area.position.x, + work_area.position.y, + work_area.size.width, + work_area.size.height, + ) } pub(super) fn monitor_placement(monitor: &tauri::Monitor) -> MonitorPlacement { @@ -70,30 +65,12 @@ pub(super) fn monitor_placement(monitor: &tauri::Monitor) -> MonitorPlacement { let size = monitor.size(); MonitorPlacement { - bounds: Rect { - x: position.x, - y: position.y, - width: size.width, - height: size.height, - }, + bounds: Rect::new(position.x, position.y, size.width, size.height), work_area: monitor_work_area_rect(monitor), scale_factor: monitor.scale_factor(), } } -pub(super) fn popout_position( - anchor_rect: Option<&Rect>, - monitor: &MonitorPlacement, - panel_size: &PanelSize, -) -> (i32, i32) { - window_positioner::calculate_popout_position( - anchor_rect, - &monitor.work_area, - panel_size, - monitor.scale_factor, - ) -} - /// Center a panel on the monitor's work area (used for Settings windows). pub(super) fn centered_position(monitor: &MonitorPlacement, panel_size: &PanelSize) -> (i32, i32) { let scale = monitor.scale_factor; @@ -103,11 +80,8 @@ pub(super) fn centered_position(monitor: &MonitorPlacement, panel_size: &PanelSi #[expect(clippy::cast_possible_truncation, reason = "whole units by design")] let ph = (panel_size.height as f64 * scale) as i32; let wa = &monitor.work_area; - // Work-area dimensions are physical pixels, bounded well below i32::MAX. - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] - let x = wa.x + (wa.width as i32 - pw) / 2; - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] - let y = wa.y + (wa.height as i32 - ph) / 2; + let x = wa.x + (wa.signed_width() - pw) / 2; + let y = wa.y + (wa.signed_height() - ph) / 2; (x, y) } @@ -115,17 +89,12 @@ pub(super) fn inferred_tray_anchor_rect(monitor: &MonitorPlacement) -> Rect { const SYNTHETIC_TRAY_ICON_SIZE: u32 = 24; const SYNTHETIC_TRAY_EDGE_PADDING: i32 = 8; - // Monitor bounds and work area are physical pixels, bounded well below i32::MAX. - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] - let work_right = monitor.work_area.x + monitor.work_area.width as i32; - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] - let work_bottom = monitor.work_area.y + monitor.work_area.height as i32; + let work_right = monitor.work_area.right(); + let work_bottom = monitor.work_area.bottom(); let bounds_left = monitor.bounds.x; - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] - let bounds_right = monitor.bounds.x + monitor.bounds.width as i32; + let bounds_right = monitor.bounds.right(); let bounds_top = monitor.bounds.y; - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] - let bounds_bottom = monitor.bounds.y + monitor.bounds.height as i32; + let bounds_bottom = monitor.bounds.bottom(); let left_gap = monitor.work_area.x - bounds_left; let right_gap = bounds_right - work_right; let top_gap = monitor.work_area.y - bounds_top; @@ -152,12 +121,7 @@ pub(super) fn inferred_tray_anchor_rect(monitor: &MonitorPlacement) -> Rect { bounds_bottom - icon_size - SYNTHETIC_TRAY_EDGE_PADDING }; - Rect { - x, - y, - width: SYNTHETIC_TRAY_ICON_SIZE, - height: SYNTHETIC_TRAY_ICON_SIZE, - } + Rect::new(x, y, SYNTHETIC_TRAY_ICON_SIZE, SYNTHETIC_TRAY_ICON_SIZE) } pub(super) fn inferred_tray_panel_position_for_monitor(monitor: &MonitorPlacement) -> (i32, i32) { @@ -178,42 +142,7 @@ pub(super) fn inferred_tray_panel_position_for_monitor_size( } pub(super) fn tray_anchor_rect(anchor: crate::state::TrayAnchor) -> Rect { - Rect { - x: anchor.x, - y: anchor.y, - width: anchor.width, - height: anchor.height, - } -} - -pub(super) fn monitor_placement_for_anchor( - monitors: &[MonitorPlacement], - anchor: crate::state::TrayAnchor, -) -> Option { - // Tray icon dimensions are small pixel counts, far below i32::MAX. - #[expect( - clippy::cast_possible_wrap, - reason = "tray icon pixel dimensions fit in i32" - )] - let anchor_cx = anchor.x + anchor.width as i32 / 2; - #[expect( - clippy::cast_possible_wrap, - reason = "tray icon pixel dimensions fit in i32" - )] - let anchor_cy = anchor.y + anchor.height as i32 / 2; - - monitor_placement_containing_point(monitors, anchor_cx, anchor_cy) -} - -pub(super) fn monitor_placement_containing_point( - monitors: &[MonitorPlacement], - x: i32, - y: i32, -) -> Option { - monitors - .iter() - .find(|monitor| point_in_rect(&monitor.bounds, x, y)) - .copied() + Rect::new(anchor.x, anchor.y, anchor.width, anchor.height) } pub(super) fn monitor_for_anchor( @@ -243,24 +172,10 @@ pub(super) fn monitor_containing_point( monitors.iter().find(|monitor| { let pos = monitor.position(); let size = monitor.size(); - point_in_rect( - &Rect { - x: pos.x, - y: pos.y, - width: size.width, - height: size.height, - }, - x, - y, - ) + point_in_rect(&Rect::new(pos.x, pos.y, size.width, size.height), x, y) }) } pub(super) fn point_in_rect(rect: &Rect, x: i32, y: i32) -> bool { - // Rect dimensions are physical pixels, bounded well below i32::MAX. - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] - let right = rect.x + rect.width as i32; - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] - let bottom = rect.y + rect.height as i32; - x >= rect.x && x < right && y >= rect.y && y < bottom + x >= rect.x && x < rect.right() && y >= rect.y && y < rect.bottom() } diff --git a/apps/desktop-tauri/src-tauri/src/shell/position.rs b/apps/desktop-tauri/src-tauri/src/shell/position.rs index 57de0f925b7..c2632cb679f 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/position.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/position.rs @@ -10,9 +10,8 @@ use crate::surface::SurfaceMode; use crate::window_positioner; use super::geometry::{ - MonitorPlacement, inferred_tray_anchor_rect, monitor_for_anchor, monitor_placement, - monitor_placement_containing_point, monitor_placement_for_anchor, monitor_work_area_rect, - point_in_rect, popout_position, surface_panel_size, tray_anchor_rect, tray_panel_size, + MonitorPlacement, monitor_for_anchor, monitor_placement, monitor_work_area_rect, point_in_rect, + surface_panel_size, tray_anchor_rect, tray_panel_size, }; pub fn inferred_tray_panel_position(app: &AppHandle) -> Option<(i32, i32)> { @@ -44,102 +43,13 @@ fn current_tray_anchor(app: &AppHandle) -> Option { st.lock().ok()?.tray_anchor } -fn visible_surface_position_for_mode(app: &AppHandle, mode: SurfaceMode) -> Option<(i32, i32)> { - let window = app.get_webview_window("main")?; - let monitor_placements = window - .available_monitors() - .ok() - .map(|monitors| monitors.iter().map(monitor_placement).collect::>()); - let current_monitor = window - .current_monitor() - .ok() - .flatten() - .map(|monitor| monitor_placement(&monitor)); - let current_window_bounds = match (window.outer_position(), window.outer_size()) { - (Ok(position), Ok(size)) => Some(((position.x, position.y), (size.width, size.height))), - _ => None, - }; - let primary_monitor = window - .primary_monitor() - .ok() - .flatten() - .map(|monitor| monitor_placement(&monitor)); - - visible_surface_position_for_mode_with_fallbacks( - mode, - monitor_placements.as_deref(), - current_tray_anchor(app), - current_monitor, - current_window_bounds, - primary_monitor, - ) -} - -pub(super) fn visible_surface_position_for_mode_with_fallbacks( - mode: SurfaceMode, - monitor_placements: Option<&[MonitorPlacement]>, - tray_anchor: Option, - current_monitor: Option, - current_window_bounds: Option<((i32, i32), (u32, u32))>, - primary_monitor: Option, -) -> Option<(i32, i32)> { - let panel_size = surface_panel_size(mode); - - if let Some(anchor) = tray_anchor - && let Some(monitors) = monitor_placements - && let Some(monitor) = monitor_placement_for_anchor(monitors, anchor) - { - return Some(popout_position( - Some(&tray_anchor_rect(anchor)), - &monitor, - &panel_size, - )); - } - - // No usable tray anchor (e.g. a right-click menu "Pop Out Dashboard" with no - // prior left-click, or a proof/automation launch). The tray icon lives on - // the primary (taskbar) monitor, so anchor the surface there. Crucially, do - // NOT fall through to the hidden main window's `current_monitor`: after a - // previous session left the surface on a now-off-view monitor, that monitor - // becomes `current_monitor` and the surface would reopen where the user - // can't see it — the multi-monitor "nothing happens" bug. - if tray_anchor.is_none() - && let Some(monitor) = primary_monitor - { - return Some(popout_position( - Some(&inferred_tray_anchor_rect(&monitor)), - &monitor, - &panel_size, - )); - } - - if let Some(monitor) = current_monitor { - return Some(popout_position(None, &monitor, &panel_size)); - } - - if let Some(monitors) = monitor_placements - && let Some((current_top_left, current_size)) = current_window_bounds - && let Some(monitor) = monitor_placement_containing_point( - monitors, - current_top_left.0 + current_size.0 as i32 / 2, - current_top_left.1 + current_size.1 as i32 / 2, - ) - { - return Some(popout_position(None, &monitor, &panel_size)); - } - - let monitor = primary_monitor?; - Some(popout_position(None, &monitor, &panel_size)) -} - pub fn default_surface_position(app: &AppHandle, mode: SurfaceMode) -> Option<(i32, i32)> { match mode { SurfaceMode::Hidden => None, SurfaceMode::TrayPanel => tray_panel_position(app) .or_else(|| inferred_tray_panel_position(app)) .or_else(|| shortcut_panel_position(app)), - SurfaceMode::PopOut => remembered_surface_position(app, mode) - .or_else(|| visible_surface_position_for_mode(app, mode)), + SurfaceMode::PopOut => remembered_surface_position(app, mode), SurfaceMode::Settings => { remembered_surface_position(app, mode).or_else(|| centered_settings_position(app)) } diff --git a/apps/desktop-tauri/src-tauri/src/shell/tests.rs b/apps/desktop-tauri/src-tauri/src/shell/tests.rs index 8a386d72350..09f9fa2f474 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/tests.rs @@ -1,18 +1,14 @@ use super::ShellTransitionRequest; use super::geometry::{ MonitorPlacement, inferred_tray_anchor_rect, inferred_tray_panel_position_for_monitor, - surface_panel_size, tray_anchor_rect, -}; -use super::position::{ - remembered_panel_size, remembered_surface_position_with_monitors, - visible_surface_position_for_mode_with_fallbacks, }; +use super::position::{remembered_panel_size, remembered_surface_position_with_monitors}; use super::transition::{ SurfaceSnapshot, TransitionResolution, hidden_surface_snapshot, monitor_for_preserved_visible_position, reclamp_preserved_visible_position, recovery_snapshot_for_failed_transition, resolve_transition_position, resolve_transition_request, restore_recovery_surface, restore_surface_snapshot, - should_force_tray_panel_reveal, should_synthesize_default_position, + should_synthesize_default_position, }; use super::window::{logical_size_from_geometry, prepare_hide_to_tray_if_current}; @@ -21,6 +17,14 @@ use crate::surface::{SurfaceMode, SurfaceTransition}; use crate::surface_target::SurfaceTarget; use crate::window_positioner::{self, Rect}; +fn monitor(bounds: Rect, work_area: Rect, scale_factor: f64) -> MonitorPlacement { + MonitorPlacement { + bounds, + work_area, + scale_factor, + } +} + #[test] fn hide_to_tray_resets_hidden_target_to_summary() { let mut state = AppState::new(); @@ -67,34 +71,6 @@ fn conditional_hide_to_tray_leaves_non_matching_surface_alone() { // dedicated window now (see `shell::flyout_window::toggle_with_blur_consume` // and its own test module in flyout_window.rs). -#[test] -fn tray_reveal_fallback_only_for_hidden_tray_panel() { - assert!(should_force_tray_panel_reveal( - SurfaceMode::TrayPanel, - false, - Some((328, 776)), - )); - assert!(!should_force_tray_panel_reveal( - SurfaceMode::TrayPanel, - true, - Some((328, 776)), - )); - assert!(!should_force_tray_panel_reveal( - SurfaceMode::Hidden, - false, - Some((16, 16)), - )); -} - -#[test] -fn tray_reveal_fallback_recovers_tiny_shell_window() { - assert!(should_force_tray_panel_reveal( - SurfaceMode::TrayPanel, - true, - Some((16, 16)), - )); -} - #[test] fn tray_show_grace_is_based_on_actual_show_time() { let mut state = AppState::new(); @@ -161,7 +137,6 @@ fn same_mode_about_request_resolves_as_retarget() { }, position: None, }, - false, ); match resolution { @@ -192,7 +167,6 @@ fn same_mode_provider_request_resolves_as_retarget() { }, position: None, }, - false, ); match resolution { @@ -209,30 +183,6 @@ fn same_mode_provider_request_resolves_as_retarget() { } } -#[test] -fn same_mode_reopen_request_resolves_as_update() { - let mut state = AppState::new(); - state.transition_surface(SurfaceMode::TrayPanel, SurfaceTarget::Summary); - - let resolution = resolve_transition_request( - &state, - &ShellTransitionRequest { - mode: SurfaceMode::TrayPanel, - target: SurfaceTarget::Summary, - position: Some((10, 20)), - }, - true, - ); - - match resolution { - TransitionResolution::SameModeReopen { mode, target } => { - assert_eq!(mode, SurfaceMode::TrayPanel); - assert_eq!(target, SurfaceTarget::Summary); - } - _ => panic!("expected same-mode reopen update"), - } -} - #[test] fn same_mode_retarget_skips_default_position_synthesis() { let resolution = TransitionResolution::SameModeRetarget { @@ -271,26 +221,6 @@ fn same_mode_retarget_preserves_explicit_position() { assert_eq!(position, Some((10, 20))); } -#[test] -fn same_mode_reopen_still_uses_default_position() { - let resolution = TransitionResolution::SameModeReopen { - mode: SurfaceMode::TrayPanel, - target: SurfaceTarget::Summary, - }; - let mut fallback_called = false; - - let position = resolve_transition_position(None, &resolution, true, || { - fallback_called = true; - Some((42, 24)) - }); - - assert_eq!(position, Some((42, 24))); - assert!( - fallback_called, - "same-mode reopen should still synthesize a default position" - ); -} - #[test] fn visible_mode_change_skips_default_position_synthesis() { let resolution = TransitionResolution::ModeChange { @@ -325,12 +255,7 @@ fn visible_mode_change_skips_default_position_synthesis() { #[test] fn larger_visible_destination_reclamps_preserved_top_left() { let current_top_left = (1492, 512); - let monitor = Rect { - x: 0, - y: 0, - width: 1920, - height: 1080, - }; + let monitor = Rect::new(0, 0, 1920, 1080); let reclamped = reclamp_preserved_visible_position(current_top_left, &monitor, SurfaceMode::Settings, 1.0); @@ -341,24 +266,8 @@ fn larger_visible_destination_reclamps_preserved_top_left() { #[test] fn preserved_visible_monitor_prefers_top_left_for_straddling_window() { let monitors = vec![ - ( - Rect { - x: 0, - y: 0, - width: 1920, - height: 1080, - }, - 1.0, - ), - ( - Rect { - x: 1920, - y: 0, - width: 1920, - height: 1080, - }, - 1.25, - ), + (Rect::new(0, 0, 1920, 1080), 1.0), + (Rect::new(1920, 0, 1920, 1080), 1.25), ]; let selected = monitor_for_preserved_visible_position(&monitors, (1800, 120), Some((600, 700))) @@ -368,264 +277,13 @@ fn preserved_visible_monitor_prefers_top_left_for_straddling_window() { assert_eq!(selected.1, 1.0); } -#[test] -fn visible_surface_position_falls_back_to_current_monitor_without_available_monitors() { - let current_monitor = MonitorPlacement { - bounds: Rect { - x: 1920, - y: 0, - width: 1920, - height: 1080, - }, - work_area: Rect { - x: 1920, - y: 0, - width: 1920, - height: 1080, - }, - scale_factor: 1.25, - }; - let primary_monitor = MonitorPlacement { - bounds: Rect { - x: 0, - y: 0, - width: 1920, - height: 1080, - }, - work_area: Rect { - x: 0, - y: 0, - width: 1920, - height: 1080, - }, - scale_factor: 1.0, - }; - let anchor = crate::state::TrayAnchor { - x: 10, - y: 10, - width: 16, - height: 16, - }; - - let position = visible_surface_position_for_mode_with_fallbacks( - SurfaceMode::PopOut, - None, - Some(anchor), - Some(current_monitor), - Some(((2000, 120), (600, 700))), - Some(primary_monitor), - ); - - assert_eq!( - position, - Some(window_positioner::calculate_popout_position( - None, - ¤t_monitor.work_area, - &surface_panel_size(SurfaceMode::PopOut), - current_monitor.scale_factor, - )) - ); -} - -#[test] -fn visible_surface_position_without_anchor_prefers_primary_over_offview_current_monitor() { - // Regression: the hidden main window is parked on a secondary monitor at - // negative coordinates (real machine: DISPLAY5 at x -2048..0). With no tray - // anchor (right-click menu / proof launch), the surface must open on the - // primary (tray/taskbar) monitor — not the off-view secondary, which is the - // "Pop Out Dashboard does nothing" bug. - let offview_current = MonitorPlacement { - bounds: Rect { - x: -2048, - y: 0, - width: 2048, - height: 1152, - }, - work_area: Rect { - x: -2048, - y: 0, - width: 2048, - height: 1104, - }, - scale_factor: 1.0, - }; - let primary = MonitorPlacement { - bounds: Rect { - x: 0, - y: 0, - width: 3413, - height: 1440, - }, - work_area: Rect { - x: 0, - y: 0, - width: 3413, - height: 1392, - }, - scale_factor: 1.0, - }; - - let position = visible_surface_position_for_mode_with_fallbacks( - SurfaceMode::PopOut, - Some(&[offview_current, primary]), - None, // no tray anchor - Some(offview_current), // hidden main window parked off-view - Some(((-1288, 8), (1024, 1088))), // last bounds also off-view - Some(primary), - ) - .expect("should resolve a position"); - - // Must land on the primary monitor (x >= 0), never the negative secondary. - assert!( - position.0 >= 0, - "expected on primary monitor, got {position:?}" - ); - assert_eq!( - position, - window_positioner::calculate_popout_position( - Some(&inferred_tray_anchor_rect(&primary)), - &primary.work_area, - &surface_panel_size(SurfaceMode::PopOut), - primary.scale_factor, - ) - ); -} - -#[test] -fn visible_surface_position_anchor_lookup_uses_monitor_bounds() { - let anchor_monitor = MonitorPlacement { - bounds: Rect { - x: 0, - y: 0, - width: 1920, - height: 1080, - }, - work_area: Rect { - x: 0, - y: 0, - width: 1920, - height: 1040, - }, - scale_factor: 1.0, - }; - let current_monitor = MonitorPlacement { - bounds: Rect { - x: 1920, - y: 0, - width: 1920, - height: 1080, - }, - work_area: Rect { - x: 1920, - y: 0, - width: 1920, - height: 1080, - }, - scale_factor: 1.25, - }; - let anchor = crate::state::TrayAnchor { - x: 1800, - y: 1040, - width: 24, - height: 24, - }; - - let position = visible_surface_position_for_mode_with_fallbacks( - SurfaceMode::PopOut, - Some(&[anchor_monitor, current_monitor]), - Some(anchor), - Some(current_monitor), - None, - None, - ); - - assert_eq!( - position, - Some(window_positioner::calculate_popout_position( - Some(&tray_anchor_rect(anchor)), - &anchor_monitor.work_area, - &surface_panel_size(SurfaceMode::PopOut), - anchor_monitor.scale_factor, - )) - ); -} - -#[test] -fn visible_surface_position_settings_surface_uses_tray_anchor_position_when_available() { - let anchor_monitor = MonitorPlacement { - bounds: Rect { - x: 0, - y: 0, - width: 1920, - height: 1080, - }, - work_area: Rect { - x: 0, - y: 0, - width: 1920, - height: 1040, - }, - scale_factor: 1.0, - }; - let current_monitor = MonitorPlacement { - bounds: Rect { - x: 1920, - y: 0, - width: 1920, - height: 1080, - }, - work_area: Rect { - x: 1920, - y: 0, - width: 1920, - height: 1080, - }, - scale_factor: 1.25, - }; - let anchor = crate::state::TrayAnchor { - x: 1800, - y: 1040, - width: 24, - height: 24, - }; - - let position = visible_surface_position_for_mode_with_fallbacks( - SurfaceMode::Settings, - Some(&[anchor_monitor, current_monitor]), - Some(anchor), - Some(current_monitor), - None, - None, - ); - - assert_eq!( - position, - Some(window_positioner::calculate_popout_position( - Some(&tray_anchor_rect(anchor)), - &anchor_monitor.work_area, - &surface_panel_size(SurfaceMode::Settings), - anchor_monitor.scale_factor, - )) - ); -} - #[test] fn inferred_tray_anchor_defaults_to_bottom_right_of_work_area() { - let monitor = MonitorPlacement { - bounds: Rect { - x: 0, - y: 0, - width: 1920, - height: 1080, - }, - work_area: Rect { - x: 0, - y: 0, - width: 1920, - height: 1040, - }, - scale_factor: 1.0, - }; + let monitor = monitor( + Rect::new(0, 0, 1920, 1080), + Rect::new(0, 0, 1920, 1040), + 1.0, + ); let anchor = inferred_tray_anchor_rect(&monitor); @@ -637,21 +295,11 @@ fn inferred_tray_anchor_defaults_to_bottom_right_of_work_area() { #[test] fn inferred_tray_anchor_supports_top_taskbar_layouts() { - let monitor = MonitorPlacement { - bounds: Rect { - x: 0, - y: 0, - width: 1920, - height: 1080, - }, - work_area: Rect { - x: 0, - y: 40, - width: 1920, - height: 1040, - }, - scale_factor: 1.0, - }; + let monitor = monitor( + Rect::new(0, 0, 1920, 1080), + Rect::new(0, 40, 1920, 1040), + 1.0, + ); let anchor = inferred_tray_anchor_rect(&monitor); @@ -661,21 +309,11 @@ fn inferred_tray_anchor_supports_top_taskbar_layouts() { #[test] fn inferred_tray_anchor_supports_left_taskbar_layouts() { - let monitor = MonitorPlacement { - bounds: Rect { - x: 0, - y: 0, - width: 1920, - height: 1080, - }, - work_area: Rect { - x: 40, - y: 0, - width: 1880, - height: 1080, - }, - scale_factor: 1.0, - }; + let monitor = monitor( + Rect::new(0, 0, 1920, 1080), + Rect::new(40, 0, 1880, 1080), + 1.0, + ); let anchor = inferred_tray_anchor_rect(&monitor); @@ -685,21 +323,11 @@ fn inferred_tray_anchor_supports_left_taskbar_layouts() { #[test] fn inferred_tray_anchor_supports_right_taskbar_layouts() { - let monitor = MonitorPlacement { - bounds: Rect { - x: 0, - y: 0, - width: 1920, - height: 1080, - }, - work_area: Rect { - x: 0, - y: 0, - width: 1880, - height: 1080, - }, - scale_factor: 1.0, - }; + let monitor = monitor( + Rect::new(0, 0, 1920, 1080), + Rect::new(0, 0, 1880, 1080), + 1.0, + ); let anchor = inferred_tray_anchor_rect(&monitor); @@ -709,33 +337,18 @@ fn inferred_tray_anchor_supports_right_taskbar_layouts() { #[test] fn inferred_tray_panel_position_uses_tray_style_corner_fallback() { - let monitor = MonitorPlacement { - bounds: Rect { - x: 0, - y: 0, - width: 1920, - height: 1080, - }, - work_area: Rect { - x: 0, - y: 0, - width: 1920, - height: 1040, - }, - scale_factor: 1.0, - }; + let monitor = monitor( + Rect::new(0, 0, 1920, 1080), + Rect::new(0, 0, 1920, 1040), + 1.0, + ); let position = inferred_tray_panel_position_for_monitor(&monitor); assert_eq!( position, window_positioner::calculate_panel_position( - &Rect { - x: 1888, - y: 1048, - width: 24, - height: 24, - }, + &Rect::new(1888, 1048, 24, 24), &monitor.bounds, &monitor.work_area, &super::geometry::tray_panel_size(), @@ -880,21 +493,7 @@ fn hidden_surface_snapshot_matches_non_visible_shell_state() { #[test] fn remembered_popout_position_clamps_using_stored_size() { - let monitor = MonitorPlacement { - bounds: Rect { - x: 0, - y: 0, - width: 1000, - height: 800, - }, - work_area: Rect { - x: 0, - y: 0, - width: 1000, - height: 800, - }, - scale_factor: 1.0, - }; + let monitor = monitor(Rect::new(0, 0, 1000, 800), Rect::new(0, 0, 1000, 800), 1.0); let stored = crate::geometry_store::StoredGeometry { x: 900, y: 700, diff --git a/apps/desktop-tauri/src-tauri/src/shell/transition.rs b/apps/desktop-tauri/src-tauri/src/shell/transition.rs index 6b079ebc1b1..19d478b2162 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/transition.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/transition.rs @@ -26,24 +26,6 @@ fn os_position(_window: &WebviewWindow, x: i32, y: i32) -> tauri::PhysicalPositi tauri::PhysicalPosition::new(x, y) } -// `should_force_tray_panel_reveal` is the predicate the (now-removed) -// startup tray-panel reveal fallback used to decide whether to force-show -// a hidden/tiny `main` window. The fallback itself was deleted once `main` -// could no longer transition into `SurfaceMode::TrayPanel` (that mode opens -// as the dedicated `flyout` window — see `shell::flyout_window`). The -// predicate is retained under `#[cfg(test)]` because `shell::tests` still -// exercises it as a pure unit. -#[cfg(test)] -pub(super) fn should_force_tray_panel_reveal( - current: SurfaceMode, - main_window_visible: bool, - main_window_size: Option<(u32, u32)>, -) -> bool { - current == SurfaceMode::TrayPanel - && (!main_window_visible - || main_window_size.is_some_and(|(width, height)| width < 100 || height < 100)) -} - fn mark_tray_panel_shown(app: &AppHandle) { if let Some(state) = app.try_state::>() && let Ok(mut guard) = state.lock() @@ -67,10 +49,6 @@ pub(super) enum TransitionResolution { mode: SurfaceMode, target: SurfaceTarget, }, - SameModeReopen { - mode: SurfaceMode, - target: SurfaceTarget, - }, Noop { mode: SurfaceMode, }, @@ -85,31 +63,20 @@ pub fn transition_to_target( position: Option<(i32, i32)>, activation: Activation, ) -> Result { - apply_transition_request_with_strategy( + apply_transition_request( app, ShellTransitionRequest { mode, target, position, }, - false, activation, ) } -fn apply_transition_request_with_strategy( - app: &AppHandle, - request: ShellTransitionRequest, - force_same_mode_apply: bool, - activation: Activation, -) -> Result { - apply_transition_request(app, request, force_same_mode_apply, activation) -} - fn apply_transition_request( app: &AppHandle, request: ShellTransitionRequest, - force_same_mode_apply: bool, activation: Activation, ) -> Result { let _transition_guard = SHELL_TRANSITION_SERIAL.lock().unwrap(); @@ -123,7 +90,7 @@ fn apply_transition_request( let (previous, resolution) = { let guard = st.lock().unwrap(); let previous = current_surface_snapshot(&guard); - let resolution = resolve_transition_request(&guard, &request, force_same_mode_apply); + let resolution = resolve_transition_request(&guard, &request); (previous, resolution) }; @@ -147,22 +114,6 @@ fn apply_transition_request( TransitionResolution::SameModeRetarget { mode, target } => { apply_same_mode_target_update(app, &window, mode, target, position, activation) } - TransitionResolution::SameModeReopen { mode, target } => { - let transition = SurfaceTransition { - from: mode, - to: mode, - properties: mode.window_properties(), - }; - apply_transition( - app, - &window, - &transition, - &previous, - target, - position, - activation, - ) - } TransitionResolution::Noop { mode } => Ok(mode), } } @@ -170,7 +121,6 @@ fn apply_transition_request( pub(super) fn resolve_transition_request( state: &AppState, request: &ShellTransitionRequest, - force_same_mode_apply: bool, ) -> TransitionResolution { let mode = state.surface_machine.current(); let target = AppState::resolved_target_for_mode(request.mode, Some(request.target.clone())); @@ -186,8 +136,6 @@ pub(super) fn resolve_transition_request( } } else if state.current_target != target { TransitionResolution::SameModeRetarget { mode, target } - } else if force_same_mode_apply { - TransitionResolution::SameModeReopen { mode, target } } else { TransitionResolution::Noop { mode } } @@ -214,13 +162,8 @@ where } match resolution { - TransitionResolution::ModeChange { .. } | TransitionResolution::SameModeReopen { .. } - if synthesize_default => - { - fallback() - } + TransitionResolution::ModeChange { .. } if synthesize_default => fallback(), TransitionResolution::ModeChange { .. } - | TransitionResolution::SameModeReopen { .. } | TransitionResolution::SameModeRetarget { .. } | TransitionResolution::Noop { .. } => None, } @@ -374,7 +317,6 @@ pub(super) fn should_synthesize_default_position(resolution: &TransitionResoluti || (transition.from == SurfaceMode::TrayPanel && transition.to == SurfaceMode::Settings) } - TransitionResolution::SameModeReopen { .. } => true, TransitionResolution::SameModeRetarget { .. } | TransitionResolution::Noop { .. } => false, } } diff --git a/apps/desktop-tauri/src-tauri/src/shell/window.rs b/apps/desktop-tauri/src-tauri/src/shell/window.rs index 0ae1237defb..b38736c2959 100644 --- a/apps/desktop-tauri/src-tauri/src/shell/window.rs +++ b/apps/desktop-tauri/src-tauri/src/shell/window.rs @@ -128,11 +128,7 @@ fn capped_logical_size(window: &WebviewWindow, width: f64, height: f64) -> (f64, }; let scale = monitor.scale_factor(); - let scale = if scale.is_finite() && scale > 0.0 { - scale - } else { - 1.0 - }; + let scale = crate::window_positioner::safe_scale(scale); let work_area = monitor.work_area(); let max_width = (work_area.size.width as f64 / scale - MARGIN).max(320.0); let max_height = (work_area.size.height as f64 / scale - MARGIN).max(240.0); diff --git a/apps/desktop-tauri/src-tauri/src/state.rs b/apps/desktop-tauri/src-tauri/src/state.rs index ad3a4176547..b90554b4518 100644 --- a/apps/desktop-tauri/src-tauri/src/state.rs +++ b/apps/desktop-tauri/src-tauri/src/state.rs @@ -161,9 +161,6 @@ pub struct AppState { /// Instant when focus loss last dismissed the tray panel. The following /// tray click consumes this marker instead of reopening the panel. pub last_blur_dismissed_at: Option, - /// One-shot grace for a blur event caused while revealing the tray panel - /// during explicit startup. - pub startup_tray_blur_grace_until: Option, /// One-shot permission for frontend layout code to reveal a newly opened /// flyout, carrying whether the revealed window may take focus. pub flyout_reveal_pending: Option, @@ -225,7 +222,6 @@ impl AppState { notification_manager: codexbar::notifications::NotificationManager::new(), last_shown_at: None, last_blur_dismissed_at: None, - startup_tray_blur_grace_until: None, flyout_reveal_pending: None, gesture_blur_guard: None, auto_resume: crate::auto_resume::AutoResumeState::default(), @@ -260,12 +256,6 @@ impl AppState { .is_some_and(|dismissed_at| now.duration_since(dismissed_at) <= max_age) } - pub fn take_startup_tray_blur_grace(&mut self, now: std::time::Instant) -> bool { - self.startup_tray_blur_grace_until - .take() - .is_some_and(|until| now <= until) - } - pub fn arm_flyout_reveal(&mut self, activation: Activation) { self.flyout_reveal_pending = Some(activation); } diff --git a/apps/desktop-tauri/src-tauri/src/surface_target.rs b/apps/desktop-tauri/src-tauri/src/surface_target.rs index 6976abf9f3f..a3de90a3709 100644 --- a/apps/desktop-tauri/src-tauri/src/surface_target.rs +++ b/apps/desktop-tauri/src-tauri/src/surface_target.rs @@ -33,36 +33,6 @@ pub enum SurfaceTarget { } impl SurfaceTarget { - #[allow( - dead_code, - reason = "surface target type reserved for future window management integration" - )] - pub fn parse(s: &str) -> Option { - match s { - "summary" => Some(Self::Summary), - "dashboard" => Some(Self::Dashboard), - _ => { - if let Some(provider_id) = s.strip_prefix("provider:") - && !provider_id.is_empty() - { - return Some(Self::Provider { - provider_id: provider_id.to_string(), - }); - } - - if let Some(tab) = s.strip_prefix("settings:") - && !tab.is_empty() - { - return Some(Self::Settings { - tab: tab.to_string(), - }); - } - - None - } - } - } - pub fn default_for_mode(mode: SurfaceMode) -> Self { match mode { SurfaceMode::Hidden | SurfaceMode::TrayPanel => Self::Summary, @@ -98,28 +68,6 @@ mod tests { use super::{SurfaceTarget, is_supported_settings_tab}; use serde_json::json; - #[test] - fn parse_provider_target() { - let target = SurfaceTarget::parse("provider:codex").unwrap(); - assert_eq!( - target, - SurfaceTarget::Provider { - provider_id: "codex".into() - } - ); - } - - #[test] - fn parse_settings_about_target() { - let target = SurfaceTarget::parse("settings:about").unwrap(); - assert_eq!( - target, - SurfaceTarget::Settings { - tab: "about".into() - } - ); - } - #[test] fn serialize_provider_target_for_bridge() { let value = serde_json::to_value(SurfaceTarget::Provider { diff --git a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs index 4dd3da92abf..3cfcf32dbce 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_accounts.rs @@ -390,13 +390,7 @@ fn grok_accounts_menu( #[cfg(test)] mod tests { use super::*; - - fn menu_contains(menu: &[TrayMenuEntry], id: &str) -> bool { - menu.iter().any(|entry| { - entry.id.as_deref() == Some(id) - || (!entry.children.is_empty() && menu_contains(&entry.children, id)) - }) - } + use crate::tray_menu::tests::menu_contains; #[test] fn account_action_ids_are_typed_and_validated() { diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 0a8709d26f2..cc4cc1d6210 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -11,8 +11,6 @@ use tauri::{AppHandle, Manager}; use crate::shell; use crate::state::{AppState, TrayAnchor}; -#[cfg(test)] -use crate::tray_menu::build_tray_menu; use crate::tray_menu::{MenuState, TrayMenuEntry, build_tray_menu_with}; use crate::tray_presentation::{TrayPresentationPlan, headline_window}; @@ -28,11 +26,7 @@ struct MonitorScaleInfo { impl MonitorScaleInfo { fn from_monitor(monitor: &tauri::Monitor) -> Self { let scale_factor = monitor.scale_factor(); - let safe_scale = if scale_factor.is_finite() && scale_factor > 0.0 { - scale_factor - } else { - 1.0 - }; + let safe_scale = crate::window_positioner::safe_scale(scale_factor); let position = monitor.position(); let size = monitor.size(); @@ -65,11 +59,7 @@ fn logical_to_physical_anchor( height: f64, scale_factor: f64, ) -> TrayAnchor { - let safe_scale = if scale_factor.is_finite() && scale_factor > 0.0 { - scale_factor - } else { - 1.0 - }; + let safe_scale = crate::window_positioner::safe_scale(scale_factor); TrayAnchor { x: (x * safe_scale).round() as i32, @@ -462,17 +452,6 @@ fn truncate_tooltip_text(text: &str, max_chars: usize) -> String { } } -#[allow( - dead_code, - reason = "tray bridge helper reserved for future system tray integration" -)] -fn menu_contains(menu: &[TrayMenuEntry], id: &str) -> bool { - menu.iter().any(|entry| { - entry.id.as_deref() == Some(id) - || (!entry.children.is_empty() && menu_contains(&entry.children, id)) - }) -} - enum NativeMenuEntry { Item(MenuItem), CheckItem(tauri::menu::CheckMenuItem), @@ -544,42 +523,7 @@ mod tests { use super::*; use crate::surface::SurfaceMode; - use crate::tray_presentation::{ - codex_lane_headline_window, pick_tray_provider, selected_tray_percents, - }; - use codexbar::core::ProviderId; - use codexbar::settings::MetricPreference; - - fn sample_provider_catalog() -> Vec { - vec![ - ProviderCatalogEntry { - id: "codex".into(), - display_name: "Codex".into(), - cookie_domain: None, - cli_aliases: Vec::new(), - }, - ProviderCatalogEntry { - id: "claude".into(), - display_name: "Claude".into(), - cookie_domain: None, - cli_aliases: Vec::new(), - }, - ] - } - - #[test] - fn tray_menu_includes_about_and_provider_entries() { - let menu = build_tray_menu( - &sample_provider_catalog(), - &[], - &["codex".to_string(), "claude".to_string()] - .into_iter() - .collect(), - ); - assert!(menu_contains(&menu, "about")); - assert!(menu_contains(&menu, "toggle_provider:codex")); - assert!(menu_contains(&menu, "quit")); - } + use crate::tray_presentation::tests::{fake_snapshot, fake_snapshot_with}; #[test] fn toggle_float_bar_routes_to_toggle_action() { @@ -680,186 +624,6 @@ mod tests { assert!(anchor.is_none()); } - fn fake_snapshot_with( - id: &str, - display: &str, - used_percent: f64, - secondary_percent: Option, - tertiary_percent: Option, - cost: Option<(f64, f64)>, - ) -> crate::commands::ProviderUsageSnapshot { - crate::commands::ProviderUsageSnapshot { - provider_id: id.into(), - display_name: display.into(), - primary: crate::commands::RateWindowSnapshot { - used_percent, - remaining_percent: 100.0 - used_percent, - window_minutes: None, - resets_at: None, - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - monthly_limit_block: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, - }, - primary_label: None, - secondary: secondary_percent.map(|pct| crate::commands::RateWindowSnapshot { - used_percent: pct, - remaining_percent: 100.0 - pct, - window_minutes: None, - resets_at: None, - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - monthly_limit_block: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, - }), - secondary_label: None, - secondary_label_key: None, - model_specific: None, - tertiary: tertiary_percent.map(|pct| crate::commands::RateWindowSnapshot { - used_percent: pct, - remaining_percent: 100.0 - pct, - window_minutes: None, - resets_at: None, - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - monthly_limit_block: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, - }), - tertiary_label: None, - extra_rate_windows: Vec::new(), - inventory: Vec::new(), - display_details: Vec::new(), - cost: cost.map(|(used, limit)| crate::commands::CostSnapshotBridge { - used, - limit: Some(limit), - remaining: Some((limit - used).max(0.0)), - currency_code: "USD".to_string(), - currency_symbol: None, - period: "monthly".to_string(), - resets_at: None, - formatted_used: format!("${used:.2}"), - formatted_limit: Some(format!("${limit:.2}")), - balance: None, - formatted_balance: None, - balance_updated_at: None, - account_id: None, - daily: Vec::new(), - always_visible: false, - folded_into_primary: false, - title_key: None, - }), - plan_name: None, - account_email: None, - account_label: None, - subscription: None, - source_label: String::new(), - has_successful_claude_cli_quota: false, - updated_at: "2025-01-01T00:00:00Z".into(), - error: None, - error_state: codexbar::core::ProviderStateKind::Ready, - pace: None, - pace_authoritative: true, - account_organization: None, - tray_status_label: None, - fetch_duration_ms: None, - wayfinder_usage: None, - quota_burndown: None, - open_ai_api_usage: None, - throughput: None, - session_equivalent_forecast: None, - } - } - - fn fake_snapshot( - id: &str, - display: &str, - used_percent: f64, - ) -> crate::commands::ProviderUsageSnapshot { - fake_snapshot_with(id, display, used_percent, None, None, None) - } - - fn used_view_settings() -> Settings { - Settings { - show_as_used: true, - ..Settings::default() - } - } - - fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { - crate::commands::NamedRateWindowSnapshot { - id: "additional_budget".to_string(), - title: "Additional Budget".to_string(), - fallback_lane: false, - icon_fallback: None, - window: crate::commands::RateWindowSnapshot { - used_percent: percent, - remaining_percent: 100.0 - percent, - window_minutes: None, - resets_at: None, - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, - monthly_limit_block: None, - }, - } - } - #[test] - fn pick_tray_provider_highest_picks_max_primary() { - let a = fake_snapshot("codex", "Codex", 30.0); - let b = fake_snapshot("claude", "Claude", 72.5); - let c = fake_snapshot("gemini", "Gemini", 50.0); - let refs: Vec<&crate::commands::ProviderUsageSnapshot> = vec![&a, &b, &c]; - let picked = pick_tray_provider(&refs, /* prefer_highest = */ true) - .expect("highest mode should pick a provider"); - assert_eq!(picked.provider_id, "claude"); - } - #[test] - fn pick_tray_provider_first_preserves_catalog_order() { - let a = fake_snapshot("codex", "Codex", 30.0); - let b = fake_snapshot("claude", "Claude", 72.5); - let refs: Vec<&crate::commands::ProviderUsageSnapshot> = vec![&a, &b]; - let picked = pick_tray_provider(&refs, /* prefer_highest = */ false) - .expect("non-highest mode should still pick the first entry"); - assert_eq!(picked.provider_id, "codex"); - } - #[test] - fn pick_tray_provider_none_when_empty() { - let refs: Vec<&crate::commands::ProviderUsageSnapshot> = vec![]; - assert!(pick_tray_provider(&refs, true).is_none()); - assert!(pick_tray_provider(&refs, false).is_none()); - } #[test] fn tooltip_uses_compact_status_labels() { let mut claude = fake_snapshot("claude", "Claude", 13.0); @@ -977,274 +741,4 @@ mod tests { assert!(english_label.contains("Resets in"), "{english_label}"); assert!(japanese_label.contains("リセットまで"), "{japanese_label}"); } - #[test] - fn selected_tray_percent_uses_cursor_extra_usage_cost() { - let mut settings = used_view_settings(); - settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); - let snapshot = fake_snapshot_with( - "cursor", - "Cursor", - 10.0, - Some(20.0), - Some(72.0), - Some((15.0, 100.0)), - ); - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 15.0); - assert_eq!(secondary, Some(20.0)); - } - #[test] - fn selected_tray_percent_tracks_extra_rate_window() { - let mut settings = used_view_settings(); - settings.set_provider_metric(ProviderId::Copilot, MetricPreference::ExtraUsage); - let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); - snapshot.extra_rate_windows.push(fake_extra_window(42.0)); - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 42.0); - assert_eq!(secondary, None); - } - #[test] - fn copilot_automatic_tracks_highest_extra_rate_window() { - let settings = used_view_settings(); - let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); - snapshot.extra_rate_windows.push(fake_extra_window(42.0)); - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 42.0); - } - #[test] - fn selected_tray_percent_respects_remaining_display_mode() { - let mut settings = Settings { - show_as_used: false, - ..Settings::default() - }; - settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); - let snapshot = fake_snapshot_with( - "cursor", - "Cursor", - 10.0, - Some(20.0), - Some(72.0), - Some((15.0, 100.0)), - ); - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 85.0); - assert_eq!(secondary, Some(80.0)); - } - #[test] - fn exhausted_automatic_window_never_renders_as_remaining_progress() { - let mut settings = Settings { - show_as_used: false, - ..Settings::default() - }; - let mut snapshot = fake_snapshot_with( - "opencodego", - "OpenCode Go", - 20.0, - Some(60.0), - Some(40.0), - None, - ); - snapshot - .tertiary - .as_mut() - .expect("monthly quota") - .is_exhausted = true; - let (remaining, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(remaining, 0.0); - settings.show_as_used = true; - let (used, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(used, 100.0); - } - #[test] - fn full_automatic_window_without_exhausted_flag_has_zero_remaining_progress() { - let mut settings = Settings { - show_as_used: false, - ..Settings::default() - }; - let mut snapshot = fake_snapshot_with( - "opencodego", - "OpenCode Go", - 20.0, - Some(60.0), - Some(100.0), - None, - ); - snapshot - .tertiary - .as_mut() - .expect("monthly quota") - .is_exhausted = false; - let (remaining, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(remaining, 0.0); - settings.show_as_used = true; - let (used, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(used, 100.0); - } - #[test] - fn missing_automatic_window_does_not_look_like_available_remaining_progress() { - let settings = Settings { - show_as_used: false, - ..Settings::default() - }; - let mut snapshot = fake_snapshot_with("opencodego", "OpenCode Go", 0.0, None, None, None); - snapshot.primary.is_informational = true; - let (remaining, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(remaining, 0.0); - } - #[test] - fn selected_tray_percent_falls_back_when_extra_usage_missing() { - let mut settings = used_view_settings(); - settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); - let snapshot = fake_snapshot_with("cursor", "Cursor", 10.0, Some(72.0), None, None); - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 72.0); - } - #[test] - fn single_meaningful_secondary_quota_uses_full_single_meter() { - let settings = used_view_settings(); - let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); - snapshot.primary.is_informational = true; - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 42.0); - assert_eq!(secondary, None); - } - #[test] - fn selected_secondary_quota_is_not_duplicated_when_tertiary_is_meaningful() { - let settings = used_view_settings(); - let mut snapshot = - fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), Some(30.0), None); - snapshot.primary.is_informational = true; - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 42.0); - assert_eq!(secondary, Some(30.0)); - } - #[test] - fn two_meaningful_quotas_keep_two_meter_layout() { - let mut settings = used_view_settings(); - settings.set_provider_metric(ProviderId::Cursor, MetricPreference::Session); - let snapshot = fake_snapshot_with("cursor", "Cursor", 15.0, Some(40.0), None, None); - let (primary, secondary) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 15.0); - assert_eq!(secondary, Some(40.0)); - } - #[test] - fn informational_primary_skips_session_and_automatic_phantom_zero() { - let mut settings = used_view_settings(); - settings.set_provider_metric(ProviderId::Claude, MetricPreference::Session); - let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); - snapshot.primary.is_informational = true; - // Session preference must not paint the synthetic 0% primary; - // it falls through to Automatic which prefers weekly (42%). - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 42.0); - assert_ne!(primary, 0.0); - // Automatic also prefers weekly over informational primary. - settings.set_provider_metric(ProviderId::Claude, MetricPreference::Automatic); - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 42.0); - } - #[test] - fn claude_automatic_prefers_weekly_when_model_exhausted() { - let settings = used_view_settings(); - let mut snapshot = fake_snapshot_with("claude", "Claude", 40.0, Some(22.0), None, None); - snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { - used_percent: 100.0, - remaining_percent: 0.0, - window_minutes: Some(10080), - resets_at: None, - reset_description: None, - is_exhausted: true, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, - monthly_limit_block: None, - }); - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 22.0); - // Explicit model override is untouched. - let mut overridden = settings.clone(); - overridden.set_provider_metric(ProviderId::Claude, MetricPreference::Model); - let (primary, _) = selected_tray_percents(&snapshot, &overridden); - assert_eq!(primary, 100.0); - } - #[test] - fn automatic_prefers_exhausted_weekly_over_low_session() { - let settings = used_view_settings(); - let snapshot = fake_snapshot_with("codex", "Codex", 20.0, Some(100.0), None, None); - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 100.0); - // Explicit session override still wins. - let mut overridden = settings.clone(); - overridden.set_provider_metric(ProviderId::Codex, MetricPreference::Session); - let (primary, _) = selected_tray_percents(&snapshot, &overridden); - assert_eq!(primary, 20.0); - } - #[test] - fn automatic_picks_highest_among_model_and_extra_windows() { - let settings = used_view_settings(); - let mut snapshot = - fake_snapshot_with("gemini", "Gemini", 10.0, Some(30.0), Some(40.0), None); - snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { - used_percent: 55.0, - remaining_percent: 45.0, - window_minutes: None, - resets_at: None, - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, - monthly_limit_block: None, - }); - snapshot.extra_rate_windows.push(fake_extra_window(90.0)); - let (primary, _) = selected_tray_percents(&snapshot, &settings); - assert_eq!(primary, 90.0); - } - #[test] - fn f5_headline_prefers_non_informational_primary() { - let snapshot = fake_snapshot_with("codex", "Codex", 50.0, Some(20.0), Some(30.0), None); - let headline = codex_lane_headline_window(&snapshot); - assert!((headline.used_percent - 50.0).abs() < f64::EPSILON); - } - #[test] - fn f5_headline_falls_back_to_secondary_when_primary_informational() { - let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(25.0), Some(30.0), None); - snapshot.primary.is_informational = true; - let headline = codex_lane_headline_window(&snapshot); - assert!((headline.used_percent - 25.0).abs() < f64::EPSILON); - } - #[test] - fn f5_headline_falls_back_to_tertiary_when_primary_and_secondary_informational() { - let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(0.0), Some(35.0), None); - snapshot.primary.is_informational = true; - snapshot.secondary.as_mut().unwrap().is_informational = true; - let headline = codex_lane_headline_window(&snapshot); - assert!((headline.used_percent - 35.0).abs() < f64::EPSILON); - } - #[test] - fn f5_headline_returns_primary_when_all_informational() { - let mut snapshot = fake_snapshot_with("codex", "Codex", 0.0, Some(0.0), Some(0.0), None); - snapshot.primary.is_informational = true; - if let Some(sec) = &mut snapshot.secondary { - sec.is_informational = true; - } - if let Some(ter) = &mut snapshot.tertiary { - ter.is_informational = true; - } - let headline = codex_lane_headline_window(&snapshot); - // Falls back to primary (the placeholder) when all are informational. - assert!(headline.is_informational); - } } diff --git a/apps/desktop-tauri/src-tauri/src/tray_menu.rs b/apps/desktop-tauri/src-tauri/src/tray_menu.rs index f259b4e72bc..b6522fee282 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_menu.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_menu.rs @@ -179,10 +179,10 @@ pub(crate) fn build_tray_menu_with( } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; - fn menu_contains(menu: &[TrayMenuEntry], id: &str) -> bool { + pub(crate) fn menu_contains(menu: &[TrayMenuEntry], id: &str) -> bool { menu.iter().any(|entry| { entry.id.as_deref() == Some(id) || (!entry.children.is_empty() && menu_contains(&entry.children, id)) @@ -212,6 +212,14 @@ mod tests { .collect() } + #[test] + fn tray_menu_includes_about_and_provider_entries() { + let menu = build_tray_menu(&sample_provider_catalog(), &[], &both_enabled()); + assert!(menu_contains(&menu, "about")); + assert!(menu_contains(&menu, "toggle_provider:codex")); + assert!(menu_contains(&menu, "quit")); + } + #[test] fn check_for_updates_item_is_present() { let menu = build_tray_menu(&sample_provider_catalog(), &[], &both_enabled()); diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs index c35ef7742c9..807a31a0992 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs @@ -574,4 +574,4 @@ fn display_metric_percent(window: &RateWindowSnapshot, show_as_used: bool) -> f6 #[cfg(test)] #[path = "tray_presentation_tests.rs"] -mod tests; +pub(crate) mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs index 5d5be6c52c1..9027a743515 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -1,12 +1,16 @@ use super::*; -use codexbar::core::{ProviderId, ProviderStateKind}; +use codexbar::core::ProviderId; use codexbar::tray::render_stacked_bars_icon_rgba_with_pace; -fn fake_snapshot(id: &str, display_name: &str, used_percent: f64) -> ProviderUsageSnapshot { +pub(crate) fn fake_snapshot( + id: &str, + display_name: &str, + used_percent: f64, +) -> ProviderUsageSnapshot { fake_snapshot_with(id, display_name, used_percent, None, None, None) } -fn fake_snapshot_with( +pub(crate) fn fake_snapshot_with( id: &str, display_name: &str, used_percent: f64, @@ -17,36 +21,18 @@ fn fake_snapshot_with( let window = |percent: f64| RateWindowSnapshot { used_percent: percent, remaining_percent: 100.0 - percent, - window_minutes: None, - resets_at: None, - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - monthly_limit_block: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, + ..Default::default() }; ProviderUsageSnapshot { provider_id: id.into(), display_name: display_name.into(), primary: window(used_percent), - primary_label: None, secondary: secondary_percent.map(window), secondary_label: None, secondary_label_key: None, model_specific: None, tertiary: tertiary_percent.map(window), - tertiary_label: None, - extra_rate_windows: Vec::new(), - inventory: Vec::new(), - display_details: Vec::new(), cost: cost.map(|(used, limit)| crate::commands::CostSnapshotBridge { used, limit: Some(limit), @@ -66,25 +52,9 @@ fn fake_snapshot_with( folded_into_primary: false, title_key: None, }), - plan_name: None, - account_email: None, - account_label: None, - subscription: None, - source_label: String::new(), - has_successful_claude_cli_quota: false, - updated_at: "2025-01-01T00:00:00Z".into(), - error: None, - error_state: ProviderStateKind::Ready, - pace: None, pace_authoritative: true, - account_organization: None, - open_ai_api_usage: None, - throughput: None, - tray_status_label: None, - fetch_duration_ms: None, - wayfinder_usage: None, - session_equivalent_forecast: None, - quota_burndown: None, + updated_at: "2025-01-01T00:00:00Z".into(), + ..Default::default() } } @@ -403,20 +373,7 @@ fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { window: crate::commands::RateWindowSnapshot { used_percent: percent, remaining_percent: 100.0 - percent, - window_minutes: None, - resets_at: None, - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - monthly_limit_block: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, + ..Default::default() }, } } @@ -630,19 +587,8 @@ fn claude_automatic_prefers_weekly_when_model_exhausted() { used_percent: 100.0, remaining_percent: 0.0, window_minutes: Some(10080), - resets_at: None, - reset_description: None, is_exhausted: true, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - monthly_limit_block: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, + ..Default::default() }); let (primary, _) = selected_tray_percents(&snapshot, &settings); @@ -677,20 +623,7 @@ fn automatic_picks_highest_among_model_and_extra_windows() { snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { used_percent: 55.0, remaining_percent: 45.0, - window_minutes: None, - resets_at: None, - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - monthly_limit_block: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, + ..Default::default() }); snapshot.extra_rate_windows.push(fake_extra_window(90.0)); @@ -1311,3 +1244,31 @@ fn color_by_provider_tints_both_stacked_bars_rows() { [CODEX_BRAND.0, CODEX_BRAND.1, CODEX_BRAND.2, 255] ); } + +#[test] +fn pick_tray_provider_highest_picks_max_primary() { + let a = fake_snapshot("codex", "Codex", 30.0); + let b = fake_snapshot("claude", "Claude", 72.5); + let c = fake_snapshot("gemini", "Gemini", 50.0); + let refs: Vec<&ProviderUsageSnapshot> = vec![&a, &b, &c]; + let picked = pick_tray_provider(&refs, /* prefer_highest = */ true) + .expect("highest mode should pick a provider"); + assert_eq!(picked.provider_id, "claude"); +} + +#[test] +fn pick_tray_provider_first_preserves_catalog_order() { + let a = fake_snapshot("codex", "Codex", 30.0); + let b = fake_snapshot("claude", "Claude", 72.5); + let refs: Vec<&ProviderUsageSnapshot> = vec![&a, &b]; + let picked = pick_tray_provider(&refs, /* prefer_highest = */ false) + .expect("non-highest mode should still pick the first entry"); + assert_eq!(picked.provider_id, "codex"); +} + +#[test] +fn pick_tray_provider_none_when_empty() { + let refs: Vec<&ProviderUsageSnapshot> = vec![]; + assert!(pick_tray_provider(&refs, true).is_none()); + assert!(pick_tray_provider(&refs, false).is_none()); +} diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index 84bf2feef0a..bc99d1fbd51 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -392,20 +392,9 @@ fn derived_window(used_percent: f64, resets_at: Option) -> RateWindowSna RateWindowSnapshot { used_percent, remaining_percent: 100.0 - used_percent, - window_minutes: None, resets_at, - reset_description: None, is_exhausted: used_percent >= 100.0, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - pace: None, - monthly_limit_block: None, - description_is_detail: false, - reset_regenerates: false, - hidden_on_card: false, + ..Default::default() } } @@ -455,783 +444,4 @@ fn automatic_window_is_exhausted(window: &RateWindowSnapshot) -> bool { } #[cfg(test)] -mod tests { - use super::*; - use codexbar::providers::copilot::SEAT_CREDIT_WINDOW_ID; - - fn window(used_percent: f64) -> RateWindowSnapshot { - derived_window(used_percent, None) - } - - fn snapshot() -> ProviderUsageSnapshot { - ProviderUsageSnapshot { - provider_id: "codex".to_string(), - display_name: "Codex".to_string(), - primary: window(20.0), - primary_label: None, - secondary: Some(window(60.0)), - secondary_label: None, - secondary_label_key: None, - model_specific: None, - tertiary: None, - tertiary_label: None, - extra_rate_windows: Vec::new(), - inventory: Vec::new(), - display_details: Vec::new(), - cost: None, - plan_name: None, - account_email: None, - account_label: None, - subscription: None, - source_label: "test".to_string(), - has_successful_claude_cli_quota: false, - updated_at: "2026-08-16T00:00:00Z".to_string(), - error: None, - error_state: codexbar::core::ProviderStateKind::Ready, - pace: None, - pace_authoritative: true, - account_organization: None, - tray_status_label: None, - fetch_duration_ms: None, - wayfinder_usage: None, - quota_burndown: None, - open_ai_api_usage: None, - throughput: None, - session_equivalent_forecast: None, - } - } - - #[test] - fn weekly_preference_selects_the_weekly_window() { - let snapshot = snapshot(); - let mut settings = Settings::default(); - settings.set_provider_metric(ProviderId::Codex, MetricPreference::Weekly); - - assert_eq!( - selected_usage_window(&snapshot, &settings).used_percent, - 60.0 - ); - } - - #[test] - fn missing_selected_session_falls_back_to_a_real_window() { - let mut snapshot = snapshot(); - snapshot.primary.is_informational = true; - snapshot.primary.used_percent = 0.0; - let mut settings = Settings::default(); - settings.set_provider_metric(ProviderId::Codex, MetricPreference::Session); - - assert_eq!( - selected_usage_window(&snapshot, &settings).used_percent, - 60.0 - ); - } - - #[test] - fn automatic_selects_the_highest_real_window() { - let snapshot = snapshot(); - - assert_eq!( - selected_usage_window(&snapshot, &Settings::default()).used_percent, - 60.0 - ); - } - - #[test] - fn copilot_automatic_uses_seat_credit_progress_without_metered_quota() { - let mut snapshot = snapshot(); - snapshot.provider_id = "copilot".to_string(); - snapshot.primary = RateWindowSnapshot { - is_informational: true, - ..window(0.0) - }; - snapshot.secondary = None; - snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { - id: SEAT_CREDIT_WINDOW_ID.to_string(), - title: "Credits used".to_string(), - window: window(35.0), - fallback_lane: true, - icon_fallback: None, - }]; - - assert_eq!( - selected_usage_window(&snapshot, &Settings::default()).used_percent, - 35.0 - ); - } - - #[test] - fn copilot_automatic_keeps_metered_quota_authoritative_over_seat_credits() { - let mut snapshot = snapshot(); - snapshot.provider_id = "copilot".to_string(); - snapshot.primary = window(20.0); - snapshot.secondary = None; - snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { - id: SEAT_CREDIT_WINDOW_ID.to_string(), - title: "Credits used".to_string(), - window: window(90.0), - fallback_lane: true, - icon_fallback: None, - }]; - - assert_eq!( - selected_usage_window(&snapshot, &Settings::default()).used_percent, - 20.0 - ); - } - - #[test] - fn copilot_explicit_session_does_not_fall_back_to_seat_credits() { - let mut snapshot = snapshot(); - snapshot.provider_id = "copilot".to_string(); - snapshot.primary = RateWindowSnapshot { - is_informational: true, - ..window(0.0) - }; - snapshot.secondary = None; - snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { - id: SEAT_CREDIT_WINDOW_ID.to_string(), - title: "Credits used".to_string(), - window: window(35.0), - fallback_lane: true, - icon_fallback: None, - }]; - let mut settings = Settings::default(); - let provider = ProviderId::from_cli_name(&snapshot.provider_id).expect("copilot provider"); - settings.set_provider_metric(provider, MetricPreference::Session); - - let selected = selected_usage_window(&snapshot, &settings); - assert!(selected.is_informational); - assert_eq!(selected.used_percent, 0.0); - } - - #[test] - fn cursor_automatic_uses_semantic_monthly_lane_and_keeps_grok_bot_explicit() { - let mut snapshot = snapshot(); - snapshot.provider_id = "cursor".to_string(); - snapshot.primary = window(85.0); - snapshot.secondary = Some(window(20.0)); - snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { - id: "cursor-grok-bot".to_string(), - title: "Grok Bot".to_string(), - window: window(95.0), - fallback_lane: false, - icon_fallback: None, - }]; - - assert_eq!( - selected_usage_window(&snapshot, &Settings::default()).used_percent, - 20.0 - ); - - let mut settings = Settings::default(); - settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); - assert_eq!( - selected_usage_window(&snapshot, &settings).used_percent, - 95.0 - ); - } - - #[test] - fn kimi_monthly_only_snapshot_selects_the_total_usage_lane() { - // Upstream 0.60.5 #3694: a Code API response may report only the - // monthly Total usage pool. The weekly lane is then an informational - // placeholder, and every metric preference lands on the monthly lane. - let mut snapshot = snapshot(); - snapshot.provider_id = "kimi".to_string(); - snapshot.primary = RateWindowSnapshot { - is_informational: true, - ..window(0.0) - }; - snapshot.secondary = None; - snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { - id: "kimi-monthly".to_string(), - title: "Total usage".to_string(), - window: window(100.0), - fallback_lane: false, - icon_fallback: None, - }]; - - for preference in [ - MetricPreference::Automatic, - MetricPreference::Session, - MetricPreference::Weekly, - ] { - let mut settings = Settings::default(); - settings.set_provider_metric(ProviderId::Kimi, preference); - let selected = selected_usage_window(&snapshot, &settings); - assert!(!selected.is_informational, "{preference:?}"); - assert_eq!(selected.used_percent, 100.0, "{preference:?}"); - } - } - - #[test] - fn opencodego_automatic_prefers_explicitly_exhausted_window_over_higher_percentage() { - let mut snapshot = snapshot(); - snapshot.provider_id = "opencodego".to_string(); - snapshot.primary.is_exhausted = true; - - let selected = selected_usage_window(&snapshot, &Settings::default()); - - assert_eq!(selected.used_percent, 20.0); - assert!(selected.is_exhausted); - } - - #[test] - fn claude_and_codex_automatic_keep_highest_used_window() { - for provider_id in ["claude", "codex"] { - let mut snapshot = snapshot(); - snapshot.provider_id = provider_id.to_string(); - snapshot.primary.is_exhausted = true; - - let selected = selected_usage_window(&snapshot, &Settings::default()); - - assert_eq!( - selected.used_percent, 60.0, - "{provider_id} should keep highest-used automatic selection" - ); - assert!(!selected.is_exhausted); - } - } - - #[test] - fn automatic_treats_a_full_window_as_exhausted_even_without_the_flag() { - let mut snapshot = snapshot(); - let mut full = window(100.0); - full.is_exhausted = false; - snapshot.tertiary = Some(full); - - let selected = selected_usage_window(&snapshot, &Settings::default()); - - assert_eq!(selected.used_percent, 100.0); - assert!(!selected.is_exhausted); - } - - #[test] - fn non_automatic_highest_window_keeps_percentage_order() { - let healthy = window(80.0); - let mut exhausted = window(20.0); - exhausted.is_exhausted = true; - - let selected = highest_window([&healthy, &exhausted].into_iter()).expect("window"); - - assert_eq!(selected.used_percent, 80.0); - } - - #[test] - fn antigravity_automatic_prefers_active_core_quota_over_exhausted_extra_window() { - let mut snapshot = snapshot(); - snapshot.provider_id = "antigravity".to_string(); - snapshot.primary = window(100.0); - snapshot.primary.is_exhausted = true; - snapshot.primary_label = Some("Gemini 5h".to_string()); - snapshot.secondary = Some(window(88.0)); - snapshot.secondary_label = Some("Gemini Weekly".to_string()); - snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { - id: "antigravity-quota-summary-3p-weekly".to_string(), - title: "Claude/GPT weekly".to_string(), - window: window(100.0), - fallback_lane: false, - icon_fallback: None, - }]; - - let selected = selected_usage_window(&snapshot, &Settings::default()); - - assert_eq!(selected.used_percent, 88.0); - assert!(!selected.is_exhausted); - } - - #[test] - fn antigravity_automatic_uses_core_slots_only() { - let mut snapshot = snapshot(); - snapshot.provider_id = "antigravity".to_string(); - snapshot.primary = window(80.0); - snapshot.secondary = Some(window(20.0)); - snapshot.model_specific = Some(window(90.0)); - snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { - id: "legacy-other".to_string(), - title: "Other".to_string(), - window: window(100.0), - fallback_lane: false, - icon_fallback: None, - }]; - - let selected = selected_usage_window(&snapshot, &Settings::default()); - - assert_eq!(selected.used_percent, 90.0); - } - - #[test] - fn single_meaningful_quota_omits_the_companion_icon_lane() { - let mut snapshot = snapshot(); - snapshot - .secondary - .as_mut() - .expect("fixture has a secondary window") - .is_informational = true; - - let (selected, companion) = selected_usage_icon_windows(&snapshot, &Settings::default()); - - assert_eq!(selected.used_percent, 20.0); - assert!(companion.is_none()); - } - - #[test] - fn average_preference_derives_the_combined_percentage() { - let mut snapshot = snapshot(); - snapshot.provider_id = "gemini".to_string(); - let mut settings = Settings::default(); - settings.set_provider_metric(ProviderId::Gemini, MetricPreference::Average); - - let selected = selected_usage_window(&snapshot, &settings); - assert_eq!(selected.used_percent, 40.0); - assert_eq!(selected.remaining_percent, 60.0); - } - - fn mistral_snapshot(plan: Option) -> ProviderUsageSnapshot { - let mut snapshot = snapshot(); - snapshot.provider_id = "mistral".to_string(); - snapshot.display_name = "Mistral".to_string(); - snapshot.primary = window(2.0); - snapshot.primary_label = Some("Included API".to_string()); - snapshot.secondary = None; - snapshot.extra_rate_windows = plan - .map(|window| crate::commands::NamedRateWindowSnapshot { - id: codexbar::providers::mistral::MONTHLY_PLAN_WINDOW_ID.to_string(), - title: "Monthly Plan".to_string(), - window, - fallback_lane: false, - icon_fallback: None, - }) - .into_iter() - .collect(); - snapshot - } - - fn metric_settings(provider: ProviderId, preference: MetricPreference) -> Settings { - let mut settings = Settings::default(); - settings.set_provider_metric(provider, preference); - settings - } - - #[test] - fn mistral_monthly_plan_selects_the_vibe_plan_window() { - let mut snapshot = mistral_snapshot(Some(window(42.0))); - snapshot.primary = window(80.0); - - let monthly_plan = metric_settings(ProviderId::Mistral, MetricPreference::MonthlyPlan); - assert_eq!( - selected_usage_window(&snapshot, &monthly_plan).used_percent, - 42.0 - ); - // Included API and Automatic stay separate choices (upstream #4072). - let included_api = metric_settings(ProviderId::Mistral, MetricPreference::Session); - assert_eq!( - selected_usage_window(&snapshot, &included_api).used_percent, - 80.0 - ); - assert_eq!( - selected_usage_window(&snapshot, &Settings::default()).used_percent, - 80.0 - ); - let presentation = - crate::commands::ProviderUsagePresentationSnapshot::new(snapshot, &monthly_plan); - assert_eq!(presentation.selected_metric.used_percent, 42.0); - } - - #[test] - fn mistral_monthly_plan_falls_back_to_included_api_without_a_known_plan() { - let settings = metric_settings(ProviderId::Mistral, MetricPreference::MonthlyPlan); - let unknown_plan = RateWindowSnapshot { - is_informational: true, - ..window(0.0) - }; - - for snapshot in [mistral_snapshot(None), mistral_snapshot(Some(unknown_plan))] { - let selected = selected_usage_window(&snapshot, &settings); - assert_eq!(selected.used_percent, 2.0); - assert!(!selected.is_informational); - } - } - - #[test] - fn mistral_monthly_plan_never_selects_a_spend_window() { - let mut snapshot = mistral_snapshot(None); - snapshot.cost = Some(crate::commands::CostSnapshotBridge { - used: 45.0, - limit: Some(50.0), - remaining: Some(5.0), - currency_code: "EUR".to_string(), - currency_symbol: Some("€".to_string()), - period: "month".to_string(), - resets_at: None, - formatted_used: "€45.00".to_string(), - formatted_limit: Some("€50.00".to_string()), - balance: None, - balance_updated_at: None, - account_id: None, - formatted_balance: None, - daily: Vec::new(), - always_visible: false, - folded_into_primary: false, - title_key: None, - }); - let settings = metric_settings(ProviderId::Mistral, MetricPreference::MonthlyPlan); - - assert_eq!( - selected_usage_window(&snapshot, &settings).used_percent, - 2.0 - ); - } - - #[test] - fn monthly_plan_without_a_provider_plan_window_falls_through_to_automatic() { - let mut snapshot = snapshot(); - snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { - id: codexbar::providers::mistral::MONTHLY_PLAN_WINDOW_ID.to_string(), - title: "Monthly Plan".to_string(), - window: window(90.0), - fallback_lane: false, - icon_fallback: None, - }]; - let settings = metric_settings(ProviderId::Codex, MetricPreference::MonthlyPlan); - - // Codex declares no plan window, so a same-named lane is not special - // and the choice resolves like Automatic (highest real window). - assert_eq!( - selected_usage_window(&snapshot, &settings).used_percent, - selected_usage_window(&snapshot, &Settings::default()).used_percent - ); - assert!(monthly_plan_window(&snapshot, Some(ProviderId::Codex)).is_none()); - } - - #[test] - fn presentation_payload_flattens_the_snapshot_and_selected_metric() { - let presentation = crate::commands::ProviderUsagePresentationSnapshot::new( - snapshot(), - &Settings::default(), - ); - let value = serde_json::to_value(presentation).expect("serialize presentation"); - - assert_eq!(value["providerId"], "codex"); - assert_eq!(value["selectedMetric"]["usedPercent"], 60.0); - assert!(value.get("snapshot").is_none()); - } - - #[test] - fn hidden_usage_items_are_presentation_metadata_and_do_not_change_selected_metric() { - let mut settings = Settings::default(); - settings.set_hidden_usage_item_ids(ProviderId::Codex, vec!["metric:secondary".to_string()]); - - let presentation = - crate::commands::ProviderUsagePresentationSnapshot::new(snapshot(), &settings); - - assert!(presentation.snapshot.secondary.is_some()); - assert_eq!(presentation.selected_metric.used_percent, 60.0); - assert_eq!( - presentation.hidden_usage_item_ids, - vec!["metric:secondary".to_string()] - ); - let value = serde_json::to_value(presentation).expect("serialize presentation"); - assert_eq!( - value["hiddenUsageItemIds"], - serde_json::json!(["metric:secondary"]) - ); - } - - #[test] - fn claude_routines_hide_and_restore_preserve_raw_data_and_selected_metric() { - let mut raw = snapshot(); - raw.provider_id = "claude".to_string(); - raw.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { - id: "claude-routines".to_string(), - title: "Daily Routines".to_string(), - window: window(95.0), - fallback_lane: false, - icon_fallback: None, - }]; - - let baseline = crate::commands::ProviderUsagePresentationSnapshot::new( - raw.clone(), - &Settings::default(), - ); - assert_eq!(baseline.selected_metric.used_percent, 95.0); - - let mut settings = Settings::default(); - settings.set_hidden_usage_item_ids( - ProviderId::Claude, - vec![codexbar::settings::CLAUDE_DAILY_ROUTINES_USAGE_ITEM_ID.to_string()], - ); - let hidden = - crate::commands::ProviderUsagePresentationSnapshot::new(raw.clone(), &settings); - - assert_eq!(hidden.selected_metric.used_percent, 95.0); - assert_eq!(hidden.snapshot.extra_rate_windows[0].id, "claude-routines"); - assert_eq!( - hidden.snapshot.extra_rate_windows[0].window.used_percent, - 95.0 - ); - - settings.set_hidden_usage_item_ids(ProviderId::Claude, Vec::new()); - let restored = crate::commands::ProviderUsagePresentationSnapshot::new(raw, &settings); - - assert_eq!(restored.selected_metric.used_percent, 95.0); - assert_eq!( - restored.snapshot.extra_rate_windows[0].id, - "claude-routines" - ); - assert!(restored.hidden_usage_item_ids.is_empty()); - } - - fn litellm_budgets(personal: f64, team: Option) -> ProviderUsageSnapshot { - let mut snapshot = snapshot(); - snapshot.provider_id = "litellm".to_string(); - snapshot.primary = window(personal); - snapshot.secondary = team.map(window); - snapshot - } - - const AGENT_RESET: &str = "2026-08-16T05:00:00Z"; - - fn agent_window(used_percent: f64, minutes: u32) -> RateWindowSnapshot { - RateWindowSnapshot { - window_minutes: Some(minutes), - resets_at: Some(AGENT_RESET.to_string()), - ..window(used_percent) - } - } - - fn agent_extra( - id: &str, - window: RateWindowSnapshot, - icon_fallback: Option, - ) -> crate::commands::NamedRateWindowSnapshot { - crate::commands::NamedRateWindowSnapshot { - id: id.to_string(), - title: id.to_string(), - window, - fallback_lane: false, - icon_fallback, - } - } - - /// Bridge shape of a Doubao Coding Plan snapshot that also reports Agent - /// Plan lanes (`doubao-agent-*`), with optional Coding Plan session/weekly. - fn doubao_snapshot( - coding_session: Option, - coding_weekly: Option, - ) -> ProviderUsageSnapshot { - let mut snapshot = snapshot(); - snapshot.provider_id = "doubao".to_string(); - snapshot.primary = coding_session.map_or_else( - || RateWindowSnapshot { - window_minutes: Some(300), - is_informational: true, - ..window(0.0) - }, - window, - ); - snapshot.secondary = coding_weekly.map(window); - snapshot.extra_rate_windows = vec![ - agent_extra( - "doubao-agent-session", - agent_window(0.0, 300), - Some(IconLane::Primary), - ), - agent_extra( - "doubao-agent-weekly", - agent_window(31.0, 10_080), - Some(IconLane::Secondary), - ), - agent_extra("doubao-agent-monthly", agent_window(72.0, 43_200), None), - ]; - snapshot - } - - #[test] - fn litellm_automatic_prefers_the_team_budget_over_a_fuller_personal_budget() { - let snapshot = litellm_budgets(60.0, Some(7.0)); - - assert_eq!( - selected_usage_window(&snapshot, &Settings::default()).used_percent, - 7.0 - ); - let (selected, companion) = selected_usage_icon_windows(&snapshot, &Settings::default()); - assert_eq!(selected.used_percent, 7.0); - assert_eq!(companion.map(|window| window.used_percent), Some(60.0)); - } - - #[test] - fn litellm_automatic_shows_an_exhausted_budget_first() { - let personal_exhausted = litellm_budgets(100.0, Some(7.0)); - assert_eq!( - selected_usage_window(&personal_exhausted, &Settings::default()).used_percent, - 100.0 - ); - - let team_exhausted = litellm_budgets(40.0, Some(100.0)); - assert_eq!( - selected_usage_window(&team_exhausted, &Settings::default()).used_percent, - 100.0 - ); - } - - #[test] - fn litellm_automatic_uses_the_only_budget_and_explicit_choices_still_win() { - let personal_only = litellm_budgets(25.0, None); - assert_eq!( - selected_usage_window(&personal_only, &Settings::default()).used_percent, - 25.0 - ); - - let mut settings = Settings::default(); - settings.set_provider_metric(ProviderId::LiteLLM, MetricPreference::Session); - assert_eq!( - selected_usage_window(&litellm_budgets(60.0, Some(7.0)), &settings).used_percent, - 60.0 - ); - } - - #[test] - fn doubao_icon_windows_fall_back_to_agent_plan_lanes() { - let mut settings = Settings::default(); - settings.set_provider_metric(ProviderId::Doubao, MetricPreference::Session); - - for (has_session, has_weekly) in - [(false, false), (false, true), (true, false), (true, true)] - { - let snapshot = doubao_snapshot(has_session.then_some(10.0), has_weekly.then_some(20.0)); - let (primary, secondary) = selected_usage_icon_windows(&snapshot, &settings); - let secondary = secondary.expect("secondary icon lane"); - - assert_eq!(primary.used_percent, if has_session { 10.0 } else { 0.0 }); - assert_eq!(secondary.used_percent, if has_weekly { 20.0 } else { 31.0 }); - assert!(!primary.is_informational); - assert_eq!( - primary.window_minutes, - if has_session { None } else { Some(300) } - ); - assert_eq!( - secondary.window_minutes, - if has_weekly { None } else { Some(10_080) } - ); - assert_eq!( - primary.resets_at.as_deref(), - (!has_session).then_some(AGENT_RESET) - ); - assert_eq!( - secondary.resets_at.as_deref(), - (!has_weekly).then_some(AGENT_RESET) - ); - } - } - - #[test] - fn doubao_icon_fallback_does_not_change_the_reported_snapshot() { - let raw = doubao_snapshot(None, None); - let presentation = crate::commands::ProviderUsagePresentationSnapshot::new( - raw.clone(), - &Settings::default(), - ); - - assert!(presentation.snapshot.primary.is_informational); - assert!(presentation.snapshot.secondary.is_none()); - assert!(presentation.snapshot.tertiary.is_none()); - assert_eq!( - presentation - .snapshot - .extra_rate_windows - .iter() - .map(|extra| extra.id.as_str()) - .collect::>(), - [ - "doubao-agent-session", - "doubao-agent-weekly", - "doubao-agent-monthly" - ] - ); - assert!(!presentation.selected_metric.is_informational); - } - - #[test] - fn doubao_icon_windows_preserve_missing_lanes() { - let no_hints = |levels: &[(&str, f64, u32)]| { - let mut snapshot = doubao_snapshot(None, None); - snapshot.extra_rate_windows = levels - .iter() - .map(|(id, percent, minutes)| { - agent_extra(id, agent_window(*percent, *minutes), None) - }) - .collect(); - snapshot - }; - // Monthly and team buckets never stand in for the session/weekly lanes. - for id in ["doubao-agent-monthly", "doubao-agent-team-session"] { - let snapshot = no_hints(&[(id, 25.0, 300)]); - let resolved = with_icon_fallbacks(&snapshot); - assert!(matches!(resolved, Cow::Borrowed(_)), "{id}"); - assert!(resolved.primary.is_informational, "{id}"); - assert!(resolved.secondary.is_none(), "{id}"); - } - - // Agent weekly alone fills only the weekly lane; no session is invented. - let mut snapshot = doubao_snapshot(None, None); - snapshot - .extra_rate_windows - .retain(|extra| extra.id == "doubao-agent-weekly"); - let resolved = with_icon_fallbacks(&snapshot); - assert!(resolved.primary.is_informational); - assert_eq!(resolved.secondary.as_ref().unwrap().used_percent, 31.0); - - // Coding session alone (agent lanes absent): unchanged, nothing invented. - let mut snapshot = doubao_snapshot(Some(25.0), None); - snapshot.extra_rate_windows.clear(); - let resolved = with_icon_fallbacks(&snapshot); - assert!(matches!(resolved, Cow::Borrowed(_))); - let (primary, companion) = selected_usage_icon_windows(&snapshot, &Settings::default()); - assert_eq!(primary.used_percent, 25.0); - assert!(companion.is_none()); - } - - #[test] - fn doubao_automatic_icon_uses_agent_lanes_not_team_windows() { - let mut snapshot = doubao_snapshot(None, None); - snapshot.extra_rate_windows = vec![ - agent_extra( - "doubao-agent-session", - agent_window(42.0, 300), - Some(IconLane::Primary), - ), - agent_extra( - "doubao-agent-weekly", - agent_window(67.0, 10_080), - Some(IconLane::Secondary), - ), - agent_extra("doubao-agent-team-session", agent_window(91.0, 300), None), - agent_extra("doubao-agent-team-weekly", agent_window(88.0, 10_080), None), - ]; - let settings = Settings::default(); - - let (top, bottom) = selected_usage_icon_windows(&snapshot, &settings); - assert_eq!(top.used_percent, 42.0); - assert_eq!(bottom.expect("bottom lane").used_percent, 67.0); - assert_eq!( - selected_usage_window(&snapshot, &settings).used_percent, - 67.0 - ); - assert_eq!( - crate::tray_presentation::headline_window(&snapshot).used_percent, - 42.0 - ); - } - - #[test] - fn icon_fallback_hints_are_ignored_while_the_core_lane_is_present() { - let snapshot = doubao_snapshot(Some(10.0), Some(20.0)); - let resolved = with_icon_fallbacks(&snapshot); - - assert!(matches!(resolved, Cow::Borrowed(_))); - assert_eq!(resolved.primary.used_percent, 10.0); - assert_eq!(resolved.secondary.as_ref().unwrap().used_percent, 20.0); - } -} +mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric/tests.rs b/apps/desktop-tauri/src-tauri/src/usage_metric/tests.rs new file mode 100644 index 00000000000..76435f7abc1 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/usage_metric/tests.rs @@ -0,0 +1,741 @@ +use super::*; +use codexbar::providers::copilot::SEAT_CREDIT_WINDOW_ID; + +fn window(used_percent: f64) -> RateWindowSnapshot { + derived_window(used_percent, None) +} + +fn snapshot() -> ProviderUsageSnapshot { + ProviderUsageSnapshot { + provider_id: "codex".to_string(), + display_name: "Codex".to_string(), + primary: window(20.0), + secondary: Some(window(60.0)), + source_label: "test".to_string(), + updated_at: "2026-08-16T00:00:00Z".to_string(), + ..Default::default() + } +} + +#[test] +fn weekly_preference_selects_the_weekly_window() { + let snapshot = snapshot(); + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Codex, MetricPreference::Weekly); + + assert_eq!( + selected_usage_window(&snapshot, &settings).used_percent, + 60.0 + ); +} + +#[test] +fn missing_selected_session_falls_back_to_a_real_window() { + let mut snapshot = snapshot(); + snapshot.primary.is_informational = true; + snapshot.primary.used_percent = 0.0; + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Codex, MetricPreference::Session); + + assert_eq!( + selected_usage_window(&snapshot, &settings).used_percent, + 60.0 + ); +} + +#[test] +fn automatic_selects_the_highest_real_window() { + let snapshot = snapshot(); + + assert_eq!( + selected_usage_window(&snapshot, &Settings::default()).used_percent, + 60.0 + ); +} + +#[test] +fn copilot_automatic_uses_seat_credit_progress_without_metered_quota() { + let mut snapshot = snapshot(); + snapshot.provider_id = "copilot".to_string(); + snapshot.primary = RateWindowSnapshot { + is_informational: true, + ..window(0.0) + }; + snapshot.secondary = None; + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: SEAT_CREDIT_WINDOW_ID.to_string(), + title: "Credits used".to_string(), + window: window(35.0), + fallback_lane: true, + icon_fallback: None, + }]; + + assert_eq!( + selected_usage_window(&snapshot, &Settings::default()).used_percent, + 35.0 + ); +} + +#[test] +fn copilot_automatic_keeps_metered_quota_authoritative_over_seat_credits() { + let mut snapshot = snapshot(); + snapshot.provider_id = "copilot".to_string(); + snapshot.primary = window(20.0); + snapshot.secondary = None; + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: SEAT_CREDIT_WINDOW_ID.to_string(), + title: "Credits used".to_string(), + window: window(90.0), + fallback_lane: true, + icon_fallback: None, + }]; + + assert_eq!( + selected_usage_window(&snapshot, &Settings::default()).used_percent, + 20.0 + ); +} + +#[test] +fn copilot_explicit_session_does_not_fall_back_to_seat_credits() { + let mut snapshot = snapshot(); + snapshot.provider_id = "copilot".to_string(); + snapshot.primary = RateWindowSnapshot { + is_informational: true, + ..window(0.0) + }; + snapshot.secondary = None; + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: SEAT_CREDIT_WINDOW_ID.to_string(), + title: "Credits used".to_string(), + window: window(35.0), + fallback_lane: true, + icon_fallback: None, + }]; + let mut settings = Settings::default(); + let provider = ProviderId::from_cli_name(&snapshot.provider_id).expect("copilot provider"); + settings.set_provider_metric(provider, MetricPreference::Session); + + let selected = selected_usage_window(&snapshot, &settings); + assert!(selected.is_informational); + assert_eq!(selected.used_percent, 0.0); +} + +#[test] +fn cursor_automatic_uses_semantic_monthly_lane_and_keeps_grok_bot_explicit() { + let mut snapshot = snapshot(); + snapshot.provider_id = "cursor".to_string(); + snapshot.primary = window(85.0); + snapshot.secondary = Some(window(20.0)); + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: "cursor-grok-bot".to_string(), + title: "Grok Bot".to_string(), + window: window(95.0), + fallback_lane: false, + icon_fallback: None, + }]; + + assert_eq!( + selected_usage_window(&snapshot, &Settings::default()).used_percent, + 20.0 + ); + + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); + assert_eq!( + selected_usage_window(&snapshot, &settings).used_percent, + 95.0 + ); +} + +#[test] +fn kimi_monthly_only_snapshot_selects_the_total_usage_lane() { + // Upstream 0.60.5 #3694: a Code API response may report only the + // monthly Total usage pool. The weekly lane is then an informational + // placeholder, and every metric preference lands on the monthly lane. + let mut snapshot = snapshot(); + snapshot.provider_id = "kimi".to_string(); + snapshot.primary = RateWindowSnapshot { + is_informational: true, + ..window(0.0) + }; + snapshot.secondary = None; + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: "kimi-monthly".to_string(), + title: "Total usage".to_string(), + window: window(100.0), + fallback_lane: false, + icon_fallback: None, + }]; + + for preference in [ + MetricPreference::Automatic, + MetricPreference::Session, + MetricPreference::Weekly, + ] { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Kimi, preference); + let selected = selected_usage_window(&snapshot, &settings); + assert!(!selected.is_informational, "{preference:?}"); + assert_eq!(selected.used_percent, 100.0, "{preference:?}"); + } +} + +#[test] +fn opencodego_automatic_prefers_explicitly_exhausted_window_over_higher_percentage() { + let mut snapshot = snapshot(); + snapshot.provider_id = "opencodego".to_string(); + snapshot.primary.is_exhausted = true; + + let selected = selected_usage_window(&snapshot, &Settings::default()); + + assert_eq!(selected.used_percent, 20.0); + assert!(selected.is_exhausted); +} + +#[test] +fn claude_and_codex_automatic_keep_highest_used_window() { + for provider_id in ["claude", "codex"] { + let mut snapshot = snapshot(); + snapshot.provider_id = provider_id.to_string(); + snapshot.primary.is_exhausted = true; + + let selected = selected_usage_window(&snapshot, &Settings::default()); + + assert_eq!( + selected.used_percent, 60.0, + "{provider_id} should keep highest-used automatic selection" + ); + assert!(!selected.is_exhausted); + } +} + +#[test] +fn automatic_treats_a_full_window_as_exhausted_even_without_the_flag() { + let mut snapshot = snapshot(); + let mut full = window(100.0); + full.is_exhausted = false; + snapshot.tertiary = Some(full); + + let selected = selected_usage_window(&snapshot, &Settings::default()); + + assert_eq!(selected.used_percent, 100.0); + assert!(!selected.is_exhausted); +} + +#[test] +fn non_automatic_highest_window_keeps_percentage_order() { + let healthy = window(80.0); + let mut exhausted = window(20.0); + exhausted.is_exhausted = true; + + let selected = highest_window([&healthy, &exhausted].into_iter()).expect("window"); + + assert_eq!(selected.used_percent, 80.0); +} + +#[test] +fn antigravity_automatic_prefers_active_core_quota_over_exhausted_extra_window() { + let mut snapshot = snapshot(); + snapshot.provider_id = "antigravity".to_string(); + snapshot.primary = window(100.0); + snapshot.primary.is_exhausted = true; + snapshot.primary_label = Some("Gemini 5h".to_string()); + snapshot.secondary = Some(window(88.0)); + snapshot.secondary_label = Some("Gemini Weekly".to_string()); + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: "antigravity-quota-summary-3p-weekly".to_string(), + title: "Claude/GPT weekly".to_string(), + window: window(100.0), + fallback_lane: false, + icon_fallback: None, + }]; + + let selected = selected_usage_window(&snapshot, &Settings::default()); + + assert_eq!(selected.used_percent, 88.0); + assert!(!selected.is_exhausted); +} + +#[test] +fn antigravity_automatic_uses_core_slots_only() { + let mut snapshot = snapshot(); + snapshot.provider_id = "antigravity".to_string(); + snapshot.primary = window(80.0); + snapshot.secondary = Some(window(20.0)); + snapshot.model_specific = Some(window(90.0)); + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: "legacy-other".to_string(), + title: "Other".to_string(), + window: window(100.0), + fallback_lane: false, + icon_fallback: None, + }]; + + let selected = selected_usage_window(&snapshot, &Settings::default()); + + assert_eq!(selected.used_percent, 90.0); +} + +#[test] +fn single_meaningful_quota_omits_the_companion_icon_lane() { + let mut snapshot = snapshot(); + snapshot + .secondary + .as_mut() + .expect("fixture has a secondary window") + .is_informational = true; + + let (selected, companion) = selected_usage_icon_windows(&snapshot, &Settings::default()); + + assert_eq!(selected.used_percent, 20.0); + assert!(companion.is_none()); +} + +#[test] +fn average_preference_derives_the_combined_percentage() { + let mut snapshot = snapshot(); + snapshot.provider_id = "gemini".to_string(); + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Gemini, MetricPreference::Average); + + let selected = selected_usage_window(&snapshot, &settings); + assert_eq!(selected.used_percent, 40.0); + assert_eq!(selected.remaining_percent, 60.0); +} + +fn mistral_snapshot(plan: Option) -> ProviderUsageSnapshot { + let mut snapshot = snapshot(); + snapshot.provider_id = "mistral".to_string(); + snapshot.display_name = "Mistral".to_string(); + snapshot.primary = window(2.0); + snapshot.primary_label = Some("Included API".to_string()); + snapshot.secondary = None; + snapshot.extra_rate_windows = plan + .map(|window| crate::commands::NamedRateWindowSnapshot { + id: codexbar::providers::mistral::MONTHLY_PLAN_WINDOW_ID.to_string(), + title: "Monthly Plan".to_string(), + window, + fallback_lane: false, + icon_fallback: None, + }) + .into_iter() + .collect(); + snapshot +} + +fn metric_settings(provider: ProviderId, preference: MetricPreference) -> Settings { + let mut settings = Settings::default(); + settings.set_provider_metric(provider, preference); + settings +} + +#[test] +fn mistral_monthly_plan_selects_the_vibe_plan_window() { + let mut snapshot = mistral_snapshot(Some(window(42.0))); + snapshot.primary = window(80.0); + + let monthly_plan = metric_settings(ProviderId::Mistral, MetricPreference::MonthlyPlan); + assert_eq!( + selected_usage_window(&snapshot, &monthly_plan).used_percent, + 42.0 + ); + // Included API and Automatic stay separate choices (upstream #4072). + let included_api = metric_settings(ProviderId::Mistral, MetricPreference::Session); + assert_eq!( + selected_usage_window(&snapshot, &included_api).used_percent, + 80.0 + ); + assert_eq!( + selected_usage_window(&snapshot, &Settings::default()).used_percent, + 80.0 + ); + let presentation = + crate::commands::ProviderUsagePresentationSnapshot::new(snapshot, &monthly_plan); + assert_eq!(presentation.selected_metric.used_percent, 42.0); +} + +#[test] +fn mistral_monthly_plan_falls_back_to_included_api_without_a_known_plan() { + let settings = metric_settings(ProviderId::Mistral, MetricPreference::MonthlyPlan); + let unknown_plan = RateWindowSnapshot { + is_informational: true, + ..window(0.0) + }; + + for snapshot in [mistral_snapshot(None), mistral_snapshot(Some(unknown_plan))] { + let selected = selected_usage_window(&snapshot, &settings); + assert_eq!(selected.used_percent, 2.0); + assert!(!selected.is_informational); + } +} + +#[test] +fn mistral_monthly_plan_never_selects_a_spend_window() { + let mut snapshot = mistral_snapshot(None); + snapshot.cost = Some(crate::commands::CostSnapshotBridge { + used: 45.0, + limit: Some(50.0), + remaining: Some(5.0), + currency_code: "EUR".to_string(), + currency_symbol: Some("€".to_string()), + period: "month".to_string(), + resets_at: None, + formatted_used: "€45.00".to_string(), + formatted_limit: Some("€50.00".to_string()), + balance: None, + balance_updated_at: None, + account_id: None, + formatted_balance: None, + daily: Vec::new(), + always_visible: false, + folded_into_primary: false, + title_key: None, + }); + let settings = metric_settings(ProviderId::Mistral, MetricPreference::MonthlyPlan); + + assert_eq!( + selected_usage_window(&snapshot, &settings).used_percent, + 2.0 + ); +} + +#[test] +fn monthly_plan_without_a_provider_plan_window_falls_through_to_automatic() { + let mut snapshot = snapshot(); + snapshot.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: codexbar::providers::mistral::MONTHLY_PLAN_WINDOW_ID.to_string(), + title: "Monthly Plan".to_string(), + window: window(90.0), + fallback_lane: false, + icon_fallback: None, + }]; + let settings = metric_settings(ProviderId::Codex, MetricPreference::MonthlyPlan); + + // Codex declares no plan window, so a same-named lane is not special + // and the choice resolves like Automatic (highest real window). + assert_eq!( + selected_usage_window(&snapshot, &settings).used_percent, + selected_usage_window(&snapshot, &Settings::default()).used_percent + ); + assert!(monthly_plan_window(&snapshot, Some(ProviderId::Codex)).is_none()); +} + +#[test] +fn presentation_payload_flattens_the_snapshot_and_selected_metric() { + let presentation = + crate::commands::ProviderUsagePresentationSnapshot::new(snapshot(), &Settings::default()); + let value = serde_json::to_value(presentation).expect("serialize presentation"); + + assert_eq!(value["providerId"], "codex"); + assert_eq!(value["selectedMetric"]["usedPercent"], 60.0); + assert!(value.get("snapshot").is_none()); +} + +#[test] +fn hidden_usage_items_are_presentation_metadata_and_do_not_change_selected_metric() { + let mut settings = Settings::default(); + settings.set_hidden_usage_item_ids(ProviderId::Codex, vec!["metric:secondary".to_string()]); + + let presentation = + crate::commands::ProviderUsagePresentationSnapshot::new(snapshot(), &settings); + + assert!(presentation.snapshot.secondary.is_some()); + assert_eq!(presentation.selected_metric.used_percent, 60.0); + assert_eq!( + presentation.hidden_usage_item_ids, + vec!["metric:secondary".to_string()] + ); + let value = serde_json::to_value(presentation).expect("serialize presentation"); + assert_eq!( + value["hiddenUsageItemIds"], + serde_json::json!(["metric:secondary"]) + ); +} + +#[test] +fn claude_routines_hide_and_restore_preserve_raw_data_and_selected_metric() { + let mut raw = snapshot(); + raw.provider_id = "claude".to_string(); + raw.extra_rate_windows = vec![crate::commands::NamedRateWindowSnapshot { + id: "claude-routines".to_string(), + title: "Daily Routines".to_string(), + window: window(95.0), + fallback_lane: false, + icon_fallback: None, + }]; + + let baseline = + crate::commands::ProviderUsagePresentationSnapshot::new(raw.clone(), &Settings::default()); + assert_eq!(baseline.selected_metric.used_percent, 95.0); + + let mut settings = Settings::default(); + settings.set_hidden_usage_item_ids( + ProviderId::Claude, + vec![codexbar::settings::CLAUDE_DAILY_ROUTINES_USAGE_ITEM_ID.to_string()], + ); + let hidden = crate::commands::ProviderUsagePresentationSnapshot::new(raw.clone(), &settings); + + assert_eq!(hidden.selected_metric.used_percent, 95.0); + assert_eq!(hidden.snapshot.extra_rate_windows[0].id, "claude-routines"); + assert_eq!( + hidden.snapshot.extra_rate_windows[0].window.used_percent, + 95.0 + ); + + settings.set_hidden_usage_item_ids(ProviderId::Claude, Vec::new()); + let restored = crate::commands::ProviderUsagePresentationSnapshot::new(raw, &settings); + + assert_eq!(restored.selected_metric.used_percent, 95.0); + assert_eq!( + restored.snapshot.extra_rate_windows[0].id, + "claude-routines" + ); + assert!(restored.hidden_usage_item_ids.is_empty()); +} + +fn litellm_budgets(personal: f64, team: Option) -> ProviderUsageSnapshot { + let mut snapshot = snapshot(); + snapshot.provider_id = "litellm".to_string(); + snapshot.primary = window(personal); + snapshot.secondary = team.map(window); + snapshot +} + +const AGENT_RESET: &str = "2026-08-16T05:00:00Z"; + +fn agent_window(used_percent: f64, minutes: u32) -> RateWindowSnapshot { + RateWindowSnapshot { + window_minutes: Some(minutes), + resets_at: Some(AGENT_RESET.to_string()), + ..window(used_percent) + } +} + +fn agent_extra( + id: &str, + window: RateWindowSnapshot, + icon_fallback: Option, +) -> crate::commands::NamedRateWindowSnapshot { + crate::commands::NamedRateWindowSnapshot { + id: id.to_string(), + title: id.to_string(), + window, + fallback_lane: false, + icon_fallback, + } +} + +/// Bridge shape of a Doubao Coding Plan snapshot that also reports Agent +/// Plan lanes (`doubao-agent-*`), with optional Coding Plan session/weekly. +fn doubao_snapshot( + coding_session: Option, + coding_weekly: Option, +) -> ProviderUsageSnapshot { + let mut snapshot = snapshot(); + snapshot.provider_id = "doubao".to_string(); + snapshot.primary = coding_session.map_or_else( + || RateWindowSnapshot { + window_minutes: Some(300), + is_informational: true, + ..window(0.0) + }, + window, + ); + snapshot.secondary = coding_weekly.map(window); + snapshot.extra_rate_windows = vec![ + agent_extra( + "doubao-agent-session", + agent_window(0.0, 300), + Some(IconLane::Primary), + ), + agent_extra( + "doubao-agent-weekly", + agent_window(31.0, 10_080), + Some(IconLane::Secondary), + ), + agent_extra("doubao-agent-monthly", agent_window(72.0, 43_200), None), + ]; + snapshot +} + +#[test] +fn litellm_automatic_prefers_the_team_budget_over_a_fuller_personal_budget() { + let snapshot = litellm_budgets(60.0, Some(7.0)); + + assert_eq!( + selected_usage_window(&snapshot, &Settings::default()).used_percent, + 7.0 + ); + let (selected, companion) = selected_usage_icon_windows(&snapshot, &Settings::default()); + assert_eq!(selected.used_percent, 7.0); + assert_eq!(companion.map(|window| window.used_percent), Some(60.0)); +} + +#[test] +fn litellm_automatic_shows_an_exhausted_budget_first() { + let personal_exhausted = litellm_budgets(100.0, Some(7.0)); + assert_eq!( + selected_usage_window(&personal_exhausted, &Settings::default()).used_percent, + 100.0 + ); + + let team_exhausted = litellm_budgets(40.0, Some(100.0)); + assert_eq!( + selected_usage_window(&team_exhausted, &Settings::default()).used_percent, + 100.0 + ); +} + +#[test] +fn litellm_automatic_uses_the_only_budget_and_explicit_choices_still_win() { + let personal_only = litellm_budgets(25.0, None); + assert_eq!( + selected_usage_window(&personal_only, &Settings::default()).used_percent, + 25.0 + ); + + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::LiteLLM, MetricPreference::Session); + assert_eq!( + selected_usage_window(&litellm_budgets(60.0, Some(7.0)), &settings).used_percent, + 60.0 + ); +} + +#[test] +fn doubao_icon_windows_fall_back_to_agent_plan_lanes() { + let mut settings = Settings::default(); + settings.set_provider_metric(ProviderId::Doubao, MetricPreference::Session); + + for (has_session, has_weekly) in [(false, false), (false, true), (true, false), (true, true)] { + let snapshot = doubao_snapshot(has_session.then_some(10.0), has_weekly.then_some(20.0)); + let (primary, secondary) = selected_usage_icon_windows(&snapshot, &settings); + let secondary = secondary.expect("secondary icon lane"); + + assert_eq!(primary.used_percent, if has_session { 10.0 } else { 0.0 }); + assert_eq!(secondary.used_percent, if has_weekly { 20.0 } else { 31.0 }); + assert!(!primary.is_informational); + assert_eq!( + primary.window_minutes, + if has_session { None } else { Some(300) } + ); + assert_eq!( + secondary.window_minutes, + if has_weekly { None } else { Some(10_080) } + ); + assert_eq!( + primary.resets_at.as_deref(), + (!has_session).then_some(AGENT_RESET) + ); + assert_eq!( + secondary.resets_at.as_deref(), + (!has_weekly).then_some(AGENT_RESET) + ); + } +} + +#[test] +fn doubao_icon_fallback_does_not_change_the_reported_snapshot() { + let raw = doubao_snapshot(None, None); + let presentation = + crate::commands::ProviderUsagePresentationSnapshot::new(raw.clone(), &Settings::default()); + + assert!(presentation.snapshot.primary.is_informational); + assert!(presentation.snapshot.secondary.is_none()); + assert!(presentation.snapshot.tertiary.is_none()); + assert_eq!( + presentation + .snapshot + .extra_rate_windows + .iter() + .map(|extra| extra.id.as_str()) + .collect::>(), + [ + "doubao-agent-session", + "doubao-agent-weekly", + "doubao-agent-monthly" + ] + ); + assert!(!presentation.selected_metric.is_informational); +} + +#[test] +fn doubao_icon_windows_preserve_missing_lanes() { + let no_hints = |levels: &[(&str, f64, u32)]| { + let mut snapshot = doubao_snapshot(None, None); + snapshot.extra_rate_windows = levels + .iter() + .map(|(id, percent, minutes)| agent_extra(id, agent_window(*percent, *minutes), None)) + .collect(); + snapshot + }; + // Monthly and team buckets never stand in for the session/weekly lanes. + for id in ["doubao-agent-monthly", "doubao-agent-team-session"] { + let snapshot = no_hints(&[(id, 25.0, 300)]); + let resolved = with_icon_fallbacks(&snapshot); + assert!(matches!(resolved, Cow::Borrowed(_)), "{id}"); + assert!(resolved.primary.is_informational, "{id}"); + assert!(resolved.secondary.is_none(), "{id}"); + } + + // Agent weekly alone fills only the weekly lane; no session is invented. + let mut snapshot = doubao_snapshot(None, None); + snapshot + .extra_rate_windows + .retain(|extra| extra.id == "doubao-agent-weekly"); + let resolved = with_icon_fallbacks(&snapshot); + assert!(resolved.primary.is_informational); + assert_eq!(resolved.secondary.as_ref().unwrap().used_percent, 31.0); + + // Coding session alone (agent lanes absent): unchanged, nothing invented. + let mut snapshot = doubao_snapshot(Some(25.0), None); + snapshot.extra_rate_windows.clear(); + let resolved = with_icon_fallbacks(&snapshot); + assert!(matches!(resolved, Cow::Borrowed(_))); + let (primary, companion) = selected_usage_icon_windows(&snapshot, &Settings::default()); + assert_eq!(primary.used_percent, 25.0); + assert!(companion.is_none()); +} + +#[test] +fn doubao_automatic_icon_uses_agent_lanes_not_team_windows() { + let mut snapshot = doubao_snapshot(None, None); + snapshot.extra_rate_windows = vec![ + agent_extra( + "doubao-agent-session", + agent_window(42.0, 300), + Some(IconLane::Primary), + ), + agent_extra( + "doubao-agent-weekly", + agent_window(67.0, 10_080), + Some(IconLane::Secondary), + ), + agent_extra("doubao-agent-team-session", agent_window(91.0, 300), None), + agent_extra("doubao-agent-team-weekly", agent_window(88.0, 10_080), None), + ]; + let settings = Settings::default(); + + let (top, bottom) = selected_usage_icon_windows(&snapshot, &settings); + assert_eq!(top.used_percent, 42.0); + assert_eq!(bottom.expect("bottom lane").used_percent, 67.0); + assert_eq!( + selected_usage_window(&snapshot, &settings).used_percent, + 67.0 + ); + assert_eq!( + crate::tray_presentation::headline_window(&snapshot).used_percent, + 42.0 + ); +} + +#[test] +fn icon_fallback_hints_are_ignored_while_the_core_lane_is_present() { + let snapshot = doubao_snapshot(Some(10.0), Some(20.0)); + let resolved = with_icon_fallbacks(&snapshot); + + assert!(matches!(resolved, Cow::Borrowed(_))); + assert_eq!(resolved.primary.used_percent, 10.0); + assert_eq!(resolved.secondary.as_ref().unwrap().used_percent, 20.0); +} diff --git a/apps/desktop-tauri/src-tauri/src/window_positioner.rs b/apps/desktop-tauri/src-tauri/src/window_positioner.rs index 407074ed489..7ccb842c07e 100644 --- a/apps/desktop-tauri/src-tauri/src/window_positioner.rs +++ b/apps/desktop-tauri/src-tauri/src/window_positioner.rs @@ -1,9 +1,3 @@ -// Public positioning API — consumed by the shell and tested here. -#![allow( - dead_code, - reason = "window positioner types reserved for future window management integration" -)] - /// A rectangle in physical pixels (monitor work area or icon bounds). #[derive(Debug, Clone, Copy)] pub struct Rect { @@ -13,6 +7,45 @@ pub struct Rect { pub height: u32, } +impl Rect { + pub const fn new(x: i32, y: i32, width: u32, height: u32) -> Self { + Self { + x, + y, + width, + height, + } + } + + // Physical pixel extents are bounded well below i32::MAX. + #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] + pub const fn signed_width(&self) -> i32 { + self.width as i32 + } + + #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] + pub const fn signed_height(&self) -> i32 { + self.height as i32 + } + + pub const fn right(&self) -> i32 { + self.x + self.signed_width() + } + + pub const fn bottom(&self) -> i32 { + self.y + self.signed_height() + } +} + +/// Substitute 1.0 for a non-finite or non-positive monitor scale factor. +pub fn safe_scale(scale_factor: f64) -> f64 { + if scale_factor.is_finite() && scale_factor > 0.0 { + scale_factor + } else { + 1.0 + } +} + /// Panel dimensions in logical pixels. #[derive(Debug, Clone, Copy)] pub struct PanelSize { @@ -25,11 +58,7 @@ const MARGIN: i32 = 8; const GAP: i32 = 8; fn physical_panel_size(panel_size: &PanelSize, scale_factor: f64) -> (i32, i32) { - let scale_factor = if scale_factor.is_finite() && scale_factor > 0.0 { - scale_factor - } else { - 1.0 - }; + let scale_factor = safe_scale(scale_factor); // Physical size is rounded to whole pixels before truncation. #[expect(clippy::cast_possible_truncation, reason = "whole units by design")] @@ -39,7 +68,7 @@ fn physical_panel_size(panel_size: &PanelSize, scale_factor: f64) -> (i32, i32) (width, height) } -fn clamp_to_work_area( +pub fn clamp_position_to_work_area( target_x: i32, target_y: i32, monitor_rect: &Rect, @@ -49,17 +78,8 @@ fn clamp_to_work_area( let (pw, ph) = physical_panel_size(panel_size, scale_factor); let min_x = monitor_rect.x + MARGIN; let min_y = monitor_rect.y + MARGIN; - // Monitor dimensions are physical pixels, bounded well below i32::MAX. - #[expect( - clippy::cast_possible_wrap, - reason = "monitor pixel dimensions fit in i32" - )] - let max_x = (monitor_rect.x + monitor_rect.width as i32 - pw - MARGIN).max(min_x); - #[expect( - clippy::cast_possible_wrap, - reason = "monitor pixel dimensions fit in i32" - )] - let max_y = (monitor_rect.y + monitor_rect.height as i32 - ph - MARGIN).max(min_y); + let max_x = (monitor_rect.right() - pw - MARGIN).max(min_x); + let max_y = (monitor_rect.bottom() - ph - MARGIN).max(min_y); (target_x.clamp(min_x, max_x), target_y.clamp(min_y, max_y)) } @@ -73,12 +93,7 @@ fn calculate_anchored_position( open_above: bool, ) -> (i32, i32) { let (pw, ph) = physical_panel_size(panel_size, scale_factor); - // Tray icon widths are tens of pixels, far below i32::MAX. - #[expect( - clippy::cast_possible_wrap, - reason = "tray icon pixel dimensions fit in i32" - )] - let anchor_x = icon_rect.x + (icon_rect.width as i32) / 2; + let anchor_x = icon_rect.x + icon_rect.signed_width() / 2; let target_x = anchor_x - pw / 2; let target_y = if open_above { anchor_y - ph - GAP @@ -86,17 +101,7 @@ fn calculate_anchored_position( anchor_y + GAP }; - clamp_to_work_area(target_x, target_y, monitor_rect, panel_size, scale_factor) -} - -pub fn clamp_position_to_work_area( - target_x: i32, - target_y: i32, - monitor_rect: &Rect, - panel_size: &PanelSize, - scale_factor: f64, -) -> (i32, i32) { - clamp_to_work_area(target_x, target_y, monitor_rect, panel_size, scale_factor) + clamp_position_to_work_area(target_x, target_y, monitor_rect, panel_size, scale_factor) } /// Calculate panel position anchored to a tray icon rectangle. @@ -114,21 +119,16 @@ pub fn calculate_panel_position( scale_factor: f64, ) -> (i32, i32) { let my = work_area.y; - // Work-area and icon dimensions are physical pixels, bounded well below i32::MAX. - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] - let mh = work_area.height as i32; + let mh = work_area.signed_height(); - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] - let icon_cy = icon_rect.y + (icon_rect.height as i32) / 2; + let icon_cy = icon_rect.y + icon_rect.signed_height() / 2; let monitor_cy = my + mh / 2; let open_above = icon_cy > monitor_cy; - // Icon bounds are physical pixels, bounded well below i32::MAX. - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] let anchor_y = if open_above { icon_rect.y } else { - icon_rect.y + icon_rect.height as i32 + icon_rect.bottom() }; let position = calculate_anchored_position( @@ -139,18 +139,9 @@ pub fn calculate_panel_position( anchor_y, open_above, ); - // Monitor and work-area widths are physical pixels, bounded well below i32::MAX. - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] - let bounds_right = monitor_bounds.x + monitor_bounds.width as i32; - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] - let work_right = work_area.x + work_area.width as i32; - - if work_area.x > monitor_bounds.x || work_right < bounds_right { + if work_area.x > monitor_bounds.x || work_area.right() < monitor_bounds.right() { let (_, ph) = physical_panel_size(panel_size, scale_factor); - // Work-area height is a physical pixel count, bounded well below i32::MAX. - #[expect(clippy::cast_possible_wrap, reason = "pixel dimensions fit in i32")] - let work_height = work_area.height as i32; - (position.0, work_area.y + work_height - ph - MARGIN) + (position.0, work_area.bottom() - ph - MARGIN) } else { position } @@ -173,7 +164,7 @@ pub fn calculate_cursor_position( reason = "physical cursor coordinates fit i32" )] let y = (cursor.1.round() as i32).saturating_add(GAP); - clamp_to_work_area(x, y, work_area, panel_size, scale_factor) + clamp_position_to_work_area(x, y, work_area, panel_size, scale_factor) } /// Legacy shortcut placement: 22 % from left, vertically centred. @@ -185,17 +176,8 @@ pub fn calculate_shortcut_position( let (pw, ph) = physical_panel_size(panel_size, scale_factor); let mx = monitor_rect.x; let my = monitor_rect.y; - // Monitor dimensions are physical pixels, bounded well below i32::MAX. - #[expect( - clippy::cast_possible_wrap, - reason = "monitor pixel dimensions fit in i32" - )] - let mw = monitor_rect.width as i32; - #[expect( - clippy::cast_possible_wrap, - reason = "monitor pixel dimensions fit in i32" - )] - let mh = monitor_rect.height as i32; + let mw = monitor_rect.signed_width(); + let mh = monitor_rect.signed_height(); // Shortcut offset is truncated to a whole pixel by design. #[expect(clippy::cast_possible_truncation, reason = "whole units by design")] @@ -208,74 +190,12 @@ pub fn calculate_shortcut_position( (x, y) } -/// Calculate detached popout/settings placement. -/// -/// Placement rules: -/// - With a known tray anchor, centre horizontally on the tray parity anchor -/// point (icon centre-x, top-y) and pick above or below based on available -/// space before clamping inside the work area. -/// - Without a tray anchor, fall back to the bottom-right corner of the work -/// area and clamp so the window remains visible. -pub fn calculate_popout_position( - icon_rect: Option<&Rect>, - monitor_rect: &Rect, - panel_size: &PanelSize, - scale_factor: f64, -) -> (i32, i32) { - let (pw, ph) = physical_panel_size(panel_size, scale_factor); - let mx = monitor_rect.x; - let my = monitor_rect.y; - // Monitor dimensions are physical pixels, bounded well below i32::MAX. - #[expect( - clippy::cast_possible_wrap, - reason = "monitor pixel dimensions fit in i32" - )] - let mw = monitor_rect.width as i32; - #[expect( - clippy::cast_possible_wrap, - reason = "monitor pixel dimensions fit in i32" - )] - let mh = monitor_rect.height as i32; - - let (target_x, target_y) = if let Some(icon_rect) = icon_rect { - let space_above = icon_rect.y - my - MARGIN; - let space_below = my + mh - icon_rect.y - MARGIN; - let open_above = space_above >= ph + GAP || space_above > space_below; - calculate_anchored_position( - icon_rect, - monitor_rect, - panel_size, - scale_factor, - icon_rect.y, - open_above, - ) - } else { - (mx + mw - pw - MARGIN, my + mh - ph - MARGIN) - }; - - clamp_position_to_work_area(target_x, target_y, monitor_rect, panel_size, scale_factor) -} - #[cfg(test)] mod tests { use super::*; - fn standard_monitor() -> Rect { - Rect { - x: 0, - y: 0, - width: 2400, - height: 1080, - } - } - fn hd_monitor() -> Rect { - Rect { - x: 0, - y: 0, - width: 1920, - height: 1080, - } + Rect::new(0, 0, 1920, 1080) } fn panel() -> PanelSize { @@ -285,25 +205,11 @@ mod tests { } } - fn tall_monitor() -> Rect { - Rect { - x: 0, - y: 0, - width: 1920, - height: 1400, - } - } - // --- tray-anchor tests --- #[test] fn bottom_taskbar_panel_opens_above() { - let icon = Rect { - x: 1800, - y: 1040, - width: 24, - height: 24, - }; + let icon = Rect::new(1800, 1040, 24, 24); let monitor = hd_monitor(); let (_, y) = calculate_panel_position(&icon, &monitor, &monitor, &panel(), 1.0); assert!(y < icon.y, "panel should sit above the icon"); @@ -311,12 +217,7 @@ mod tests { #[test] fn top_taskbar_panel_opens_below() { - let icon = Rect { - x: 900, - y: 4, - width: 24, - height: 24, - }; + let icon = Rect::new(900, 4, 24, 24); let monitor = hd_monitor(); let (_, y) = calculate_panel_position(&icon, &monitor, &monitor, &panel(), 1.0); assert!( @@ -327,12 +228,7 @@ mod tests { #[test] fn horizontal_centre_on_icon() { - let icon = Rect { - x: 960, - y: 1040, - width: 24, - height: 24, - }; + let icon = Rect::new(960, 1040, 24, 24); let monitor = hd_monitor(); let (x, _) = calculate_panel_position(&icon, &monitor, &monitor, &panel(), 1.0); let icon_cx = icon.x + 12; @@ -346,12 +242,7 @@ mod tests { #[test] fn clamped_left_edge() { - let icon = Rect { - x: 0, - y: 1040, - width: 24, - height: 24, - }; + let icon = Rect::new(0, 1040, 24, 24); let monitor = hd_monitor(); let (x, _) = calculate_panel_position(&icon, &monitor, &monitor, &panel(), 1.0); assert!(x >= MARGIN, "panel must not exceed left margin"); @@ -359,12 +250,7 @@ mod tests { #[test] fn clamped_right_edge() { - let icon = Rect { - x: 1900, - y: 1040, - width: 24, - height: 24, - }; + let icon = Rect::new(1900, 1040, 24, 24); let monitor = hd_monitor(); let (x, _) = calculate_panel_position(&icon, &monitor, &monitor, &panel(), 1.0); assert!( @@ -375,12 +261,7 @@ mod tests { #[test] fn clamped_top_edge() { - let icon = Rect { - x: 960, - y: 4, - width: 24, - height: 24, - }; + let icon = Rect::new(960, 4, 24, 24); let monitor = hd_monitor(); let (_, y) = calculate_panel_position(&icon, &monitor, &monitor, &panel(), 1.0); assert!(y >= MARGIN, "panel must not exceed top margin"); @@ -388,18 +269,8 @@ mod tests { #[test] fn top_taskbar_work_area_clamps_open_below_to_min_y() { - let work_area = Rect { - x: 0, - y: 40, - width: 1920, - height: 1040, - }; - let icon = Rect { - x: 960, - y: 4, - width: 24, - height: 24, - }; + let work_area = Rect::new(0, 40, 1920, 1040); + let icon = Rect::new(960, 4, 24, 24); let monitor = hd_monitor(); let (_, y) = calculate_panel_position(&icon, &monitor, &work_area, &panel(), 1.0); assert_eq!(y, work_area.y + MARGIN); @@ -408,18 +279,8 @@ mod tests { #[test] fn left_taskbar_bottom_aligns_panel() { let monitor = hd_monitor(); - let work_area = Rect { - x: 40, - y: 0, - width: 1880, - height: 1080, - }; - let icon = Rect { - x: 8, - y: 1048, - width: 24, - height: 24, - }; + let work_area = Rect::new(40, 0, 1880, 1080); + let icon = Rect::new(8, 1048, 24, 24); let (_, y) = calculate_panel_position(&icon, &monitor, &work_area, &panel(), 1.0); @@ -428,24 +289,9 @@ mod tests { #[test] fn high_dpi_right_taskbar_bottom_aligns_panel() { - let monitor = Rect { - x: 0, - y: 0, - width: 3840, - height: 2160, - }; - let work_area = Rect { - x: 0, - y: 0, - width: 3760, - height: 2160, - }; - let icon = Rect { - x: 3808, - y: 2128, - width: 24, - height: 24, - }; + let monitor = Rect::new(0, 0, 3840, 2160); + let work_area = Rect::new(0, 0, 3760, 2160); + let icon = Rect::new(3808, 2128, 24, 24); let (_, y) = calculate_panel_position(&icon, &monitor, &work_area, &panel(), 2.0); @@ -454,18 +300,8 @@ mod tests { #[test] fn multi_monitor_offset() { - let monitor = Rect { - x: 1920, - y: 0, - width: 1920, - height: 1080, - }; - let icon = Rect { - x: 3700, - y: 1040, - width: 24, - height: 24, - }; + let monitor = Rect::new(1920, 0, 1920, 1080); + let icon = Rect::new(3700, 1040, 24, 24); let (x, _) = calculate_panel_position(&icon, &monitor, &monitor, &panel(), 1.0); assert!(x >= monitor.x + MARGIN); assert!( @@ -476,12 +312,7 @@ mod tests { #[test] fn high_dpi_positioning() { - let icon = Rect { - x: 960, - y: 1040, - width: 24, - height: 24, - }; + let icon = Rect::new(960, 1040, 24, 24); let monitor = hd_monitor(); let (x1, y1) = calculate_panel_position(&icon, &monitor, &monitor, &panel(), 1.0); let (x2, y2) = calculate_panel_position(&icon, &monitor, &monitor, &panel(), 2.0); @@ -517,12 +348,7 @@ mod tests { #[test] fn cursor_open_clamps_on_high_dpi_monitor_with_negative_origin() { - let work_area = Rect { - x: -2560, - y: 0, - width: 2560, - height: 1400, - }; + let work_area = Rect::new(-2560, 0, 2560, 1400); let panel = PanelSize { width: 400, height: 600, @@ -535,12 +361,7 @@ mod tests { #[test] fn oversized_cursor_panel_keeps_its_top_left_visible() { - let work_area = Rect { - x: 1920, - y: -100, - width: 500, - height: 400, - }; + let work_area = Rect::new(1920, -100, 500, 400); assert_eq!( calculate_cursor_position((2200.0, 250.0), &work_area, &panel(), 2.0), (1928, -92) @@ -566,12 +387,7 @@ mod tests { #[test] fn shortcut_clamped_small_monitor() { - let monitor = Rect { - x: 0, - y: 0, - width: 500, - height: 600, - }; + let monitor = Rect::new(0, 0, 500, 600); let (x, y) = calculate_shortcut_position(&monitor, &panel(), 1.0); assert!(x >= MARGIN); assert!( @@ -584,119 +400,4 @@ mod tests { <= i32::try_from(monitor.height).unwrap() ); } - - // --- visible-surface popout tests --- - - #[test] - fn anchored_popout_keeps_no_anchor_bottom_right_fallback() { - let work_area = Rect { - x: 0, - y: 0, - width: 1920, - height: 1080, - }; - let target = calculate_popout_position(None, &work_area, &panel(), 1.0); - assert_eq!(target, (1492, 512)); - } - - #[test] - fn anchored_popout_uses_same_tray_anchor_x_as_panel_positioning() { - let icon = Rect { - x: 1800, - y: 1040, - width: 24, - height: 24, - }; - - let monitor = standard_monitor(); - let (panel_x, _) = calculate_panel_position(&icon, &monitor, &monitor, &panel(), 1.0); - let (popout_x, _) = - calculate_popout_position(Some(&icon), &standard_monitor(), &panel(), 1.0); - - assert_eq!(popout_x, panel_x); - } - - #[test] - fn popout_clamps_inside_monitor_bounds() { - let icon = Rect { - x: 1910, - y: 1040, - width: 24, - height: 24, - }; - let (x, y) = calculate_popout_position(Some(&icon), &standard_monitor(), &panel(), 1.0); - assert!(x >= 8); - assert!(y >= 8); - } - - #[test] - fn tray_anchored_popout_centres_on_known_anchor() { - let icon = Rect { - x: 1800, - y: 1040, - width: 24, - height: 24, - }; - let (x, _) = calculate_popout_position(Some(&icon), &standard_monitor(), &panel(), 1.0); - let icon_cx = icon.x + 12; - let panel_cx = x + 210; - assert!((icon_cx - panel_cx).abs() <= 1); - } - - #[test] - fn popout_prefers_above_tray_when_space_below_is_tight() { - let icon = Rect { - x: 1600, - y: 1040, - width: 24, - height: 24, - }; - let (_, y) = calculate_popout_position(Some(&icon), &standard_monitor(), &panel(), 1.0); - assert!(y < icon.y); - } - - #[test] - fn popout_prefers_below_tray_when_space_above_is_tight() { - let icon = Rect { - x: 1600, - y: 8, - width: 24, - height: 24, - }; - let (_, y) = calculate_popout_position(Some(&icon), &tall_monitor(), &panel(), 1.0); - assert!(y > icon.y); - } - - #[test] - fn top_taskbar_popout_uses_tray_top_anchor_point() { - let icon = Rect { - x: 1600, - y: 8, - width: 24, - height: 24, - }; - let (_, y) = calculate_popout_position(Some(&icon), &tall_monitor(), &panel(), 1.0); - assert_eq!(y, icon.y + GAP); - } - - #[test] - fn popout_high_dpi_uses_physical_panel_size() { - let icon = Rect { - x: 1800, - y: 1040, - width: 24, - height: 24, - }; - let (x1, y1) = calculate_popout_position(Some(&icon), &standard_monitor(), &panel(), 1.0); - let (x2, y2) = calculate_popout_position(Some(&icon), &standard_monitor(), &panel(), 2.0); - - assert!( - x2 < x1, - "higher scale should account for wider physical popout bounds" - ); - assert!( - y2 < y1, - "higher scale should account for taller physical popout bounds" - ); - } }