Skip to main content

ai_usagebar/
config.rs

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