Skip to main content

ai_usagebar/
config.rs

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