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