Skip to main content

ai_usagebar/
config.rs

1//! Config file at `~/.config/ai-usagebar/config.toml`.
2//!
3//! Layout:
4//! ```toml
5//! [anthropic]  enabled = true
6//! [openai]     enabled = true   # Codex OAuth from ~/.codex/auth.json
7//! [copilot]    enabled = false  # GitHub CLI OAuth, or an explicit env override
8//! [zai]        enabled = true
9//! [openrouter] enabled = true
10//! [deepseek]   enabled = false
11//! [kimi]       enabled = false
12//! [grokbot]    enabled = false  # Grok Bot desktop app's own session
13//! [modelstudio] enabled = false # `bl` CLI's own console login (Token Plan)
14//! [[custom]]   id = "mytool"   # user-defined HTTP provider, static token
15//! ```
16//!
17//! Every field is optional with sensible defaults — missing config file is
18//! treated as "use defaults". API keys are read from env vars (the relevant
19//! `*_api_key_env` field lets the user override which env var name).
20
21use std::collections::{BTreeMap, HashSet};
22use std::path::{Path, PathBuf};
23
24#[cfg(unix)]
25use std::os::unix::fs::{MetadataExt, PermissionsExt};
26
27use serde::{Deserialize, Serialize};
28
29use crate::anthropic::creds::CredsTarget;
30use crate::balance::{DisplayPrefs, Headline};
31use crate::cache::Cache;
32use crate::error::{AppError, Result};
33use crate::vendor::VendorId;
34
35/// A misspelled section name is silently ignored without this: `[openrouer]`
36/// leaves OpenRouter on its defaults and the user sees the wrong vendor set
37/// with no diagnostic. Denying unknown keys is deliberately applied at the
38/// *section* level only — the set of sections is small and stable, whereas
39/// denying unknown keys inside every section would hard-fail configs that
40/// carry a field from a future or removed version.
41#[derive(Debug, Clone, Default, Deserialize, Serialize)]
42#[serde(default, deny_unknown_fields)]
43pub struct Config {
44    pub ui: UiConfig,
45    pub tray: TrayConfig,
46    pub context: ContextConfig,
47    pub anthropic: AnthropicConfig,
48    pub anthropic_api: AnthropicApiConfig,
49    pub openai: OpenAiConfig,
50    pub copilot: CopilotConfig,
51    pub zai: ZaiConfig,
52    pub openrouter: OpenRouterConfig,
53    pub deepseek: DeepseekConfig,
54    pub kimi: KimiConfig,
55    pub kilo: KiloConfig,
56    pub novita: NovitaConfig,
57    pub moonshot: MoonshotConfig,
58    pub grok: GrokConfig,
59    pub supergrok: SuperGrokConfig,
60    pub grokbot: GrokbotConfig,
61    pub antigravity: AntigravityConfig,
62    pub cursor: CursorConfig,
63    pub minimax: MinimaxConfig,
64    pub kiro: KiroConfig,
65    pub nous: NousConfig,
66    #[serde(rename = "opencode-go")]
67    pub opencode_go: OpenCodeGoConfig,
68    pub commandcode: CommandCodeConfig,
69    pub ollama: OllamaConfig,
70    pub orcarouter: OrcaRouterConfig,
71    pub modelstudio: ModelStudioConfig,
72    /// Quota-threshold desktop notifications (`[notifications]`).
73    pub notifications: NotificationsConfig,
74    /// User-defined providers, one `[[custom]]` table each.
75    pub custom: Vec<CustomProviderConfig>,
76}
77
78/// UI / dispatch preferences. Currently just `primary` — which vendor the
79/// widget shows when `--vendor` is omitted, and which TUI tab is selected
80/// at startup.
81#[derive(Debug, Clone, Default, Deserialize, Serialize)]
82#[serde(default)]
83pub struct UiConfig {
84    /// `None` → fall back to anthropic for backward compatibility.
85    pub primary: Option<VendorId>,
86    /// Which vendors the Overview shows (the TUI's first tab and the macOS
87    /// menu-bar's top section), in this order. `None` → every enabled vendor,
88    /// in the canonical order.
89    pub overview_vendors: Option<Vec<VendorId>>,
90    /// Layout style for vendor navigation in the TUI: sidebar | navbar | none.
91    pub vendor_box: Option<VendorBoxStyle>,
92}
93
94impl UiConfig {
95    pub fn vendor_box(&self) -> VendorBoxStyle {
96        self.vendor_box.unwrap_or_default()
97    }
98}
99
100/// Tray preferences the host process needs before the WebView is up: shortcut,
101/// polling, updates, and the macOS menu-bar summary. Screen-only preferences
102/// (theme, density, time format) live in the popover's own storage instead.
103#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
104#[serde(default)]
105pub struct TrayConfig {
106    /// Global shortcut that toggles the popover, in the canonical
107    /// "Ctrl+Shift+U" spelling. `None` → no shortcut registered.
108    pub shortcut: Option<String>,
109    /// How often the tray re-reads every provider, in minutes: 1, 5 or 10.
110    /// The footer's Refresh is always immediate. `None` → 5.
111    pub refresh_minutes: Option<u64>,
112    /// What the tray does when a newer release is published.
113    pub updates: Option<UpdateMode>,
114    /// macOS menu-bar presentation: `provider` (Omarchy-style) or `bars`.
115    pub menu_bar_style: Option<String>,
116    /// The last provider selected in the macOS menu bar. A missing entry falls
117    /// back for display without erasing this choice during a transient gap.
118    pub menu_bar_provider: Option<String>,
119    /// Show all ready providers in the macOS menu bar; defaults to true.
120    pub menu_bar_show_all: Option<bool>,
121    /// Hide headline values, leaving only provider codes.
122    pub menu_bar_hide_value: bool,
123    /// Which quota window the macOS menu bar displays.
124    pub menu_bar_window: Option<String>,
125}
126
127/// Poll intervals the tray offers, in minutes. The provider cache TTL is
128/// 60 s regardless; this only decides how often the tray asks.
129pub const TRAY_REFRESH_MINUTES: [u64; 3] = [1, 5, 10];
130const DEFAULT_TRAY_REFRESH_MINUTES: u64 = 5;
131
132/// Quota-threshold desktop notifications. On by default at 97%: the bar's
133/// whole job is to make an exhausted window visible before a request fails,
134/// and a notification is that signal for a window you are not looking at.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
136#[serde(default)]
137pub struct NotificationsConfig {
138    pub enabled: bool,
139    /// Percentage of a quota window at which a notification fires (1..=100;
140    /// 100 means only an exhausted window notifies).
141    pub threshold: u8,
142}
143
144impl Default for NotificationsConfig {
145    fn default() -> Self {
146        Self {
147            enabled: true,
148            threshold: 97,
149        }
150    }
151}
152
153impl TrayConfig {
154    pub fn menu_bar_show_all(&self) -> bool {
155        self.menu_bar_show_all.unwrap_or(true)
156    }
157
158    pub fn refresh_minutes(&self) -> u64 {
159        self.refresh_minutes.unwrap_or(DEFAULT_TRAY_REFRESH_MINUTES)
160    }
161
162    pub fn updates(&self) -> UpdateMode {
163        self.updates.unwrap_or_default()
164    }
165}
166
167/// How the tray handles a newer release: install it unattended, show a
168/// banner with an Install button, or never check in the background.
169#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
170#[serde(rename_all = "lowercase")]
171pub enum UpdateMode {
172    Auto,
173    #[default]
174    Notify,
175    Off,
176}
177
178impl UpdateMode {
179    pub fn as_str(self) -> &'static str {
180        match self {
181            Self::Auto => "auto",
182            Self::Notify => "notify",
183            Self::Off => "off",
184        }
185    }
186
187    pub fn parse(text: &str) -> Option<Self> {
188        match text.trim().to_ascii_lowercase().as_str() {
189            "auto" => Some(Self::Auto),
190            "notify" => Some(Self::Notify),
191            "off" => Some(Self::Off),
192            _ => None,
193        }
194    }
195}
196
197/// Presentation style of the TUI vendor navigation box.
198#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
199#[serde(rename_all = "lowercase")]
200pub enum VendorBoxStyle {
201    /// Vertical sidebar box on wide terminals; falls back to top navbar on narrow terminals.
202    #[default]
203    Sidebar,
204    /// Horizontal navbar strip above the dashboard detail panel.
205    Navbar,
206    /// Completely hide vendor navigation (dashboards expand to fill full width).
207    None,
208}
209
210/// Where the context view docks in the dashboard body. `v` cycles it while the
211/// overlay is open; the config value is what it opens with.
212#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
213#[serde(rename_all = "lowercase")]
214pub enum ContextLayout {
215    /// Takes the whole body, the way a vendor panel does.
216    #[default]
217    Full,
218    /// Beside the dashboard.
219    Split,
220    /// Below the dashboard.
221    Bottom,
222}
223
224impl ContextLayout {
225    pub fn next(self) -> Self {
226        match self {
227            ContextLayout::Full => ContextLayout::Split,
228            ContextLayout::Split => ContextLayout::Bottom,
229            ContextLayout::Bottom => ContextLayout::Full,
230        }
231    }
232
233    pub fn label(self) -> &'static str {
234        match self {
235            ContextLayout::Full => "full",
236            ContextLayout::Split => "split",
237            ContextLayout::Bottom => "bottom",
238        }
239    }
240}
241
242/// Optional local Claude Code context-window monitor. This is deliberately
243/// separate from vendors: sessions are discovered from local transcripts and
244/// change while the TUI is running, whereas vendor tabs are config-declared
245/// account identities.
246#[derive(Debug, Clone, Default, Deserialize, Serialize)]
247#[serde(default)]
248pub struct ContextConfig {
249    /// Keep the filesystem scanner completely dormant unless explicitly
250    /// enabled. The `c` key and its footer hint are hidden while disabled.
251    pub enabled: bool,
252    /// Override Claude Code's normal `~/.claude/projects` transcript root.
253    pub projects_path: Option<PathBuf>,
254    /// Optional fallback denominator. When absent, sessions without an exact
255    /// model override show their input-token count without inventing a %.
256    pub context_window_tokens: Option<u64>,
257    /// Exact Claude model id -> context-window size. This takes precedence
258    /// over `context_window_tokens`, which keeps mixed 200K/1M histories safe.
259    pub model_context_window_tokens: BTreeMap<String, u64>,
260    /// Where the view opens: full | split | bottom.
261    pub layout: ContextLayout,
262}
263
264impl ContextConfig {
265    pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
266        model
267            .and_then(|model| self.model_context_window_tokens.get(model).copied())
268            .filter(|tokens| *tokens > 0)
269            .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
270    }
271}
272
273#[derive(Debug, Clone, Deserialize, Serialize)]
274#[serde(default)]
275pub struct AnthropicConfig {
276    pub enabled: bool,
277    /// Override the credentials file path (defaults to `~/.claude/.credentials.json`).
278    /// This is the *default* account; extra subscriptions go in `accounts`.
279    pub credentials_path: Option<PathBuf>,
280    /// Extra Anthropic accounts beyond the default, each selected on the CLI
281    /// with `--account <label>` (issue #14). Empty by default, so existing
282    /// single-account configs are byte-for-byte unchanged.
283    pub accounts: Vec<AnthropicAccount>,
284    /// Directory to auto-discover extra accounts from, in Claude Code's own
285    /// `CLAUDE_CONFIG_DIR` layout: each immediate subdirectory becomes an
286    /// account labeled by the subdirectory name. The credentials may live in
287    /// that directory's `.credentials.json` or in the macOS Keychain, so
288    /// discovery intentionally does not probe for the credentials file.
289    /// Merged with `accounts` (explicit wins on a label clash); each is
290    /// refreshed independently.
291    pub accounts_dir: Option<PathBuf>,
292    /// Whether the default (unnamed) Claude account gets its own tab. Defaults
293    /// to `true` for back-compat. Set `false` when every account is managed
294    /// explicitly (via `accounts`/`accounts_dir`) so the ambient
295    /// Keychain/`~/.claude` login doesn't add a redundant "Claude" tab. Ignored
296    /// when there are no named accounts, so Anthropic never loses its only tab.
297    pub show_default_account: bool,
298    /// Where the Claude **Desktop app**'s saved account profiles live. Defaults
299    /// to `~/.claude-acc/profiles`, the store claude-acc
300    /// (<https://github.com/ohmaseclaro/claude-acc>) creates — `account switch`
301    /// reads and writes that layout so the two tools stay interchangeable.
302    /// Unrelated to `accounts_dir`, which is the `claude` CLI's own accounts.
303    pub desktop_profiles_dir: Option<PathBuf>,
304}
305
306impl Default for AnthropicConfig {
307    fn default() -> Self {
308        Self {
309            enabled: true,
310            credentials_path: None,
311            accounts: Vec::new(),
312            accounts_dir: None,
313            show_default_account: true,
314            desktop_profiles_dir: None,
315        }
316    }
317}
318
319/// One extra Anthropic account beyond the default (issue #14). The default
320/// account stays the singular `[anthropic] credentials_path`; each entry here
321/// is an additional subscription selected on the CLI with `--account <label>`.
322///
323/// ```toml
324/// [[anthropic.accounts]]
325/// label = "work"
326/// credentials_path = "~/.config/ai-usagebar/accounts/work/.credentials.json"
327/// ```
328#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
329pub struct AnthropicAccount {
330    /// Stable name used on the CLI (`--account <label>`) and as the cache
331    /// subdir (`~/.cache/ai-usagebar/anthropic/<label>`).
332    pub label: String,
333    /// OAuth credentials file for this account (same JSON shape Claude Code
334    /// writes). Token refreshes are written back here, so each account keeps
335    /// itself alive independently.
336    pub credentials_path: PathBuf,
337}
338
339impl AnthropicAccount {
340    /// The `CLAUDE_CONFIG_DIR` this account occupies — the credential file's
341    /// own directory. Claude Code hashes exactly this path for the account's
342    /// Keychain item, so it is also the account's identity for
343    /// [`crate::anthropic::keychain`].
344    pub fn config_dir(&self) -> PathBuf {
345        self.credentials_path
346            .parent()
347            .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
348    }
349}
350
351impl AnthropicConfig {
352    /// Every extra account: the explicit `[[anthropic.accounts]]` entries plus
353    /// any auto-discovered under [`accounts_dir`](AnthropicConfig::accounts_dir).
354    /// Explicit entries take precedence on a label clash. This is what tabs and
355    /// `--account` enumerate, so a discovered account behaves exactly like a
356    /// hand-written one (own cache subdir, independent refresh).
357    pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
358        let mut out = self.accounts.clone();
359        if let Some(dir) = &self.accounts_dir {
360            for acct in discover_accounts(dir) {
361                if !out.iter().any(|a| a.label == acct.label) {
362                    out.push(acct);
363                }
364            }
365        }
366        out
367    }
368
369    /// Find an extra account by label (explicit or discovered), or error listing
370    /// the known labels so a typo fails loudly instead of silently hitting the
371    /// default. Returns an owned account because discovered entries are
372    /// synthesized, not stored.
373    pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
374        validate_account_label(label)?;
375        let all = self.all_accounts();
376        all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
377            let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
378            AppError::Credentials(format!(
379                "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
380                 known labels: {known:?}"
381            ))
382        })
383    }
384
385    /// Resolve a named account to the credentials target + isolated cache it
386    /// fetches through: [`CredsTarget::Named`], which on macOS prefers the
387    /// Keychain item scoped to the file's own directory (that is where
388    /// `CLAUDE_CONFIG_DIR=<dir> claude` actually writes) and falls back to
389    /// the file elsewhere — never a *different* account's item, since the
390    /// hash is per-directory, so issue #15's cross-account concern doesn't
391    /// apply. Plus an `anthropic/<label>` cache subdir. Shared by the widget
392    /// (`--account`) and the TUI's per-account tab (#14, #17) so both resolve
393    /// accounts identically; the widget layers its `--cache-dir` override on
394    /// top of the cache returned here.
395    pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
396        let active = crate::anthropic::cli_account::home_claude_json()
397            .ok()
398            .and_then(|path| {
399                crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
400            });
401        self.account_target_with(label, active.as_deref())
402    }
403
404    /// The pure half of [`account_target`](AnthropicConfig::account_target),
405    /// with "which account the `claude` CLI is signed into" injected — the same
406    /// shape as `Cli::resolve_vendor_with`. Probes the account's own credential
407    /// file with [`Path::exists`]; [`account_target_probing`] takes that probe
408    /// as an argument.
409    ///
410    /// [`account_target_probing`]: AnthropicConfig::account_target_probing
411    pub fn account_target_with(
412        &self,
413        label: &str,
414        cli_active: Option<&str>,
415    ) -> Result<(CredsTarget, Cache)> {
416        self.account_target_probing(label, cli_active, |path| path.exists())
417    }
418
419    /// The pure core: `exists` answers whether the account's own credential
420    /// file is there.
421    ///
422    /// When `label` *is* the live CLI login, `account switch` has moved its
423    /// credential into the default slot **and removed it from the named
424    /// one** — so reading the default keeps exactly one live lineage, and a
425    /// refresh here cannot invalidate the credential `claude` is using (or the
426    /// other way round). That only holds while the named slot really is empty,
427    /// which is why it is probed rather than assumed: a `CLAUDE_CONFIG_DIR`
428    /// layout keeps a live credential in every directory, and two of those
429    /// directories can hold the *same* account, which is what
430    /// `resolve_active_label` matches on. Believing the marker there sent every
431    /// fetch for that label to `~/.claude/.credentials.json` — a slot the user
432    /// never logs into, whose refresh token had expired, so a working account
433    /// reported "run `claude` to re-auth" while its own file sat live and
434    /// unread next to it.
435    ///
436    /// The cache directory is unchanged either way, so the tab keeps its
437    /// identity and its cached usage across a switch.
438    pub fn account_target_probing(
439        &self,
440        label: &str,
441        cli_active: Option<&str>,
442        exists: impl Fn(&Path) -> bool,
443    ) -> Result<(CredsTarget, Cache)> {
444        let account = self.account(label)?;
445        let cache = Cache::for_vendor_account("anthropic", label)?;
446        if cli_active == Some(label) && !exists(&account.credentials_path) {
447            return Ok((
448                CredsTarget::Default(crate::anthropic::creds::default_path()?),
449                cache,
450            ));
451        }
452        Ok((
453            CredsTarget::Named {
454                config_dir: account.config_dir(),
455                path: account.credentials_path,
456            },
457            cache,
458        ))
459    }
460}
461
462/// The label doubles as a cache subdirectory name
463/// (`~/.cache/ai-usagebar/anthropic/<label>/`), which nests inside the default
464/// account's cache dir — so path separators, control characters, or reserved
465/// cache sidecar names would escape, spoof terminal output, or collide with the
466/// cache layout (`usage.json`, `.stale`, …).
467pub fn validate_account_label(label: &str) -> Result<()> {
468    validate_account_label_for("anthropic", label)
469}
470
471fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
472    const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
473    let bad = label.is_empty()
474        || label == "."
475        || label == ".."
476        || label.contains(['/', '\\'])
477        || label.contains(':')
478        || label.chars().any(char::is_control)
479        || RESERVED.contains(&label);
480    if bad {
481        return Err(AppError::Credentials(format!(
482            "invalid {vendor} account label {label:?}: must be a non-empty name \
483             without path separators, drive prefixes, control characters, or reserved cache names"
484        )));
485    }
486    Ok(())
487}
488
489/// Discover accounts under `accounts_dir` in the `CLAUDE_CONFIG_DIR` layout:
490/// each immediate subdirectory becomes an account labeled by the subdirectory
491/// name. Best-effort: an unreadable directory or unusable label is skipped
492/// silently rather than failing the whole config — discovery is convenience,
493/// while an explicit `[[anthropic.accounts]]` entry stays authoritative. The
494/// fetch path resolves credentials from either `.credentials.json` or the macOS
495/// Keychain. Sorted by label so the tab order is stable across runs.
496fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
497    let Ok(entries) = std::fs::read_dir(accounts_dir) else {
498        return Vec::new();
499    };
500    let mut found: Vec<AnthropicAccount> = entries
501        .flatten()
502        .filter_map(|entry| {
503            let path = entry.path();
504            if !path.is_dir() {
505                return None;
506            }
507            let label = path.file_name()?.to_str()?.to_string();
508            validate_account_label(&label).ok()?;
509            Some(AnthropicAccount {
510                label,
511                credentials_path: path.join(".credentials.json"),
512            })
513        })
514        .collect();
515    found.sort_by(|a, b| a.label.cmp(&b.label));
516    found
517}
518
519/// Render a path with `$HOME` collapsed back to `~`, matching the style the docs
520/// and existing `[[anthropic.accounts]]` entries use. Pure so it's testable;
521/// paths outside home are returned verbatim.
522pub fn tildify(path: &Path, home: &Path) -> String {
523    path.strip_prefix(home)
524        .map(|rest| {
525            let rendered = rest.display().to_string();
526            // Config paths use the same portable `~/...` spelling on every
527            // platform. A Windows `~\...` would not be expanded by the loader.
528            #[cfg(windows)]
529            let rendered = rendered.replace('\\', "/");
530            format!("~/{rendered}")
531        })
532        .unwrap_or_else(|_| path.display().to_string())
533}
534
535/// Where a newly-registered account's credentials file lives by default: next
536/// to `config.toml`, under `accounts/<label>/.credentials.json`. Returns the
537/// absolute path (for `mkdir`) — tilde-render it with [`tildify`] for display
538/// and for the value written into config.
539pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
540    let base = config_path.parent().unwrap_or_else(|| Path::new("."));
541    base.join("accounts").join(label).join(".credentials.json")
542}
543
544/// Append a `[[anthropic.accounts]]` entry to a parsed config document, in
545/// place. Pure over a `toml_edit` document so the validation, duplicate check,
546/// and formatting are testable without disk. Preserves the rest of the file
547/// (comments, key order, other sections) — only the new array-of-tables entry
548/// is added. Errors on an invalid label or a label that already exists.
549pub fn add_anthropic_account_to_doc(
550    doc: &mut toml_edit::DocumentMut,
551    label: &str,
552    credentials_path: &str,
553) -> Result<()> {
554    use toml_edit::{Item, Table, value};
555
556    validate_account_label(label)?;
557
558    let anthropic = doc
559        .entry("anthropic")
560        .or_insert_with(|| Item::Table(Table::new()));
561    let anthropic = anthropic
562        .as_table_mut()
563        .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
564
565    let accounts = anthropic
566        .entry("accounts")
567        .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
568    let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
569        AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
570    })?;
571
572    let exists = accounts
573        .iter()
574        .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
575    if exists {
576        return Err(AppError::Credentials(format!(
577            "anthropic account {label:?} already exists in config.toml"
578        )));
579    }
580
581    let mut table = Table::new();
582    table["label"] = value(label);
583    table["credentials_path"] = value(credentials_path);
584    accounts.push(table);
585    Ok(())
586}
587
588/// Where a newly-registered Codex account's `auth.json` lives by default:
589/// `~/.codex-<label>/auth.json`, the `CODEX_HOME` the docs have always
590/// suggested for a second login.
591pub fn default_codex_auth_path(home: &Path, label: &str) -> PathBuf {
592    home.join(format!(".codex-{label}")).join("auth.json")
593}
594
595/// Append a `[[openai.accounts]]` entry to a parsed config document, in place.
596/// The Codex counterpart of [`add_anthropic_account_to_doc`], with the same
597/// guarantees: only the new entry is added, and an invalid or duplicate label
598/// is an error.
599pub fn add_openai_account_to_doc(
600    doc: &mut toml_edit::DocumentMut,
601    label: &str,
602    codex_auth_path: &str,
603) -> Result<()> {
604    use toml_edit::{Item, Table, value};
605
606    validate_account_label_for("openai", label)?;
607
608    let openai = doc
609        .entry("openai")
610        .or_insert_with(|| Item::Table(Table::new()));
611    let openai = openai
612        .as_table_mut()
613        .ok_or_else(|| AppError::Other("[openai] in config.toml is not a table".into()))?;
614
615    let accounts = openai
616        .entry("accounts")
617        .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
618    let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
619        AppError::Other("[[openai.accounts]] in config.toml is not an array of tables".into())
620    })?;
621
622    if accounts
623        .iter()
624        .any(|t| t.get("label").and_then(Item::as_str) == Some(label))
625    {
626        return Err(AppError::Credentials(format!(
627            "openai account {label:?} already exists in config.toml"
628        )));
629    }
630
631    let mut table = Table::new();
632    table["label"] = value(label);
633    table["codex_auth_path"] = value(codex_auth_path);
634    accounts.push(table);
635    Ok(())
636}
637
638/// Set or update a boolean field in a TOML section, preserving comments and
639/// formatting of unaffected nodes. Shared by the Settings overlay and
640/// [`enable_vendors_in`] so both writers shape `enabled = true` identically.
641pub(crate) fn set_bool(
642    doc: &mut toml_edit::DocumentMut,
643    section: &str,
644    key: &str,
645    new_value: bool,
646) -> Result<()> {
647    let table = doc
648        .entry(section)
649        .or_insert_with(toml_edit::table)
650        .as_table_mut()
651        .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
652
653    if let Some(item) = table.get_mut(key)
654        && let Some(v) = item.as_value_mut()
655    {
656        // Keep a trailing `# comment` on the line being rewritten: the value
657        // is the only thing that changed, and the note beside it is the
658        // user's.
659        let suffix = v.decor().suffix().cloned();
660        *v = toml_edit::Value::from(new_value);
661        v.decor_mut().set_prefix(" ");
662        if let Some(suffix) = suffix {
663            v.decor_mut().set_suffix(suffix);
664        }
665        return Ok(());
666    }
667    table.insert(key, toml_edit::value(new_value));
668    Ok(())
669}
670
671/// Set, replace or remove a scalar field in a TOML section, preserving
672/// comments and formatting of unaffected nodes. `None` removes the key so a
673/// cleared preference does not linger as an empty string. The value keeps
674/// its own TOML type on disk — an integer preference such as
675/// `refresh_minutes` must not be quoted, or `Config::load_from` rejects it.
676pub(crate) fn set_value(
677    doc: &mut toml_edit::DocumentMut,
678    section: &str,
679    key: &str,
680    new_value: Option<toml_edit::Value>,
681) -> Result<()> {
682    let table = doc
683        .entry(section)
684        .or_insert_with(toml_edit::table)
685        .as_table_mut()
686        .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
687
688    let Some(mut new_value) = new_value else {
689        table.remove(key);
690        return Ok(());
691    };
692    if let Some(item) = table.get_mut(key)
693        && let Some(v) = item.as_value_mut()
694    {
695        let suffix = v.decor().suffix().cloned();
696        new_value.decor_mut().set_prefix(" ");
697        if let Some(suffix) = suffix {
698            new_value.decor_mut().set_suffix(suffix);
699        }
700        *v = new_value;
701        return Ok(());
702    }
703    table.insert(key, toml_edit::Item::Value(new_value));
704    Ok(())
705}
706
707/// Write one `[tray]` preference into the config at `path`, creating the
708/// file when it doesn't exist and leaving every other line as it was.
709/// `None` removes the key. The value keeps the TOML type it is given
710/// (`"notify"` stays a string, `5` stays an integer). The tray host is the
711/// only writer.
712pub fn set_tray_value(path: &Path, key: &str, value: Option<toml_edit::Value>) -> Result<()> {
713    let mut doc = read_config_document(path)?;
714    let before = doc.to_string();
715    set_value(&mut doc, "tray", key, value)?;
716    if doc.to_string() == before {
717        return Ok(());
718    }
719    write_config_document(path, &doc)
720}
721
722/// Persist one validated notification preference without disturbing other
723/// config sections or their comments.
724pub fn set_notification_value(path: &Path, key: &str, value: toml_edit::Value) -> Result<()> {
725    match key {
726        "enabled" if value.as_bool().is_some() => {}
727        "threshold" if value.as_integer().is_some_and(|n| (1..=100).contains(&n)) => {}
728        _ => {
729            return Err(AppError::Other(format!(
730                "invalid notification preference: {key}"
731            )));
732        }
733    }
734    let mut doc = read_config_document(path)?;
735    let before = doc.to_string();
736    set_value(&mut doc, "notifications", key, Some(value))?;
737    if doc.to_string() == before {
738        return Ok(());
739    }
740    write_config_document(path, &doc)
741}
742
743/// Read `path` into a `toml_edit` document with comments intact. A missing
744/// file is an empty document, so a writer can create the config from nothing;
745/// any other I/O failure or a parse error is reported rather than clobbered.
746pub(crate) fn read_config_document(path: &Path) -> Result<toml_edit::DocumentMut> {
747    let original = match std::fs::read_to_string(path) {
748        Ok(contents) => contents,
749        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
750        Err(error) => return Err(AppError::io_at(path, error)),
751    };
752    if original.trim().is_empty() {
753        return Ok(toml_edit::DocumentMut::new());
754    }
755    original.parse().map_err(|e: toml_edit::TomlError| {
756        AppError::Other(format!("config.toml not parseable: {e}"))
757    })
758}
759
760/// Persist an edited config document: parent dir created, atomic
761/// tempfile-and-rename write, and `chmod 600` on Unix because the file may
762/// carry inline credentials. The one write path for every config editor.
763pub(crate) fn write_config_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> {
764    let bytes = doc.to_string();
765    crate::cache::atomic_write(path, bytes.as_bytes())?;
766
767    #[cfg(unix)]
768    {
769        if let Ok(meta) = std::fs::metadata(path) {
770            let mut perms = meta.permissions();
771            perms.set_mode(0o600);
772            let _ = std::fs::set_permissions(path, perms);
773        }
774    }
775    Ok(())
776}
777
778/// Flip `enabled = true` for each vendor's section in the config at `path`,
779/// creating the file when it doesn't exist and leaving every other line —
780/// comments, keys, unrelated sections — exactly as it was. Never writes
781/// `false`: `config.toml` stays the user's source of truth and this only ever
782/// widens it. A document that comes out textually unchanged (every vendor
783/// already enabled) is not rewritten, so an idempotent call doesn't touch the
784/// file's mtime or race a concurrent editor.
785pub fn enable_vendors_in(path: &Path, vendors: &[VendorId]) -> Result<Vec<VendorId>> {
786    let mut doc = read_config_document(path)?;
787    let before = doc.to_string();
788    let written: Vec<VendorId> = vendors
789        .iter()
790        .copied()
791        .filter(|vendor| !is_explicitly_disabled(&doc, *vendor))
792        .collect();
793    for vendor in &written {
794        set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
795    }
796    if doc.to_string() == before {
797        return Ok(written);
798    }
799    write_config_document(path, &doc)?;
800    Ok(written)
801}
802
803/// Whether the config *says* `enabled = false` for this vendor, as opposed to
804/// not mentioning it.
805///
806/// This is the durable record of a user having turned a vendor off. `detect`
807/// also keeps a set of vendors it has already considered, but that lives in the
808/// cache directory, which is by convention safe to delete — so it cannot be the
809/// only thing standing between "the user opted out" and re-enabling a provider
810/// (and resuming requests to it) behind their back. The config file is the one
811/// place that outlives a cache wipe, so the explicit `false` is honored here,
812/// at the write, where no caller can route around it.
813fn is_explicitly_disabled(doc: &toml_edit::DocumentMut, vendor: VendorId) -> bool {
814    doc.get(vendor.config_section())
815        .and_then(|section| section.get("enabled"))
816        .and_then(|enabled| enabled.as_bool())
817        == Some(false)
818}
819
820#[derive(Debug, Clone, Deserialize, Serialize)]
821#[serde(default)]
822pub struct OpenAiConfig {
823    pub enabled: bool,
824    /// Override the Codex auth file path (defaults to `~/.codex/auth.json`).
825    pub codex_auth_path: Option<PathBuf>,
826    /// Extra Codex logins, each its own `auth.json`. Same shape as
827    /// [`AnthropicAccount`] and for the same reason: Codex is an OAuth vendor,
828    /// so an account *is* a credential file, and `openai::creds::write_back`
829    /// refreshes into whichever one it read.
830    #[serde(default)]
831    pub accounts: Vec<OpenAiAccount>,
832    /// Whether the default (unnamed) Codex login gets its own tab. Defaults to
833    /// `true`. Set `false` once every login is a named account — typically
834    /// after `account add <label> --codex --adopt-current` — so the default
835    /// `~/.codex/auth.json` does not also appear as a second copy of whichever
836    /// account is active. Ignored when there are no named accounts.
837    pub show_default_account: bool,
838    /// Reserved, and inert: names the env var an API-key-only path *would*
839    /// read (admin key → `/v1/organization/costs`). Nothing consumes it —
840    /// OpenAI usage comes solely from Codex OAuth. Kept because that path is
841    /// still intended, not for back-compat: `[openai]` doesn't deny unknown
842    /// fields, so an existing `admin_key_env` would load either way. See
843    /// `config.example.toml`, which ships it commented out so nobody sets it
844    /// expecting an effect.
845    pub admin_key_env: String,
846}
847
848/// One extra Codex login.
849///
850/// ```toml
851/// [[openai.accounts]]
852/// label = "work"
853/// codex_auth_path = "~/.config/ai-usagebar/accounts/work-codex/auth.json"
854/// ```
855///
856/// A second login is made with `CODEX_HOME=~/.codex-work codex login`; point
857/// `codex_auth_path` at the `auth.json` it writes.
858#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
859pub struct OpenAiAccount {
860    /// Stable name used on the CLI (`--account <label>`) and as the cache
861    /// subdir (`~/.cache/ai-usagebar/openai/<label>`).
862    pub label: String,
863    /// Codex OAuth file for this account. Refreshed tokens are written back
864    /// here, so each account keeps itself alive independently.
865    pub codex_auth_path: PathBuf,
866}
867
868impl OpenAiConfig {
869    /// The auth file for `label`, or the singular/default one when `label` is
870    /// `None`. An unknown label is an error rather than a silent fall back to
871    /// the default account, which would report the wrong login's usage.
872    pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
873        let Some(label) = label else {
874            return match &self.codex_auth_path {
875                Some(path) => Ok(path.clone()),
876                None => crate::openai::creds::default_path(),
877            };
878        };
879        self.accounts
880            .iter()
881            .find(|account| account.label == label)
882            .map(|account| account.codex_auth_path.clone())
883            .ok_or_else(|| {
884                AppError::Credentials(format!(
885                    "no OpenAI account named {label:?}. Add it under \
886                     [[openai.accounts]], or drop --account to use the default login."
887                ))
888            })
889    }
890
891    /// The auth file a fetch for `label` reads. Unlike
892    /// [`resolve_auth_path`](OpenAiConfig::resolve_auth_path), this follows
893    /// `account switch --codex`: the active account's login has been moved
894    /// into the default slot, so it is read there. The answer holds only while
895    /// no switch runs, so a fetch asks it under the credentials lock; see
896    /// [`fetch_snapshot_routed`](crate::openai::fetch_snapshot_routed).
897    pub fn fetch_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
898        let Some(label) = label else {
899            return self.resolve_auth_path(None);
900        };
901        let default = self.resolve_auth_path(None)?;
902        let active = crate::openai::account::resolve_active_label(&default, &self.accounts);
903        self.fetch_auth_path_probing(label, active.as_deref(), Path::exists)
904    }
905
906    /// The pure core of [`fetch_auth_path`](OpenAiConfig::fetch_auth_path),
907    /// with the active label and the file probe injected. The own file is
908    /// probed rather than assumed gone: an account signed in again under its
909    /// own `CODEX_HOME` keeps reading that file.
910    pub fn fetch_auth_path_probing(
911        &self,
912        label: &str,
913        active: Option<&str>,
914        exists: impl Fn(&Path) -> bool,
915    ) -> Result<PathBuf> {
916        let own = self.resolve_auth_path(Some(label))?;
917        if active == Some(label) && !exists(&own) {
918            return self.resolve_auth_path(None);
919        }
920        Ok(own)
921    }
922}
923
924impl Default for OpenAiConfig {
925    fn default() -> Self {
926        Self {
927            enabled: true,
928            codex_auth_path: None,
929            accounts: Vec::new(),
930            show_default_account: true,
931            admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
932        }
933    }
934}
935
936/// GitHub Copilot quota from the private endpoint used by VS Code. The token
937/// comes from an explicit environment override or the official GitHub CLI;
938/// this app never reads, copies, or writes GitHub credential stores.
939#[derive(Debug, Clone, Default, Deserialize, Serialize)]
940#[serde(default)]
941pub struct CopilotConfig {
942    pub enabled: bool,
943    /// Path to the official GitHub CLI. Unset looks `gh` up on `PATH`, which
944    /// is how `gh` is normally installed; set it to pin the executable.
945    pub gh_binary: Option<PathBuf>,
946}
947
948impl CopilotConfig {
949    pub fn resolve_token(&self) -> Result<String> {
950        self.resolve_token_with(
951            |name| std::env::var_os(name),
952            &crate::copilot::credentials::SystemGhAuthTokenRunner,
953        )
954    }
955
956    fn resolve_token_with(
957        &self,
958        environment: impl Fn(&str) -> Option<std::ffi::OsString>,
959        runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
960    ) -> Result<String> {
961        if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
962            let token = value.into_string().map_err(|_| {
963                AppError::Credentials(
964                    "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
965                )
966            })?;
967            if !token.is_empty() {
968                return Ok(token);
969            }
970        }
971        crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref())
972    }
973}
974
975#[derive(Debug, Clone, Default, Deserialize, Serialize)]
976#[serde(default)]
977pub struct NousConfig {
978    pub enabled: bool,
979}
980
981#[derive(Debug, Clone, Deserialize, Serialize)]
982#[serde(default)]
983pub struct OpenCodeGoConfig {
984    pub enabled: bool,
985    pub api_key_env: String,
986    pub api_key: Option<String>,
987}
988
989/// Command Code reads the OAuth credential from the official CLI or pi, so it
990/// has no API key of its own. `auth_paths` overrides that search list for a
991/// non-standard install. It is enabled by default, like OpenAI/Codex; when no
992/// local credential exists the TUI reports that tab as unavailable instead of
993/// silently hiding the provider.
994#[derive(Debug, Clone, Deserialize, Serialize)]
995#[serde(default)]
996pub struct CommandCodeConfig {
997    pub enabled: bool,
998    pub auth_paths: Option<Vec<PathBuf>>,
999}
1000
1001impl Default for CommandCodeConfig {
1002    fn default() -> Self {
1003        Self {
1004            enabled: true,
1005            auth_paths: None,
1006        }
1007    }
1008}
1009
1010/// Ollama Cloud (`ollama.com/api/usage`). Disabled by default: the local
1011/// `ollama` daemon is the product most users reach for, and it has no quota
1012/// route to query. Cloud quota is opt-in, with the key taken from
1013/// `OLLAMA_API_KEY` (or `api_key` as a fallback for `chmod 600` configs).
1014#[derive(Debug, Clone, Deserialize, Serialize)]
1015#[serde(default)]
1016pub struct OllamaConfig {
1017    pub enabled: bool,
1018    pub api_key_env: String,
1019    pub api_key: Option<String>,
1020    /// Display label for the plan row. The API itself does not report a plan
1021    /// name; "pro" is what an Ollama Cloud Pro account shows in the UI.
1022    pub plan: String,
1023}
1024
1025impl Default for OllamaConfig {
1026    fn default() -> Self {
1027        Self {
1028            enabled: false,
1029            api_key_env: "OLLAMA_API_KEY".to_string(),
1030            api_key: None,
1031            plan: "pro".to_string(),
1032        }
1033    }
1034}
1035
1036/// OrcaRouter (`api.orcarouter.ai/v1/dashboard/billing/*`, one-api
1037/// compatible). Opt-in like DeepSeek/Kilo: needs an explicit API key.
1038#[derive(Debug, Clone, Deserialize, Serialize)]
1039#[serde(default)]
1040pub struct OrcaRouterConfig {
1041    pub enabled: bool,
1042    pub api_key_env: String,
1043    pub api_key: Option<String>,
1044}
1045
1046impl Default for OrcaRouterConfig {
1047    fn default() -> Self {
1048        Self {
1049            enabled: false,
1050            api_key_env: "ORCAROUTER_API_KEY".to_string(),
1051            api_key: None,
1052        }
1053    }
1054}
1055
1056/// Alibaba Cloud Model Studio (Token Plan) — a local-login vendor, like
1057/// Grok Bot: the credential is the official `bl` CLI's own console-login file
1058/// (`~/.bailian/config.json`, read-only; AK/SK refresh is out of scope).
1059/// No API key exists, so there is no `api_key_env`. The `BAILIAN_CONFIG_DIR`
1060/// environment variable overrides the directory at runtime; `config_dir`
1061/// here overrides it in config, and wins.
1062#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1063#[serde(default)]
1064pub struct ModelStudioConfig {
1065    /// Opt-in (defaults to `false`), like every vendor riding a local CLI's
1066    /// session.
1067    pub enabled: bool,
1068    /// Override for the `bl` CLI's config directory (default `~/.bailian`),
1069    /// mirroring `[grokbot] secrets_path`.
1070    pub config_dir: Option<PathBuf>,
1071}
1072
1073impl Default for OpenCodeGoConfig {
1074    fn default() -> Self {
1075        Self {
1076            enabled: false,
1077            api_key_env: "OPENCODE_GO_API_KEY".to_string(),
1078            api_key: None,
1079        }
1080    }
1081}
1082
1083#[derive(Debug, Clone, Deserialize, Serialize)]
1084#[serde(default)]
1085pub struct ZaiConfig {
1086    pub enabled: bool,
1087    /// Env var name to read the key from (env wins over `api_key`).
1088    pub api_key_env: String,
1089    /// Inline key (fallback when the env var is unset). Chmod 600 your
1090    /// config file if you put a real key here.
1091    pub api_key: Option<String>,
1092    /// Optional plan tier label (lite/pro/max) — display-only.
1093    pub plan_tier: Option<String>,
1094}
1095
1096impl Default for ZaiConfig {
1097    fn default() -> Self {
1098        Self {
1099            enabled: true,
1100            api_key_env: "ZAI_API_KEY".to_string(),
1101            api_key: None,
1102            plan_tier: None,
1103        }
1104    }
1105}
1106
1107#[derive(Debug, Clone, Deserialize, Serialize)]
1108#[serde(default)]
1109pub struct OpenRouterConfig {
1110    pub enabled: bool,
1111    /// Extra OpenRouter accounts beyond the default key. Each account gets a
1112    /// separate aggregate-view entry and cache directory.
1113    pub accounts: Vec<OpenRouterAccount>,
1114    /// Whether aggregate views include the default (unnamed) key when named
1115    /// accounts exist. Ignored when `accounts` is empty so OpenRouter never
1116    /// loses its only tab.
1117    pub show_default_account: bool,
1118    pub api_key_env: String,
1119    pub api_key: Option<String>,
1120    /// Which number goes on the bar. OpenRouter states its own denominator —
1121    /// credits purchased — so it is a quota vendor and defaults to `percent`.
1122    /// See [`DisplayPrefs`].
1123    ///
1124    /// There is deliberately **no** `display_limit` here. A setting that is
1125    /// accepted and then always ignored is a footgun, and the one case where it
1126    /// would not be ignored — a free-tier account whose `total_credits` is 0 —
1127    /// is the case where honouring it would be wrong: the percentage on the bar
1128    /// comes from `OpenRouterSnapshot::consumed_pct`, which is 0 without
1129    /// credits, so a tank would name the headline `percent` and then show 0%
1130    /// for an account with money in it.
1131    pub headline: Headline,
1132}
1133
1134impl Default for OpenRouterConfig {
1135    fn default() -> Self {
1136        Self {
1137            enabled: true,
1138            accounts: Vec::new(),
1139            show_default_account: true,
1140            api_key_env: "OPENROUTER_API_KEY".to_string(),
1141            api_key: None,
1142            headline: Headline::Percent,
1143        }
1144    }
1145}
1146
1147/// One named OpenRouter account. The default account continues to use the
1148/// singular `api_key_env` / `api_key` fields under `[openrouter]`.
1149#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1150pub struct OpenRouterAccount {
1151    /// Stable CLI/report label and account-scoped cache subdirectory.
1152    pub label: String,
1153    /// Optional environment variable containing this account's key.
1154    #[serde(default)]
1155    pub api_key_env: Option<String>,
1156    /// Inline fallback when the account environment variable is unset.
1157    #[serde(default)]
1158    pub api_key: Option<String>,
1159}
1160
1161impl OpenRouterConfig {
1162    /// Find a named account or fail loudly instead of falling back to the
1163    /// default key (which would show the wrong account's usage).
1164    pub fn account(&self, label: &str) -> Result<&OpenRouterAccount> {
1165        validate_account_label_for("openrouter", label)?;
1166        self.accounts
1167            .iter()
1168            .find(|account| account.label == label)
1169            .ok_or_else(|| {
1170                let known: Vec<&str> = self
1171                    .accounts
1172                    .iter()
1173                    .map(|account| account.label.as_str())
1174                    .collect();
1175                AppError::Credentials(format!(
1176                    "openrouter account {label:?} not found in [[openrouter.accounts]]; \
1177                     known labels: {known:?}"
1178                ))
1179            })
1180    }
1181
1182    /// Resolve either the backward-compatible default key or one named
1183    /// account. Configured values are never included in an error message.
1184    pub fn resolve_api_key(&self, label: Option<&str>) -> Result<String> {
1185        match label {
1186            None => resolve_api_key("OpenRouter", &self.api_key_env, self.api_key.as_deref()),
1187            Some(label) => {
1188                let account = self.account(label)?;
1189                resolve_api_key_in_section(
1190                    &format!("OpenRouter account {label:?}"),
1191                    "[[openrouter.accounts]]",
1192                    account.api_key_env.as_deref().unwrap_or(""),
1193                    account.api_key.as_deref(),
1194                )
1195            }
1196        }
1197    }
1198}
1199
1200#[derive(Debug, Clone, Deserialize, Serialize)]
1201#[serde(default)]
1202pub struct DeepseekConfig {
1203    pub enabled: bool,
1204    pub api_key_env: String,
1205    pub api_key: Option<String>,
1206    /// Tank size in the currency `/user/balance` reports, so the remaining
1207    /// balance can be drawn as a meter. See [`DisplayPrefs`].
1208    pub display_limit: Option<f64>,
1209    /// Which number goes on the bar. See [`DisplayPrefs`].
1210    pub headline: Headline,
1211}
1212
1213impl Default for DeepseekConfig {
1214    fn default() -> Self {
1215        Self {
1216            enabled: false,
1217            api_key_env: "DEEPSEEK_API_KEY".to_string(),
1218            api_key: None,
1219            display_limit: None,
1220            headline: Headline::Amount,
1221        }
1222    }
1223}
1224
1225#[derive(Debug, Clone, Deserialize, Serialize)]
1226#[serde(default)]
1227pub struct KimiConfig {
1228    pub enabled: bool,
1229    pub api_key_env: String,
1230    /// Optional: with no key set, the vendor falls back to the Kimi Code CLI's
1231    /// own OAuth login, which is what a subscriber already has locally.
1232    pub api_key: Option<String>,
1233    /// Override for kimi-code's credential file (default
1234    /// `~/.kimi-code/credentials/kimi-code.json`), mirroring `[cursor] db_path`
1235    /// and `[kiro] db_path`. Useful with a relocated `KIMI_CODE_HOME`.
1236    pub credentials_path: Option<PathBuf>,
1237    /// `"auto"` follows kimi-code's own install marker (`~/.kimi-code/region`);
1238    /// `"cn"` pins `api.kimi.com` / `auth.kimi.com`, `"global"` pins
1239    /// `api.kimi.ai` / `auth.kimi.ai`. A token minted by one deployment means
1240    /// nothing to the other, so this picks the instance, not a currency.
1241    pub region: String,
1242}
1243
1244impl Default for KimiConfig {
1245    fn default() -> Self {
1246        Self {
1247            enabled: false,
1248            api_key_env: "KIMI_API_KEY".to_string(),
1249            api_key: None,
1250            credentials_path: None,
1251            region: "auto".to_string(),
1252        }
1253    }
1254}
1255
1256#[derive(Debug, Clone, Deserialize, Serialize)]
1257#[serde(default)]
1258pub struct KiloConfig {
1259    pub enabled: bool,
1260    pub api_key_env: String,
1261    pub api_key: Option<String>,
1262    /// Optional Kilo organization id — scopes the balance to a team via the
1263    /// `x-kilocode-organizationid` header. Omit for the personal balance.
1264    pub organization_id: Option<String>,
1265    /// Tank size in USD, so the remaining balance can be drawn as a meter.
1266    /// See [`DisplayPrefs`].
1267    pub display_limit: Option<f64>,
1268    /// Which number goes on the bar. See [`DisplayPrefs`].
1269    pub headline: Headline,
1270}
1271
1272impl Default for KiloConfig {
1273    fn default() -> Self {
1274        // Opt-in like DeepSeek: requires an explicit API key, so it defaults to
1275        // disabled and never affects existing installs.
1276        Self {
1277            enabled: false,
1278            api_key_env: "KILO_API_KEY".to_string(),
1279            api_key: None,
1280            organization_id: None,
1281            display_limit: None,
1282            headline: Headline::Amount,
1283        }
1284    }
1285}
1286
1287#[derive(Debug, Clone, Deserialize, Serialize)]
1288#[serde(default)]
1289pub struct NovitaConfig {
1290    pub enabled: bool,
1291    pub api_key_env: String,
1292    pub api_key: Option<String>,
1293    /// Tank size in USD, so the available balance can be drawn as a meter.
1294    /// Novita's `credit_limit` is a credit line, not a spend cap, so it is not
1295    /// a denominator. See [`DisplayPrefs`].
1296    pub display_limit: Option<f64>,
1297    /// Which number goes on the bar. See [`DisplayPrefs`].
1298    pub headline: Headline,
1299}
1300
1301impl Default for NovitaConfig {
1302    fn default() -> Self {
1303        // Opt-in like DeepSeek/Kilo: needs an explicit API key.
1304        Self {
1305            enabled: false,
1306            api_key_env: "NOVITA_API_KEY".to_string(),
1307            api_key: None,
1308            display_limit: None,
1309            headline: Headline::Amount,
1310        }
1311    }
1312}
1313
1314#[derive(Debug, Clone, Deserialize, Serialize)]
1315#[serde(default)]
1316pub struct MinimaxConfig {
1317    pub enabled: bool,
1318    pub api_key_env: String,
1319    pub api_key: Option<String>,
1320    /// `"global"` → api.minimax.io; `"cn"` → api.minimaxi.com. Unlike
1321    /// Moonshot's, this does not change the unit — MiniMax reports quota as a
1322    /// percentage either way. It picks the *instance*: a key issued for one
1323    /// host is rejected by the other (`status_code 2049`), so pointing this at
1324    /// the wrong region reads as an invalid key rather than an empty plan.
1325    pub region: String,
1326}
1327
1328impl Default for MinimaxConfig {
1329    fn default() -> Self {
1330        // Opt-in like the other API-key vendors: needs an explicit key.
1331        Self {
1332            enabled: false,
1333            api_key_env: "MINIMAX_API_KEY".to_string(),
1334            api_key: None,
1335            region: "global".to_string(),
1336        }
1337    }
1338}
1339
1340#[derive(Debug, Clone, Deserialize, Serialize)]
1341#[serde(default)]
1342pub struct MoonshotConfig {
1343    pub enabled: bool,
1344    pub api_key_env: String,
1345    pub api_key: Option<String>,
1346    /// `"global"` → api.moonshot.ai (USD); `"cn"` → api.moonshot.cn (CNY).
1347    pub region: String,
1348    /// Tank size in the currency the chosen region reports — USD for `global`,
1349    /// CNY for `cn`. See [`DisplayPrefs`].
1350    pub display_limit: Option<f64>,
1351    /// Which number goes on the bar. See [`DisplayPrefs`].
1352    pub headline: Headline,
1353}
1354
1355impl Default for MoonshotConfig {
1356    fn default() -> Self {
1357        // Opt-in like DeepSeek/Kilo/Novita: needs an explicit API key.
1358        Self {
1359            enabled: false,
1360            api_key_env: "MOONSHOT_API_KEY".to_string(),
1361            api_key: None,
1362            region: "global".to_string(),
1363            display_limit: None,
1364            headline: Headline::Amount,
1365        }
1366    }
1367}
1368
1369#[derive(Debug, Clone, Deserialize, Serialize)]
1370#[serde(default)]
1371pub struct GrokConfig {
1372    pub enabled: bool,
1373    /// Env var for the xAI **Management** key (distinct from the inference key).
1374    pub api_key_env: String,
1375    pub api_key: Option<String>,
1376    /// Optional team id. When absent, it's auto-resolved from the management
1377    /// key via `/auth/management-keys/validation`.
1378    pub team_id: Option<String>,
1379    /// Tank size in USD for the prepaid credit balance. See [`DisplayPrefs`].
1380    pub display_limit: Option<f64>,
1381    /// Which number goes on the bar. See [`DisplayPrefs`].
1382    pub headline: Headline,
1383}
1384
1385impl Default for GrokConfig {
1386    fn default() -> Self {
1387        // Opt-in: needs a management key (and, for prepaid, a team).
1388        Self {
1389            enabled: false,
1390            api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1391            api_key: None,
1392            team_id: None,
1393            display_limit: None,
1394            headline: Headline::Amount,
1395        }
1396    }
1397}
1398
1399/// SuperGrok subscription auth — no API key of its own. Billing and banked
1400/// resets use the `key` already in Grok Build's `auth.json` (read-only).
1401/// Login, issuer, proxy, and token rotation stay inside Grok Build.
1402///
1403/// Opt-in like Cursor/Kiro (`enabled` defaults to `false`): it requires a
1404/// separate official executable and signed-in session, so it stays off until
1405/// the user explicitly turns it on.
1406#[derive(Debug, Clone, Deserialize, Serialize)]
1407#[serde(default)]
1408pub struct SuperGrokConfig {
1409    pub enabled: bool,
1410    /// Trusted official Grok Build executable. Defaults to its canonical
1411    /// `$GROK_HOME/bin/grok` (or `~/.grok/bin/grok`) installation path instead
1412    /// of searching PATH, where unrelated programs can share the name.
1413    pub grok_binary: PathBuf,
1414    /// Opaque auth/config files used only to fingerprint the active cache
1415    /// scope. Their contents are never parsed or copied to the cache.
1416    pub auth_path: Option<PathBuf>,
1417    pub config_path: Option<PathBuf>,
1418}
1419
1420impl Default for SuperGrokConfig {
1421    fn default() -> Self {
1422        Self {
1423            enabled: false,
1424            grok_binary: default_grok_binary(),
1425            auth_path: None,
1426            config_path: None,
1427        }
1428    }
1429}
1430
1431/// Grok Bot — the desktop app's weekly included-usage pool, from its own
1432/// Connect-RPC dashboard call. Distinct from `[grok]` (Management API prepaid
1433/// dollars) and `[supergrok]` (Grok Build subscription). No API key: the
1434/// credential is the app's own session in `sand-secrets.json` (read-only).
1435/// Linux and macOS; Windows fails closed at fetch time.
1436#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1437#[serde(default)]
1438pub struct GrokbotConfig {
1439    /// Opt-in (defaults to `false`), like every vendor riding a local app's
1440    /// session.
1441    pub enabled: bool,
1442    /// Override for the app's credential file (default
1443    /// `~/.config/Grok Bot/sand-secrets.json` on Linux,
1444    /// `~/Library/Application Support/Grok Bot/sand-secrets.json` on macOS),
1445    /// mirroring `[cursor] db_path` and `[kimi] credentials_path`.
1446    pub secrets_path: Option<PathBuf>,
1447}
1448
1449fn default_grok_binary() -> PathBuf {
1450    let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1451    let grok_home = std::env::var_os("GROK_HOME")
1452        .filter(|value| !value.is_empty())
1453        .map(PathBuf::from)
1454        .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1455    grok_home
1456        .map(|home| home.join("bin").join(executable))
1457        .unwrap_or_else(|| PathBuf::from(executable))
1458}
1459
1460/// Antigravity reads its quota from a usable local Antigravity product. When no
1461/// product is up — or `agy` requires the CSRF token it does not publish — it
1462/// falls back to the Google session Antigravity saved in the OS keyring and
1463/// talks to Cloud Code directly. Renewing that session needs Antigravity's
1464/// OAuth client id and secret, which are not shipped in source: set them here
1465/// (they are public installed-app credentials) or the fallback only lasts as
1466/// long as the saved access token does.
1467#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1468#[serde(default)]
1469pub struct AntigravityConfig {
1470    pub enabled: bool,
1471    /// OAuth client id used to refresh the keyring session.
1472    pub oauth_client_id: Option<String>,
1473    /// OAuth client secret paired with `oauth_client_id`. An
1474    /// installed-app secret is not confidential by Google's definition, but
1475    /// it is still treated as an inline credential for file-permission purposes.
1476    pub oauth_client_secret: Option<String>,
1477}
1478
1479/// Cursor reads its quota through a session token the Cursor IDE already
1480/// wrote to its local `state.vscdb` — no API key, but (unlike Antigravity)
1481/// there is a real on-disk path that can need overriding (e.g. a portable or
1482/// non-default Cursor install), mirroring `openai.codex_auth_path`.
1483///
1484/// Opt-in like DeepSeek/Kilo/etc (`enabled` defaults to `false`, matching
1485/// `bool::default()`): reads an undocumented endpoint via a session token
1486/// scraped from a local IDE file, so it stays off until the user explicitly
1487/// turns it on.
1488#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1489#[serde(default)]
1490pub struct CursorConfig {
1491    pub enabled: bool,
1492    /// Override Cursor's local state database path (defaults to the
1493    /// platform-standard `.../User/globalStorage/state.vscdb` — see
1494    /// `cursor::db::default_db_path`).
1495    pub db_path: Option<PathBuf>,
1496    /// Override the headless `cursor-agent` CLI's own login file (defaults to
1497    /// `.../cursor/auth.json` — see `cursor::db::default_agent_auth_path`).
1498    /// Used as a fallback when `db_path` doesn't exist, so a text-only
1499    /// machine that never runs the desktop IDE still gets usage.
1500    pub agent_auth_path: Option<PathBuf>,
1501}
1502
1503/// Kiro CLI reads its quota through the AWS SSO OIDC session kiro-cli already
1504/// wrote to its own local `data.sqlite3` — no API key, but (like Cursor) a
1505/// real on-disk path that can need overriding.
1506///
1507/// Opt-in like Cursor/DeepSeek/Kilo/etc (`enabled` defaults to `false`):
1508/// calls a reverse-engineered CodeWhisperer endpoint via a session token
1509/// scraped from a local CLI database, so it stays off until the user
1510/// explicitly turns it on.
1511#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1512#[serde(default)]
1513pub struct KiroConfig {
1514    pub enabled: bool,
1515    /// Override kiro-cli's local database path (defaults to the
1516    /// platform-standard `.../kiro-cli/data.sqlite3` — see
1517    /// `kiro::db::default_db_path`).
1518    pub db_path: Option<PathBuf>,
1519}
1520
1521#[derive(Debug, Clone, Deserialize, Serialize)]
1522#[serde(default)]
1523pub struct AnthropicApiConfig {
1524    pub enabled: bool,
1525    /// Env var for the Console **Admin key** (`sk-ant-admin01-…`), distinct from
1526    /// an inference key and from the Claude Code OAuth login.
1527    pub api_key_env: String,
1528    pub api_key: Option<String>,
1529    /// Monthly USD spend limit, used only for the spend-vs-limit % display. The
1530    /// API exposes neither this limit nor the remaining prepaid balance.
1531    pub monthly_limit: Option<f64>,
1532}
1533
1534impl Default for AnthropicApiConfig {
1535    fn default() -> Self {
1536        // Opt-in: needs an explicit Admin key.
1537        Self {
1538            enabled: false,
1539            api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
1540            api_key: None,
1541            monthly_limit: None,
1542        }
1543    }
1544}
1545
1546/// A user-defined HTTP provider: one GET with a static token, projected onto
1547/// the shared report shape through RFC 6901 JSON Pointers.
1548///
1549/// Everything a built-in vendor hard-codes is a field here, which is why this
1550/// type validates so much more than the others: a typo in `[deepseek]` hits a
1551/// fixed endpoint and fails loudly, while a typo here quietly sends the user's
1552/// key to the wrong host. The `id` doubles as the cache directory name and the
1553/// `--vendor` selector, so it is held to the character class of the built-in
1554/// slugs.
1555#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1556#[serde(default, remote = "Self")]
1557pub struct CustomProviderConfig {
1558    /// `[a-z0-9][a-z0-9_-]{0,31}`; unique, and never a built-in vendor's slug.
1559    pub id: String,
1560    /// Display name, 1 to 48 characters. Defaults to `id`.
1561    pub name: String,
1562    /// Exactly three lowercase ASCII letters, unique across built-in vendors
1563    /// and other custom providers — it is the `{vendor_short}` bar tag.
1564    pub short_name: String,
1565    /// A built-in vendor slug whose mark supporting frontends may use.
1566    /// `None` preserves the custom provider's `short_name` tag.
1567    pub brand: Option<String>,
1568    pub enabled: bool,
1569    /// `https://` unless `allow_http`; never carries `user:pass@`.
1570    pub url: String,
1571    pub allow_http: bool,
1572    /// Env var read first; `""` means the inline `api_key` is the only source.
1573    pub api_key_env: String,
1574    pub api_key: Option<String>,
1575    /// The header that carries the key.
1576    pub auth_header: String,
1577    /// Sent as `"<scheme> <key>"`; `""` sends the bare key.
1578    pub auth_scheme: String,
1579    /// Extra non-secret headers.
1580    pub headers: BTreeMap<String, String>,
1581    /// Literal plan label.
1582    pub plan: Option<String>,
1583    /// Pointer to the plan label in the response; wins over `plan`.
1584    pub plan_path: Option<String>,
1585    /// Must be within `10..=3600`.
1586    pub cache_ttl_secs: u64,
1587    pub metrics: Vec<CustomMetricSpec>,
1588    pub texts: Vec<CustomTextSpec>,
1589}
1590
1591impl Default for CustomProviderConfig {
1592    fn default() -> Self {
1593        Self {
1594            id: String::new(),
1595            name: String::new(),
1596            short_name: String::new(),
1597            brand: None,
1598            enabled: false,
1599            url: String::new(),
1600            allow_http: false,
1601            api_key_env: String::new(),
1602            api_key: None,
1603            auth_header: "Authorization".to_string(),
1604            auth_scheme: "Bearer".to_string(),
1605            headers: BTreeMap::new(),
1606            plan: None,
1607            plan_path: None,
1608            cache_ttl_secs: 60,
1609            metrics: Vec::new(),
1610            texts: Vec::new(),
1611        }
1612    }
1613}
1614
1615/// `name` defaults to `id`, which a per-field serde default cannot express (a
1616/// default sees no sibling field). The derive is routed through
1617/// `remote = "Self"` so the fill-in happens here, on every parse path, rather
1618/// than only in `Config::load_from`.
1619impl<'de> Deserialize<'de> for CustomProviderConfig {
1620    fn deserialize<D: serde::Deserializer<'de>>(
1621        deserializer: D,
1622    ) -> std::result::Result<Self, D::Error> {
1623        let mut this = Self::deserialize(deserializer)?;
1624        if this.name.is_empty() {
1625            this.name = this.id.clone();
1626        }
1627        Ok(this)
1628    }
1629}
1630
1631impl Serialize for CustomProviderConfig {
1632    fn serialize<S: serde::Serializer>(
1633        &self,
1634        serializer: S,
1635    ) -> std::result::Result<S::Ok, S::Error> {
1636        Self::serialize(self, serializer)
1637    }
1638}
1639
1640/// One percentage row. Either `percent` alone, or `used` and `limit`
1641/// together — never a mix, so a row cannot show a percentage from one field
1642/// and a footnote from another.
1643#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1644#[serde(default)]
1645pub struct CustomMetricSpec {
1646    pub label: String,
1647    pub used: Option<String>,
1648    pub limit: Option<String>,
1649    pub percent: Option<String>,
1650    /// Pointer to an RFC 3339 string or a Unix epoch (seconds or milliseconds).
1651    pub resets_at: Option<String>,
1652    /// Window length for pacing, at least 60.
1653    pub window_secs: Option<u64>,
1654}
1655
1656/// One free-text row: a string, number, or boolean at `value`.
1657#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1658#[serde(default)]
1659pub struct CustomTextSpec {
1660    pub label: String,
1661    pub value: String,
1662}
1663
1664impl CustomProviderConfig {
1665    /// The TOML locator for error messages: `[[custom]] id = "mytool"`.
1666    pub fn section_label(&self) -> String {
1667        format!("[[custom]] id = {:?}", self.id)
1668    }
1669
1670    /// Env var (when `api_key_env` is set) → inline `api_key` → a
1671    /// `Credentials` error that names the section and never the key.
1672    pub fn resolve_api_key(&self) -> Result<String> {
1673        if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
1674            return Ok(key);
1675        }
1676        let advice = if self.api_key_env.is_empty() {
1677            "set `api_key`, or name an environment variable in `api_key_env`".to_string()
1678        } else {
1679            format!("export {} or set `api_key`", self.api_key_env)
1680        };
1681        Err(AppError::Credentials(format!(
1682            "custom {}: no API key. Either {advice} under {} in {}.",
1683            self.id,
1684            self.section_label(),
1685            config_path_hint()
1686        )))
1687    }
1688
1689    pub fn cache_ttl(&self) -> std::time::Duration {
1690        std::time::Duration::from_secs(self.cache_ttl_secs)
1691    }
1692
1693    /// Every rule that serde cannot express, each naming the section. Runs
1694    /// for disabled entries too: a broken entry is a broken config, and the
1695    /// day it is enabled is the wrong day to find out.
1696    fn validate(&self, index: usize) -> Result<()> {
1697        if !is_valid_custom_id(&self.id) {
1698            return Err(AppError::Other(format!(
1699                "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
1700                index + 1,
1701                self.id
1702            )));
1703        }
1704        let section = self.section_label();
1705        let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
1706
1707        if VendorId::all().iter().any(|v| v.slug() == self.id) {
1708            return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
1709        }
1710        let name_len = self.name.chars().count();
1711        if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
1712            return Err(bad(
1713                "name must be 1 to 48 characters without control characters".into(),
1714            ));
1715        }
1716        if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
1717            return Err(bad(format!(
1718                "short_name {:?} must be exactly 3 lowercase ASCII letters",
1719                self.short_name
1720            )));
1721        }
1722        if let Some(brand) = &self.brand
1723            && !VendorId::all().iter().any(|v| v.slug() == brand)
1724        {
1725            return Err(bad(format!(
1726                "brand {brand:?} must name a built-in vendor (it borrows that \
1727                 vendor's mark); leave it unset to keep the short_name tag"
1728            )));
1729        }
1730        let url = reqwest::Url::parse(&self.url)
1731            .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
1732        match url.scheme() {
1733            "https" => {}
1734            "http" if self.allow_http => {}
1735            "http" => {
1736                return Err(bad(
1737                    "url must use https:// (set allow_http = true to permit http://)".into(),
1738                ));
1739            }
1740            other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
1741        }
1742        if !url.username().is_empty() || url.password().is_some() {
1743            return Err(bad("url must not carry credentials (user:pass@)".into()));
1744        }
1745        if url.host_str().is_none() {
1746            return Err(bad("url has no host".into()));
1747        }
1748        if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
1749            return Err(bad(format!(
1750                "api_key_env {:?} is not a valid environment variable name",
1751                self.api_key_env
1752            )));
1753        }
1754        validate_header_name(&section, "auth_header", &self.auth_header)?;
1755        if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
1756            return Err(bad(
1757                "auth_scheme contains characters that are not valid in an HTTP header".into(),
1758            ));
1759        }
1760        for (name, value) in &self.headers {
1761            validate_header_name(&section, "headers", name)?;
1762            if name.eq_ignore_ascii_case(&self.auth_header) {
1763                return Err(bad(format!(
1764                    "headers must not repeat auth_header {:?}",
1765                    self.auth_header
1766                )));
1767            }
1768            if reqwest::header::HeaderValue::from_str(value).is_err() {
1769                return Err(bad(format!(
1770                    "header {name:?} has a value that is not valid in an HTTP header"
1771                )));
1772            }
1773        }
1774        if let Some(plan) = &self.plan {
1775            validate_custom_label(&section, "plan", plan)?;
1776        }
1777        if let Some(pointer) = &self.plan_path {
1778            validate_pointer(&section, "plan_path", pointer)?;
1779        }
1780        if !(10..=3600).contains(&self.cache_ttl_secs) {
1781            return Err(bad(format!(
1782                "cache_ttl_secs must be between 10 and 3600, got {}",
1783                self.cache_ttl_secs
1784            )));
1785        }
1786        if self.metrics.is_empty() && self.texts.is_empty() {
1787            return Err(bad(
1788                "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
1789            ));
1790        }
1791        let mut metric_labels = HashSet::new();
1792        for metric in &self.metrics {
1793            validate_custom_label(&section, "metric label", &metric.label)?;
1794            if !metric_labels.insert(metric.label.as_str()) {
1795                return Err(bad(format!("duplicate metric label {:?}", metric.label)));
1796            }
1797            let pair = (metric.used.is_some(), metric.limit.is_some());
1798            let well_formed = if metric.percent.is_some() {
1799                pair == (false, false)
1800            } else {
1801                pair == (true, true)
1802            };
1803            if !well_formed {
1804                return Err(bad(format!(
1805                    "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
1806                    metric.label
1807                )));
1808            }
1809            for (field, pointer) in [
1810                ("used", &metric.used),
1811                ("limit", &metric.limit),
1812                ("percent", &metric.percent),
1813                ("resets_at", &metric.resets_at),
1814            ] {
1815                if let Some(pointer) = pointer {
1816                    validate_pointer(&section, field, pointer)?;
1817                }
1818            }
1819            if let Some(secs) = metric.window_secs
1820                && secs < 60
1821            {
1822                return Err(bad(format!(
1823                    "metric {:?} window_secs must be at least 60, got {secs}",
1824                    metric.label
1825                )));
1826            }
1827        }
1828        let mut text_labels = HashSet::new();
1829        for text in &self.texts {
1830            validate_custom_label(&section, "text label", &text.label)?;
1831            if !text_labels.insert(text.label.as_str()) {
1832                return Err(bad(format!("duplicate text label {:?}", text.label)));
1833            }
1834            validate_pointer(&section, "value", &text.value)?;
1835        }
1836        Ok(())
1837    }
1838}
1839
1840fn is_valid_custom_id(id: &str) -> bool {
1841    let bytes = id.as_bytes();
1842    let Some(&first) = bytes.first() else {
1843        return false;
1844    };
1845    bytes.len() <= 32
1846        && (first.is_ascii_lowercase() || first.is_ascii_digit())
1847        && bytes
1848            .iter()
1849            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
1850}
1851
1852fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
1853    if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
1854        return Err(AppError::Other(format!(
1855            "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
1856        )));
1857    }
1858    Ok(())
1859}
1860
1861fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
1862    let len = label.chars().count();
1863    if len == 0 || len > 64 || label.chars().any(char::is_control) {
1864        return Err(AppError::Other(format!(
1865            "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
1866        )));
1867    }
1868    Ok(())
1869}
1870
1871fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
1872    if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
1873        return Err(AppError::Other(format!(
1874            "{section}: {field} {name:?} is not a valid HTTP header name"
1875        )));
1876    }
1877    Ok(())
1878}
1879
1880/// Resolve an API key for a vendor: a valid env-var name wins, then inline
1881/// config, then a clear error naming both fields. Used by every API-key vendor.
1882pub fn resolve_api_key(
1883    vendor_label: &str,
1884    env_var_name: &str,
1885    inline: Option<&str>,
1886) -> crate::error::Result<String> {
1887    let section = match vendor_label {
1888        "OpenCode Go" => "[opencode-go]".to_string(),
1889        _ => format!("[{}]", vendor_label.to_lowercase()),
1890    };
1891    resolve_api_key_in_section(vendor_label, &section, env_var_name, inline)
1892}
1893
1894/// The env-then-inline lookup without the "or fail" ending, for vendors where
1895/// an absent API key is a legitimate state rather than an error — Kimi accepts
1896/// a Kimi Code CLI subscription login instead.
1897pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
1898    if is_valid_env_var_name(env_var_name)
1899        && let Ok(v) = std::env::var(env_var_name)
1900        && !v.is_empty()
1901    {
1902        return Some(v);
1903    }
1904    inline.filter(|v| !v.is_empty()).map(str::to_string)
1905}
1906
1907fn resolve_api_key_in_section(
1908    vendor_label: &str,
1909    section: &str,
1910    env_var_name: &str,
1911    inline: Option<&str>,
1912) -> crate::error::Result<String> {
1913    if let Some(key) = optional_api_key(env_var_name, inline) {
1914        return Ok(key);
1915    }
1916    let valid_env_name = is_valid_env_var_name(env_var_name);
1917    let advice = if valid_env_name {
1918        "set an API key in a valid environment variable or set `api_key`"
1919    } else {
1920        "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
1921    };
1922    Err(crate::error::AppError::Credentials(format!(
1923        "{vendor_label}: no API key. Either {advice} under {section} in {}.",
1924        config_path_hint()
1925    )))
1926}
1927
1928pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
1929    let mut chars = name.chars();
1930    let Some(first) = chars.next() else {
1931        return false;
1932    };
1933    (first.is_ascii_alphabetic() || first == '_')
1934        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1935}
1936
1937impl Config {
1938    /// Load from `~/.config/ai-usagebar/config.toml`. Returns defaults if the
1939    /// file doesn't exist; errors only on actual parse failures.
1940    pub fn load() -> Result<Self> {
1941        let Some(path) = resolved_path() else {
1942            return Ok(Self::default());
1943        };
1944        Self::load_from(&path)
1945    }
1946
1947    pub fn load_from(path: &std::path::Path) -> Result<Self> {
1948        match std::fs::read_to_string(path) {
1949            Ok(s) => {
1950                let mut config: Self = toml::from_str(&s)?;
1951                // `~` is shell syntax, not path syntax: `PathBuf` keeps it
1952                // literally, so a documented `credentials_path = "~/..."`
1953                // silently pointed at a directory named `~`.
1954                config.expand_paths();
1955                config.validate()?;
1956                #[cfg(unix)]
1957                config.protect_inline_secrets(path)?;
1958                // A custom provider's token variable is as secret as any
1959                // built-in one; subprocesses (`gh`, `grok`, `claude`) must
1960                // not inherit it.
1961                crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
1962                Ok(config)
1963            }
1964            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1965            Err(e) => Err(AppError::io_at(path, e)),
1966        }
1967    }
1968
1969    fn expand_paths(&mut self) {
1970        expand_tilde_opt(&mut self.context.projects_path);
1971        expand_tilde_opt(&mut self.anthropic.credentials_path);
1972        expand_tilde_opt(&mut self.anthropic.accounts_dir);
1973        expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
1974        expand_tilde_opt(&mut self.openai.codex_auth_path);
1975        expand_tilde_opt(&mut self.cursor.db_path);
1976        expand_tilde_opt(&mut self.cursor.agent_auth_path);
1977        expand_tilde_opt(&mut self.kiro.db_path);
1978        expand_tilde_opt(&mut self.kimi.credentials_path);
1979        expand_tilde_opt(&mut self.grokbot.secrets_path);
1980        expand_tilde_opt(&mut self.modelstudio.config_dir);
1981        self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
1982        expand_tilde_opt(&mut self.supergrok.auth_path);
1983        expand_tilde_opt(&mut self.supergrok.config_path);
1984        for account in &mut self.anthropic.accounts {
1985            account.credentials_path = expand_tilde(&account.credentials_path);
1986        }
1987        for account in &mut self.openai.accounts {
1988            account.codex_auth_path = expand_tilde(&account.codex_auth_path);
1989        }
1990    }
1991
1992    /// Explicitly enumerate every inline credential field. Adding a new
1993    /// credential vendor must add it here so its config receives the same
1994    /// protection.
1995    #[cfg(unix)]
1996    fn has_inline_secrets(&self) -> bool {
1997        [
1998            self.zai.api_key.as_deref(),
1999            self.openrouter.api_key.as_deref(),
2000            self.deepseek.api_key.as_deref(),
2001            self.kimi.api_key.as_deref(),
2002            self.kilo.api_key.as_deref(),
2003            self.novita.api_key.as_deref(),
2004            self.minimax.api_key.as_deref(),
2005            self.moonshot.api_key.as_deref(),
2006            self.grok.api_key.as_deref(),
2007            self.anthropic_api.api_key.as_deref(),
2008            self.opencode_go.api_key.as_deref(),
2009            self.orcarouter.api_key.as_deref(),
2010            self.antigravity.oauth_client_secret.as_deref(),
2011        ]
2012        .into_iter()
2013        .chain(
2014            self.openrouter
2015                .accounts
2016                .iter()
2017                .map(|account| account.api_key.as_deref()),
2018        )
2019        .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
2020        .any(|key| key.is_some_and(|key| !key.is_empty()))
2021    }
2022
2023    fn custom_secret_env_vars(&self) -> Vec<String> {
2024        self.custom
2025            .iter()
2026            .filter(|c| !c.api_key_env.is_empty())
2027            .map(|c| c.api_key_env.clone())
2028            .collect()
2029    }
2030
2031    /// The `[[custom]]` providers that are switched on, in config order.
2032    pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
2033        self.custom.iter().filter(|c| c.enabled)
2034    }
2035
2036    /// A `[[custom]]` provider by `id`, enabled or not.
2037    pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
2038        self.custom.iter().find(|c| c.id == id)
2039    }
2040
2041    #[cfg(unix)]
2042    fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
2043        if !self.has_inline_secrets() {
2044            return Ok(());
2045        }
2046
2047        let metadata = std::fs::metadata(path).map_err(|_| {
2048            AppError::Credentials(format!(
2049                "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
2050                path.display()
2051            ))
2052        })?;
2053        if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
2054            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
2055                AppError::Credentials(format!(
2056                    "config at {} contains inline credentials but is group/other-readable and could not be tightened to 0600; fix permissions or move credentials to environment variables",
2057                    path.display()
2058                ))
2059            })?;
2060        }
2061        Ok(())
2062    }
2063
2064    pub fn is_enabled(&self, id: VendorId) -> bool {
2065        match id {
2066            VendorId::Anthropic => self.anthropic.enabled,
2067            VendorId::AnthropicApi => self.anthropic_api.enabled,
2068            VendorId::Openai => self.openai.enabled,
2069            VendorId::Copilot => self.copilot.enabled,
2070            VendorId::Zai => self.zai.enabled,
2071            VendorId::Openrouter => self.openrouter.enabled,
2072            VendorId::Deepseek => self.deepseek.enabled,
2073            VendorId::Kimi => self.kimi.enabled,
2074            VendorId::Kilo => self.kilo.enabled,
2075            VendorId::Novita => self.novita.enabled,
2076            VendorId::Moonshot => self.moonshot.enabled,
2077            VendorId::Grok => self.grok.enabled,
2078            VendorId::Supergrok => self.supergrok.enabled,
2079            VendorId::Grokbot => self.grokbot.enabled,
2080            VendorId::Antigravity => self.antigravity.enabled,
2081            VendorId::Cursor => self.cursor.enabled,
2082            VendorId::Minimax => self.minimax.enabled,
2083            VendorId::Kiro => self.kiro.enabled,
2084            VendorId::NousResearch => self.nous.enabled,
2085            VendorId::OpenCodeGo => self.opencode_go.enabled,
2086            VendorId::CommandCode => self.commandcode.enabled,
2087            VendorId::Ollama => self.ollama.enabled,
2088            VendorId::OrcaRouter => self.orcarouter.enabled,
2089            VendorId::ModelStudio => self.modelstudio.enabled,
2090        }
2091    }
2092
2093    /// The environment variable this provider's API key is read from, honoring
2094    /// a per-vendor `api_key_env` override; `""` for a provider that takes no
2095    /// key. Matching on [`VendorId`] rather than on a section name is
2096    /// deliberate: a new key vendor that nobody adds here fails to compile,
2097    /// where a `_ =>` arm over `&str` sections would silently hand back the
2098    /// wrong default and report the provider as unconfigured for ever.
2099    pub fn api_key_env_for(&self, id: VendorId) -> &str {
2100        match id {
2101            VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
2102            VendorId::Zai => &self.zai.api_key_env,
2103            VendorId::Openrouter => &self.openrouter.api_key_env,
2104            VendorId::Deepseek => &self.deepseek.api_key_env,
2105            VendorId::Kimi => &self.kimi.api_key_env,
2106            VendorId::Kilo => &self.kilo.api_key_env,
2107            VendorId::Novita => &self.novita.api_key_env,
2108            VendorId::Moonshot => &self.moonshot.api_key_env,
2109            VendorId::Grok => &self.grok.api_key_env,
2110            VendorId::Minimax => &self.minimax.api_key_env,
2111            VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
2112            VendorId::Ollama => &self.ollama.api_key_env,
2113            VendorId::OrcaRouter => &self.orcarouter.api_key_env,
2114            // Fixed names: OAuth-first providers whose environment override is
2115            // not user-renameable, and the providers with no key at all.
2116            VendorId::Anthropic
2117            | VendorId::Openai
2118            | VendorId::Copilot
2119            | VendorId::Supergrok
2120            | VendorId::Grokbot
2121            | VendorId::Antigravity
2122            | VendorId::Cursor
2123            | VendorId::Kiro
2124            | VendorId::NousResearch
2125            | VendorId::CommandCode
2126            | VendorId::ModelStudio => id.api_key_env(),
2127        }
2128    }
2129
2130    /// A non-empty inline `api_key` from this provider's config section. An
2131    /// empty string counts as unset, the same way the vendors' own
2132    /// `resolve_api_key` treats it.
2133    pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
2134        let raw = match id {
2135            VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
2136            VendorId::Zai => self.zai.api_key.as_deref(),
2137            VendorId::Openrouter => self.openrouter.api_key.as_deref(),
2138            VendorId::Deepseek => self.deepseek.api_key.as_deref(),
2139            VendorId::Kimi => self.kimi.api_key.as_deref(),
2140            VendorId::Kilo => self.kilo.api_key.as_deref(),
2141            VendorId::Novita => self.novita.api_key.as_deref(),
2142            VendorId::Moonshot => self.moonshot.api_key.as_deref(),
2143            VendorId::Grok => self.grok.api_key.as_deref(),
2144            VendorId::Minimax => self.minimax.api_key.as_deref(),
2145            VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
2146            VendorId::Ollama => self.ollama.api_key.as_deref(),
2147            VendorId::OrcaRouter => self.orcarouter.api_key.as_deref(),
2148            VendorId::Anthropic
2149            | VendorId::Openai
2150            | VendorId::Copilot
2151            | VendorId::Supergrok
2152            | VendorId::Grokbot
2153            | VendorId::Antigravity
2154            | VendorId::Cursor
2155            | VendorId::Kiro
2156            | VendorId::NousResearch
2157            | VendorId::CommandCode
2158            | VendorId::ModelStudio => None,
2159        };
2160        raw.filter(|key| !key.is_empty())
2161    }
2162
2163    /// Bar-number settings for one vendor.
2164    ///
2165    /// Only the prepaid-balance vendors declare these; everything else keeps
2166    /// the quota shape ([`DisplayPrefs::default`]) and is unaffected.
2167    pub fn display_prefs(&self, vendor: VendorId) -> DisplayPrefs {
2168        match vendor {
2169            VendorId::Deepseek => {
2170                DisplayPrefs::balance(self.deepseek.display_limit, self.deepseek.headline)
2171            }
2172            VendorId::Kilo => DisplayPrefs::balance(self.kilo.display_limit, self.kilo.headline),
2173            VendorId::Novita => {
2174                DisplayPrefs::balance(self.novita.display_limit, self.novita.headline)
2175            }
2176            VendorId::Moonshot => {
2177                DisplayPrefs::balance(self.moonshot.display_limit, self.moonshot.headline)
2178            }
2179            VendorId::Grok => DisplayPrefs::balance(self.grok.display_limit, self.grok.headline),
2180            // No tank: OpenRouter reports its own credits. See
2181            // [`OpenRouterConfig::headline`].
2182            VendorId::Openrouter => DisplayPrefs::balance(None, self.openrouter.headline),
2183            _ => DisplayPrefs::default(),
2184        }
2185    }
2186
2187    pub fn enabled_vendors(&self) -> Vec<VendorId> {
2188        VendorId::all()
2189            .iter()
2190            .copied()
2191            .filter(|id| self.is_enabled(*id))
2192            .collect()
2193    }
2194
2195    /// Validate cross-entry constraints that serde cannot express. Account
2196    /// labels are both CLI selectors and TUI tab identities, so duplicates
2197    /// would make either destination ambiguous.
2198    pub fn validate(&self) -> Result<()> {
2199        if let Some(minutes) = self.tray.refresh_minutes
2200            && !TRAY_REFRESH_MINUTES.contains(&minutes)
2201        {
2202            return Err(AppError::Other(format!(
2203                "[tray] refresh_minutes must be one of 1, 5 or 10, got {minutes}"
2204            )));
2205        }
2206        if !(1..=100).contains(&self.notifications.threshold) {
2207            return Err(AppError::Other(format!(
2208                "[notifications] threshold must be between 1 and 100, got {}",
2209                self.notifications.threshold
2210            )));
2211        }
2212        if self.context.context_window_tokens == Some(0) {
2213            return Err(AppError::Other(
2214                "[context] context_window_tokens must be greater than zero".into(),
2215            ));
2216        }
2217        for (model, tokens) in &self.context.model_context_window_tokens {
2218            if model.trim().is_empty() {
2219                return Err(AppError::Other(
2220                    "[context] model_context_window_tokens keys must not be empty".into(),
2221                ));
2222            }
2223            if *tokens == 0 {
2224                return Err(AppError::Other(format!(
2225                    "[context] model_context_window_tokens entry {model:?} must be greater than zero"
2226                )));
2227            }
2228        }
2229        if let Some(limit) = self.anthropic_api.monthly_limit
2230            && (!limit.is_finite() || limit <= 0.0)
2231        {
2232            return Err(AppError::Other(
2233                "[anthropic_api] monthly_limit must be finite and greater than zero; \
2234                 remove it to show spend without a limit"
2235                    .into(),
2236            ));
2237        }
2238        // Same rule as `monthly_limit` above: a tank size that cannot divide is
2239        // a typo, and silently ignoring it would draw a meter the user never
2240        // asked for — or none, with no diagnostic either way.
2241        for (section, limit) in [
2242            ("deepseek", self.deepseek.display_limit),
2243            ("kilo", self.kilo.display_limit),
2244            ("novita", self.novita.display_limit),
2245            ("moonshot", self.moonshot.display_limit),
2246            ("grok", self.grok.display_limit),
2247        ] {
2248            if let Some(limit) = limit
2249                && (!limit.is_finite() || limit <= 0.0)
2250            {
2251                return Err(AppError::Other(format!(
2252                    "[{section}] display_limit must be finite and greater than zero; \
2253                     remove it to show the balance without a limit"
2254                )));
2255            }
2256        }
2257        if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
2258            && !self.kimi.region.eq_ignore_ascii_case("auto")
2259        {
2260            return Err(AppError::Other(format!(
2261                "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
2262                self.kimi.region
2263            )));
2264        }
2265        if !self.minimax.region.eq_ignore_ascii_case("global")
2266            && !self.minimax.region.eq_ignore_ascii_case("cn")
2267        {
2268            return Err(AppError::Other(format!(
2269                "[minimax] region must be \"global\" or \"cn\", got {:?}",
2270                self.minimax.region
2271            )));
2272        }
2273        if self.supergrok.grok_binary.as_os_str().is_empty() {
2274            return Err(AppError::Other(
2275                "[supergrok] grok_binary must not be empty".into(),
2276            ));
2277        }
2278        let mut labels = HashSet::new();
2279        for account in &self.anthropic.accounts {
2280            validate_account_label(&account.label)?;
2281            if !labels.insert(&account.label) {
2282                return Err(AppError::Credentials(format!(
2283                    "duplicate anthropic account label {:?}",
2284                    account.label
2285                )));
2286            }
2287        }
2288        let mut openai_labels = HashSet::new();
2289        for account in &self.openai.accounts {
2290            validate_account_label_for("openai", &account.label)?;
2291            if !openai_labels.insert(&account.label) {
2292                return Err(AppError::Credentials(format!(
2293                    "duplicate openai account label {:?}",
2294                    account.label
2295                )));
2296            }
2297        }
2298        let mut openrouter_labels = HashSet::new();
2299        for account in &self.openrouter.accounts {
2300            validate_account_label_for("openrouter", &account.label)?;
2301            if !openrouter_labels.insert(&account.label) {
2302                return Err(AppError::Credentials(format!(
2303                    "duplicate openrouter account label {:?}",
2304                    account.label
2305                )));
2306            }
2307            let has_env = account
2308                .api_key_env
2309                .as_deref()
2310                .is_some_and(|name| !name.is_empty());
2311            let has_inline = account
2312                .api_key
2313                .as_deref()
2314                .is_some_and(|key| !key.is_empty());
2315            if !has_env && !has_inline {
2316                return Err(AppError::Credentials(format!(
2317                    "openrouter account {:?} must set api_key_env or api_key",
2318                    account.label
2319                )));
2320            }
2321        }
2322        self.validate_custom()
2323    }
2324
2325    /// Per-entry rules live on `CustomProviderConfig`; the cross-entry ones —
2326    /// `id` and `short_name` uniqueness, including against the built-in
2327    /// vendors — need the whole list and live here.
2328    fn validate_custom(&self) -> Result<()> {
2329        let mut ids = HashSet::new();
2330        let mut short_names: HashSet<&str> =
2331            VendorId::all().iter().map(|v| v.short_name()).collect();
2332        for (index, custom) in self.custom.iter().enumerate() {
2333            custom.validate(index)?;
2334            if !ids.insert(custom.id.as_str()) {
2335                return Err(AppError::Other(format!(
2336                    "{}: duplicate id",
2337                    custom.section_label()
2338                )));
2339            }
2340            if !short_names.insert(custom.short_name.as_str()) {
2341                return Err(AppError::Other(format!(
2342                    "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
2343                    custom.section_label(),
2344                    custom.short_name
2345                )));
2346            }
2347        }
2348        Ok(())
2349    }
2350}
2351
2352#[cfg(unix)]
2353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2354enum InlineKeyPermissionDecision {
2355    Ok,
2356    Tighten,
2357}
2358
2359#[cfg(unix)]
2360fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
2361    if mode & 0o077 == 0 {
2362        InlineKeyPermissionDecision::Ok
2363    } else {
2364        InlineKeyPermissionDecision::Tighten
2365    }
2366}
2367
2368pub fn default_path() -> Option<PathBuf> {
2369    let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
2370    Some(proj.config_dir().join("config.toml"))
2371}
2372
2373/// The Unix-conventional location, which is what every doc, the config
2374/// example, and both desktop integrations have always pointed at. On Linux it
2375/// *is* [`default_path`]; on macOS `ProjectDirs` resolves to
2376/// `~/Library/Application Support/…` instead, so the two diverge.
2377fn legacy_xdg_path() -> Option<PathBuf> {
2378    let home = crate::cache::home_dir().ok()?;
2379    Some(home.join(".config").join("ai-usagebar").join("config.toml"))
2380}
2381
2382/// The config file actually in effect.
2383///
2384/// A `--config` override (see [`set_override_path`]) wins outright so a test
2385/// run never touches the real file. Otherwise [`default_path`] stays
2386/// canonical, but on macOS a file at the documented
2387/// `~/.config/ai-usagebar/config.toml` is honored when the canonical one does
2388/// not exist — otherwise everyone who followed the README (and both desktop
2389/// integrations, which read that path) silently got defaults. The legacy file
2390/// is never moved or rewritten: it may hold API keys, and relocating a secret
2391/// behind the user's back is not this tool's business.
2392pub fn resolved_path() -> Option<PathBuf> {
2393    if let Some(path) = override_path() {
2394        return Some(path);
2395    }
2396    let canonical = default_path();
2397    if let Some(p) = &canonical
2398        && p.exists()
2399    {
2400        return canonical;
2401    }
2402    if let Some(legacy) = legacy_xdg_path()
2403        && legacy.exists()
2404    {
2405        return Some(legacy);
2406    }
2407    canonical
2408}
2409
2410static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
2411
2412/// Point every config load, save, and hint at one explicit file — the
2413/// `--config` flag. Takes precedence over the canonical and legacy locations.
2414/// The file does not have to exist yet: loads treat it as defaults while
2415/// Settings saves create it. Process-wide, so call it once at startup before
2416/// any config is read.
2417pub fn set_override_path(path: &std::path::Path) {
2418    if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2419        *slot = Some(path.to_path_buf());
2420    }
2421}
2422
2423/// Drop the override again. Used only by tests so they can restore the
2424/// process-wide state they changed.
2425#[doc(hidden)]
2426pub fn clear_override_path() {
2427    if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2428        *slot = None;
2429    }
2430}
2431
2432fn override_path() -> Option<PathBuf> {
2433    PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
2434}
2435
2436/// Value of a `--config=PATH` argument, split at the OS-string level so a
2437/// path with bytes Windows/Unix can store but UTF-8 cannot represent (an
2438/// undecodable filename on Unix, a lone surrogate on Windows) survives
2439/// intact instead of being mangled by `to_string_lossy`. `None` when the
2440/// argument is not in that form. Used by both binaries' argv pre-parsers.
2441#[doc(hidden)]
2442pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
2443    #[cfg(unix)]
2444    {
2445        use std::os::unix::ffi::{OsStrExt, OsStringExt};
2446        let rest = arg.as_bytes().strip_prefix(b"--config=")?;
2447        Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
2448    }
2449    #[cfg(windows)]
2450    {
2451        use std::os::windows::ffi::{OsStrExt, OsStringExt};
2452        const PREFIX: &[u16] = &[
2453            b'-' as u16,
2454            b'-' as u16,
2455            b'c' as u16,
2456            b'o' as u16,
2457            b'n' as u16,
2458            b'f' as u16,
2459            b'i' as u16,
2460            b'g' as u16,
2461            b'=' as u16,
2462        ];
2463        let wide: Vec<u16> = arg.encode_wide().collect();
2464        let rest = wide.strip_prefix(PREFIX)?;
2465        Some(std::ffi::OsString::from_wide(rest).into())
2466    }
2467    #[cfg(not(any(unix, windows)))]
2468    {
2469        Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
2470    }
2471}
2472
2473/// Expand a leading `~` (or `~/`) against the user's home directory. Anything
2474/// else — including `~user` — is left untouched.
2475fn expand_tilde(p: &std::path::Path) -> PathBuf {
2476    let Some(s) = p.to_str() else {
2477        return p.to_path_buf();
2478    };
2479    let rest = if s == "~" {
2480        ""
2481    } else if let Some(r) = s.strip_prefix("~/") {
2482        r
2483    } else {
2484        return p.to_path_buf();
2485    };
2486    match crate::cache::home_dir() {
2487        Ok(home) if rest.is_empty() => home,
2488        Ok(home) => home.join(rest),
2489        Err(_) => p.to_path_buf(),
2490    }
2491}
2492
2493fn expand_tilde_opt(p: &mut Option<PathBuf>) {
2494    if let Some(inner) = p.as_ref() {
2495        *p = Some(expand_tilde(inner));
2496    }
2497}
2498
2499/// Resolved `config.toml` path as a string for user-facing messages. Uses the
2500/// platform's config dir (`directories::ProjectDirs`), so it reads correctly on
2501/// Linux, macOS, and Windows instead of hard-coding the Unix `~/.config` path.
2502/// Falls back to the bare filename if the path can't be resolved.
2503pub fn config_path_hint() -> String {
2504    resolved_path()
2505        .map(|p| p.display().to_string())
2506        .unwrap_or_else(|| "config.toml".to_string())
2507}
2508
2509#[cfg(test)]
2510mod tests {
2511    use super::*;
2512    use std::io::Write;
2513    use tempfile::NamedTempFile;
2514
2515    #[cfg(unix)]
2516    use std::os::unix::fs::{MetadataExt, PermissionsExt};
2517
2518    fn write_toml(s: &str) -> NamedTempFile {
2519        let mut f = NamedTempFile::new().unwrap();
2520        f.write_all(s.as_bytes()).unwrap();
2521        f.flush().unwrap();
2522        f
2523    }
2524
2525    /// The back-compat guarantee #134 asks for: a config with no
2526    /// `[[openai.accounts]]` resolves exactly what it resolved before, whether
2527    /// it sets `codex_auth_path` or leaves it to the default.
2528    #[test]
2529    fn openai_without_accounts_resolves_the_singular_path() {
2530        let explicit = OpenAiConfig {
2531            codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2532            ..OpenAiConfig::default()
2533        };
2534        assert_eq!(
2535            explicit.resolve_auth_path(None).unwrap(),
2536            PathBuf::from("/tmp/codex/auth.json")
2537        );
2538
2539        let bare = OpenAiConfig::default();
2540        assert_eq!(
2541            bare.resolve_auth_path(None).unwrap(),
2542            crate::openai::creds::default_path().unwrap(),
2543            "no codex_auth_path must still mean ~/.codex/auth.json"
2544        );
2545    }
2546
2547    /// Each named account resolves its own file, and the default login is still
2548    /// reachable alongside them.
2549    #[test]
2550    fn openai_named_accounts_resolve_their_own_auth_file() {
2551        let config: Config = toml::from_str(
2552            r#"
2553            [openai]
2554            codex_auth_path = "/tmp/personal/auth.json"
2555            [[openai.accounts]]
2556            label = "work"
2557            codex_auth_path = "/tmp/work/auth.json"
2558            "#,
2559        )
2560        .unwrap();
2561
2562        assert_eq!(
2563            config.openai.resolve_auth_path(Some("work")).unwrap(),
2564            PathBuf::from("/tmp/work/auth.json")
2565        );
2566        assert_eq!(
2567            config.openai.resolve_auth_path(None).unwrap(),
2568            PathBuf::from("/tmp/personal/auth.json")
2569        );
2570    }
2571
2572    /// An unknown label must fail rather than quietly fall back to the default
2573    /// login — reporting the wrong subscription's usage is worse than an error.
2574    #[test]
2575    fn an_unknown_openai_account_is_an_error_not_a_fallback() {
2576        let config = OpenAiConfig {
2577            codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
2578            accounts: vec![OpenAiAccount {
2579                label: "work".into(),
2580                codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
2581            }],
2582            ..OpenAiConfig::default()
2583        };
2584        let err = config
2585            .resolve_auth_path(Some("nope"))
2586            .unwrap_err()
2587            .to_string();
2588        assert!(err.contains("nope"), "{err}");
2589        assert!(err.contains("[[openai.accounts]]"), "{err}");
2590    }
2591
2592    fn two_codex_accounts() -> OpenAiConfig {
2593        OpenAiConfig {
2594            codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2595            accounts: vec![
2596                OpenAiAccount {
2597                    label: "main".into(),
2598                    codex_auth_path: PathBuf::from("/tmp/codex-main/auth.json"),
2599                },
2600                OpenAiAccount {
2601                    label: "work".into(),
2602                    codex_auth_path: PathBuf::from("/tmp/codex-work/auth.json"),
2603                },
2604            ],
2605            ..OpenAiConfig::default()
2606        }
2607    }
2608
2609    #[test]
2610    fn the_active_codex_account_is_read_from_the_default_slot() {
2611        let config = two_codex_accounts();
2612        assert_eq!(
2613            config
2614                .fetch_auth_path_probing("work", Some("work"), |_| false)
2615                .unwrap(),
2616            PathBuf::from("/tmp/codex/auth.json")
2617        );
2618        assert_eq!(
2619            config
2620                .fetch_auth_path_probing("main", Some("work"), |_| false)
2621                .unwrap(),
2622            PathBuf::from("/tmp/codex-main/auth.json")
2623        );
2624    }
2625
2626    #[test]
2627    fn an_active_codex_account_with_its_own_file_keeps_reading_it() {
2628        let config = two_codex_accounts();
2629        assert_eq!(
2630            config
2631                .fetch_auth_path_probing("work", Some("work"), |_| true)
2632                .unwrap(),
2633            PathBuf::from("/tmp/codex-work/auth.json")
2634        );
2635    }
2636
2637    #[test]
2638    fn adding_an_openai_account_keeps_the_rest_of_the_file() {
2639        let mut doc: toml_edit::DocumentMut = "# mine\n[zai]\nenabled = true\n".parse().unwrap();
2640        add_openai_account_to_doc(&mut doc, "work", "~/.codex-work/auth.json").unwrap();
2641        let text = doc.to_string();
2642        assert!(
2643            text.starts_with("# mine\n[zai]\nenabled = true\n"),
2644            "{text}"
2645        );
2646        let parsed: Config = toml::from_str(&text).unwrap();
2647        assert_eq!(parsed.openai.accounts[0].label, "work");
2648        assert!(add_openai_account_to_doc(&mut doc, "work", "x").is_err());
2649        assert!(add_openai_account_to_doc(&mut doc, "../x", "x").is_err());
2650    }
2651
2652    #[test]
2653    fn defaults_enable_only_the_five_core_vendors() {
2654        let c = Config::default();
2655        assert!(c.is_enabled(VendorId::Anthropic));
2656        assert!(c.is_enabled(VendorId::Openai));
2657        assert!(c.is_enabled(VendorId::Zai));
2658        assert!(c.is_enabled(VendorId::Openrouter));
2659        assert!(c.is_enabled(VendorId::CommandCode));
2660        for opt_in in [
2661            VendorId::AnthropicApi,
2662            VendorId::Copilot,
2663            VendorId::Deepseek,
2664            VendorId::Kimi,
2665            VendorId::Kilo,
2666            VendorId::Novita,
2667            VendorId::Moonshot,
2668            VendorId::Grok,
2669            VendorId::Supergrok,
2670            VendorId::Grokbot,
2671            VendorId::Cursor,
2672            VendorId::Minimax,
2673            VendorId::Kiro,
2674            VendorId::OrcaRouter,
2675            VendorId::ModelStudio,
2676        ] {
2677            assert!(!c.is_enabled(opt_in), "{opt_in:?}");
2678        }
2679        assert_eq!(c.enabled_vendors().len(), 5);
2680    }
2681
2682    #[test]
2683    fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
2684        let config = Config::default();
2685        assert!(!config.is_enabled(VendorId::NousResearch));
2686        assert!(!config.is_enabled(VendorId::OpenCodeGo));
2687        assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
2688        assert!(config.opencode_go.api_key.is_none());
2689        assert!(!config.is_enabled(VendorId::Copilot));
2690    }
2691
2692    #[cfg(unix)]
2693    #[test]
2694    fn inline_credentials_are_protected() {
2695        let mut config = Config::default();
2696        config.opencode_go.api_key = Some("<redacted>".to_string());
2697        assert!(config.has_inline_secrets());
2698    }
2699
2700    #[test]
2701    fn antigravity_oauth_client_overrides_parse() {
2702        let config: Config = toml::from_str(
2703            "[antigravity]
2704enabled = true
2705oauth_client_id = \"test-client\"
2706oauth_client_secret = \"test-client-secret\"
2707",
2708        )
2709        .unwrap();
2710        assert!(config.antigravity.enabled);
2711        assert_eq!(
2712            config.antigravity.oauth_client_id.as_deref(),
2713            Some("test-client")
2714        );
2715        assert_eq!(
2716            config.antigravity.oauth_client_secret.as_deref(),
2717            Some("test-client-secret")
2718        );
2719        let bare: Config = toml::from_str(
2720            "[antigravity]
2721enabled = true
2722",
2723        )
2724        .unwrap();
2725        assert!(bare.antigravity.oauth_client_id.is_none());
2726        assert!(bare.antigravity.oauth_client_secret.is_none());
2727    }
2728
2729    #[cfg(unix)]
2730    #[test]
2731    fn antigravity_inline_oauth_secret_receives_config_file_protection() {
2732        let mut config = Config::default();
2733        config.antigravity.oauth_client_id = Some("test-client".into());
2734        assert!(!config.has_inline_secrets());
2735        config.antigravity.oauth_client_secret = Some("<redacted>".into());
2736        assert!(config.has_inline_secrets());
2737    }
2738
2739    #[cfg(unix)]
2740    #[test]
2741    fn openrouter_named_inline_keys_receive_config_file_protection() {
2742        let mut config = Config::default();
2743        config.openrouter.accounts.push(OpenRouterAccount {
2744            label: "work".into(),
2745            api_key_env: None,
2746            api_key: Some("<redacted>".into()),
2747        });
2748        assert!(config.has_inline_secrets());
2749    }
2750
2751    #[test]
2752    fn missing_file_uses_defaults() {
2753        let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
2754        let c = Config::load_from(path).unwrap();
2755        assert!(c.is_enabled(VendorId::Anthropic));
2756    }
2757
2758    #[test]
2759    fn parses_full_config() {
2760        let f = write_toml(
2761            r#"
2762            [anthropic]
2763            enabled = true
2764
2765            [openai]
2766            enabled = false
2767            admin_key_env = "MY_ADMIN_KEY"
2768
2769            [zai]
2770            enabled = true
2771            api_key_env = "MY_ZAI"
2772            plan_tier = "pro"
2773
2774            [openrouter]
2775            enabled = false
2776            "#,
2777        );
2778        let c = Config::load_from(f.path()).unwrap();
2779        assert!(c.is_enabled(VendorId::Anthropic));
2780        assert!(!c.is_enabled(VendorId::Openai));
2781        assert!(c.is_enabled(VendorId::Zai));
2782        assert!(!c.is_enabled(VendorId::Openrouter));
2783        assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
2784        assert_eq!(c.zai.api_key_env, "MY_ZAI");
2785        assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
2786        assert!(c.openrouter.accounts.is_empty());
2787        assert!(c.openrouter.show_default_account);
2788    }
2789
2790    #[test]
2791    fn partial_config_falls_back_to_defaults() {
2792        let f = write_toml(
2793            r#"[openai]
2794enabled = false
2795"#,
2796        );
2797        let c = Config::load_from(f.path()).unwrap();
2798        assert!(!c.is_enabled(VendorId::Openai));
2799        // Other vendors keep their defaults.
2800        assert!(c.is_enabled(VendorId::Anthropic));
2801        assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
2802    }
2803
2804    #[test]
2805    fn malformed_toml_returns_error() {
2806        let f = write_toml("this is not = = valid");
2807        assert!(Config::load_from(f.path()).is_err());
2808    }
2809
2810    #[cfg(unix)]
2811    #[test]
2812    fn load_from_tightens_world_readable_config_with_inline_api_key() {
2813        let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
2814        std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
2815
2816        Config::load_from(file.path()).unwrap();
2817
2818        assert_eq!(
2819            std::fs::metadata(file.path()).unwrap().mode() & 0o777,
2820            0o600
2821        );
2822    }
2823
2824    #[cfg(unix)]
2825    #[test]
2826    fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
2827        let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
2828        std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
2829
2830        Config::load_from(file.path()).unwrap();
2831
2832        assert_eq!(
2833            std::fs::metadata(file.path()).unwrap().mode() & 0o777,
2834            0o644
2835        );
2836    }
2837
2838    #[cfg(unix)]
2839    #[test]
2840    fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
2841        assert_eq!(
2842            inline_key_permission_decision(0o600),
2843            InlineKeyPermissionDecision::Ok
2844        );
2845        assert_eq!(
2846            inline_key_permission_decision(0o640),
2847            InlineKeyPermissionDecision::Tighten
2848        );
2849        assert_eq!(
2850            inline_key_permission_decision(0o604),
2851            InlineKeyPermissionDecision::Tighten
2852        );
2853    }
2854
2855    #[test]
2856    fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
2857        for value in ["0", "-1", "inf", "nan"] {
2858            let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
2859            let error = Config::load_from(file.path()).unwrap_err().to_string();
2860            assert!(error.contains("monthly_limit"), "value {value}: {error}");
2861        }
2862
2863        let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
2864        assert_eq!(
2865            Config::load_from(file.path())
2866                .unwrap()
2867                .anthropic_api
2868                .monthly_limit,
2869            Some(1000.0)
2870        );
2871    }
2872
2873    #[test]
2874    fn display_limit_must_be_positive_and_finite_on_every_balance_vendor() {
2875        // `[openrouter]` is absent on purpose: it has no `display_limit`.
2876        for section in ["deepseek", "kilo", "novita", "moonshot", "grok"] {
2877            for value in ["0", "-1", "inf", "nan"] {
2878                let file = write_toml(&format!("[{section}]\ndisplay_limit = {value}\n"));
2879                let error = Config::load_from(file.path()).unwrap_err().to_string();
2880                assert!(
2881                    error.contains(&format!("[{section}] display_limit")),
2882                    "{section} = {value}: {error}"
2883                );
2884            }
2885            let file = write_toml(&format!("[{section}]\ndisplay_limit = 200\n"));
2886            let config = Config::load_from(file.path()).unwrap();
2887            assert_eq!(
2888                config.display_prefs(vendor_of(section)).display_limit,
2889                Some(200.0),
2890                "{section}"
2891            );
2892        }
2893    }
2894
2895    /// No baked-in tank: a vendor nobody configured has no denominator.
2896    #[test]
2897    fn display_limit_is_absent_until_the_user_states_one() {
2898        let config = Config::default();
2899        for vendor in VendorId::all() {
2900            assert_eq!(
2901                config.display_prefs(*vendor).display_limit,
2902                None,
2903                "{vendor:?}"
2904            );
2905        }
2906    }
2907
2908    /// A balance vendor headlines its money; a vendor with a denominator of its
2909    /// own headlines the percentage. Everything else keeps the quota default.
2910    #[test]
2911    fn the_default_headline_follows_the_kind_of_vendor() {
2912        let config = Config::default();
2913        for vendor in [
2914            VendorId::Deepseek,
2915            VendorId::Kilo,
2916            VendorId::Novita,
2917            VendorId::Moonshot,
2918            VendorId::Grok,
2919        ] {
2920            assert_eq!(
2921                config.display_prefs(vendor).headline,
2922                Headline::Amount,
2923                "{vendor:?}"
2924            );
2925        }
2926        assert_eq!(
2927            config.display_prefs(VendorId::Openrouter).headline,
2928            Headline::Percent
2929        );
2930        assert_eq!(
2931            config.display_prefs(VendorId::Anthropic),
2932            DisplayPrefs::default()
2933        );
2934    }
2935
2936    #[test]
2937    fn the_headline_is_configurable_per_vendor_and_a_typo_is_loud() {
2938        let file = write_toml("[deepseek]\nheadline = \"percent\"\n");
2939        assert_eq!(
2940            Config::load_from(file.path())
2941                .unwrap()
2942                .display_prefs(VendorId::Deepseek)
2943                .headline,
2944            Headline::Percent
2945        );
2946
2947        let file = write_toml("[openrouter]\nheadline = \"amount\"\n");
2948        assert_eq!(
2949            Config::load_from(file.path())
2950                .unwrap()
2951                .display_prefs(VendorId::Openrouter)
2952                .headline,
2953            Headline::Amount
2954        );
2955
2956        let file = write_toml("[deepseek]\nheadline = \"dollars\"\n");
2957        let error = Config::load_from(file.path()).unwrap_err().to_string();
2958        assert!(error.contains("headline"), "{error}");
2959    }
2960
2961    /// `[openrouter]` has no tank at all. The API reports credits purchased, so
2962    /// there is nothing to fall back to — and in the one case where a tank
2963    /// would not be ignored (a free-tier account with `total_credits == 0`)
2964    /// honouring it would put "0%" on the bar for an account with money in it,
2965    /// because the percentage comes from the snapshot, not from the tank.
2966    #[test]
2967    fn openrouter_has_no_display_limit_to_be_ignored() {
2968        let file = write_toml("[openrouter]\ndisplay_limit = 200\nheadline = \"percent\"\n");
2969        let config = Config::load_from(file.path()).unwrap();
2970        let prefs = config.display_prefs(VendorId::Openrouter);
2971        assert_eq!(prefs.display_limit, None);
2972        assert_eq!(prefs.headline, Headline::Percent);
2973    }
2974
2975    /// Setting a tank does not move the money off the bar by itself; the two
2976    /// are independent choices.
2977    #[test]
2978    fn a_display_limit_alone_leaves_the_headline_where_it_was() {
2979        let file = write_toml("[deepseek]\ndisplay_limit = 200\n");
2980        let prefs = Config::load_from(file.path())
2981            .unwrap()
2982            .display_prefs(VendorId::Deepseek);
2983        assert_eq!(prefs.display_limit, Some(200.0));
2984        assert_eq!(prefs.headline, Headline::Amount);
2985    }
2986
2987    fn vendor_of(section: &str) -> VendorId {
2988        VendorId::all()
2989            .iter()
2990            .copied()
2991            .find(|vendor| vendor.config_section() == section)
2992            .unwrap_or_else(|| panic!("no vendor for [{section}]"))
2993    }
2994
2995    #[test]
2996    fn minimax_region_accepts_only_known_instances() {
2997        for region in ["global", "GLOBAL", "cn", "CN"] {
2998            let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
2999            assert_eq!(
3000                Config::load_from(file.path()).unwrap().minimax.region,
3001                region
3002            );
3003        }
3004
3005        for region in ["", "china", "us"] {
3006            let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3007            let error = Config::load_from(file.path()).unwrap_err().to_string();
3008            assert!(error.contains("[minimax] region"), "{error}");
3009        }
3010    }
3011
3012    #[test]
3013    fn kimi_region_accepts_auto_and_both_deployments() {
3014        for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
3015            let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3016            assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
3017        }
3018
3019        for region in ["", "us", "oversea"] {
3020            let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3021            let error = Config::load_from(file.path()).unwrap_err().to_string();
3022            assert!(error.contains("[kimi] region"), "{error}");
3023        }
3024    }
3025
3026    #[test]
3027    fn kimi_defaults_to_auto_region_and_no_credential_override() {
3028        let defaults = KimiConfig::default();
3029        assert_eq!(defaults.region, "auto");
3030        assert_eq!(defaults.credentials_path, None);
3031        assert!(!defaults.enabled);
3032    }
3033
3034    #[test]
3035    fn kimi_credentials_path_expands_a_tilde() {
3036        let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
3037        let path = Config::load_from(file.path())
3038            .unwrap()
3039            .kimi
3040            .credentials_path
3041            .unwrap();
3042        assert!(!path.starts_with("~"), "{}", path.display());
3043        assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
3044    }
3045
3046    #[test]
3047    fn grokbot_is_opt_in_and_takes_no_api_key() {
3048        let defaults = GrokbotConfig::default();
3049        assert!(!defaults.enabled);
3050        assert_eq!(defaults.secrets_path, None);
3051        // No key surface of any kind: the app's own session is the login.
3052        let config = Config::default();
3053        assert_eq!(config.api_key_env_for(VendorId::Grokbot), "");
3054        assert_eq!(config.inline_api_key(VendorId::Grokbot), None);
3055
3056        let file = write_toml("[grokbot]\nenabled = true\n");
3057        let config = Config::load_from(file.path()).unwrap();
3058        assert!(config.is_enabled(VendorId::Grokbot));
3059        assert!(config.enabled_vendors().contains(&VendorId::Grokbot));
3060    }
3061
3062    #[test]
3063    fn grokbot_secrets_path_expands_a_tilde() {
3064        let file = write_toml("[grokbot]\nsecrets_path = \"~/gb/secrets.json\"\n");
3065        let path = Config::load_from(file.path())
3066            .unwrap()
3067            .grokbot
3068            .secrets_path
3069            .unwrap();
3070        assert!(!path.starts_with("~"), "{}", path.display());
3071        assert!(path.ends_with("gb/secrets.json"), "{}", path.display());
3072    }
3073
3074    #[test]
3075    fn modelstudio_is_opt_in_and_takes_no_api_key() {
3076        let defaults = ModelStudioConfig::default();
3077        assert!(!defaults.enabled);
3078        assert_eq!(defaults.config_dir, None);
3079        // No key surface of any kind: the bl CLI's console session is the login.
3080        let config = Config::default();
3081        assert_eq!(config.api_key_env_for(VendorId::ModelStudio), "");
3082        assert_eq!(config.inline_api_key(VendorId::ModelStudio), None);
3083
3084        let file = write_toml("[modelstudio]\nenabled = true\n");
3085        let config = Config::load_from(file.path()).unwrap();
3086        assert!(config.is_enabled(VendorId::ModelStudio));
3087        assert!(config.enabled_vendors().contains(&VendorId::ModelStudio));
3088    }
3089
3090    #[test]
3091    fn modelstudio_config_dir_expands_a_tilde() {
3092        let file = write_toml("[modelstudio]\nconfig_dir = \"~/bl\"\n");
3093        let path = Config::load_from(file.path())
3094            .unwrap()
3095            .modelstudio
3096            .config_dir
3097            .unwrap();
3098        assert!(!path.starts_with("~"), "{}", path.display());
3099        assert!(path.ends_with("bl"), "{}", path.display());
3100    }
3101
3102    #[test]
3103    fn optional_api_key_reports_absence_instead_of_failing() {
3104        assert_eq!(
3105            optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", Some("inline")),
3106            Some("inline".to_string())
3107        );
3108        assert_eq!(
3109            optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", None),
3110            None
3111        );
3112        assert_eq!(optional_api_key("KIMI_API_KEY_UNSET", Some("")), None);
3113        // An unusable `api_key_env` still lets an inline key through, exactly
3114        // as `resolve_api_key` does.
3115        assert_eq!(
3116            optional_api_key("9INVALID", Some("inline")),
3117            Some("inline".to_string())
3118        );
3119    }
3120
3121    #[test]
3122    fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
3123        let defaults = Config::default();
3124        assert!(!defaults.context.enabled);
3125        assert_eq!(
3126            defaults.context.window_tokens_for(Some("claude-test")),
3127            None
3128        );
3129
3130        let file = write_toml(
3131            r#"
3132            [context]
3133            enabled = true
3134            context_window_tokens = 200000
3135
3136            [context.model_context_window_tokens]
3137            claude-opus-1m = 1000000
3138            "claude exact id" = 300000
3139            "#,
3140        );
3141        let config = Config::load_from(file.path()).unwrap();
3142        assert!(config.context.enabled);
3143        assert_eq!(
3144            config.context.window_tokens_for(Some("claude-opus-1m")),
3145            Some(1_000_000)
3146        );
3147        assert_eq!(
3148            config.context.window_tokens_for(Some("claude exact id")),
3149            Some(300_000)
3150        );
3151        assert_eq!(
3152            config.context.window_tokens_for(Some("another-model")),
3153            Some(200_000)
3154        );
3155    }
3156
3157    #[test]
3158    fn context_layout_defaults_to_full_and_parses_each_variant() {
3159        assert_eq!(Config::default().context.layout, ContextLayout::Full);
3160        for (text, want) in [
3161            ("full", ContextLayout::Full),
3162            ("split", ContextLayout::Split),
3163            ("bottom", ContextLayout::Bottom),
3164        ] {
3165            let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
3166            assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
3167        }
3168        let file = write_toml("[context]\nlayout = \"floating\"\n");
3169        assert!(
3170            Config::load_from(file.path()).is_err(),
3171            "an unknown layout must be rejected, not silently defaulted"
3172        );
3173    }
3174
3175    #[test]
3176    fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
3177        assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
3178        for (text, want) in [
3179            ("sidebar", VendorBoxStyle::Sidebar),
3180            ("navbar", VendorBoxStyle::Navbar),
3181            ("none", VendorBoxStyle::None),
3182        ] {
3183            let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
3184            assert_eq!(
3185                Config::load_from(file.path()).unwrap().ui.vendor_box(),
3186                want
3187            );
3188        }
3189        let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
3190        assert!(
3191            Config::load_from(file.path()).is_err(),
3192            "an unknown vendor_box style must be rejected, not silently defaulted"
3193        );
3194    }
3195
3196    #[test]
3197    fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
3198        for source in [
3199            "[context]\ncontext_window_tokens = 0\n",
3200            "[context.model_context_window_tokens]\nclaude = 0\n",
3201            "[context.model_context_window_tokens]\n\" \" = 200000\n",
3202        ] {
3203            let file = write_toml(source);
3204            let error = Config::load_from(file.path()).unwrap_err().to_string();
3205            assert!(error.contains("context"), "{error}");
3206        }
3207    }
3208
3209    // serial guard for env-var manipulation tests so they don't race
3210    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
3211        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
3212        M.lock().unwrap_or_else(|p| p.into_inner())
3213    }
3214
3215    #[test]
3216    fn resolve_api_key_prefers_env_over_inline() {
3217        let _g = env_guard();
3218        // Use a unique env var name so we don't clobber test parallelism.
3219        let var = "AI_USAGEBAR_TEST_ENV_WINS";
3220        // SAFETY: tests are single-threaded under env_guard.
3221        unsafe { std::env::set_var(var, "from-env") };
3222        let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
3223        unsafe { std::env::remove_var(var) };
3224        assert_eq!(got, "from-env");
3225    }
3226
3227    #[test]
3228    fn resolve_api_key_falls_back_to_inline() {
3229        let _g = env_guard();
3230        let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
3231        unsafe { std::env::remove_var(var) };
3232        let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
3233        assert_eq!(got, "inline-key");
3234    }
3235
3236    #[test]
3237    fn copilot_token_prefers_explicit_environment_over_gh_cli() {
3238        struct NeverRun;
3239        impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
3240            fn run(
3241                &self,
3242                _: &crate::copilot::credentials::GhAuthTokenCommand,
3243            ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3244                panic!("environment override must not invoke gh")
3245            }
3246        }
3247
3248        let token = CopilotConfig::default()
3249            .resolve_token_with(
3250                |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
3251                &NeverRun,
3252            )
3253            .unwrap();
3254        assert_eq!(token, "from-environment");
3255    }
3256
3257    #[test]
3258    fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
3259        struct FailedGh;
3260        impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
3261            fn run(
3262                &self,
3263                _: &crate::copilot::credentials::GhAuthTokenCommand,
3264            ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3265                Ok(crate::copilot::credentials::GhAuthTokenOutput {
3266                    success: false,
3267                    stdout: b"never-echo-gh-output".to_vec(),
3268                })
3269            }
3270        }
3271        let error = CopilotConfig::default()
3272            .resolve_token_with(|_| None, &FailedGh)
3273            .unwrap_err()
3274            .to_string();
3275        assert!(error.contains("gh auth login --web"));
3276        assert!(!error.contains("never-echo-gh-output"));
3277    }
3278
3279    #[test]
3280    fn resolve_api_key_errors_when_both_missing() {
3281        let _g = env_guard();
3282        let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
3283        unsafe { std::env::remove_var(var) };
3284        let err = resolve_api_key("Zai", var, None).unwrap_err();
3285        match err {
3286            crate::error::AppError::Credentials(msg) => {
3287                assert!(
3288                    msg.contains("api_key"),
3289                    "error should suggest config field: {msg}"
3290                );
3291            }
3292            other => panic!("expected Credentials error, got {other:?}"),
3293        }
3294    }
3295
3296    #[test]
3297    fn resolve_api_key_uses_exact_opencode_go_section_name() {
3298        let _g = env_guard();
3299        unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
3300        let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
3301        let message = err.to_string();
3302        assert!(
3303            message.contains("[opencode-go]"),
3304            "wrong section hint: {message}"
3305        );
3306        assert!(
3307            !message.contains("[opencode go]"),
3308            "wrong section hint: {message}"
3309        );
3310    }
3311
3312    fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
3313        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
3314        M.lock().unwrap_or_else(|p| p.into_inner())
3315    }
3316
3317    /// Serializes the override tests *and* guarantees the process-wide
3318    /// override is dropped when the test ends — including via a panic, which
3319    /// a bare set/clear pair does not survive. A leaked override makes every
3320    /// later test in this process resolve a deleted temp file, turning one
3321    /// failure into a cascade of confusing sibling failures.
3322    struct ScopedPathOverride {
3323        _serial: std::sync::MutexGuard<'static, ()>,
3324    }
3325
3326    impl Drop for ScopedPathOverride {
3327        fn drop(&mut self) {
3328            clear_override_path();
3329        }
3330    }
3331
3332    fn scoped_path_override() -> ScopedPathOverride {
3333        ScopedPathOverride {
3334            _serial: path_override_guard(),
3335        }
3336    }
3337
3338    #[test]
3339    fn override_path_wins_over_canonical_and_legacy() {
3340        let _scoped = scoped_path_override();
3341        let file = NamedTempFile::new().unwrap();
3342        set_override_path(file.path());
3343        assert_eq!(resolved_path().as_deref(), Some(file.path()));
3344        assert_eq!(config_path_hint(), file.path().display().to_string());
3345        clear_override_path();
3346        // The usual locations decide again once the override is gone.
3347        let p = resolved_path().expect("a config path must resolve");
3348        assert!(p.ends_with("config.toml"));
3349    }
3350
3351    #[test]
3352    fn scoped_override_guard_clears_the_override_on_panic() {
3353        // Silence the simulated failure's hook output; the assertion below is
3354        // the real report.
3355        let hook = std::panic::take_hook();
3356        std::panic::set_hook(Box::new(|_| {}));
3357        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3358            let _scoped = scoped_path_override();
3359            set_override_path(std::path::Path::new("panicked-override.toml"));
3360            panic!("simulated mid-test failure");
3361        }))
3362        .is_err();
3363        std::panic::set_hook(hook);
3364        assert!(panicked, "the simulated failure must run");
3365        let _serial = path_override_guard();
3366        assert!(
3367            override_path().is_none(),
3368            "a panicking test must not leak the override into siblings"
3369        );
3370    }
3371
3372    #[test]
3373    fn config_path_hint_ends_with_config_toml() {
3374        let _g = path_override_guard();
3375        // Platform-resolved (Linux/macOS/Windows), but always ends in the
3376        // config filename — the trailing segment is what messages rely on.
3377        assert!(config_path_hint().ends_with("config.toml"));
3378    }
3379
3380    #[test]
3381    fn config_flag_value_splits_the_equals_form() {
3382        use std::ffi::OsStr;
3383        assert_eq!(
3384            config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
3385            Some(std::path::Path::new("work.toml"))
3386        );
3387        assert_eq!(
3388            config_flag_value(OsStr::new("--config=")).as_deref(),
3389            Some(std::path::Path::new(""))
3390        );
3391        assert_eq!(config_flag_value(OsStr::new("--config")), None);
3392        assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
3393        assert_eq!(config_flag_value(OsStr::new("account")), None);
3394    }
3395
3396    /// The `--config=PATH` form must preserve a path the platform can store
3397    /// but UTF-8 cannot represent — `to_string_lossy` would replace the bad
3398    /// bytes with U+FFFD and produce a false "config file not found".
3399    #[cfg(unix)]
3400    #[test]
3401    fn config_flag_value_keeps_undecodable_bytes_intact() {
3402        use std::ffi::OsString;
3403        use std::os::unix::ffi::{OsStrExt, OsStringExt};
3404        let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
3405        let value = config_flag_value(&raw).expect("prefix matches");
3406        assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
3407    }
3408
3409    #[cfg(windows)]
3410    #[test]
3411    fn config_flag_value_keeps_lone_surrogates_intact() {
3412        use std::ffi::OsString;
3413        use std::os::windows::ffi::{OsStrExt, OsStringExt};
3414        let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
3415        wide.push(0xDC00); // lone low surrogate: not valid Unicode
3416        wide.extend("x.toml".encode_utf16());
3417        let raw = OsString::from_wide(&wide);
3418        let value = config_flag_value(&raw).expect("prefix matches");
3419        let mut expected = vec![0xDC00u16];
3420        expected.extend("x.toml".encode_utf16());
3421        assert_eq!(
3422            value.as_os_str().encode_wide().collect::<Vec<_>>(),
3423            expected
3424        );
3425    }
3426
3427    #[test]
3428    fn resolve_api_key_treats_empty_env_as_unset() {
3429        let _g = env_guard();
3430        let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
3431        unsafe { std::env::set_var(var, "") };
3432        let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
3433        unsafe { std::env::remove_var(var) };
3434        assert_eq!(got, "inline");
3435    }
3436
3437    #[test]
3438    fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
3439        let _g = env_guard();
3440        // Simulates a user accidentally pasting the key into api_key_env.
3441        let bad = "sk-kimi-very-real-looking-pasted-secret";
3442        let err = resolve_api_key("Kimi", bad, None).unwrap_err();
3443        let msg = err.to_string();
3444        assert!(
3445            msg.contains("invalid") && msg.contains("api_key_env"),
3446            "error should explain misconfiguration: {msg}"
3447        );
3448        assert!(
3449            !msg.contains(bad),
3450            "error must not echo the misconfigured value: {msg}"
3451        );
3452        assert!(msg.contains("valid environment variable name"));
3453        assert!(
3454            msg.contains("[kimi]"),
3455            "error should point at the lowercase TOML section: {msg}"
3456        );
3457    }
3458
3459    #[test]
3460    fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
3461        let _g = env_guard();
3462        let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
3463        assert_eq!(got, "inline-key");
3464    }
3465
3466    #[test]
3467    fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
3468        let _g = env_guard();
3469        // This is syntactically a valid environment variable name, but could
3470        // be a pasted secret and must not be reflected in the error.
3471        let pasted_secret = "sk_pasted_secret";
3472        unsafe { std::env::remove_var(pasted_secret) };
3473        let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
3474        assert!(
3475            !err.to_string().contains(pasted_secret),
3476            "error must not echo configured api_key_env values"
3477        );
3478    }
3479
3480    #[test]
3481    fn is_valid_env_var_name_rules() {
3482        // Valid: alphabetic or underscore first, then alnum/underscore.
3483        for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
3484            assert!(is_valid_env_var_name(valid), "{valid} should be valid");
3485        }
3486        // Invalid: empty, digit-first, or shell-illegal characters.
3487        for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
3488            assert!(
3489                !is_valid_env_var_name(invalid),
3490                "{invalid} should be invalid"
3491            );
3492        }
3493    }
3494
3495    #[test]
3496    fn config_parses_with_inline_api_key_and_primary() {
3497        let f = write_toml(
3498            r#"
3499            [ui]
3500            primary = "openrouter"
3501
3502            [zai]
3503            enabled = true
3504            api_key_env = "MY_ZAI"
3505            api_key = "sk-zai-inline"
3506
3507            [openrouter]
3508            enabled = true
3509            api_key = "sk-or-inline"
3510            "#,
3511        );
3512        let c = Config::load_from(f.path()).unwrap();
3513        assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
3514        assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
3515        assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
3516    }
3517
3518    #[test]
3519    fn openrouter_named_accounts_preserve_the_default_contract() {
3520        let f = write_toml(
3521            r#"
3522            [openrouter]
3523            enabled = true
3524            api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
3525            api_key = "default-inline"
3526            show_default_account = false
3527
3528            [[openrouter.accounts]]
3529            label = "work"
3530            api_key_env = "OPENROUTER_WORK_API_KEY"
3531
3532            [[openrouter.accounts]]
3533            label = "personal"
3534            api_key = "personal-inline"
3535            "#,
3536        );
3537        let _g = env_guard();
3538        unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
3539        let config = Config::load_from(f.path()).unwrap();
3540        assert!(!config.openrouter.show_default_account);
3541        assert_eq!(config.openrouter.accounts.len(), 2);
3542        assert_eq!(
3543            config.openrouter.resolve_api_key(None).unwrap(),
3544            "default-inline"
3545        );
3546        assert_eq!(
3547            config.openrouter.resolve_api_key(Some("personal")).unwrap(),
3548            "personal-inline"
3549        );
3550    }
3551
3552    #[test]
3553    fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
3554        for source in [
3555            r#"
3556            [[openrouter.accounts]]
3557            label = "work"
3558            api_key = "one"
3559            [[openrouter.accounts]]
3560            label = "work"
3561            api_key = "two"
3562            "#,
3563            r#"
3564            [[openrouter.accounts]]
3565            label = "../work"
3566            api_key = "one"
3567            "#,
3568            r#"
3569            [[openrouter.accounts]]
3570            label = "work"
3571            "#,
3572        ] {
3573            let f = write_toml(source);
3574            assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
3575        }
3576    }
3577
3578    #[test]
3579    fn openrouter_unknown_account_never_falls_back_to_default_key() {
3580        let mut config = OpenRouterConfig {
3581            api_key: Some("default-secret".into()),
3582            ..OpenRouterConfig::default()
3583        };
3584        config.accounts.push(OpenRouterAccount {
3585            label: "work".into(),
3586            api_key_env: None,
3587            api_key: Some("work-secret".into()),
3588        });
3589        let message = config
3590            .resolve_api_key(Some("missing"))
3591            .unwrap_err()
3592            .to_string();
3593        assert!(message.contains("missing") && message.contains("work"));
3594        assert!(!message.contains("default-secret"));
3595        assert!(!message.contains("work-secret"));
3596    }
3597
3598    #[test]
3599    fn openrouter_account_key_errors_do_not_echo_configured_values() {
3600        let config = OpenRouterConfig {
3601            accounts: vec![OpenRouterAccount {
3602                label: "work".into(),
3603                api_key_env: Some("sk_pasted_secret".into()),
3604                api_key: None,
3605            }],
3606            ..OpenRouterConfig::default()
3607        };
3608        let _g = env_guard();
3609        unsafe { std::env::remove_var("sk_pasted_secret") };
3610        let message = config
3611            .resolve_api_key(Some("work"))
3612            .unwrap_err()
3613            .to_string();
3614        assert!(message.contains("[[openrouter.accounts]]"));
3615        assert!(!message.contains("sk_pasted_secret"));
3616    }
3617
3618    #[test]
3619    fn enabled_vendors_preserves_canonical_order() {
3620        // DeepSeek and Kimi are disabled by default (require explicit API key
3621        // config), so they are absent from the enabled list unless enabled.
3622        let c = Config::default();
3623        assert_eq!(
3624            c.enabled_vendors(),
3625            vec![
3626                VendorId::Anthropic,
3627                VendorId::Openai,
3628                VendorId::Zai,
3629                VendorId::Openrouter,
3630                VendorId::CommandCode,
3631            ]
3632        );
3633    }
3634
3635    #[test]
3636    fn deepseek_appears_when_enabled() {
3637        let f = write_toml(
3638            r#"
3639            [deepseek]
3640            enabled = true
3641            api_key = "sk-test"
3642            "#,
3643        );
3644        let c = Config::load_from(f.path()).unwrap();
3645        assert!(c.is_enabled(VendorId::Deepseek));
3646        assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
3647        assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
3648    }
3649
3650    #[test]
3651    fn tilde_paths_are_expanded_on_load() {
3652        // `PathBuf` keeps `~` literally, so the documented
3653        // `credentials_path = "~/..."` used to resolve to a directory named
3654        // `~` relative to the process's cwd.
3655        let f = write_toml(
3656            r#"
3657            [context]
3658            projects_path = "~/.claude/projects"
3659
3660            [anthropic]
3661            credentials_path = "~/.claude/.credentials.json"
3662
3663            [[anthropic.accounts]]
3664            label = "work"
3665            credentials_path = "~/work.json"
3666            "#,
3667        );
3668        let c = Config::load_from(f.path()).unwrap();
3669        let home = crate::cache::home_dir().unwrap();
3670
3671        assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
3672        let got = c.anthropic.credentials_path.unwrap();
3673        assert_eq!(got, home.join(".claude/.credentials.json"));
3674        assert!(!got.to_string_lossy().contains('~'));
3675        assert_eq!(
3676            c.anthropic.accounts[0].credentials_path,
3677            home.join("work.json")
3678        );
3679    }
3680
3681    #[test]
3682    fn absolute_and_relative_paths_are_left_alone() {
3683        let f = write_toml(
3684            r#"
3685            [anthropic]
3686            credentials_path = "/etc/creds.json"
3687            "#,
3688        );
3689        let c = Config::load_from(f.path()).unwrap();
3690        assert_eq!(
3691            c.anthropic.credentials_path.unwrap(),
3692            std::path::Path::new("/etc/creds.json")
3693        );
3694
3695        // `~user` is not ours to interpret.
3696        let f2 = write_toml(
3697            r#"
3698            [anthropic]
3699            credentials_path = "~someone/creds.json"
3700            "#,
3701        );
3702        let c2 = Config::load_from(f2.path()).unwrap();
3703        assert_eq!(
3704            c2.anthropic.credentials_path.unwrap(),
3705            std::path::Path::new("~someone/creds.json")
3706        );
3707    }
3708
3709    #[test]
3710    fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
3711        let _g = path_override_guard();
3712        // Hermetic: only asserts the shape, never which file happens to exist
3713        // on the machine running the tests.
3714        let p = resolved_path().expect("a config path must resolve");
3715        assert!(p.ends_with("config.toml"));
3716        let canonical = default_path().unwrap();
3717        let legacy = legacy_xdg_path().unwrap();
3718        assert!(
3719            p == canonical || p == legacy,
3720            "resolved to an unexpected location: {}",
3721            p.display()
3722        );
3723    }
3724
3725    #[test]
3726    fn misspelled_section_is_rejected_not_ignored() {
3727        // The regression this guards: `[openrouer]` used to parse fine, leave
3728        // OpenRouter on its defaults, and give the user no hint at all.
3729        let f = write_toml(
3730            r#"
3731            [openrouer]
3732            enabled = true
3733            api_key = "sk-or-v1-typo"
3734            "#,
3735        );
3736        let err = Config::load_from(f.path()).unwrap_err().to_string();
3737        assert!(
3738            err.contains("openrouer"),
3739            "error should name the typo: {err}"
3740        );
3741    }
3742
3743    #[test]
3744    fn invalid_toml_is_an_error_not_silent_defaults() {
3745        let f = write_toml("[zai\nenabled = true\n");
3746        assert!(Config::load_from(f.path()).is_err());
3747    }
3748
3749    #[test]
3750    fn a_missing_file_is_still_just_defaults() {
3751        // Absence stays the legitimate "use defaults" case — only real parse
3752        // and I/O failures are errors.
3753        let dir = tempfile::tempdir().unwrap();
3754        let missing = dir.path().join("nope").join("config.toml");
3755        let c = Config::load_from(&missing).unwrap();
3756        assert!(c.is_enabled(VendorId::Anthropic));
3757    }
3758
3759    #[test]
3760    fn kimi_appears_when_enabled() {
3761        let f = write_toml(
3762            r#"
3763            [kimi]
3764            enabled = true
3765            api_key = "sk-test"
3766            "#,
3767        );
3768        let c = Config::load_from(f.path()).unwrap();
3769        assert!(c.is_enabled(VendorId::Kimi));
3770        assert!(c.enabled_vendors().contains(&VendorId::Kimi));
3771        assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
3772    }
3773
3774    #[test]
3775    fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
3776        let f = write_toml(
3777            r#"
3778            [deepseek]
3779            enabled = true
3780            api_key = "sk-ds"
3781
3782            [kimi]
3783            enabled = true
3784            api_key = "sk-kimi"
3785            "#,
3786        );
3787        let c = Config::load_from(f.path()).unwrap();
3788        assert_eq!(
3789            c.enabled_vendors(),
3790            vec![
3791                VendorId::Anthropic,
3792                VendorId::Openai,
3793                VendorId::Zai,
3794                VendorId::Openrouter,
3795                VendorId::Deepseek,
3796                VendorId::Kimi,
3797                VendorId::CommandCode,
3798            ]
3799        );
3800    }
3801
3802    #[test]
3803    fn parses_anthropic_accounts_and_looks_them_up() {
3804        let f = write_toml(
3805            r#"
3806            [anthropic]
3807            enabled = true
3808
3809            [[anthropic.accounts]]
3810            label = "personal"
3811            credentials_path = "/creds/personal.json"
3812
3813            [[anthropic.accounts]]
3814            label = "work"
3815            credentials_path = "/creds/work.json"
3816            "#,
3817        );
3818        let c = Config::load_from(f.path()).unwrap();
3819        assert_eq!(c.anthropic.accounts.len(), 2);
3820        let work = c.anthropic.account("work").unwrap();
3821        assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
3822        // A typo names the offending label and lists the known ones.
3823        let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
3824        assert!(err.contains("missing") && err.contains("work"), "{err}");
3825    }
3826
3827    #[test]
3828    fn duplicate_anthropic_account_labels_are_rejected_on_load() {
3829        let f = write_toml(
3830            r#"
3831            [[anthropic.accounts]]
3832            label = "work"
3833            credentials_path = "/creds/work-one.json"
3834
3835            [[anthropic.accounts]]
3836            label = "work"
3837            credentials_path = "/creds/work-two.json"
3838            "#,
3839        );
3840        let err = Config::load_from(f.path()).unwrap_err().to_string();
3841        assert!(
3842            err.contains("duplicate anthropic account label \"work\""),
3843            "{err}"
3844        );
3845    }
3846
3847    #[test]
3848    fn account_label_rejects_path_like_names() {
3849        let cfg = AnthropicConfig::default();
3850        for bad in [
3851            "",
3852            ".",
3853            "..",
3854            "a/b",
3855            r"a\b",
3856            "C:work",
3857            "line\nbreak",
3858            "tab\tname",
3859            "usage.json",
3860            ".stale",
3861            ".last_error",
3862            ".fetch.lock",
3863        ] {
3864            let err = cfg.account(bad).unwrap_err();
3865            assert!(
3866                format!("{err:?}").contains("invalid anthropic account label"),
3867                "{bad:?} should be rejected as a label"
3868            );
3869        }
3870    }
3871
3872    #[test]
3873    fn anthropic_accounts_default_to_empty() {
3874        // No [[anthropic.accounts]] → the single default account, empty list,
3875        // nothing to migrate (issue #14, back-compat rule 1).
3876        assert!(Config::default().anthropic.accounts.is_empty());
3877        assert!(Config::default().anthropic.accounts_dir.is_none());
3878    }
3879
3880    // --- accounts_dir: CLAUDE_CONFIG_DIR-style auto-discovery ----------------
3881    // All hermetic: discovery reads a TempDir, never the user's real config.
3882
3883    /// Create `<root>/<label>/.credentials.json` (contents irrelevant here —
3884    /// discovery keys on the file existing, the fetch path parses it).
3885    fn seed_account_dir(root: &std::path::Path, label: &str) {
3886        let dir = root.join(label);
3887        std::fs::create_dir_all(&dir).unwrap();
3888        std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
3889    }
3890
3891    #[test]
3892    fn discovers_account_dirs_in_claude_config_dir_layout() {
3893        let td = tempfile::tempdir().unwrap();
3894        seed_account_dir(td.path(), "work");
3895        seed_account_dir(td.path(), "personal");
3896        // Keychain-backed macOS logins may not write .credentials.json; their
3897        // config directories are still account entries.
3898        std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
3899        // A loose file (not a dir) is ignored.
3900        std::fs::write(td.path().join("stray.json"), "{}").unwrap();
3901
3902        let cfg = AnthropicConfig {
3903            accounts_dir: Some(td.path().to_path_buf()),
3904            ..Default::default()
3905        };
3906        let all = cfg.all_accounts();
3907        let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
3908        assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
3909        assert_eq!(
3910            all[2].credentials_path,
3911            td.path().join("work").join(".credentials.json")
3912        );
3913    }
3914
3915    #[test]
3916    fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
3917        let td = tempfile::tempdir().unwrap();
3918        seed_account_dir(td.path(), "work");
3919        let cfg = AnthropicConfig {
3920            accounts: vec![AnthropicAccount {
3921                label: "work".into(),
3922                credentials_path: "/explicit/work.json".into(),
3923            }],
3924            accounts_dir: Some(td.path().to_path_buf()),
3925            ..Default::default()
3926        };
3927        let all = cfg.all_accounts();
3928        assert_eq!(all.len(), 1, "no duplicate label");
3929        assert_eq!(
3930            all[0].credentials_path,
3931            std::path::Path::new("/explicit/work.json"),
3932            "explicit entry wins"
3933        );
3934        // A discovered account is still reachable through `account()`.
3935        seed_account_dir(td.path(), "other");
3936        assert_eq!(cfg.account("other").unwrap().label, "other");
3937    }
3938
3939    #[test]
3940    fn missing_accounts_dir_is_silently_empty_not_an_error() {
3941        let cfg = AnthropicConfig {
3942            accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
3943            ..Default::default()
3944        };
3945        assert!(cfg.all_accounts().is_empty());
3946    }
3947
3948    #[test]
3949    fn openai_account_auth_paths_are_tilde_expanded_on_load() {
3950        let f = write_toml(
3951            r#"
3952            [[openai.accounts]]
3953            label = "work"
3954            codex_auth_path = "~/.codex-work/auth.json"
3955            "#,
3956        );
3957        let c = Config::load_from(f.path()).unwrap();
3958        let home = crate::cache::home_dir().unwrap();
3959        assert_eq!(
3960            c.openai.accounts[0].codex_auth_path,
3961            home.join(".codex-work/auth.json")
3962        );
3963    }
3964
3965    #[test]
3966    fn accounts_dir_is_tilde_expanded_on_load() {
3967        let f = write_toml(
3968            r#"
3969            [anthropic]
3970            accounts_dir = "~/.config/ai-usagebar/accounts"
3971            "#,
3972        );
3973        let c = Config::load_from(f.path()).unwrap();
3974        let home = crate::cache::home_dir().unwrap();
3975        assert_eq!(
3976            c.anthropic.accounts_dir,
3977            Some(home.join(".config/ai-usagebar/accounts"))
3978        );
3979    }
3980
3981    #[test]
3982    fn desktop_profiles_dir_is_tilde_expanded_on_load() {
3983        let f = write_toml(
3984            r#"
3985            [anthropic]
3986            desktop_profiles_dir = "~/.claude-acc/profiles"
3987            "#,
3988        );
3989        let c = Config::load_from(f.path()).unwrap();
3990        let home = crate::cache::home_dir().unwrap();
3991        assert_eq!(
3992            c.anthropic.desktop_profiles_dir,
3993            Some(home.join(".claude-acc/profiles"))
3994        );
3995    }
3996
3997    #[test]
3998    fn the_live_cli_account_is_read_from_the_default_credential_slot() {
3999        let cfg = AnthropicConfig {
4000            accounts: vec![
4001                AnthropicAccount {
4002                    label: "work".into(),
4003                    credentials_path: "/tmp/accounts/work/.credentials.json".into(),
4004                },
4005                AnthropicAccount {
4006                    label: "personal".into(),
4007                    credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
4008                },
4009            ],
4010            ..Default::default()
4011        };
4012
4013        let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
4014        assert!(
4015            matches!(&idle, CredsTarget::Named { config_dir, .. }
4016                if config_dir == std::path::Path::new("/tmp/accounts/work")),
4017            "{idle:?}"
4018        );
4019
4020        // Same label, but it is the login `claude` itself is using: one lineage.
4021        let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
4022        assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
4023
4024        // The cache must not move, or a switch would silently orphan the tab's
4025        // usage history and show "Loading…" until the next fetch.
4026        assert_eq!(idle_cache.dir(), live_cache.dir());
4027    }
4028
4029    #[test]
4030    fn the_live_cli_account_keeps_its_own_slot_while_that_file_is_there() {
4031        // Two CLAUDE_CONFIG_DIRs can hold the same account, and each keeps its
4032        // own live credential — `resolve_active_label` matches the account, not
4033        // the lineage. Reading the default slot then hands back a credential
4034        // the user never logs into.
4035        let cfg = AnthropicConfig {
4036            accounts: vec![AnthropicAccount {
4037                label: "personal".into(),
4038                credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
4039            }],
4040            ..Default::default()
4041        };
4042
4043        let (present, _) = cfg
4044            .account_target_probing("personal", Some("personal"), |_| true)
4045            .unwrap();
4046        assert!(
4047            matches!(&present, CredsTarget::Named { path, .. }
4048                if path == Path::new("/tmp/accounts/personal/.credentials.json")),
4049            "{present:?}"
4050        );
4051
4052        // Emptied by `account switch`: the credential really did move.
4053        let (moved, _) = cfg
4054            .account_target_probing("personal", Some("personal"), |_| false)
4055            .unwrap();
4056        assert!(matches!(moved, CredsTarget::Default(_)), "{moved:?}");
4057    }
4058
4059    #[test]
4060    fn the_live_cli_accounts_own_file_is_probed_on_disk() {
4061        // `account_target_probing` proves the decision; only the entry point
4062        // proves that the shipping caller probes at all. Fails on main, where
4063        // the live label is routed to the default slot unconditionally.
4064        let creds = NamedTempFile::new().unwrap();
4065        let cfg = AnthropicConfig {
4066            accounts: vec![AnthropicAccount {
4067                label: "personal".into(),
4068                credentials_path: creds.path().to_path_buf(),
4069            }],
4070            ..Default::default()
4071        };
4072        let (target, _) = cfg
4073            .account_target_with("personal", Some("personal"))
4074            .unwrap();
4075        assert!(
4076            matches!(&target, CredsTarget::Named { path, .. } if path == creds.path()),
4077            "read {target:?} instead of the account's own file"
4078        );
4079    }
4080
4081    #[test]
4082    fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
4083        let cfg = AnthropicConfig {
4084            accounts: vec![AnthropicAccount {
4085                label: "work".into(),
4086                credentials_path: "/tmp/accounts/work/.credentials.json".into(),
4087            }],
4088            ..Default::default()
4089        };
4090        let (target, _) = cfg.account_target_with("work", None).unwrap();
4091        assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
4092    }
4093
4094    /// The shipped example, which `make install` puts in
4095    /// `share/ai-usagebar/config.example.toml`. Repo-relative, so this stays
4096    /// hermetic — it never touches the user's real config.
4097    fn config_example() -> PathBuf {
4098        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
4099    }
4100
4101    #[test]
4102    fn shipped_example_parses_as_a_real_config() {
4103        // The example is documentation users copy verbatim, but nothing used
4104        // to parse it — so a renamed section or field could rot there
4105        // unnoticed, and `deny_unknown_fields` would reject the copy on the
4106        // user's machine instead of in CI.
4107        let c = Config::load_from(&config_example()).unwrap();
4108        assert!(!c.context.enabled);
4109        assert!(c.is_enabled(VendorId::Anthropic));
4110        assert!(c.is_enabled(VendorId::Openai));
4111        assert!(!c.is_enabled(VendorId::AnthropicApi));
4112        assert!(!c.is_enabled(VendorId::Deepseek));
4113        assert!(!c.is_enabled(VendorId::Kimi));
4114        assert!(!c.is_enabled(VendorId::Kilo));
4115        assert!(!c.is_enabled(VendorId::Novita));
4116        assert!(!c.is_enabled(VendorId::Moonshot));
4117        assert!(!c.is_enabled(VendorId::Grok));
4118        assert!(!c.is_enabled(VendorId::Cursor));
4119        assert!(!c.is_enabled(VendorId::Minimax));
4120    }
4121
4122    #[test]
4123    fn shipped_example_does_not_advertise_admin_key_env_as_working() {
4124        // The regression: the example shipped an *uncommented*
4125        // `admin_key_env = "OPENAI_ADMIN_KEY"`, indistinguishable from a live
4126        // setting. Nothing reads it, so a user could set it, skip
4127        // `codex login`, and wait for usage that never arrives.
4128        let text = std::fs::read_to_string(config_example()).unwrap();
4129        let live: Vec<&str> = text
4130            .lines()
4131            .map(str::trim)
4132            .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
4133            .collect();
4134        assert!(
4135            live.is_empty(),
4136            "admin_key_env must stay commented out while it is inert: {live:?}"
4137        );
4138        // Still documented, though — silently dropping it would leave users
4139        // who already set it with no explanation of why it does nothing.
4140        assert!(
4141            text.contains("admin_key_env") && text.contains("RESERVED"),
4142            "the example should keep describing admin_key_env as reserved"
4143        );
4144    }
4145
4146    #[test]
4147    fn admin_key_env_is_accepted_but_changes_nothing() {
4148        // The field survives because the API-key-only path is still intended.
4149        // What has to hold today is narrower: setting it loads without error
4150        // and moves nothing the code actually acts on.
4151        let f = write_toml(
4152            r#"
4153            [openai]
4154            admin_key_env = "SOME_ADMIN_KEY"
4155            "#,
4156        );
4157        let c = Config::load_from(f.path()).unwrap();
4158        assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
4159        // Nothing else moved: OpenAI still resolves through Codex OAuth only.
4160        let default = OpenAiConfig::default();
4161        assert_eq!(c.openai.enabled, default.enabled);
4162        assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
4163        assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
4164    }
4165
4166    #[test]
4167    fn config_example_documents_every_vendor_without_secrets() {
4168        let raw = std::fs::read_to_string(config_example()).unwrap();
4169        let cfg = Config::load_from(&config_example()).unwrap();
4170        // Every vendor the binary can dispatch needs a documented section, or
4171        // users have no way to discover how to turn it on.
4172        for id in VendorId::all() {
4173            let section = id.slug();
4174            assert!(
4175                raw.contains(&format!("[{section}]")),
4176                "config.example.toml has no [{section}] section"
4177            );
4178        }
4179
4180        // The example must not ship anything enabled-by-key-only, and must not
4181        // carry a real secret.
4182        assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
4183        assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
4184        assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
4185        assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
4186        assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
4187        assert!(!cfg.supergrok.enabled);
4188        assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
4189        assert_eq!(
4190            cfg.supergrok
4191                .grok_binary
4192                .file_name()
4193                .and_then(|p| p.to_str()),
4194            Some(if cfg!(windows) { "grok.exe" } else { "grok" })
4195        );
4196        assert!(cfg.supergrok.auth_path.is_none());
4197        assert!(cfg.supergrok.config_path.is_none());
4198        assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
4199        assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
4200    }
4201
4202    #[test]
4203    fn supergrok_binary_must_not_be_empty() {
4204        let file = write_toml(
4205            r#"
4206            [supergrok]
4207            enabled = true
4208            grok_binary = ""
4209            "#,
4210        );
4211        let error = Config::load_from(file.path()).unwrap_err().to_string();
4212        assert!(error.contains("grok_binary must not be empty"));
4213    }
4214
4215    #[test]
4216    fn supergrok_paths_are_tilde_expanded() {
4217        let file = write_toml(
4218            r#"
4219            [supergrok]
4220            grok_binary = "~/bin/grok"
4221            auth_path = "~/.grok/auth.json"
4222            config_path = "~/.grok/config.toml"
4223            "#,
4224        );
4225        let config = Config::load_from(file.path()).unwrap();
4226        let home = crate::cache::home_dir().unwrap();
4227        assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
4228        assert_eq!(
4229            config.supergrok.auth_path,
4230            Some(home.join(".grok/auth.json"))
4231        );
4232        assert_eq!(
4233            config.supergrok.config_path,
4234            Some(home.join(".grok/config.toml"))
4235        );
4236    }
4237
4238    #[test]
4239    fn kiro_db_path_is_tilde_expanded() {
4240        let f = write_toml(
4241            r#"
4242            [kiro]
4243            db_path = "~/kiro-data.sqlite3"
4244            "#,
4245        );
4246        let c = Config::load_from(f.path()).unwrap();
4247        let home = crate::cache::home_dir().unwrap();
4248        assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
4249    }
4250
4251    #[test]
4252    fn kiro_appears_when_enabled() {
4253        let f = write_toml(
4254            r#"
4255            [kiro]
4256            enabled = true
4257            "#,
4258        );
4259        let c = Config::load_from(f.path()).unwrap();
4260        assert!(c.is_enabled(VendorId::Kiro));
4261        assert!(c.enabled_vendors().contains(&VendorId::Kiro));
4262    }
4263
4264    #[test]
4265    fn cursor_db_path_is_tilde_expanded() {
4266        let f = write_toml(
4267            r#"
4268            [cursor]
4269            db_path = "~/cursor-state.vscdb"
4270            "#,
4271        );
4272        let c = Config::load_from(f.path()).unwrap();
4273        let home = crate::cache::home_dir().unwrap();
4274        assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
4275    }
4276
4277    #[test]
4278    fn cursor_agent_auth_path_is_tilde_expanded() {
4279        let f = write_toml(
4280            r#"
4281            [cursor]
4282            agent_auth_path = "~/cursor-agent-auth.json"
4283            "#,
4284        );
4285        let c = Config::load_from(f.path()).unwrap();
4286        let home = crate::cache::home_dir().unwrap();
4287        assert_eq!(
4288            c.cursor.agent_auth_path,
4289            Some(home.join("cursor-agent-auth.json"))
4290        );
4291    }
4292
4293    #[test]
4294    fn cursor_appears_when_enabled() {
4295        let f = write_toml(
4296            r#"
4297            [cursor]
4298            enabled = true
4299            "#,
4300        );
4301        let c = Config::load_from(f.path()).unwrap();
4302        assert!(c.is_enabled(VendorId::Cursor));
4303        assert!(c.enabled_vendors().contains(&VendorId::Cursor));
4304    }
4305
4306    #[test]
4307    fn add_account_appends_and_preserves_existing() {
4308        let mut doc: toml_edit::DocumentMut = r#"
4309# keep me
4310[anthropic]
4311enabled = true
4312
4313[[anthropic.accounts]]
4314label = "personal"
4315credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
4316"#
4317        .parse()
4318        .unwrap();
4319        add_anthropic_account_to_doc(
4320            &mut doc,
4321            "work",
4322            "~/.config/ai-usagebar/accounts/work/.credentials.json",
4323        )
4324        .unwrap();
4325        let rendered = doc.to_string();
4326        assert!(rendered.contains("# keep me"), "comment must survive");
4327        // Round-trips through the real loader with both accounts intact and ordered.
4328        let f = write_toml(&rendered);
4329        let c = Config::load_from(f.path()).unwrap();
4330        let labels: Vec<&str> = c
4331            .anthropic
4332            .accounts
4333            .iter()
4334            .map(|a| a.label.as_str())
4335            .collect();
4336        assert_eq!(labels, vec!["personal", "work"]);
4337    }
4338
4339    #[test]
4340    fn add_account_to_empty_doc_is_loadable() {
4341        let mut doc = toml_edit::DocumentMut::new();
4342        add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
4343        let f = write_toml(&doc.to_string());
4344        let c = Config::load_from(f.path()).unwrap();
4345        assert_eq!(c.anthropic.accounts.len(), 1);
4346        assert_eq!(c.anthropic.accounts[0].label, "solo");
4347    }
4348
4349    #[test]
4350    fn add_account_rejects_duplicate_label() {
4351        let mut doc: toml_edit::DocumentMut = r#"
4352[[anthropic.accounts]]
4353label = "work"
4354credentials_path = "~/w/.credentials.json"
4355"#
4356        .parse()
4357        .unwrap();
4358        assert!(
4359            add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
4360            "a duplicate label must be rejected, not appended"
4361        );
4362    }
4363
4364    #[test]
4365    fn add_account_rejects_bad_label() {
4366        let mut doc = toml_edit::DocumentMut::new();
4367        assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
4368        assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
4369    }
4370
4371    #[test]
4372    fn tildify_collapses_home_only() {
4373        let home = Path::new("/Users/me");
4374        assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
4375        assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
4376    }
4377
4378    #[test]
4379    fn default_account_credentials_path_nests_under_config_dir() {
4380        let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
4381        assert_eq!(
4382            default_account_credentials_path(cfg, "work"),
4383            Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
4384        );
4385    }
4386    // ----- [[custom]] providers -----
4387
4388    const CUSTOM_BLOCK: &str = r#"
4389[[custom]]
4390id = "mytool"
4391name = "My Tool"
4392short_name = "myt"
4393enabled = true
4394url = "https://api.example.test/v1/usage"
4395api_key_env = "MYTOOL_API_KEY"
4396auth_header = "Authorization"
4397auth_scheme = "Bearer"
4398plan = "Pro"
4399cache_ttl_secs = 120
4400[custom.headers]
4401X-Org = "org_1"
4402[[custom.metrics]]
4403label = "Requests"
4404used = "/requests/used"
4405limit = "/requests/limit"
4406resets_at = "/requests/reset"
4407window_secs = 3600
4408[[custom.texts]]
4409label = "Tier"
4410value = "/tier"
4411"#;
4412
4413    fn custom_with(from: &str, to: &str) -> String {
4414        assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
4415        CUSTOM_BLOCK.replace(from, to)
4416    }
4417
4418    fn custom_error(toml: &str) -> String {
4419        Config::load_from(write_toml(toml).path())
4420            .unwrap_err()
4421            .to_string()
4422    }
4423
4424    fn assert_custom_rejected(toml: &str, needle: &str) {
4425        let msg = custom_error(toml);
4426        assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
4427        assert!(
4428            msg.contains("[[custom]]"),
4429            "the error must locate the section: {msg}"
4430        );
4431    }
4432
4433    #[test]
4434    fn custom_block_parses_every_field() {
4435        let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
4436        assert_eq!(config.custom.len(), 1);
4437        let c = &config.custom[0];
4438        assert_eq!(c.id, "mytool");
4439        assert_eq!(c.name, "My Tool");
4440        assert_eq!(c.short_name, "myt");
4441        assert_eq!(
4442            c.brand, None,
4443            "a custom provider has no mark unless it asks"
4444        );
4445        assert!(c.enabled);
4446        assert_eq!(c.url, "https://api.example.test/v1/usage");
4447        assert!(!c.allow_http);
4448        assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
4449        assert_eq!(c.api_key, None);
4450        assert_eq!(c.auth_header, "Authorization");
4451        assert_eq!(c.auth_scheme, "Bearer");
4452        assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
4453        assert_eq!(c.plan.as_deref(), Some("Pro"));
4454        assert_eq!(c.plan_path, None);
4455        assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
4456        assert_eq!(c.metrics.len(), 1);
4457        assert_eq!(c.metrics[0].label, "Requests");
4458        assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
4459        assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
4460        assert_eq!(c.metrics[0].percent, None);
4461        assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
4462        assert_eq!(c.metrics[0].window_secs, Some(3600));
4463        assert_eq!(c.texts.len(), 1);
4464        assert_eq!(c.texts[0].label, "Tier");
4465        assert_eq!(c.texts[0].value, "/tier");
4466        assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
4467    }
4468
4469    #[test]
4470    fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
4471        let config: Config = toml::from_str(
4472            r#"
4473            [[custom]]
4474            id = "bare"
4475            short_name = "bre"
4476            url = "https://example.test/u"
4477            [[custom.metrics]]
4478            label = "Q"
4479            percent = "/pct"
4480            "#,
4481        )
4482        .unwrap();
4483        let c = &config.custom[0];
4484        assert_eq!(c.name, "bare", "name must default to id on a plain parse");
4485        assert!(!c.enabled);
4486        assert!(!c.allow_http);
4487        assert_eq!(c.api_key_env, "");
4488        assert_eq!(c.auth_header, "Authorization");
4489        assert_eq!(c.auth_scheme, "Bearer");
4490        assert_eq!(c.cache_ttl_secs, 60);
4491        assert!(config.validate().is_ok());
4492        assert!(Config::default().custom.is_empty());
4493    }
4494
4495    #[test]
4496    fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
4497        let config = Config::load_from(
4498            write_toml(&custom_with(
4499                r#"short_name = "myt""#,
4500                "short_name = \"myt\"\nbrand = \"opencode-go\"",
4501            ))
4502            .path(),
4503        )
4504        .unwrap();
4505        assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
4506
4507        // The mark is borrowed from a vendor, so only a vendor can name one.
4508        // A free-form slug here would reach the frontend as artwork it does
4509        // not ship and draw nothing at all.
4510        for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
4511            assert_custom_rejected(
4512                &custom_with(
4513                    r#"short_name = "myt""#,
4514                    &format!("short_name = \"myt\"\nbrand = {brand:?}"),
4515                ),
4516                "must name a built-in vendor",
4517            );
4518        }
4519    }
4520
4521    #[test]
4522    fn custom_rejects_a_malformed_id() {
4523        let long = "a".repeat(33);
4524        for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
4525            let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
4526            assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
4527            assert!(msg.contains("must match"), "{id:?}: {msg}");
4528        }
4529    }
4530
4531    #[test]
4532    fn custom_rejects_a_builtin_slug_as_id() {
4533        assert_custom_rejected(
4534            &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
4535            "is a built-in vendor",
4536        );
4537        assert_custom_rejected(
4538            &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
4539            "is a built-in vendor",
4540        );
4541    }
4542
4543    #[test]
4544    fn custom_rejects_duplicate_ids() {
4545        let twice = format!(
4546            "{}{}",
4547            CUSTOM_BLOCK,
4548            custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
4549        );
4550        assert_custom_rejected(&twice, "duplicate id");
4551    }
4552
4553    #[test]
4554    fn custom_rejects_a_name_over_48_chars() {
4555        let long = "n".repeat(49);
4556        assert_custom_rejected(
4557            &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
4558            "name must be 1 to 48 characters",
4559        );
4560    }
4561
4562    #[test]
4563    fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
4564        for short in ["my", "myto", "MYT", "m1t"] {
4565            assert_custom_rejected(
4566                &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
4567                "exactly 3 lowercase ASCII letters",
4568            );
4569        }
4570    }
4571
4572    #[test]
4573    fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
4574        assert_custom_rejected(
4575            &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
4576            "already used by a built-in vendor",
4577        );
4578        let twice = format!(
4579            "{}{}",
4580            CUSTOM_BLOCK,
4581            custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
4582        );
4583        assert_custom_rejected(&twice, "already used by a built-in vendor");
4584    }
4585
4586    #[test]
4587    fn custom_rejects_http_unless_allowed() {
4588        let plain = custom_with(
4589            r#"url = "https://api.example.test/v1/usage""#,
4590            r#"url = "http://localhost:8080/usage""#,
4591        );
4592        assert_custom_rejected(&plain, "url must use https://");
4593        let allowed = plain.replace(
4594            r#"url = "http://localhost:8080/usage""#,
4595            "url = \"http://localhost:8080/usage\"\nallow_http = true",
4596        );
4597        assert!(
4598            Config::load_from(write_toml(&allowed).path()).is_ok(),
4599            "allow_http must permit http://"
4600        );
4601    }
4602
4603    #[test]
4604    fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
4605        assert_custom_rejected(
4606            &custom_with(
4607                r#"url = "https://api.example.test/v1/usage""#,
4608                r#"url = "https://user:pw@api.example.test/v1/usage""#,
4609            ),
4610            "must not carry credentials",
4611        );
4612        assert_custom_rejected(
4613            &custom_with(
4614                r#"url = "https://api.example.test/v1/usage""#,
4615                r#"url = "not a url""#,
4616            ),
4617            "is not a valid URL",
4618        );
4619        assert_custom_rejected(
4620            &custom_with(
4621                r#"url = "https://api.example.test/v1/usage""#,
4622                r#"url = "ftp://api.example.test/v1/usage""#,
4623            ),
4624            "is not http or https",
4625        );
4626    }
4627
4628    #[test]
4629    fn custom_rejects_an_invalid_api_key_env() {
4630        assert_custom_rejected(
4631            &custom_with(
4632                r#"api_key_env = "MYTOOL_API_KEY""#,
4633                r#"api_key_env = "1BAD-NAME""#,
4634            ),
4635            "is not a valid environment variable name",
4636        );
4637        let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
4638        assert!(
4639            Config::load_from(write_toml(&none).path()).is_ok(),
4640            "an empty api_key_env means inline-only and is valid"
4641        );
4642    }
4643
4644    #[test]
4645    fn custom_rejects_an_invalid_auth_header_name() {
4646        assert_custom_rejected(
4647            &custom_with(
4648                r#"auth_header = "Authorization""#,
4649                r#"auth_header = "X Api Key""#,
4650            ),
4651            "auth_header \"X Api Key\" is not a valid HTTP header name",
4652        );
4653    }
4654
4655    #[test]
4656    fn custom_rejects_a_control_char_in_auth_scheme() {
4657        assert_custom_rejected(
4658            &custom_with(
4659                r#"auth_scheme = "Bearer""#,
4660                "auth_scheme = \"Bearer\\u0007\"",
4661            ),
4662            "auth_scheme contains characters that are not valid",
4663        );
4664        let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
4665        assert!(
4666            Config::load_from(write_toml(&bare).path()).is_ok(),
4667            "an empty scheme (bare key) is valid"
4668        );
4669    }
4670
4671    #[test]
4672    fn custom_rejects_a_bad_extra_header() {
4673        assert_custom_rejected(
4674            &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
4675            "headers must not repeat auth_header",
4676        );
4677        assert_custom_rejected(
4678            &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
4679            "is not a valid HTTP header name",
4680        );
4681        assert_custom_rejected(
4682            &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
4683            "has a value that is not valid in an HTTP header",
4684        );
4685    }
4686
4687    #[test]
4688    fn custom_rejects_cache_ttl_outside_10_to_3600() {
4689        for ttl in ["9", "3601"] {
4690            assert_custom_rejected(
4691                &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
4692                "cache_ttl_secs must be between 10 and 3600",
4693            );
4694        }
4695    }
4696
4697    #[test]
4698    fn custom_rejects_an_entry_with_no_metrics_or_texts() {
4699        let toml = r#"
4700[[custom]]
4701id = "empty"
4702short_name = "emp"
4703url = "https://example.test/u"
4704"#;
4705        assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
4706    }
4707
4708    #[test]
4709    fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
4710        assert_custom_rejected(
4711            &custom_with(
4712                r#"limit = "/requests/limit""#,
4713                "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
4714            ),
4715            "must set `percent`, or both `used` and `limit`",
4716        );
4717        assert_custom_rejected(
4718            &custom_with("limit = \"/requests/limit\"\n", ""),
4719            "must set `percent`, or both `used` and `limit`",
4720        );
4721    }
4722
4723    #[test]
4724    fn custom_rejects_a_pointer_without_a_leading_slash() {
4725        assert_custom_rejected(
4726            &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
4727            "used \"requests.used\" must be an RFC 6901 JSON Pointer",
4728        );
4729        assert_custom_rejected(
4730            &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
4731            "value \"tier\" must be an RFC 6901 JSON Pointer",
4732        );
4733        assert_custom_rejected(
4734            &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
4735            "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
4736        );
4737        assert_custom_rejected(
4738            &custom_with(
4739                r#"resets_at = "/requests/reset""#,
4740                "resets_at = \"/re\\u001bset\"",
4741            ),
4742            "resets_at",
4743        );
4744    }
4745
4746    #[test]
4747    fn custom_rejects_a_label_outside_1_to_64_chars() {
4748        let long = "l".repeat(65);
4749        assert_custom_rejected(
4750            &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
4751            "metric label",
4752        );
4753        assert_custom_rejected(
4754            &custom_with(r#"label = "Tier""#, r#"label = """#),
4755            "text label \"\" must be 1 to 64 characters",
4756        );
4757    }
4758
4759    #[test]
4760    fn custom_rejects_window_secs_under_60() {
4761        assert_custom_rejected(
4762            &custom_with("window_secs = 3600", "window_secs = 59"),
4763            "window_secs must be at least 60",
4764        );
4765    }
4766
4767    #[test]
4768    fn custom_rejects_duplicate_metric_and_text_labels() {
4769        let metric_twice = custom_with(
4770            "window_secs = 3600\n",
4771            "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
4772        );
4773        assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
4774        let text_twice =
4775            format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
4776        assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
4777    }
4778
4779    #[test]
4780    fn enabled_custom_and_custom_by_id_select_entries() {
4781        let two = format!(
4782            "{}{}",
4783            CUSTOM_BLOCK,
4784            custom_with(r#"id = "mytool""#, r#"id = "off""#)
4785                .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
4786                .replace("enabled = true", "enabled = false")
4787        );
4788        let config = Config::load_from(write_toml(&two).path()).unwrap();
4789        let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
4790        assert_eq!(enabled, ["mytool"]);
4791        assert_eq!(
4792            config.custom_by_id("off").map(|c| c.name.as_str()),
4793            Some("My Tool")
4794        );
4795        assert!(config.custom_by_id("nope").is_none());
4796    }
4797
4798    #[cfg(unix)]
4799    #[test]
4800    fn has_inline_secrets_sees_a_custom_inline_key() {
4801        let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
4802        assert!(!without.has_inline_secrets());
4803        let with: Config = toml::from_str(&custom_with(
4804            r#"api_key_env = "MYTOOL_API_KEY""#,
4805            "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
4806        ))
4807        .unwrap();
4808        assert!(with.has_inline_secrets());
4809    }
4810
4811    #[test]
4812    fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
4813        let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
4814        let mut spec = CustomProviderConfig {
4815            id: "mytool".into(),
4816            api_key_env: var.into(),
4817            api_key: Some("sk-inline-secret".into()),
4818            ..CustomProviderConfig::default()
4819        };
4820        unsafe { std::env::set_var(var, "sk-env-secret") };
4821        let from_env = spec.resolve_api_key();
4822        unsafe { std::env::remove_var(var) };
4823        assert_eq!(from_env.unwrap(), "sk-env-secret");
4824
4825        assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
4826
4827        spec.api_key = Some(String::new());
4828        let err = spec.resolve_api_key().unwrap_err();
4829        assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
4830        let msg = err.to_string();
4831        assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
4832        assert!(msg.contains(var), "{msg}");
4833        assert!(!msg.contains("secret"), "{msg}");
4834
4835        spec.api_key_env = String::new();
4836        let msg = spec.resolve_api_key().unwrap_err().to_string();
4837        assert!(msg.contains("set `api_key`"), "{msg}");
4838    }
4839
4840    #[test]
4841    fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
4842        let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
4843        assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
4844        let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
4845        Config::load_from(file.path()).unwrap();
4846        assert!(
4847            crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
4848            "a custom provider's env var must be scrubbed from subprocesses"
4849        );
4850    }
4851
4852    /// `VendorId::config_section` is what every by-name config writer uses;
4853    /// this proves each section name is one the parser actually recognizes
4854    /// (the `deny_unknown_fields` on `Config` makes a misspelling fail loudly)
4855    /// and lands on that vendor's `enabled` switch.
4856    #[test]
4857    fn every_config_section_parses_to_its_vendors_enabled_switch() {
4858        for vendor in VendorId::all() {
4859            let text = format!(
4860                "[{}]
4861enabled = true
4862",
4863                vendor.config_section()
4864            );
4865            let config: Config = toml::from_str(&text)
4866                .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
4867            assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
4868            let others = VendorId::all()
4869                .iter()
4870                .filter(|other| *other != vendor && config.is_enabled(**other))
4871                .count();
4872            assert_eq!(
4873                others,
4874                Config::default().enabled_vendors().len()
4875                    - usize::from(Config::default().is_enabled(*vendor)),
4876                "[{}] enabled a different vendor",
4877                vendor.config_section()
4878            );
4879        }
4880    }
4881
4882    #[test]
4883    fn tray_section_parses_and_defaults_to_notify() {
4884        let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
4885        let config = Config::load_from(file.path()).unwrap();
4886        assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
4887        assert_eq!(config.tray.updates(), UpdateMode::Auto);
4888
4889        let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4890        assert_eq!(empty.tray, TrayConfig::default());
4891        assert_eq!(empty.tray.updates(), UpdateMode::Notify);
4892        assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
4893        assert_eq!(UpdateMode::parse("weekly"), None);
4894        assert_eq!(UpdateMode::Auto.as_str(), "auto");
4895    }
4896
4897    #[test]
4898    fn tray_section_rejects_a_misspelled_mode() {
4899        let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
4900        assert!(Config::load_from(file.path()).is_err());
4901    }
4902
4903    #[test]
4904    fn tray_refresh_minutes_defaults_to_five_and_parses() {
4905        let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4906        assert_eq!(empty.tray.refresh_minutes, None);
4907        assert_eq!(empty.tray.refresh_minutes(), 5);
4908
4909        let file = write_toml("[tray]\nrefresh_minutes = 10\n");
4910        let config = Config::load_from(file.path()).unwrap();
4911        assert_eq!(config.tray.refresh_minutes(), 10);
4912    }
4913
4914    #[test]
4915    fn tray_refresh_minutes_rejects_values_outside_the_menu() {
4916        for minutes in ["3", "0"] {
4917            let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
4918            let error = Config::load_from(file.path()).unwrap_err().to_string();
4919            assert!(error.contains("[tray] refresh_minutes"), "{error}");
4920            assert!(error.contains("1, 5 or 10"), "{error}");
4921        }
4922    }
4923
4924    #[test]
4925    fn notifications_default_on_at_97_and_parse_overrides() {
4926        let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4927        assert!(empty.notifications.enabled);
4928        assert_eq!(empty.notifications.threshold, 97);
4929
4930        let file = write_toml("[notifications]\nenabled = false\nthreshold = 100\n");
4931        let config = Config::load_from(file.path()).unwrap();
4932        assert!(!config.notifications.enabled);
4933        assert_eq!(config.notifications.threshold, 100);
4934    }
4935
4936    #[test]
4937    fn notifications_threshold_rejects_values_outside_1_to_100() {
4938        for threshold in ["0", "101", "255"] {
4939            let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
4940            let error = Config::load_from(file.path()).unwrap_err().to_string();
4941            assert!(
4942                error.contains("[notifications] threshold must be between 1 and 100"),
4943                "threshold {threshold}: {error}"
4944            );
4945        }
4946        // The boundaries themselves are valid.
4947        for threshold in ["1", "50", "100"] {
4948            let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
4949            let config = Config::load_from(file.path()).unwrap();
4950            assert_eq!(config.notifications.threshold.to_string(), threshold);
4951        }
4952    }
4953
4954    #[test]
4955    fn notification_preferences_round_trip_without_changing_other_sections() {
4956        let dir = tempfile::tempdir().unwrap();
4957        let path = dir.path().join("config.toml");
4958        std::fs::write(&path, "# keep this\n[tray]\nrefresh_minutes = 10\n").unwrap();
4959        set_notification_value(&path, "enabled", false.into()).unwrap();
4960        set_notification_value(&path, "threshold", 85i64.into()).unwrap();
4961        let text = std::fs::read_to_string(&path).unwrap();
4962        assert!(text.contains("# keep this\n[tray]\nrefresh_minutes = 10"));
4963        let config = Config::load_from(&path).unwrap();
4964        assert!(!config.notifications.enabled);
4965        assert_eq!(config.notifications.threshold, 85);
4966        assert!(set_notification_value(&path, "threshold", 101i64.into()).is_err());
4967        assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
4968    }
4969
4970    #[test]
4971    fn set_tray_value_writes_refresh_minutes_as_an_integer() {
4972        let dir = tempfile::tempdir().unwrap();
4973        let path = dir.path().join("config.toml");
4974        std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
4975
4976        set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
4977        let text = std::fs::read_to_string(&path).unwrap();
4978        assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
4979        assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
4980
4981        set_tray_value(&path, "refresh_minutes", None).unwrap();
4982        assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
4983    }
4984
4985    #[test]
4986    fn set_tray_value_creates_replaces_and_removes_keys() {
4987        let dir = tempfile::tempdir().unwrap();
4988        let path = dir.path().join("config.toml");
4989        std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
4990
4991        set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
4992        let text = std::fs::read_to_string(&path).unwrap();
4993        assert!(text.contains("# primary = \"anthropic\""), "{text}");
4994        assert!(
4995            text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
4996            "{text}"
4997        );
4998
4999        set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
5000        set_tray_value(&path, "updates", Some("off".into())).unwrap();
5001        let config = Config::load_from(&path).unwrap();
5002        assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
5003        assert_eq!(config.tray.updates(), UpdateMode::Off);
5004
5005        set_tray_value(&path, "shortcut", None).unwrap();
5006        let text = std::fs::read_to_string(&path).unwrap();
5007        assert!(!text.contains("shortcut"), "{text}");
5008        assert!(text.contains("updates = \"off\""), "{text}");
5009
5010        // Idempotent removal does not rewrite the file.
5011        let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
5012        set_tray_value(&path, "shortcut", None).unwrap();
5013        assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
5014    }
5015
5016    #[test]
5017    fn set_value_keeps_the_trailing_comment_when_replacing() {
5018        let mut doc: toml_edit::DocumentMut =
5019            "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
5020        set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
5021        assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
5022    }
5023
5024    #[test]
5025    fn enable_vendors_in_creates_a_missing_config() {
5026        let dir = tempfile::TempDir::new().unwrap();
5027        let path = dir.path().join("sub").join("config.toml");
5028
5029        enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5030
5031        assert_eq!(
5032            std::fs::read_to_string(&path).unwrap(),
5033            "[grok]
5034enabled = true
5035"
5036        );
5037        assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
5038    }
5039
5040    #[test]
5041    fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
5042        let dir = tempfile::TempDir::new().unwrap();
5043        let path = dir.path().join("config.toml");
5044        let original = "# my settings
5045[zai]
5046api_key = \"x\" # keep
5047enabled = false
5048";
5049        std::fs::write(&path, original).unwrap();
5050
5051        enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
5052
5053        let text = std::fs::read_to_string(&path).unwrap();
5054        assert!(
5055            text.starts_with(
5056                "# my settings
5057"
5058            ),
5059            "{text}"
5060        );
5061        assert!(
5062            text.contains(
5063                "api_key = \"x\" # keep
5064"
5065            ),
5066            "{text}"
5067        );
5068        assert!(
5069            text.contains(
5070                "[grok]
5071enabled = true
5072"
5073            ),
5074            "{text}"
5075        );
5076        assert!(
5077            text.contains(
5078                "[opencode-go]
5079enabled = true
5080"
5081            ),
5082            "{text}"
5083        );
5084        let config = Config::load_from(&path).unwrap();
5085        assert!(
5086            !config.is_enabled(VendorId::Zai),
5087            "never widens to false, never flips others"
5088        );
5089        assert!(config.is_enabled(VendorId::Grok));
5090        assert!(config.is_enabled(VendorId::OpenCodeGo));
5091    }
5092
5093    #[test]
5094    fn enable_vendors_in_leaves_an_explicit_false_alone() {
5095        let dir = tempfile::TempDir::new().unwrap();
5096        let path = dir.path().join("config.toml");
5097        let original = "[grok]
5098enabled = false # off
5099api_key = \"k\"
5100";
5101        std::fs::write(&path, original).unwrap();
5102
5103        // `enabled = false` in the file is the user having said no. Only the
5104        // automatic path goes through here — the Settings overlay writes with
5105        // `set_bool` — so nothing a person does by hand is blocked by this.
5106        let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5107
5108        assert!(written.is_empty(), "{written:?}");
5109        assert_eq!(
5110            std::fs::read_to_string(&path).unwrap(),
5111            original,
5112            "the file must not be rewritten at all"
5113        );
5114    }
5115
5116    #[test]
5117    fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
5118        let dir = tempfile::TempDir::new().unwrap();
5119        let path = dir.path().join("config.toml");
5120        std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
5121
5122        let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5123
5124        assert_eq!(written, vec![VendorId::Grok]);
5125        assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
5126    }
5127
5128    #[test]
5129    fn enable_vendors_in_is_textually_idempotent() {
5130        let dir = tempfile::TempDir::new().unwrap();
5131        let path = dir.path().join("config.toml");
5132        let original = "[grok]
5133enabled = true
5134
5135# trailing
5136";
5137        std::fs::write(&path, original).unwrap();
5138        let before = std::fs::metadata(&path).unwrap().modified().unwrap();
5139
5140        enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5141
5142        assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
5143        assert_eq!(
5144            std::fs::metadata(&path).unwrap().modified().unwrap(),
5145            before,
5146            "an unchanged document must not be rewritten"
5147        );
5148    }
5149
5150    #[test]
5151    fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
5152        let dir = tempfile::TempDir::new().unwrap();
5153        let path = dir.path().join("config.toml");
5154
5155        enable_vendors_in(&path, &[]).unwrap();
5156
5157        assert!(!path.exists());
5158    }
5159}