Skip to main content

ai_usagebar/
config.rs

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