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