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_like_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_like_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    /// Optional plan tier label (go/standard/big) — display-only. The
1697    /// quotas API carries no tier identifier, so the label is manual.
1698    pub plan_tier: Option<String>,
1699}
1700
1701impl Default for SyntheticConfig {
1702    fn default() -> Self {
1703        Self {
1704            enabled: false,
1705            api_key_env: "SYNTHETIC_API_KEY".into(),
1706            api_key: None,
1707            plan_tier: None,
1708        }
1709    }
1710}
1711
1712#[derive(Debug, Clone, Deserialize, Serialize)]
1713#[serde(default)]
1714pub struct LyceumConfig {
1715    pub enabled: bool,
1716    pub accounts: Vec<ApiKeyAccount>,
1717    pub show_default_account: bool,
1718    pub api_key_env: String,
1719    pub api_key: Option<String>,
1720}
1721impl Default for LyceumConfig {
1722    fn default() -> Self {
1723        Self {
1724            enabled: false,
1725            accounts: Vec::new(),
1726            show_default_account: true,
1727            api_key_env: "LYCEUM_API_KEY".into(),
1728            api_key: None,
1729        }
1730    }
1731}
1732
1733#[derive(Debug, Clone, Deserialize, Serialize)]
1734#[serde(default)]
1735pub struct MinimaxConfig {
1736    pub enabled: bool,
1737    /// Extra accounts beyond the default key (#221's array, generalized).
1738    /// Each account gets a separate aggregate-view entry and cache directory.
1739    pub accounts: Vec<ApiKeyAccount>,
1740    /// Whether aggregate views include the default (unnamed) key when named
1741    /// accounts exist. Ignored when `accounts` is empty so MiniMax never
1742    /// loses its only tab.
1743    pub show_default_account: bool,
1744    pub api_key_env: String,
1745    pub api_key: Option<String>,
1746    /// `"global"` → api.minimax.io; `"cn"` → api.minimaxi.com. Unlike
1747    /// Moonshot's, this does not change the unit — MiniMax reports quota as a
1748    /// percentage either way. It picks the *instance*: a key issued for one
1749    /// host is rejected by the other (`status_code 2049`), so pointing this at
1750    /// the wrong region reads as an invalid key rather than an empty plan.
1751    pub region: String,
1752}
1753
1754impl Default for MinimaxConfig {
1755    fn default() -> Self {
1756        // Opt-in like the other API-key vendors: needs an explicit key.
1757        Self {
1758            enabled: false,
1759            accounts: Vec::new(),
1760            show_default_account: true,
1761            api_key_env: "MINIMAX_API_KEY".to_string(),
1762            api_key: None,
1763            region: "global".to_string(),
1764        }
1765    }
1766}
1767
1768#[derive(Debug, Clone, Deserialize, Serialize)]
1769#[serde(default)]
1770pub struct MoonshotConfig {
1771    pub enabled: bool,
1772    /// Extra accounts beyond the default key (#221's array, generalized).
1773    /// Each account gets a separate aggregate-view entry and cache directory.
1774    pub accounts: Vec<ApiKeyAccount>,
1775    /// Whether aggregate views include the default (unnamed) key when named
1776    /// accounts exist. Ignored when `accounts` is empty so Moonshot never
1777    /// loses its only tab.
1778    pub show_default_account: bool,
1779    pub api_key_env: String,
1780    pub api_key: Option<String>,
1781    /// `"global"` → api.moonshot.ai (USD); `"cn"` → api.moonshot.cn (CNY).
1782    pub region: String,
1783    /// Tank size in the currency the chosen region reports — USD for `global`,
1784    /// CNY for `cn`. See [`DisplayPrefs`].
1785    pub display_limit: Option<f64>,
1786    /// Which number goes on the bar. See [`DisplayPrefs`].
1787    pub headline: Headline,
1788}
1789
1790impl Default for MoonshotConfig {
1791    fn default() -> Self {
1792        // Opt-in like DeepSeek/Kilo/Novita: needs an explicit API key.
1793        Self {
1794            enabled: false,
1795            accounts: Vec::new(),
1796            show_default_account: true,
1797            api_key_env: "MOONSHOT_API_KEY".to_string(),
1798            api_key: None,
1799            region: "global".to_string(),
1800            display_limit: None,
1801            headline: Headline::Amount,
1802        }
1803    }
1804}
1805
1806#[derive(Debug, Clone, Deserialize, Serialize)]
1807#[serde(default)]
1808pub struct GrokConfig {
1809    pub enabled: bool,
1810    /// Extra accounts beyond the default key (#221's array, generalized).
1811    /// Each account gets a separate aggregate-view entry and cache directory.
1812    pub accounts: Vec<ApiKeyAccount>,
1813    /// Whether aggregate views include the default (unnamed) key when named
1814    /// accounts exist. Ignored when `accounts` is empty so Grok never
1815    /// loses its only tab.
1816    pub show_default_account: bool,
1817    /// Env var for the xAI **Management** key (distinct from the inference key).
1818    pub api_key_env: String,
1819    pub api_key: Option<String>,
1820    /// Optional team id. When absent, it's auto-resolved from the management
1821    /// key via `/auth/management-keys/validation`.
1822    pub team_id: Option<String>,
1823    /// Tank size in USD for the prepaid credit balance. See [`DisplayPrefs`].
1824    pub display_limit: Option<f64>,
1825    /// Which number goes on the bar. See [`DisplayPrefs`].
1826    pub headline: Headline,
1827}
1828
1829impl Default for GrokConfig {
1830    fn default() -> Self {
1831        // Opt-in: needs a management key (and, for prepaid, a team).
1832        Self {
1833            enabled: false,
1834            accounts: Vec::new(),
1835            show_default_account: true,
1836            api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1837            api_key: None,
1838            team_id: None,
1839            display_limit: None,
1840            headline: Headline::Amount,
1841        }
1842    }
1843}
1844
1845/// SuperGrok subscription auth — no API key of its own. Billing and banked
1846/// resets use the `key` already in Grok Build's `auth.json` (read-only).
1847/// Login, issuer, proxy, and token rotation stay inside Grok Build.
1848///
1849/// Opt-in like Cursor/Kiro (`enabled` defaults to `false`): it requires a
1850/// separate official executable and signed-in session, so it stays off until
1851/// the user explicitly turns it on.
1852#[derive(Debug, Clone, Deserialize, Serialize)]
1853#[serde(default)]
1854pub struct SuperGrokConfig {
1855    pub enabled: bool,
1856    /// Trusted official Grok Build executable. Defaults to its canonical
1857    /// `$GROK_HOME/bin/grok` (or `~/.grok/bin/grok`) installation path instead
1858    /// of searching PATH, where unrelated programs can share the name.
1859    pub grok_binary: PathBuf,
1860    /// Opaque auth/config files used only to fingerprint the active cache
1861    /// scope. Their contents are never parsed or copied to the cache.
1862    pub auth_path: Option<PathBuf>,
1863    pub config_path: Option<PathBuf>,
1864}
1865
1866impl Default for SuperGrokConfig {
1867    fn default() -> Self {
1868        Self {
1869            enabled: false,
1870            grok_binary: default_grok_binary(),
1871            auth_path: None,
1872            config_path: None,
1873        }
1874    }
1875}
1876
1877/// Grok Bot — the desktop app's weekly included-usage pool, from its own
1878/// Connect-RPC dashboard call. Distinct from `[grok]` (Management API prepaid
1879/// dollars) and `[supergrok]` (Grok Build subscription). No API key: the
1880/// credential is the app's own session in `sand-secrets.json` (read-only).
1881/// Linux and macOS; Windows fails closed at fetch time.
1882#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1883#[serde(default)]
1884pub struct GrokbotConfig {
1885    /// Opt-in (defaults to `false`), like every vendor riding a local app's
1886    /// session.
1887    pub enabled: bool,
1888    /// Override for the app's credential file (default
1889    /// `~/.config/Grok Bot/sand-secrets.json` on Linux,
1890    /// `~/Library/Application Support/Grok Bot/sand-secrets.json` on macOS),
1891    /// mirroring `[cursor] db_path` and `[kimi] credentials_path`.
1892    pub secrets_path: Option<PathBuf>,
1893}
1894
1895fn default_grok_binary() -> PathBuf {
1896    let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1897    let grok_home = std::env::var_os("GROK_HOME")
1898        .filter(|value| !value.is_empty())
1899        .map(PathBuf::from)
1900        .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1901    grok_home
1902        .map(|home| home.join("bin").join(executable))
1903        .unwrap_or_else(|| PathBuf::from(executable))
1904}
1905
1906/// Antigravity reads its quota from a usable local Antigravity product. When no
1907/// product is up — or `agy` requires the CSRF token it does not publish — it
1908/// falls back to the Google session Antigravity saved in the OS keyring and
1909/// talks to Cloud Code directly. Renewing that session needs Antigravity's
1910/// OAuth client id and secret, which are not shipped in source: set them here
1911/// (they are public installed-app credentials) or the fallback only lasts as
1912/// long as the saved access token does.
1913#[derive(Debug, Clone, Deserialize, Serialize)]
1914#[serde(default)]
1915pub struct AntigravityConfig {
1916    pub enabled: bool,
1917    /// Exact `agy` executable to run for quota and saved-session renewal.
1918    /// Unset preserves discovery on `PATH` and in `~/.local/bin`.
1919    pub agy_binary: Option<PathBuf>,
1920    /// OAuth client id used to refresh the keyring session.
1921    pub oauth_client_id: Option<String>,
1922    /// OAuth client secret paired with `oauth_client_id`. An
1923    /// installed-app secret is not confidential by Google's definition, but
1924    /// it is still treated as an inline credential for file-permission purposes.
1925    pub oauth_client_secret: Option<String>,
1926    /// Probe the Antigravity IDE's own language server as a quota source
1927    /// (default true). Set false when the IDE is installed but its sign-in is
1928    /// managed separately: a signed-out IDE language server answers 401/403,
1929    /// which outranks and masks a working `agy` session (#405).
1930    pub ide_language_server: bool,
1931}
1932
1933impl Default for AntigravityConfig {
1934    fn default() -> Self {
1935        Self {
1936            enabled: false,
1937            agy_binary: None,
1938            oauth_client_id: None,
1939            oauth_client_secret: None,
1940            ide_language_server: true,
1941        }
1942    }
1943}
1944
1945/// Cursor reads its quota through a session token the Cursor IDE already
1946/// wrote to its local `state.vscdb` — no API key, but (unlike Antigravity)
1947/// there is a real on-disk path that can need overriding (e.g. a portable or
1948/// non-default Cursor install), mirroring `openai.codex_auth_path`.
1949///
1950/// Opt-in like DeepSeek/Kilo/etc (`enabled` defaults to `false`, matching
1951/// `bool::default()`): reads an undocumented endpoint via a session token
1952/// scraped from a local IDE file, so it stays off until the user explicitly
1953/// turns it on.
1954#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1955#[serde(default)]
1956pub struct CursorConfig {
1957    pub enabled: bool,
1958    /// Override Cursor's local state database path (defaults to the
1959    /// platform-standard `.../User/globalStorage/state.vscdb` — see
1960    /// `cursor::db::default_db_path`).
1961    pub db_path: Option<PathBuf>,
1962    /// Override the headless `cursor-agent` CLI's own login file (defaults to
1963    /// `.../cursor/auth.json` — see `cursor::db::default_agent_auth_path`).
1964    /// Used as a fallback when `db_path` doesn't exist, so a text-only
1965    /// machine that never runs the desktop IDE still gets usage.
1966    pub agent_auth_path: Option<PathBuf>,
1967}
1968
1969/// Kiro CLI reads its quota through the AWS SSO OIDC session kiro-cli already
1970/// wrote to its own local `data.sqlite3` — no API key, but (like Cursor) a
1971/// real on-disk path that can need overriding.
1972///
1973/// Opt-in like Cursor/DeepSeek/Kilo/etc (`enabled` defaults to `false`):
1974/// calls a reverse-engineered CodeWhisperer endpoint via a session token
1975/// scraped from a local CLI database, so it stays off until the user
1976/// explicitly turns it on.
1977#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1978#[serde(default)]
1979pub struct KiroConfig {
1980    pub enabled: bool,
1981    /// Override kiro-cli's local database path (defaults to the
1982    /// platform-standard `.../kiro-cli/data.sqlite3` — see
1983    /// `kiro::db::default_db_path`).
1984    pub db_path: Option<PathBuf>,
1985}
1986
1987#[derive(Debug, Clone, Deserialize, Serialize)]
1988#[serde(default)]
1989pub struct AnthropicApiConfig {
1990    pub enabled: bool,
1991    /// Env var for the Console **Admin key** (`sk-ant-admin01-…`), distinct from
1992    /// an inference key and from the Claude Code OAuth login.
1993    pub api_key_env: String,
1994    pub api_key: Option<String>,
1995    /// Monthly USD spend limit, used only for the spend-vs-limit % display. The
1996    /// API exposes neither this limit nor the remaining prepaid balance.
1997    pub monthly_limit: Option<f64>,
1998}
1999
2000impl Default for AnthropicApiConfig {
2001    fn default() -> Self {
2002        // Opt-in: needs an explicit Admin key.
2003        Self {
2004            enabled: false,
2005            api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
2006            api_key: None,
2007            monthly_limit: None,
2008        }
2009    }
2010}
2011
2012/// A user-defined HTTP provider: one GET with a static token, projected onto
2013/// the shared report shape through RFC 6901 JSON Pointers.
2014///
2015/// Everything a built-in vendor hard-codes is a field here, which is why this
2016/// type validates so much more than the others: a typo in `[deepseek]` hits a
2017/// fixed endpoint and fails loudly, while a typo here quietly sends the user's
2018/// key to the wrong host. The `id` doubles as the cache directory name and the
2019/// `--vendor` selector, so it is held to the character class of the built-in
2020/// slugs.
2021#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
2022#[serde(default, remote = "Self")]
2023pub struct CustomProviderConfig {
2024    /// `[a-z0-9][a-z0-9_-]{0,31}`; unique, and never a built-in vendor's slug.
2025    pub id: String,
2026    /// Display name, 1 to 48 characters. Defaults to `id`.
2027    pub name: String,
2028    /// Exactly three lowercase ASCII letters, unique across built-in vendors
2029    /// and other custom providers — it is the `{vendor_short}` bar tag.
2030    pub short_name: String,
2031    /// A built-in vendor slug whose mark supporting frontends may use.
2032    /// `None` preserves the custom provider's `short_name` tag.
2033    pub brand: Option<String>,
2034    pub enabled: bool,
2035    /// `https://` unless `allow_http`; never carries `user:pass@`.
2036    pub url: String,
2037    pub allow_http: bool,
2038    /// Env var read first; `""` means the inline `api_key` is the only source.
2039    pub api_key_env: String,
2040    pub api_key: Option<String>,
2041    /// The header that carries the key.
2042    pub auth_header: String,
2043    /// Sent as `"<scheme> <key>"`; `""` sends the bare key.
2044    pub auth_scheme: String,
2045    /// Extra non-secret headers.
2046    pub headers: BTreeMap<String, String>,
2047    /// Literal plan label.
2048    pub plan: Option<String>,
2049    /// Pointer to the plan label in the response; wins over `plan`.
2050    pub plan_path: Option<String>,
2051    /// Must be within `10..=3600`.
2052    pub cache_ttl_secs: u64,
2053    pub metrics: Vec<CustomMetricSpec>,
2054    pub texts: Vec<CustomTextSpec>,
2055}
2056
2057impl Default for CustomProviderConfig {
2058    fn default() -> Self {
2059        Self {
2060            id: String::new(),
2061            name: String::new(),
2062            short_name: String::new(),
2063            brand: None,
2064            enabled: false,
2065            url: String::new(),
2066            allow_http: false,
2067            api_key_env: String::new(),
2068            api_key: None,
2069            auth_header: "Authorization".to_string(),
2070            auth_scheme: "Bearer".to_string(),
2071            headers: BTreeMap::new(),
2072            plan: None,
2073            plan_path: None,
2074            cache_ttl_secs: 60,
2075            metrics: Vec::new(),
2076            texts: Vec::new(),
2077        }
2078    }
2079}
2080
2081/// `name` defaults to `id`, which a per-field serde default cannot express (a
2082/// default sees no sibling field). The derive is routed through
2083/// `remote = "Self"` so the fill-in happens here, on every parse path, rather
2084/// than only in `Config::load_from`.
2085impl<'de> Deserialize<'de> for CustomProviderConfig {
2086    fn deserialize<D: serde::Deserializer<'de>>(
2087        deserializer: D,
2088    ) -> std::result::Result<Self, D::Error> {
2089        let mut this = Self::deserialize(deserializer)?;
2090        if this.name.is_empty() {
2091            this.name = this.id.clone();
2092        }
2093        Ok(this)
2094    }
2095}
2096
2097impl Serialize for CustomProviderConfig {
2098    fn serialize<S: serde::Serializer>(
2099        &self,
2100        serializer: S,
2101    ) -> std::result::Result<S::Ok, S::Error> {
2102        Self::serialize(self, serializer)
2103    }
2104}
2105
2106/// One percentage row. Either `percent` alone, or `used` and `limit`
2107/// together — never a mix, so a row cannot show a percentage from one field
2108/// and a footnote from another.
2109#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
2110#[serde(default)]
2111pub struct CustomMetricSpec {
2112    pub label: String,
2113    pub used: Option<String>,
2114    pub limit: Option<String>,
2115    pub percent: Option<String>,
2116    /// Pointer to an RFC 3339 string or a Unix epoch (seconds or milliseconds).
2117    pub resets_at: Option<String>,
2118    /// Window length for pacing, at least 60.
2119    pub window_secs: Option<u64>,
2120    /// Three-letter uppercase currency code used to format `used` and `limit` as money.
2121    pub currency: Option<String>,
2122}
2123
2124/// One free-text row: a string, number, or boolean at `value`.
2125#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
2126#[serde(default)]
2127pub struct CustomTextSpec {
2128    pub label: String,
2129    pub value: String,
2130    /// Three-letter uppercase currency code used to format numeric values as money.
2131    pub currency: Option<String>,
2132}
2133
2134impl CustomProviderConfig {
2135    /// The TOML locator for error messages: `[[custom]] id = "mytool"`.
2136    pub fn section_label(&self) -> String {
2137        format!("[[custom]] id = {:?}", self.id)
2138    }
2139
2140    /// Env var (when `api_key_env` is set) → inline `api_key` → a
2141    /// `Credentials` error that names the section and never the key.
2142    pub fn resolve_api_key(&self) -> Result<String> {
2143        if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
2144            return Ok(key);
2145        }
2146        let advice = if self.api_key_env.is_empty() {
2147            "set `api_key`, or name an environment variable in `api_key_env`".to_string()
2148        } else {
2149            format!("export {} or set `api_key`", self.api_key_env)
2150        };
2151        Err(AppError::Credentials(format!(
2152            "custom {}: no API key. Either {advice} under {} in {}.",
2153            self.id,
2154            self.section_label(),
2155            config_path_hint()
2156        )))
2157    }
2158
2159    pub fn cache_ttl(&self) -> std::time::Duration {
2160        std::time::Duration::from_secs(self.cache_ttl_secs)
2161    }
2162
2163    /// Every rule that serde cannot express, each naming the section. Runs
2164    /// for disabled entries too: a broken entry is a broken config, and the
2165    /// day it is enabled is the wrong day to find out.
2166    fn validate(&self, index: usize) -> Result<()> {
2167        if !is_valid_custom_id(&self.id) {
2168            return Err(AppError::Other(format!(
2169                "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
2170                index + 1,
2171                self.id
2172            )));
2173        }
2174        let section = self.section_label();
2175        let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
2176
2177        if VendorId::all().iter().any(|v| v.slug() == self.id) {
2178            return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
2179        }
2180        let name_len = self.name.chars().count();
2181        if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
2182            return Err(bad(
2183                "name must be 1 to 48 characters without control characters".into(),
2184            ));
2185        }
2186        if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
2187            return Err(bad(format!(
2188                "short_name {:?} must be exactly 3 lowercase ASCII letters",
2189                self.short_name
2190            )));
2191        }
2192        if let Some(brand) = &self.brand
2193            && !VendorId::all().iter().any(|v| v.slug() == brand)
2194        {
2195            return Err(bad(format!(
2196                "brand {brand:?} must name a built-in vendor (it borrows that \
2197                 vendor's mark); leave it unset to keep the short_name tag"
2198            )));
2199        }
2200        let url = reqwest::Url::parse(&self.url)
2201            .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
2202        match url.scheme() {
2203            "https" => {}
2204            "http" if self.allow_http => {}
2205            "http" => {
2206                return Err(bad(
2207                    "url must use https:// (set allow_http = true to permit http://)".into(),
2208                ));
2209            }
2210            other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
2211        }
2212        if !url.username().is_empty() || url.password().is_some() {
2213            return Err(bad("url must not carry credentials (user:pass@)".into()));
2214        }
2215        if url.host_str().is_none() {
2216            return Err(bad("url has no host".into()));
2217        }
2218        if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
2219            // The value is not repeated: one that is not a variable name is
2220            // most likely a key pasted into the wrong field, and a config
2221            // error reaches every frontend.
2222            return Err(bad(
2223                "api_key_env is not a valid environment variable name".into()
2224            ));
2225        }
2226        validate_header_name(&section, "auth_header", &self.auth_header)?;
2227        if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
2228            return Err(bad(
2229                "auth_scheme contains characters that are not valid in an HTTP header".into(),
2230            ));
2231        }
2232        for (name, value) in &self.headers {
2233            validate_header_name(&section, "headers", name)?;
2234            if name.eq_ignore_ascii_case(&self.auth_header) {
2235                return Err(bad(format!(
2236                    "headers must not repeat auth_header {:?}",
2237                    self.auth_header
2238                )));
2239            }
2240            if reqwest::header::HeaderValue::from_str(value).is_err() {
2241                return Err(bad(format!(
2242                    "header {name:?} has a value that is not valid in an HTTP header"
2243                )));
2244            }
2245        }
2246        if let Some(plan) = &self.plan {
2247            validate_custom_label(&section, "plan", plan)?;
2248        }
2249        if let Some(pointer) = &self.plan_path {
2250            validate_pointer(&section, "plan_path", pointer)?;
2251        }
2252        if !(10..=3600).contains(&self.cache_ttl_secs) {
2253            return Err(bad(format!(
2254                "cache_ttl_secs must be between 10 and 3600, got {}",
2255                self.cache_ttl_secs
2256            )));
2257        }
2258        if self.metrics.is_empty() && self.texts.is_empty() {
2259            return Err(bad(
2260                "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
2261            ));
2262        }
2263        let mut metric_labels = HashSet::new();
2264        for metric in &self.metrics {
2265            validate_custom_label(&section, "metric label", &metric.label)?;
2266            if !metric_labels.insert(metric.label.as_str()) {
2267                return Err(bad(format!("duplicate metric label {:?}", metric.label)));
2268            }
2269            let pair = (metric.used.is_some(), metric.limit.is_some());
2270            let well_formed = if metric.percent.is_some() {
2271                pair == (false, false)
2272            } else {
2273                pair == (true, true)
2274            };
2275            if !well_formed {
2276                return Err(bad(format!(
2277                    "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
2278                    metric.label
2279                )));
2280            }
2281            if metric.currency.is_some() && metric.percent.is_some() {
2282                return Err(bad(format!(
2283                    "metric {:?} cannot combine `currency` with `percent`",
2284                    metric.label
2285                )));
2286            }
2287            if let Some(currency) = &metric.currency {
2288                validate_currency(&section, &format!("metric {:?}", metric.label), currency)?;
2289            }
2290            for (field, pointer) in [
2291                ("used", &metric.used),
2292                ("limit", &metric.limit),
2293                ("percent", &metric.percent),
2294                ("resets_at", &metric.resets_at),
2295            ] {
2296                if let Some(pointer) = pointer {
2297                    validate_pointer(&section, field, pointer)?;
2298                }
2299            }
2300            if let Some(secs) = metric.window_secs
2301                && secs < 60
2302            {
2303                return Err(bad(format!(
2304                    "metric {:?} window_secs must be at least 60, got {secs}",
2305                    metric.label
2306                )));
2307            }
2308        }
2309        let mut text_labels = HashSet::new();
2310        for text in &self.texts {
2311            validate_custom_label(&section, "text label", &text.label)?;
2312            if !text_labels.insert(text.label.as_str()) {
2313                return Err(bad(format!("duplicate text label {:?}", text.label)));
2314            }
2315            validate_pointer(&section, "value", &text.value)?;
2316            if let Some(currency) = &text.currency {
2317                validate_currency(&section, &format!("text {:?}", text.label), currency)?;
2318            }
2319        }
2320        Ok(())
2321    }
2322}
2323
2324fn is_valid_custom_id(id: &str) -> bool {
2325    let bytes = id.as_bytes();
2326    let Some(&first) = bytes.first() else {
2327        return false;
2328    };
2329    bytes.len() <= 32
2330        && (first.is_ascii_lowercase() || first.is_ascii_digit())
2331        && bytes
2332            .iter()
2333            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
2334}
2335
2336fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
2337    if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
2338        return Err(AppError::Other(format!(
2339            "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
2340        )));
2341    }
2342    Ok(())
2343}
2344
2345fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
2346    let len = label.chars().count();
2347    if len == 0 || len > 64 || label.chars().any(char::is_control) {
2348        return Err(AppError::Other(format!(
2349            "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
2350        )));
2351    }
2352    Ok(())
2353}
2354
2355fn validate_currency(section: &str, context: &str, currency: &str) -> Result<()> {
2356    if currency.len() != 3 || !currency.bytes().all(|b| b.is_ascii_uppercase()) {
2357        return Err(AppError::Other(format!(
2358            "{section}: {context} currency {currency:?} must be a 3-letter uppercase code (e.g. \"USD\")"
2359        )));
2360    }
2361    Ok(())
2362}
2363
2364fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
2365    if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
2366        return Err(AppError::Other(format!(
2367            "{section}: {field} {name:?} is not a valid HTTP header name"
2368        )));
2369    }
2370    Ok(())
2371}
2372
2373/// Resolve an API key for a vendor: a valid env-var name wins, then inline
2374/// config, then a clear error naming both fields. Used by every API-key vendor.
2375pub fn resolve_api_key(
2376    vendor_label: &str,
2377    env_var_name: &str,
2378    inline: Option<&str>,
2379) -> crate::error::Result<String> {
2380    let section = match vendor_label {
2381        "OpenCode Go" => "[opencode-go]".to_string(),
2382        _ => format!("[{}]", vendor_label.to_lowercase()),
2383    };
2384    resolve_api_key_in_section(vendor_label, &section, env_var_name, inline)
2385}
2386
2387/// The env-then-inline lookup without the "or fail" ending, for vendors where
2388/// an absent API key is a legitimate state rather than an error — Kimi accepts
2389/// a Kimi Code CLI subscription login instead.
2390pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
2391    optional_api_key_with(env_var_name, inline, |name| std::env::var(name).ok())
2392}
2393
2394fn optional_api_key_with(
2395    env_var_name: &str,
2396    inline: Option<&str>,
2397    environment: impl FnOnce(&str) -> Option<String>,
2398) -> Option<String> {
2399    if is_valid_env_var_name(env_var_name)
2400        && let Some(v) = environment(env_var_name)
2401        && !v.is_empty()
2402    {
2403        return Some(v);
2404    }
2405    inline.filter(|v| !v.is_empty()).map(str::to_string)
2406}
2407
2408fn resolve_api_key_in_section(
2409    vendor_label: &str,
2410    section: &str,
2411    env_var_name: &str,
2412    inline: Option<&str>,
2413) -> crate::error::Result<String> {
2414    if let Some(key) = optional_api_key(env_var_name, inline) {
2415        return Ok(key);
2416    }
2417    let valid_env_name = is_valid_env_var_name(env_var_name);
2418    let advice = if valid_env_name {
2419        "set an API key in a valid environment variable or set `api_key`"
2420    } else {
2421        "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
2422    };
2423    Err(crate::error::AppError::Credentials(format!(
2424        "{vendor_label}: no API key. Either {advice} under {section} in {}.",
2425        config_path_hint()
2426    )))
2427}
2428
2429pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
2430    let mut chars = name.chars();
2431    let Some(first) = chars.next() else {
2432        return false;
2433    };
2434    (first.is_ascii_alphabetic() || first == '_')
2435        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2436}
2437
2438impl Config {
2439    /// Load from `~/.config/ai-usagebar/config.toml`. Returns defaults if the
2440    /// file doesn't exist; errors only on actual parse failures.
2441    pub fn load() -> Result<Self> {
2442        let Some(path) = resolved_path() else {
2443            return Ok(Self::default());
2444        };
2445        Self::load_from(&path)
2446    }
2447
2448    pub fn load_from(path: &std::path::Path) -> Result<Self> {
2449        match std::fs::read_to_string(path) {
2450            Ok(s) => {
2451                let mut config: Self = toml::from_str(&s).map_err(|mut e| {
2452                    let span = e.span();
2453                    // Without its input the error's `Display` drops the quoted
2454                    // line but keeps the key path, `in deepseek.headline`.
2455                    e.set_input(None);
2456                    let summary = toml_error_summary(&s, span, &e.to_string());
2457                    AppError::Other(format!("config.toml: {summary}"))
2458                })?;
2459                // `~` is shell syntax, not path syntax: `PathBuf` keeps it
2460                // literally, so a documented `credentials_path = "~/..."`
2461                // silently pointed at a directory named `~`.
2462                config.expand_paths();
2463                config.validate()?;
2464                #[cfg(unix)]
2465                config.protect_inline_secrets(path)?;
2466                // A custom provider's token variable is as secret as any
2467                // built-in one; subprocesses (`gh`, `grok`, `claude`) must
2468                // not inherit it. Nor a named account's, or a renamed one.
2469                crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
2470                crate::vendor::register_secret_env_vars(&config.provider_secret_env_vars());
2471                Ok(config)
2472            }
2473            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
2474            Err(e) => Err(AppError::io_at(path, e)),
2475        }
2476    }
2477
2478    fn expand_paths(&mut self) {
2479        expand_tilde_opt(&mut self.context.projects_path);
2480        expand_tilde_opt(&mut self.anthropic.credentials_path);
2481        expand_tilde_opt(&mut self.anthropic.accounts_dir);
2482        expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
2483        expand_tilde_opt(&mut self.anthropic.desktop_prepare_binary);
2484        expand_tilde_opt(&mut self.openai.codex_auth_path);
2485        expand_tilde_opt(&mut self.antigravity.agy_binary);
2486        expand_tilde_opt(&mut self.cursor.db_path);
2487        expand_tilde_opt(&mut self.cursor.agent_auth_path);
2488        expand_tilde_opt(&mut self.kiro.db_path);
2489        expand_tilde_opt(&mut self.kimi.credentials_path);
2490        expand_tilde_opt(&mut self.grokbot.secrets_path);
2491        expand_tilde_opt(&mut self.modelstudio.config_dir);
2492        expand_tilde_opt(&mut self.devin.credentials_path);
2493        expand_tilde_opt(&mut self.muse.credentials_path);
2494        self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
2495        expand_tilde_opt(&mut self.supergrok.auth_path);
2496        expand_tilde_opt(&mut self.supergrok.config_path);
2497        expand_tilde_opt(&mut self.copilot.gh_binary);
2498        if let Some(paths) = &mut self.cline.auth_paths {
2499            for path in paths {
2500                *path = expand_tilde(path);
2501            }
2502        }
2503        for account in &mut self.anthropic.accounts {
2504            account.credentials_path = expand_tilde(&account.credentials_path);
2505        }
2506        for account in &mut self.openai.accounts {
2507            account.codex_auth_path = expand_tilde(&account.codex_auth_path);
2508        }
2509        for account in &mut self.devin.accounts {
2510            account.credentials_path = expand_tilde(&account.credentials_path);
2511        }
2512        if let Some(paths) = &mut self.commandcode.auth_paths {
2513            for path in paths {
2514                *path = expand_tilde(path);
2515            }
2516        }
2517        if let Some(paths) = &mut self.opencode_go.auth_paths {
2518            for path in paths {
2519                *path = expand_tilde(path);
2520            }
2521        }
2522        expand_tilde_opt(&mut self.droid.cli_path);
2523    }
2524
2525    /// Explicitly enumerate every inline credential field. Adding a new
2526    /// credential vendor must add it here so its config receives the same
2527    /// protection.
2528    #[cfg(unix)]
2529    fn has_inline_secrets(&self) -> bool {
2530        [
2531            self.zai.api_key.as_deref(),
2532            self.openrouter.api_key.as_deref(),
2533            self.deepseek.api_key.as_deref(),
2534            self.deepinfra.api_key.as_deref(),
2535            self.kimi.api_key.as_deref(),
2536            self.kilo.api_key.as_deref(),
2537            self.novita.api_key.as_deref(),
2538            self.synthetic.api_key.as_deref(),
2539            self.minimax.api_key.as_deref(),
2540            self.moonshot.api_key.as_deref(),
2541            self.grok.api_key.as_deref(),
2542            self.anthropic_api.api_key.as_deref(),
2543            self.opencode_go.api_key.as_deref(),
2544            self.ollama.api_key.as_deref(),
2545            self.orcarouter.api_key.as_deref(),
2546            self.lyceum.api_key.as_deref(),
2547            self.antigravity.oauth_client_secret.as_deref(),
2548            self.cline.api_key.as_deref(),
2549        ]
2550        .into_iter()
2551        .chain(
2552            Self::API_KEY_ACCOUNT_VENDORS
2553                .into_iter()
2554                .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2555                .map(|account| account.api_key.as_deref()),
2556        )
2557        .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
2558        .any(|key| key.is_some_and(|key| !key.is_empty()))
2559    }
2560
2561    fn custom_secret_env_vars(&self) -> Vec<String> {
2562        self.custom
2563            .iter()
2564            .filter(|c| !c.api_key_env.is_empty())
2565            .map(|c| c.api_key_env.clone())
2566            .collect()
2567    }
2568
2569    /// The variables built-in providers read keys from under names that
2570    /// `VENDOR_SECRET_ENV_VARS` cannot list: a renamed `api_key_env` or
2571    /// OpenRouter `management_api_key_env`, and each named account's
2572    /// (`DEEPSEEK_WORK_API_KEY`). Default names come back too and are skipped
2573    /// by the registration, as are empty ones.
2574    fn provider_secret_env_vars(&self) -> Vec<String> {
2575        let renamed = VendorId::all().iter().map(|&id| self.api_key_env_for(id));
2576        let management = std::iter::once(self.openrouter.management_api_key_env.as_str());
2577        let accounts = Self::API_KEY_ACCOUNT_VENDORS
2578            .into_iter()
2579            .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2580            .flat_map(|account| [&account.api_key_env, &account.management_api_key_env])
2581            .filter_map(|name| name.as_deref());
2582        renamed
2583            .chain(management)
2584            .chain(accounts)
2585            .map(str::to_string)
2586            .collect()
2587    }
2588
2589    /// The `[[custom]]` providers that are switched on, in config order.
2590    pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
2591        self.custom.iter().filter(|c| c.enabled)
2592    }
2593
2594    /// A `[[custom]]` provider by `id`, enabled or not.
2595    pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
2596        self.custom.iter().find(|c| c.id == id)
2597    }
2598
2599    #[cfg(unix)]
2600    fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
2601        if !self.has_inline_secrets() {
2602            return Ok(());
2603        }
2604
2605        let metadata = std::fs::metadata(path).map_err(|_| {
2606            AppError::Credentials(format!(
2607                "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
2608                path.display()
2609            ))
2610        })?;
2611        if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
2612            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
2613                AppError::Credentials(format!(
2614                    "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",
2615                    path.display()
2616                ))
2617            })?;
2618        }
2619        Ok(())
2620    }
2621
2622    pub fn is_enabled(&self, id: VendorId) -> bool {
2623        match id {
2624            VendorId::Anthropic => self.anthropic.enabled,
2625            VendorId::AnthropicApi => self.anthropic_api.enabled,
2626            VendorId::Openai => self.openai.enabled,
2627            VendorId::Copilot => self.copilot.enabled,
2628            VendorId::Zai => self.zai.enabled,
2629            VendorId::Openrouter => self.openrouter.enabled,
2630            VendorId::Deepseek => self.deepseek.enabled,
2631            VendorId::Deepinfra => self.deepinfra.enabled,
2632            VendorId::Kimi => self.kimi.enabled,
2633            VendorId::Kilo => self.kilo.enabled,
2634            VendorId::Novita => self.novita.enabled,
2635            VendorId::Synthetic => self.synthetic.enabled,
2636            VendorId::Moonshot => self.moonshot.enabled,
2637            VendorId::Grok => self.grok.enabled,
2638            VendorId::Supergrok => self.supergrok.enabled,
2639            VendorId::Grokbot => self.grokbot.enabled,
2640            VendorId::Antigravity => self.antigravity.enabled,
2641            VendorId::Cursor => self.cursor.enabled,
2642            VendorId::Minimax => self.minimax.enabled,
2643            VendorId::Kiro => self.kiro.enabled,
2644            VendorId::NousResearch => self.nous.enabled,
2645            VendorId::Cline => self.cline.enabled,
2646            VendorId::OpenCodeGo => self.opencode_go.enabled,
2647            VendorId::CommandCode => self.commandcode.enabled,
2648            VendorId::Ollama => self.ollama.enabled,
2649            VendorId::OrcaRouter => self.orcarouter.enabled,
2650            VendorId::ModelStudio => self.modelstudio.enabled,
2651            VendorId::Lyceum => self.lyceum.enabled,
2652            VendorId::Devin => self.devin.enabled,
2653            VendorId::Droid => self.droid.enabled,
2654            VendorId::MuseCode => self.muse.enabled,
2655        }
2656    }
2657
2658    /// The environment variable this provider's API key is read from, honoring
2659    /// a per-vendor `api_key_env` override; `""` for a provider that takes no
2660    /// key. Matching on [`VendorId`] rather than on a section name is
2661    /// deliberate: a new key vendor that nobody adds here fails to compile,
2662    /// where a `_ =>` arm over `&str` sections would silently hand back the
2663    /// wrong default and report the provider as unconfigured for ever.
2664    pub fn api_key_env_for(&self, id: VendorId) -> &str {
2665        match id {
2666            VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
2667            VendorId::Zai => &self.zai.api_key_env,
2668            VendorId::Openrouter => &self.openrouter.api_key_env,
2669            VendorId::Deepseek => &self.deepseek.api_key_env,
2670            VendorId::Deepinfra => &self.deepinfra.api_key_env,
2671            VendorId::Kimi => &self.kimi.api_key_env,
2672            VendorId::Kilo => &self.kilo.api_key_env,
2673            VendorId::Novita => &self.novita.api_key_env,
2674            VendorId::Synthetic => &self.synthetic.api_key_env,
2675            VendorId::Moonshot => &self.moonshot.api_key_env,
2676            VendorId::Grok => &self.grok.api_key_env,
2677            VendorId::Minimax => &self.minimax.api_key_env,
2678            VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
2679            VendorId::Ollama => &self.ollama.api_key_env,
2680            VendorId::OrcaRouter => &self.orcarouter.api_key_env,
2681            VendorId::Lyceum => &self.lyceum.api_key_env,
2682            VendorId::Cline => &self.cline.api_key_env,
2683            // Fixed names: OAuth-first providers whose environment override is
2684            // not user-renameable, and the providers with no key at all.
2685            VendorId::Anthropic
2686            | VendorId::Openai
2687            | VendorId::Copilot
2688            | VendorId::Supergrok
2689            | VendorId::Grokbot
2690            | VendorId::Antigravity
2691            | VendorId::Cursor
2692            | VendorId::Kiro
2693            | VendorId::NousResearch
2694            | VendorId::CommandCode
2695            | VendorId::ModelStudio
2696            | VendorId::Devin
2697            | VendorId::MuseCode
2698            | VendorId::Droid => id.api_key_env(),
2699        }
2700    }
2701
2702    /// A non-empty inline `api_key` from this provider's config section. An
2703    /// empty string counts as unset, the same way the vendors' own
2704    /// `resolve_api_key` treats it.
2705    pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
2706        let raw = match id {
2707            VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
2708            VendorId::Zai => self.zai.api_key.as_deref(),
2709            VendorId::Openrouter => self.openrouter.api_key.as_deref(),
2710            VendorId::Deepseek => self.deepseek.api_key.as_deref(),
2711            VendorId::Deepinfra => self.deepinfra.api_key.as_deref(),
2712            VendorId::Kimi => self.kimi.api_key.as_deref(),
2713            VendorId::Kilo => self.kilo.api_key.as_deref(),
2714            VendorId::Novita => self.novita.api_key.as_deref(),
2715            VendorId::Synthetic => self.synthetic.api_key.as_deref(),
2716            VendorId::Moonshot => self.moonshot.api_key.as_deref(),
2717            VendorId::Grok => self.grok.api_key.as_deref(),
2718            VendorId::Minimax => self.minimax.api_key.as_deref(),
2719            VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
2720            VendorId::Ollama => self.ollama.api_key.as_deref(),
2721            VendorId::OrcaRouter => self.orcarouter.api_key.as_deref(),
2722            VendorId::Lyceum => self.lyceum.api_key.as_deref(),
2723            VendorId::Cline => self.cline.api_key.as_deref(),
2724            VendorId::Anthropic
2725            | VendorId::Openai
2726            | VendorId::Copilot
2727            | VendorId::Supergrok
2728            | VendorId::Grokbot
2729            | VendorId::Antigravity
2730            | VendorId::Cursor
2731            | VendorId::Kiro
2732            | VendorId::NousResearch
2733            | VendorId::CommandCode
2734            | VendorId::ModelStudio
2735            | VendorId::Devin
2736            | VendorId::MuseCode
2737            | VendorId::Droid => None,
2738        };
2739        raw.filter(|key| !key.is_empty())
2740    }
2741
2742    /// The API-key vendors that take a `[[<vendor>.accounts]]` array —
2743    /// OpenRouter's (#221), generalized. Kimi is left out on purpose: its
2744    /// fallback is the Kimi Code CLI's single OAuth login, not a key.
2745    pub const API_KEY_ACCOUNT_VENDORS: [VendorId; 11] = [
2746        VendorId::Zai,
2747        VendorId::Openrouter,
2748        VendorId::Deepseek,
2749        VendorId::Deepinfra,
2750        VendorId::Kilo,
2751        VendorId::Novita,
2752        VendorId::Moonshot,
2753        VendorId::Grok,
2754        VendorId::Minimax,
2755        VendorId::OrcaRouter,
2756        VendorId::Lyceum,
2757    ];
2758
2759    /// The named `[[<vendor>.accounts]]` array, or `None` for a vendor that
2760    /// has no such array (see [`Self::API_KEY_ACCOUNT_VENDORS`]).
2761    pub fn api_key_accounts(&self, id: VendorId) -> Option<&[ApiKeyAccount]> {
2762        match id {
2763            VendorId::Zai => Some(&self.zai.accounts),
2764            VendorId::Openrouter => Some(&self.openrouter.accounts),
2765            VendorId::Deepseek => Some(&self.deepseek.accounts),
2766            VendorId::Deepinfra => Some(&self.deepinfra.accounts),
2767            VendorId::Kilo => Some(&self.kilo.accounts),
2768            VendorId::Novita => Some(&self.novita.accounts),
2769            VendorId::Moonshot => Some(&self.moonshot.accounts),
2770            VendorId::Grok => Some(&self.grok.accounts),
2771            VendorId::Minimax => Some(&self.minimax.accounts),
2772            VendorId::OrcaRouter => Some(&self.orcarouter.accounts),
2773            VendorId::Lyceum => Some(&self.lyceum.accounts),
2774            _ => None,
2775        }
2776    }
2777
2778    /// Whether the default key keeps its tab next to the named accounts.
2779    /// `true` for every vendor without an accounts array.
2780    pub fn show_default_api_key_account(&self, id: VendorId) -> bool {
2781        match id {
2782            VendorId::Zai => self.zai.show_default_account,
2783            VendorId::Openrouter => self.openrouter.show_default_account,
2784            VendorId::Deepseek => self.deepseek.show_default_account,
2785            VendorId::Deepinfra => self.deepinfra.show_default_account,
2786            VendorId::Kilo => self.kilo.show_default_account,
2787            VendorId::Novita => self.novita.show_default_account,
2788            VendorId::Moonshot => self.moonshot.show_default_account,
2789            VendorId::Grok => self.grok.show_default_account,
2790            VendorId::Minimax => self.minimax.show_default_account,
2791            VendorId::OrcaRouter => self.orcarouter.show_default_account,
2792            VendorId::Lyceum => self.lyceum.show_default_account,
2793            _ => true,
2794        }
2795    }
2796
2797    /// The default key, or one named `[[<vendor>.accounts]]` key, for a vendor
2798    /// in [`Self::API_KEY_ACCOUNT_VENDORS`]. The default path is exactly the
2799    /// single-key resolution each vendor always had, error text included. A
2800    /// label for a vendor without the array fails loudly rather than falling
2801    /// back to the default key, which would show the wrong account's usage.
2802    pub fn resolve_account_api_key_for(&self, id: VendorId, label: Option<&str>) -> Result<String> {
2803        // The missing-key error predates `display_name()`; Z.AI's always said
2804        // "Zai", and its section is derived from this spelling.
2805        let name = match id {
2806            VendorId::Zai => "Zai",
2807            other => other.display_name(),
2808        };
2809        let Some(label) = label else {
2810            return resolve_api_key(name, self.api_key_env_for(id), self.inline_api_key(id));
2811        };
2812        let slug = id.config_section();
2813        let account = api_key_account(slug, self.api_key_accounts(id).unwrap_or(&[]), label)?;
2814        // Names the account and its array section; never a configured value.
2815        resolve_api_key_in_section(
2816            &format!("{name} account {label:?}"),
2817            &format!("[[{slug}.accounts]]"),
2818            account.api_key_env.as_deref().unwrap_or(""),
2819            account.api_key.as_deref(),
2820        )
2821    }
2822
2823    /// The optional OpenRouter *management* key for the default or a named
2824    /// account. `None` means none is configured, in which case the caller
2825    /// skips the `/api/v1/activity` request rather than firing a doomed 401 —
2826    /// the regular inference key is never sent there. A named account only
2827    /// resolves a management key when it names its own
2828    /// `management_api_key_env`; it never inherits the default account's.
2829    pub fn openrouter_management_key(&self, label: Option<&str>) -> Option<String> {
2830        let env_name = match label {
2831            Some(label) => api_key_account("openrouter", &self.openrouter.accounts, label)
2832                .ok()?
2833                .management_api_key_env
2834                .as_deref()?,
2835            None => self.openrouter.management_api_key_env.as_str(),
2836        };
2837        optional_api_key(env_name, None)
2838    }
2839
2840    /// Bar-number settings for one vendor.
2841    ///
2842    /// Only the prepaid-balance vendors declare these; everything else keeps
2843    /// the quota shape ([`DisplayPrefs::default`]) and is unaffected.
2844    pub fn display_prefs(&self, vendor: VendorId) -> DisplayPrefs {
2845        match vendor {
2846            VendorId::Deepseek => {
2847                DisplayPrefs::balance(self.deepseek.display_limit, self.deepseek.headline)
2848            }
2849            VendorId::Deepinfra => {
2850                DisplayPrefs::balance(self.deepinfra.display_limit, self.deepinfra.headline)
2851            }
2852            VendorId::Kilo => DisplayPrefs::balance(self.kilo.display_limit, self.kilo.headline),
2853            VendorId::Novita => {
2854                DisplayPrefs::balance(self.novita.display_limit, self.novita.headline)
2855            }
2856            VendorId::Moonshot => {
2857                DisplayPrefs::balance(self.moonshot.display_limit, self.moonshot.headline)
2858            }
2859            VendorId::Grok => DisplayPrefs::balance(self.grok.display_limit, self.grok.headline),
2860            // No tank: OpenRouter reports its own credits. See
2861            // [`OpenRouterConfig::headline`].
2862            VendorId::Openrouter => DisplayPrefs::balance(None, self.openrouter.headline),
2863            // No user tank: the percentage's own denominator is the plan's
2864            // monthly allocation, so only the headline choice applies.
2865            VendorId::NousResearch => DisplayPrefs::balance(None, self.nous.headline),
2866            _ => DisplayPrefs::default(),
2867        }
2868    }
2869
2870    pub fn enabled_vendors(&self) -> Vec<VendorId> {
2871        VendorId::all()
2872            .iter()
2873            .copied()
2874            .filter(|id| self.is_enabled(*id))
2875            .collect()
2876    }
2877
2878    /// Validate cross-entry constraints that serde cannot express. Account
2879    /// labels are both CLI selectors and TUI tab identities, so duplicates
2880    /// would make either destination ambiguous.
2881    pub fn validate(&self) -> Result<()> {
2882        if let Some(minutes) = self.tray.refresh_minutes
2883            && !TRAY_REFRESH_MINUTES.contains(&minutes)
2884        {
2885            return Err(AppError::Other(format!(
2886                "[tray] refresh_minutes must be one of 1, 5, 10, 15, 30 or 60, got {minutes}"
2887            )));
2888        }
2889        if !(1..=100).contains(&self.notifications.threshold) {
2890            return Err(AppError::Other(format!(
2891                "[notifications] threshold must be between 1 and 100, got {}",
2892                self.notifications.threshold
2893            )));
2894        }
2895        if self.context.context_window_tokens == Some(0) {
2896            return Err(AppError::Other(
2897                "[context] context_window_tokens must be greater than zero".into(),
2898            ));
2899        }
2900        for (model, tokens) in &self.context.model_context_window_tokens {
2901            if model.trim().is_empty() {
2902                return Err(AppError::Other(
2903                    "[context] model_context_window_tokens keys must not be empty".into(),
2904                ));
2905            }
2906            if *tokens == 0 {
2907                return Err(AppError::Other(format!(
2908                    "[context] model_context_window_tokens entry {model:?} must be greater than zero"
2909                )));
2910            }
2911        }
2912        if let Some(limit) = self.anthropic_api.monthly_limit
2913            && (!limit.is_finite() || limit <= 0.0)
2914        {
2915            return Err(AppError::Other(
2916                "[anthropic_api] monthly_limit must be finite and greater than zero; \
2917                 remove it to show spend without a limit"
2918                    .into(),
2919            ));
2920        }
2921        // Same rule as `monthly_limit` above: a tank size that cannot divide is
2922        // a typo, and silently ignoring it would draw a meter the user never
2923        // asked for — or none, with no diagnostic either way.
2924        for (section, limit) in [
2925            ("deepseek", self.deepseek.display_limit),
2926            ("deepinfra", self.deepinfra.display_limit),
2927            ("kilo", self.kilo.display_limit),
2928            ("novita", self.novita.display_limit),
2929            ("moonshot", self.moonshot.display_limit),
2930            ("grok", self.grok.display_limit),
2931        ] {
2932            if let Some(limit) = limit
2933                && (!limit.is_finite() || limit <= 0.0)
2934            {
2935                return Err(AppError::Other(format!(
2936                    "[{section}] display_limit must be finite and greater than zero; \
2937                     remove it to show the balance without a limit"
2938                )));
2939            }
2940        }
2941        if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
2942            && !self.kimi.region.eq_ignore_ascii_case("auto")
2943        {
2944            return Err(AppError::Other(format!(
2945                "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
2946                self.kimi.region
2947            )));
2948        }
2949        if !self.minimax.region.eq_ignore_ascii_case("global")
2950            && !self.minimax.region.eq_ignore_ascii_case("cn")
2951        {
2952            return Err(AppError::Other(format!(
2953                "[minimax] region must be \"global\" or \"cn\", got {:?}",
2954                self.minimax.region
2955            )));
2956        }
2957        if self.supergrok.grok_binary.as_os_str().is_empty() {
2958            return Err(AppError::Other(
2959                "[supergrok] grok_binary must not be empty".into(),
2960            ));
2961        }
2962        if self
2963            .antigravity
2964            .agy_binary
2965            .as_ref()
2966            .is_some_and(|path| path.as_os_str().is_empty())
2967        {
2968            return Err(AppError::Other(
2969                "[antigravity] agy_binary must not be empty".into(),
2970            ));
2971        }
2972        let mut labels = HashSet::new();
2973        for account in &self.anthropic.accounts {
2974            validate_account_label(&account.label)?;
2975            if !labels.insert(&account.label) {
2976                return Err(AppError::Credentials(format!(
2977                    "duplicate anthropic account label {:?}",
2978                    account.label
2979                )));
2980            }
2981        }
2982        let mut openai_labels = HashSet::new();
2983        for account in &self.openai.accounts {
2984            validate_account_label_for("openai", &account.label)?;
2985            if !openai_labels.insert(&account.label) {
2986                return Err(AppError::Credentials(format!(
2987                    "duplicate openai account label {:?}",
2988                    account.label
2989                )));
2990            }
2991        }
2992        let mut devin_labels = HashSet::new();
2993        for account in &self.devin.accounts {
2994            validate_account_label_for("devin", &account.label)?;
2995            if !devin_labels.insert(&account.label) {
2996                return Err(AppError::Credentials(format!(
2997                    "duplicate devin account label {:?}",
2998                    account.label
2999                )));
3000            }
3001        }
3002        // Every `[[<vendor>.accounts]]` array follows OpenRouter's rules: labels
3003        // become cache subdirectories, stay unique as CLI selectors and tab
3004        // identities, and each entry names a key source.
3005        for id in Self::API_KEY_ACCOUNT_VENDORS {
3006            let slug = id.config_section();
3007            let mut labels = HashSet::new();
3008            for account in self.api_key_accounts(id).unwrap_or(&[]) {
3009                validate_account_label_for(slug, &account.label)?;
3010                if !labels.insert(&account.label) {
3011                    return Err(AppError::Credentials(format!(
3012                        "duplicate {slug} account label {:?}",
3013                        account.label
3014                    )));
3015                }
3016                let has_env = account
3017                    .api_key_env
3018                    .as_deref()
3019                    .is_some_and(|name| !name.is_empty());
3020                let has_inline = account
3021                    .api_key
3022                    .as_deref()
3023                    .is_some_and(|key| !key.is_empty());
3024                if !has_env && !has_inline {
3025                    return Err(AppError::Credentials(format!(
3026                        "{slug} account {:?} must set api_key_env or api_key",
3027                        account.label
3028                    )));
3029                }
3030            }
3031        }
3032        self.validate_custom()
3033    }
3034
3035    /// Per-entry rules live on `CustomProviderConfig`; the cross-entry ones —
3036    /// `id` and `short_name` uniqueness, including against the built-in
3037    /// vendors — need the whole list and live here.
3038    fn validate_custom(&self) -> Result<()> {
3039        let mut ids = HashSet::new();
3040        let mut short_names: HashSet<&str> =
3041            VendorId::all().iter().map(|v| v.short_name()).collect();
3042        for (index, custom) in self.custom.iter().enumerate() {
3043            custom.validate(index)?;
3044            if !ids.insert(custom.id.as_str()) {
3045                return Err(AppError::Other(format!(
3046                    "{}: duplicate id",
3047                    custom.section_label()
3048                )));
3049            }
3050            if !short_names.insert(custom.short_name.as_str()) {
3051                return Err(AppError::Other(format!(
3052                    "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
3053                    custom.section_label(),
3054                    custom.short_name
3055                )));
3056            }
3057        }
3058        Ok(())
3059    }
3060}
3061
3062#[cfg(unix)]
3063#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3064enum InlineKeyPermissionDecision {
3065    Ok,
3066    Tighten,
3067}
3068
3069#[cfg(unix)]
3070fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
3071    if mode & 0o077 == 0 {
3072        InlineKeyPermissionDecision::Ok
3073    } else {
3074        InlineKeyPermissionDecision::Tighten
3075    }
3076}
3077
3078pub fn default_path() -> Option<PathBuf> {
3079    let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
3080    Some(proj.config_dir().join("config.toml"))
3081}
3082
3083/// The Unix-conventional location, which is what every doc, the config
3084/// example, and both desktop integrations have always pointed at. On Linux it
3085/// *is* [`default_path`]; on macOS `ProjectDirs` resolves to
3086/// `~/Library/Application Support/…` instead, so the two diverge.
3087fn legacy_xdg_path() -> Option<PathBuf> {
3088    let home = crate::cache::home_dir().ok()?;
3089    Some(home.join(".config").join("ai-usagebar").join("config.toml"))
3090}
3091
3092/// The config file actually in effect.
3093///
3094/// A `--config` override (see [`set_override_path`]) wins outright so a test
3095/// run never touches the real file. Otherwise [`default_path`] stays
3096/// canonical, but on macOS a file at the documented
3097/// `~/.config/ai-usagebar/config.toml` is honored when the canonical one does
3098/// not exist — otherwise everyone who followed the README (and both desktop
3099/// integrations, which read that path) silently got defaults. The legacy file
3100/// is never moved or rewritten: it may hold API keys, and relocating a secret
3101/// behind the user's back is not this tool's business.
3102pub fn resolved_path() -> Option<PathBuf> {
3103    if let Some(path) = override_path() {
3104        return Some(path);
3105    }
3106    let canonical = default_path();
3107    if let Some(p) = &canonical
3108        && p.exists()
3109    {
3110        return canonical;
3111    }
3112    if let Some(legacy) = legacy_xdg_path()
3113        && legacy.exists()
3114    {
3115        return Some(legacy);
3116    }
3117    canonical
3118}
3119
3120static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
3121
3122/// Point every config load, save, and hint at one explicit file — the
3123/// `--config` flag. Takes precedence over the canonical and legacy locations.
3124/// The file does not have to exist yet: loads treat it as defaults while
3125/// Settings saves create it. Process-wide, so call it once at startup before
3126/// any config is read.
3127pub fn set_override_path(path: &std::path::Path) {
3128    if let Ok(mut slot) = PATH_OVERRIDE.lock() {
3129        *slot = Some(path.to_path_buf());
3130    }
3131}
3132
3133/// Drop the override again. Used only by tests so they can restore the
3134/// process-wide state they changed.
3135#[doc(hidden)]
3136pub fn clear_override_path() {
3137    if let Ok(mut slot) = PATH_OVERRIDE.lock() {
3138        *slot = None;
3139    }
3140}
3141
3142fn override_path() -> Option<PathBuf> {
3143    PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
3144}
3145
3146/// Value of a `--config=PATH` argument, split at the OS-string level so a
3147/// path with bytes Windows/Unix can store but UTF-8 cannot represent (an
3148/// undecodable filename on Unix, a lone surrogate on Windows) survives
3149/// intact instead of being mangled by `to_string_lossy`. `None` when the
3150/// argument is not in that form. Used by both binaries' argv pre-parsers.
3151#[doc(hidden)]
3152pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
3153    #[cfg(unix)]
3154    {
3155        use std::os::unix::ffi::{OsStrExt, OsStringExt};
3156        let rest = arg.as_bytes().strip_prefix(b"--config=")?;
3157        Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
3158    }
3159    #[cfg(windows)]
3160    {
3161        use std::os::windows::ffi::{OsStrExt, OsStringExt};
3162        const PREFIX: &[u16] = &[
3163            b'-' as u16,
3164            b'-' as u16,
3165            b'c' as u16,
3166            b'o' as u16,
3167            b'n' as u16,
3168            b'f' as u16,
3169            b'i' as u16,
3170            b'g' as u16,
3171            b'=' as u16,
3172        ];
3173        let wide: Vec<u16> = arg.encode_wide().collect();
3174        let rest = wide.strip_prefix(PREFIX)?;
3175        Some(std::ffi::OsString::from_wide(rest).into())
3176    }
3177    #[cfg(not(any(unix, windows)))]
3178    {
3179        Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
3180    }
3181}
3182
3183/// Expand a leading `~` (or `~/`) against the user's home directory. Anything
3184/// else — including `~user` — is left untouched.
3185fn expand_tilde(p: &std::path::Path) -> PathBuf {
3186    let Some(s) = p.to_str() else {
3187        return p.to_path_buf();
3188    };
3189    let rest = if s == "~" {
3190        ""
3191    } else if let Some(r) = s.strip_prefix("~/") {
3192        r
3193    } else {
3194        return p.to_path_buf();
3195    };
3196    match crate::cache::home_dir() {
3197        Ok(home) if rest.is_empty() => home,
3198        Ok(home) => home.join(rest),
3199        Err(_) => p.to_path_buf(),
3200    }
3201}
3202
3203fn expand_tilde_opt(p: &mut Option<PathBuf>) {
3204    if let Some(inner) = p.as_ref() {
3205        *p = Some(expand_tilde(inner));
3206    }
3207}
3208
3209/// Resolved `config.toml` path as a string for user-facing messages. Uses the
3210/// platform's config dir (`directories::ProjectDirs`), so it reads correctly on
3211/// Linux, macOS, and Windows instead of hard-coding the Unix `~/.config` path.
3212/// Falls back to the bare filename if the path can't be resolved.
3213pub fn config_path_hint() -> String {
3214    resolved_path()
3215        .map(|p| p.display().to_string())
3216        .unwrap_or_else(|| "config.toml".to_string())
3217}
3218
3219#[cfg(test)]
3220mod tests {
3221    use super::*;
3222    use std::io::Write;
3223    use tempfile::NamedTempFile;
3224
3225    #[cfg(unix)]
3226    use std::os::unix::fs::{MetadataExt, PermissionsExt};
3227
3228    fn write_toml(s: &str) -> NamedTempFile {
3229        let mut f = NamedTempFile::new().unwrap();
3230        f.write_all(s.as_bytes()).unwrap();
3231        f.flush().unwrap();
3232        f
3233    }
3234
3235    /// The back-compat guarantee #134 asks for: a config with no
3236    /// `[[openai.accounts]]` resolves exactly what it resolved before, whether
3237    /// it sets `codex_auth_path` or leaves it to the default.
3238    #[test]
3239    fn openai_without_accounts_resolves_the_singular_path() {
3240        let explicit = OpenAiConfig {
3241            codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
3242            ..OpenAiConfig::default()
3243        };
3244        assert_eq!(
3245            explicit.resolve_auth_path(None).unwrap(),
3246            PathBuf::from("/tmp/codex/auth.json")
3247        );
3248
3249        let bare = OpenAiConfig::default();
3250        assert_eq!(
3251            bare.resolve_auth_path(None).unwrap(),
3252            crate::openai::creds::default_path().unwrap(),
3253            "no codex_auth_path must still mean ~/.codex/auth.json"
3254        );
3255    }
3256
3257    /// Each named account resolves its own file, and the default login is still
3258    /// reachable alongside them.
3259    #[test]
3260    fn openai_named_accounts_resolve_their_own_auth_file() {
3261        let config: Config = toml::from_str(
3262            r#"
3263            [openai]
3264            codex_auth_path = "/tmp/personal/auth.json"
3265            [[openai.accounts]]
3266            label = "work"
3267            codex_auth_path = "/tmp/work/auth.json"
3268            "#,
3269        )
3270        .unwrap();
3271
3272        assert_eq!(
3273            config.openai.resolve_auth_path(Some("work")).unwrap(),
3274            PathBuf::from("/tmp/work/auth.json")
3275        );
3276        assert_eq!(
3277            config.openai.resolve_auth_path(None).unwrap(),
3278            PathBuf::from("/tmp/personal/auth.json")
3279        );
3280    }
3281
3282    /// An unknown label must fail rather than quietly fall back to the default
3283    /// login — reporting the wrong subscription's usage is worse than an error.
3284    #[test]
3285    fn an_unknown_openai_account_is_an_error_not_a_fallback() {
3286        let config = OpenAiConfig {
3287            codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
3288            accounts: vec![OpenAiAccount {
3289                label: "work".into(),
3290                codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
3291            }],
3292            ..OpenAiConfig::default()
3293        };
3294        let err = config
3295            .resolve_auth_path(Some("nope"))
3296            .unwrap_err()
3297            .to_string();
3298        assert!(err.contains("nope"), "{err}");
3299        assert!(err.contains("[[openai.accounts]]"), "{err}");
3300    }
3301
3302    fn two_codex_accounts() -> OpenAiConfig {
3303        OpenAiConfig {
3304            codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
3305            accounts: vec![
3306                OpenAiAccount {
3307                    label: "main".into(),
3308                    codex_auth_path: PathBuf::from("/tmp/codex-main/auth.json"),
3309                },
3310                OpenAiAccount {
3311                    label: "work".into(),
3312                    codex_auth_path: PathBuf::from("/tmp/codex-work/auth.json"),
3313                },
3314            ],
3315            ..OpenAiConfig::default()
3316        }
3317    }
3318
3319    #[test]
3320    fn the_active_codex_account_is_read_from_the_default_slot() {
3321        let config = two_codex_accounts();
3322        assert_eq!(
3323            config
3324                .fetch_auth_path_probing("work", Some("work"), |_| false)
3325                .unwrap(),
3326            PathBuf::from("/tmp/codex/auth.json")
3327        );
3328        assert_eq!(
3329            config
3330                .fetch_auth_path_probing("main", Some("work"), |_| false)
3331                .unwrap(),
3332            PathBuf::from("/tmp/codex-main/auth.json")
3333        );
3334    }
3335
3336    #[test]
3337    fn an_active_codex_account_with_its_own_file_keeps_reading_it() {
3338        let config = two_codex_accounts();
3339        assert_eq!(
3340            config
3341                .fetch_auth_path_probing("work", Some("work"), |_| true)
3342                .unwrap(),
3343            PathBuf::from("/tmp/codex-work/auth.json")
3344        );
3345    }
3346
3347    #[test]
3348    fn adding_an_openai_account_keeps_the_rest_of_the_file() {
3349        let mut doc: toml_edit::DocumentMut = "# mine\n[zai]\nenabled = true\n".parse().unwrap();
3350        add_openai_account_to_doc(&mut doc, "work", "~/.codex-work/auth.json").unwrap();
3351        let text = doc.to_string();
3352        assert!(
3353            text.starts_with("# mine\n[zai]\nenabled = true\n"),
3354            "{text}"
3355        );
3356        let parsed: Config = toml::from_str(&text).unwrap();
3357        assert_eq!(parsed.openai.accounts[0].label, "work");
3358        assert!(add_openai_account_to_doc(&mut doc, "work", "x").is_err());
3359        assert!(add_openai_account_to_doc(&mut doc, "../x", "x").is_err());
3360    }
3361
3362    #[test]
3363    fn defaults_enable_only_the_four_core_vendors() {
3364        let c = Config::default();
3365        assert!(c.is_enabled(VendorId::Anthropic));
3366        assert!(c.is_enabled(VendorId::Openai));
3367        assert!(c.is_enabled(VendorId::Zai));
3368        assert!(c.is_enabled(VendorId::Openrouter));
3369        for opt_in in [
3370            VendorId::AnthropicApi,
3371            VendorId::Copilot,
3372            VendorId::Deepseek,
3373            VendorId::Kimi,
3374            VendorId::Kilo,
3375            VendorId::Novita,
3376            VendorId::Moonshot,
3377            VendorId::Grok,
3378            VendorId::Supergrok,
3379            VendorId::Grokbot,
3380            VendorId::Cursor,
3381            VendorId::Minimax,
3382            VendorId::Kiro,
3383            VendorId::CommandCode,
3384            VendorId::OrcaRouter,
3385            VendorId::ModelStudio,
3386            VendorId::Devin,
3387            VendorId::Droid,
3388        ] {
3389            assert!(!c.is_enabled(opt_in), "{opt_in:?}");
3390        }
3391        assert_eq!(c.enabled_vendors().len(), 4);
3392    }
3393
3394    #[test]
3395    fn commandcode_is_opt_in_when_loading_existing_configs() {
3396        let absent: Config = toml::from_str("[openai]\nenabled = true\n").unwrap();
3397        assert!(!absent.is_enabled(VendorId::CommandCode));
3398
3399        let opted_in: Config = toml::from_str("[commandcode]\nenabled = true\n").unwrap();
3400        assert!(opted_in.is_enabled(VendorId::CommandCode));
3401
3402        let opted_out: Config = toml::from_str("[commandcode]\nenabled = false\n").unwrap();
3403        assert!(!opted_out.is_enabled(VendorId::CommandCode));
3404    }
3405
3406    #[test]
3407    fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
3408        let config = Config::default();
3409        assert!(!config.is_enabled(VendorId::NousResearch));
3410        assert!(!config.is_enabled(VendorId::OpenCodeGo));
3411        assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
3412        assert!(config.opencode_go.api_key.is_none());
3413        assert!(!config.is_enabled(VendorId::Copilot));
3414    }
3415
3416    #[cfg(unix)]
3417    #[test]
3418    fn inline_credentials_are_protected() {
3419        let mut config = Config::default();
3420        config.opencode_go.api_key = Some("<redacted>".to_string());
3421        assert!(config.has_inline_secrets());
3422    }
3423
3424    #[test]
3425    fn antigravity_oauth_client_overrides_parse() {
3426        let config: Config = toml::from_str(
3427            "[antigravity]
3428enabled = true
3429oauth_client_id = \"test-client\"
3430oauth_client_secret = \"test-client-secret\"
3431",
3432        )
3433        .unwrap();
3434        assert!(config.antigravity.enabled);
3435        assert_eq!(
3436            config.antigravity.oauth_client_id.as_deref(),
3437            Some("test-client")
3438        );
3439        assert_eq!(
3440            config.antigravity.oauth_client_secret.as_deref(),
3441            Some("test-client-secret")
3442        );
3443        let bare: Config = toml::from_str(
3444            "[antigravity]
3445enabled = true
3446",
3447        )
3448        .unwrap();
3449        assert!(bare.antigravity.oauth_client_id.is_none());
3450        assert!(bare.antigravity.oauth_client_secret.is_none());
3451    }
3452
3453    /// #405: the IDE language-server opt-out parses, and the default keeps
3454    /// probing it so discovery is unchanged for everyone else.
3455    #[test]
3456    fn antigravity_ide_language_server_defaults_to_probing() {
3457        assert!(Config::default().antigravity.ide_language_server);
3458        let bare: Config = toml::from_str(
3459            "[antigravity]
3460enabled = true
3461",
3462        )
3463        .unwrap();
3464        assert!(bare.antigravity.ide_language_server);
3465        let opted_out: Config = toml::from_str(
3466            "[antigravity]
3467enabled = true
3468ide_language_server = false
3469",
3470        )
3471        .unwrap();
3472        assert!(!opted_out.antigravity.ide_language_server);
3473    }
3474
3475    #[test]
3476    fn antigravity_agy_binary_is_optional_and_tilde_expanded() {
3477        assert!(Config::default().antigravity.agy_binary.is_none());
3478        let file = write_toml(
3479            r#"
3480            [antigravity]
3481            enabled = true
3482            agy_binary = "~/bin/agy"
3483            "#,
3484        );
3485        let config = Config::load_from(file.path()).unwrap();
3486        assert_eq!(
3487            config.antigravity.agy_binary,
3488            Some(crate::cache::home_dir().unwrap().join("bin/agy"))
3489        );
3490    }
3491
3492    #[test]
3493    fn antigravity_agy_binary_must_not_be_empty() {
3494        let file = write_toml(
3495            r#"
3496            [antigravity]
3497            agy_binary = ""
3498            "#,
3499        );
3500        let error = Config::load_from(file.path()).unwrap_err().to_string();
3501        assert!(error.contains("[antigravity] agy_binary must not be empty"));
3502    }
3503
3504    #[cfg(unix)]
3505    #[test]
3506    fn antigravity_inline_oauth_secret_receives_config_file_protection() {
3507        let mut config = Config::default();
3508        config.antigravity.oauth_client_id = Some("test-client".into());
3509        assert!(!config.has_inline_secrets());
3510        config.antigravity.oauth_client_secret = Some("<redacted>".into());
3511        assert!(config.has_inline_secrets());
3512    }
3513
3514    #[cfg(unix)]
3515    #[test]
3516    fn openrouter_named_inline_keys_receive_config_file_protection() {
3517        let mut config = Config::default();
3518        config.openrouter.accounts.push(ApiKeyAccount {
3519            label: "work".into(),
3520            api_key_env: None,
3521            api_key: Some("<redacted>".into()),
3522            management_api_key_env: None,
3523        });
3524        assert!(config.has_inline_secrets());
3525    }
3526
3527    #[cfg(unix)]
3528    #[test]
3529    fn every_named_inline_api_key_receives_config_file_protection() {
3530        for vendor in Config::API_KEY_ACCOUNT_VENDORS {
3531            let section = vendor.config_section();
3532            let config: Config = toml::from_str(&format!(
3533                "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"<redacted>\"\n"
3534            ))
3535            .unwrap();
3536            assert!(config.has_inline_secrets(), "{vendor:?}");
3537        }
3538    }
3539
3540    /// `has_inline_secrets` lists the fields by hand, and Ollama's was missed
3541    /// once. Walk every vendor instead: whatever `inline_api_key` resolves as
3542    /// a key must also put the config file under 0600 protection.
3543    #[cfg(unix)]
3544    #[test]
3545    fn every_inline_api_key_receives_config_file_protection() {
3546        for &vendor in VendorId::all() {
3547            let section = vendor.config_section();
3548            let config: Config =
3549                toml::from_str(&format!("[{section}]\napi_key = \"<redacted>\"\n")).unwrap();
3550            if config.inline_api_key(vendor).is_some() {
3551                assert!(config.has_inline_secrets(), "{vendor:?}");
3552            }
3553        }
3554    }
3555
3556    #[test]
3557    fn missing_file_uses_defaults() {
3558        let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
3559        let c = Config::load_from(path).unwrap();
3560        assert!(c.is_enabled(VendorId::Anthropic));
3561    }
3562
3563    #[test]
3564    fn parses_full_config() {
3565        let f = write_toml(
3566            r#"
3567            [anthropic]
3568            enabled = true
3569
3570            [openai]
3571            enabled = false
3572            admin_key_env = "MY_ADMIN_KEY"
3573
3574            [zai]
3575            enabled = true
3576            api_key_env = "MY_ZAI"
3577            plan_tier = "pro"
3578
3579            [openrouter]
3580            enabled = false
3581            "#,
3582        );
3583        let c = Config::load_from(f.path()).unwrap();
3584        assert!(c.is_enabled(VendorId::Anthropic));
3585        assert!(!c.is_enabled(VendorId::Openai));
3586        assert!(c.is_enabled(VendorId::Zai));
3587        assert!(!c.is_enabled(VendorId::Openrouter));
3588        assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
3589        assert_eq!(c.zai.api_key_env, "MY_ZAI");
3590        assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
3591        assert!(c.openrouter.accounts.is_empty());
3592        assert!(c.openrouter.show_default_account);
3593    }
3594
3595    #[test]
3596    fn partial_config_falls_back_to_defaults() {
3597        let f = write_toml(
3598            r#"[openai]
3599enabled = false
3600"#,
3601        );
3602        let c = Config::load_from(f.path()).unwrap();
3603        assert!(!c.is_enabled(VendorId::Openai));
3604        // Other vendors keep their defaults.
3605        assert!(c.is_enabled(VendorId::Anthropic));
3606        assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
3607    }
3608
3609    #[test]
3610    fn malformed_toml_returns_error() {
3611        let f = write_toml("this is not = = valid");
3612        assert!(Config::load_from(f.path()).is_err());
3613    }
3614
3615    #[cfg(unix)]
3616    #[test]
3617    fn load_from_tightens_world_readable_config_with_inline_api_key() {
3618        let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
3619        std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3620
3621        Config::load_from(file.path()).unwrap();
3622
3623        assert_eq!(
3624            std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3625            0o600
3626        );
3627    }
3628
3629    #[cfg(unix)]
3630    #[test]
3631    fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
3632        let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
3633        std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3634
3635        Config::load_from(file.path()).unwrap();
3636
3637        assert_eq!(
3638            std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3639            0o644
3640        );
3641    }
3642
3643    #[cfg(unix)]
3644    #[test]
3645    fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
3646        assert_eq!(
3647            inline_key_permission_decision(0o600),
3648            InlineKeyPermissionDecision::Ok
3649        );
3650        assert_eq!(
3651            inline_key_permission_decision(0o640),
3652            InlineKeyPermissionDecision::Tighten
3653        );
3654        assert_eq!(
3655            inline_key_permission_decision(0o604),
3656            InlineKeyPermissionDecision::Tighten
3657        );
3658    }
3659
3660    #[test]
3661    fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
3662        for value in ["0", "-1", "inf", "nan"] {
3663            let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
3664            let error = Config::load_from(file.path()).unwrap_err().to_string();
3665            assert!(error.contains("monthly_limit"), "value {value}: {error}");
3666        }
3667
3668        let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
3669        assert_eq!(
3670            Config::load_from(file.path())
3671                .unwrap()
3672                .anthropic_api
3673                .monthly_limit,
3674            Some(1000.0)
3675        );
3676    }
3677
3678    #[test]
3679    fn display_limit_must_be_positive_and_finite_on_every_balance_vendor() {
3680        // `[openrouter]` is absent on purpose: it has no `display_limit`.
3681        for section in ["deepseek", "kilo", "novita", "moonshot", "grok"] {
3682            for value in ["0", "-1", "inf", "nan"] {
3683                let file = write_toml(&format!("[{section}]\ndisplay_limit = {value}\n"));
3684                let error = Config::load_from(file.path()).unwrap_err().to_string();
3685                assert!(
3686                    error.contains(&format!("[{section}] display_limit")),
3687                    "{section} = {value}: {error}"
3688                );
3689            }
3690            let file = write_toml(&format!("[{section}]\ndisplay_limit = 200\n"));
3691            let config = Config::load_from(file.path()).unwrap();
3692            assert_eq!(
3693                config.display_prefs(vendor_of(section)).display_limit,
3694                Some(200.0),
3695                "{section}"
3696            );
3697        }
3698    }
3699
3700    /// No baked-in tank: a vendor nobody configured has no denominator.
3701    #[test]
3702    fn display_limit_is_absent_until_the_user_states_one() {
3703        let config = Config::default();
3704        for vendor in VendorId::all() {
3705            assert_eq!(
3706                config.display_prefs(*vendor).display_limit,
3707                None,
3708                "{vendor:?}"
3709            );
3710        }
3711    }
3712
3713    /// A balance vendor headlines its money; a vendor with a denominator of its
3714    /// own headlines the percentage. Everything else keeps the quota default.
3715    #[test]
3716    fn the_default_headline_follows_the_kind_of_vendor() {
3717        let config = Config::default();
3718        for vendor in [
3719            VendorId::Deepseek,
3720            VendorId::Kilo,
3721            VendorId::Novita,
3722            VendorId::Moonshot,
3723            VendorId::Grok,
3724        ] {
3725            assert_eq!(
3726                config.display_prefs(vendor).headline,
3727                Headline::Amount,
3728                "{vendor:?}"
3729            );
3730        }
3731        assert_eq!(
3732            config.display_prefs(VendorId::Openrouter).headline,
3733            Headline::Percent
3734        );
3735        assert_eq!(
3736            config.display_prefs(VendorId::Anthropic),
3737            DisplayPrefs::default()
3738        );
3739    }
3740
3741    #[test]
3742    fn the_headline_is_configurable_per_vendor_and_a_typo_is_loud() {
3743        let file = write_toml("[deepseek]\nheadline = \"percent\"\n");
3744        assert_eq!(
3745            Config::load_from(file.path())
3746                .unwrap()
3747                .display_prefs(VendorId::Deepseek)
3748                .headline,
3749            Headline::Percent
3750        );
3751
3752        let file = write_toml("[openrouter]\nheadline = \"amount\"\n");
3753        assert_eq!(
3754            Config::load_from(file.path())
3755                .unwrap()
3756                .display_prefs(VendorId::Openrouter)
3757                .headline,
3758            Headline::Amount
3759        );
3760
3761        let file = write_toml("[deepseek]\nheadline = \"dollars\"\n");
3762        let error = Config::load_from(file.path()).unwrap_err().to_string();
3763        assert!(error.contains("headline"), "{error}");
3764    }
3765
3766    /// `[openrouter]` has no tank at all. The API reports credits purchased, so
3767    /// there is nothing to fall back to — and in the one case where a tank
3768    /// would not be ignored (a free-tier account with `total_credits == 0`)
3769    /// honouring it would put "0%" on the bar for an account with money in it,
3770    /// because the percentage comes from the snapshot, not from the tank.
3771    #[test]
3772    fn openrouter_has_no_display_limit_to_be_ignored() {
3773        let file = write_toml("[openrouter]\ndisplay_limit = 200\nheadline = \"percent\"\n");
3774        let config = Config::load_from(file.path()).unwrap();
3775        let prefs = config.display_prefs(VendorId::Openrouter);
3776        assert_eq!(prefs.display_limit, None);
3777        assert_eq!(prefs.headline, Headline::Percent);
3778    }
3779
3780    /// Setting a tank does not move the money off the bar by itself; the two
3781    /// are independent choices.
3782    #[test]
3783    fn a_display_limit_alone_leaves_the_headline_where_it_was() {
3784        let file = write_toml("[deepseek]\ndisplay_limit = 200\n");
3785        let prefs = Config::load_from(file.path())
3786            .unwrap()
3787            .display_prefs(VendorId::Deepseek);
3788        assert_eq!(prefs.display_limit, Some(200.0));
3789        assert_eq!(prefs.headline, Headline::Amount);
3790    }
3791
3792    fn vendor_of(section: &str) -> VendorId {
3793        VendorId::all()
3794            .iter()
3795            .copied()
3796            .find(|vendor| vendor.config_section() == section)
3797            .unwrap_or_else(|| panic!("no vendor for [{section}]"))
3798    }
3799
3800    #[test]
3801    fn minimax_region_accepts_only_known_instances() {
3802        for region in ["global", "GLOBAL", "cn", "CN"] {
3803            let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3804            assert_eq!(
3805                Config::load_from(file.path()).unwrap().minimax.region,
3806                region
3807            );
3808        }
3809
3810        for region in ["", "china", "us"] {
3811            let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3812            let error = Config::load_from(file.path()).unwrap_err().to_string();
3813            assert!(error.contains("[minimax] region"), "{error}");
3814        }
3815    }
3816
3817    #[test]
3818    fn kimi_region_accepts_auto_and_both_deployments() {
3819        for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
3820            let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3821            assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
3822        }
3823
3824        for region in ["", "us", "oversea"] {
3825            let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3826            let error = Config::load_from(file.path()).unwrap_err().to_string();
3827            assert!(error.contains("[kimi] region"), "{error}");
3828        }
3829    }
3830
3831    #[test]
3832    fn kimi_defaults_to_auto_region_and_no_credential_override() {
3833        let defaults = KimiConfig::default();
3834        assert_eq!(defaults.region, "auto");
3835        assert_eq!(defaults.credentials_path, None);
3836        assert!(!defaults.enabled);
3837    }
3838
3839    #[test]
3840    fn kimi_credentials_path_expands_a_tilde() {
3841        let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
3842        let path = Config::load_from(file.path())
3843            .unwrap()
3844            .kimi
3845            .credentials_path
3846            .unwrap();
3847        assert!(!path.starts_with("~"), "{}", path.display());
3848        assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
3849    }
3850
3851    #[test]
3852    fn grokbot_is_opt_in_and_takes_no_api_key() {
3853        let defaults = GrokbotConfig::default();
3854        assert!(!defaults.enabled);
3855        assert_eq!(defaults.secrets_path, None);
3856        // No key surface of any kind: the app's own session is the login.
3857        let config = Config::default();
3858        assert_eq!(config.api_key_env_for(VendorId::Grokbot), "");
3859        assert_eq!(config.inline_api_key(VendorId::Grokbot), None);
3860
3861        let file = write_toml("[grokbot]\nenabled = true\n");
3862        let config = Config::load_from(file.path()).unwrap();
3863        assert!(config.is_enabled(VendorId::Grokbot));
3864        assert!(config.enabled_vendors().contains(&VendorId::Grokbot));
3865    }
3866
3867    #[test]
3868    fn grokbot_secrets_path_expands_a_tilde() {
3869        let file = write_toml("[grokbot]\nsecrets_path = \"~/gb/secrets.json\"\n");
3870        let path = Config::load_from(file.path())
3871            .unwrap()
3872            .grokbot
3873            .secrets_path
3874            .unwrap();
3875        assert!(!path.starts_with("~"), "{}", path.display());
3876        assert!(path.ends_with("gb/secrets.json"), "{}", path.display());
3877    }
3878
3879    #[test]
3880    fn modelstudio_is_opt_in_and_takes_no_api_key() {
3881        let defaults = ModelStudioConfig::default();
3882        assert!(!defaults.enabled);
3883        assert_eq!(defaults.config_dir, None);
3884        // No key surface of any kind: the bl CLI's console session is the login.
3885        let config = Config::default();
3886        assert_eq!(config.api_key_env_for(VendorId::ModelStudio), "");
3887        assert_eq!(config.inline_api_key(VendorId::ModelStudio), None);
3888
3889        let file = write_toml("[modelstudio]\nenabled = true\n");
3890        let config = Config::load_from(file.path()).unwrap();
3891        assert!(config.is_enabled(VendorId::ModelStudio));
3892        assert!(config.enabled_vendors().contains(&VendorId::ModelStudio));
3893    }
3894
3895    #[test]
3896    fn modelstudio_config_dir_expands_a_tilde() {
3897        let file = write_toml("[modelstudio]\nconfig_dir = \"~/bl\"\n");
3898        let path = Config::load_from(file.path())
3899            .unwrap()
3900            .modelstudio
3901            .config_dir
3902            .unwrap();
3903        assert!(!path.starts_with("~"), "{}", path.display());
3904        assert!(path.ends_with("bl"), "{}", path.display());
3905    }
3906
3907    #[test]
3908    fn droid_is_opt_in_and_takes_no_api_key() {
3909        let defaults = DroidConfig::default();
3910        assert!(!defaults.enabled);
3911        assert!(!defaults.cli_renewal);
3912        assert_eq!(defaults.cli_path, None);
3913
3914        let config = Config::default();
3915        assert_eq!(config.api_key_env_for(VendorId::Droid), "");
3916        assert_eq!(config.inline_api_key(VendorId::Droid), None);
3917
3918        let file = write_toml("[droid]\nenabled = true\n");
3919        let config = Config::load_from(file.path()).unwrap();
3920        assert!(config.is_enabled(VendorId::Droid));
3921        assert!(config.enabled_vendors().contains(&VendorId::Droid));
3922    }
3923
3924    #[test]
3925    fn devin_is_opt_in_and_takes_no_api_key() {
3926        let defaults = DevinConfig::default();
3927        assert!(!defaults.enabled);
3928        assert_eq!(defaults.credentials_path, None);
3929
3930        let config = Config::default();
3931        assert_eq!(config.api_key_env_for(VendorId::Devin), "");
3932        assert_eq!(config.inline_api_key(VendorId::Devin), None);
3933
3934        let file = write_toml("[devin]\nenabled = true\n");
3935        let config = Config::load_from(file.path()).unwrap();
3936        assert!(config.is_enabled(VendorId::Devin));
3937        assert!(config.enabled_vendors().contains(&VendorId::Devin));
3938    }
3939
3940    #[test]
3941    fn devin_credentials_path_expands_a_tilde() {
3942        let file = write_toml("[devin]\ncredentials_path = \"~/devin/credentials.toml\"\n");
3943        let path = Config::load_from(file.path())
3944            .unwrap()
3945            .devin
3946            .credentials_path
3947            .unwrap();
3948        assert!(!path.starts_with("~"), "{}", path.display());
3949        assert!(
3950            path.ends_with("devin/credentials.toml"),
3951            "{}",
3952            path.display()
3953        );
3954    }
3955
3956    #[test]
3957    fn devin_without_accounts_resolves_the_singular_path() {
3958        let explicit = DevinConfig {
3959            credentials_path: Some(PathBuf::from("/tmp/devin/credentials.toml")),
3960            ..DevinConfig::default()
3961        };
3962        assert_eq!(
3963            explicit.resolve_credentials_path(None).unwrap(),
3964            PathBuf::from("/tmp/devin/credentials.toml")
3965        );
3966    }
3967
3968    #[test]
3969    fn devin_named_accounts_resolve_their_own_credentials_file() {
3970        let config: Config = toml::from_str(
3971            r#"
3972            [devin]
3973            credentials_path = "/tmp/personal/credentials.toml"
3974            [[devin.accounts]]
3975            label = "work"
3976            credentials_path = "/tmp/work/credentials.toml"
3977            "#,
3978        )
3979        .unwrap();
3980
3981        assert_eq!(
3982            config.devin.resolve_credentials_path(Some("work")).unwrap(),
3983            PathBuf::from("/tmp/work/credentials.toml")
3984        );
3985        assert_eq!(
3986            config.devin.resolve_credentials_path(None).unwrap(),
3987            PathBuf::from("/tmp/personal/credentials.toml")
3988        );
3989    }
3990
3991    #[test]
3992    fn devin_named_accounts_reject_an_unregistered_label() {
3993        let config = DevinConfig {
3994            accounts: vec![crate::config::DevinAccount {
3995                label: "work".into(),
3996                credentials_path: PathBuf::from("/tmp/work/credentials.toml"),
3997            }],
3998            ..DevinConfig::default()
3999        };
4000        let err = config
4001            .resolve_credentials_path(Some("nope"))
4002            .unwrap_err()
4003            .to_string();
4004        assert!(err.contains("nope"), "{err}");
4005        assert!(err.contains("[[devin.accounts]]"), "{err}");
4006    }
4007
4008    #[test]
4009    fn devin_account_credentials_paths_are_tilde_expanded_on_load() {
4010        let f = write_toml(
4011            r#"
4012            [[devin.accounts]]
4013            label = "work"
4014            credentials_path = "~/.devin-work/credentials.toml"
4015            "#,
4016        );
4017        let c = Config::load_from(f.path()).unwrap();
4018        let home = crate::cache::home_dir().unwrap();
4019        assert_eq!(
4020            c.devin.accounts[0].credentials_path,
4021            home.join(".devin-work/credentials.toml")
4022        );
4023    }
4024
4025    #[test]
4026    fn devin_rejects_duplicate_account_labels() {
4027        let f = write_toml(
4028            r#"
4029            [[devin.accounts]]
4030            label = "work"
4031            credentials_path = "/tmp/work1/credentials.toml"
4032            [[devin.accounts]]
4033            label = "work"
4034            credentials_path = "/tmp/work2/credentials.toml"
4035            "#,
4036        );
4037        let err = Config::load_from(f.path()).unwrap_err().to_string();
4038        assert!(
4039            err.contains("duplicate devin account label \"work\""),
4040            "{err}"
4041        );
4042    }
4043
4044    #[test]
4045    fn devin_rejects_invalid_account_labels() {
4046        let f = write_toml(
4047            r#"
4048            [[devin.accounts]]
4049            label = "../bad"
4050            credentials_path = "/tmp/bad/credentials.toml"
4051            "#,
4052        );
4053        assert!(Config::load_from(f.path()).is_err());
4054    }
4055
4056    #[test]
4057    fn lyceum_is_opt_in_and_uses_shared_api_key_account_resolution() {
4058        let defaults = LyceumConfig::default();
4059        assert!(!defaults.enabled);
4060        assert_eq!(defaults.api_key_env, "LYCEUM_API_KEY");
4061        let override_file = write_toml("[lyceum]\napi_key_env = \"LYCEUM_CUSTOM_TEST_KEY\"\n");
4062        let override_config = Config::load_from(override_file.path()).unwrap();
4063        assert_eq!(
4064            override_config.api_key_env_for(VendorId::Lyceum),
4065            "LYCEUM_CUSTOM_TEST_KEY"
4066        );
4067        let file = write_toml(
4068            r#"[lyceum]
4069enabled = true
4070api_key_env = ""
4071api_key = "synthetic-inline"
4072[[lyceum.accounts]]
4073label = "work"
4074api_key = "synthetic-account"
4075"#,
4076        );
4077        let config = Config::load_from(file.path()).unwrap();
4078        assert!(config.is_enabled(VendorId::Lyceum));
4079        assert_eq!(config.api_key_env_for(VendorId::Lyceum), "");
4080        assert_eq!(
4081            config
4082                .resolve_account_api_key_for(VendorId::Lyceum, None)
4083                .unwrap(),
4084            "synthetic-inline"
4085        );
4086        assert_eq!(
4087            config
4088                .resolve_account_api_key_for(VendorId::Lyceum, Some("work"))
4089                .unwrap(),
4090            "synthetic-account"
4091        );
4092    }
4093
4094    #[test]
4095    fn optional_api_key_reports_absence_instead_of_failing() {
4096        assert_eq!(
4097            optional_api_key("9INVALID", Some("inline")),
4098            Some("inline".to_string())
4099        );
4100        assert_eq!(optional_api_key("9INVALID", None), None);
4101        assert_eq!(optional_api_key("9INVALID", Some("")), None);
4102    }
4103
4104    #[test]
4105    fn optional_api_key_prefers_env_and_falls_through_to_inline() {
4106        let from_env = |name: &str| (name == "TEST_API_KEY").then(|| "from-env".to_string());
4107        assert_eq!(
4108            optional_api_key_with("TEST_API_KEY", Some("from-config"), from_env),
4109            Some("from-env".to_string())
4110        );
4111        assert_eq!(
4112            optional_api_key_with("TEST_API_KEY", Some("from-config"), |_| None),
4113            Some("from-config".to_string())
4114        );
4115        assert_eq!(
4116            optional_api_key_with("TEST_API_KEY", Some("from-config"), |_| {
4117                Some(String::new())
4118            }),
4119            Some("from-config".to_string())
4120        );
4121        assert_eq!(optional_api_key_with("TEST_API_KEY", None, |_| None), None);
4122    }
4123
4124    #[test]
4125    fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
4126        let defaults = Config::default();
4127        assert!(!defaults.context.enabled);
4128        assert_eq!(
4129            defaults.context.window_tokens_for(Some("claude-test")),
4130            None
4131        );
4132
4133        let file = write_toml(
4134            r#"
4135            [context]
4136            enabled = true
4137            context_window_tokens = 200000
4138
4139            [context.model_context_window_tokens]
4140            claude-opus-1m = 1000000
4141            "claude exact id" = 300000
4142            "#,
4143        );
4144        let config = Config::load_from(file.path()).unwrap();
4145        assert!(config.context.enabled);
4146        assert_eq!(
4147            config.context.window_tokens_for(Some("claude-opus-1m")),
4148            Some(1_000_000)
4149        );
4150        assert_eq!(
4151            config.context.window_tokens_for(Some("claude exact id")),
4152            Some(300_000)
4153        );
4154        assert_eq!(
4155            config.context.window_tokens_for(Some("another-model")),
4156            Some(200_000)
4157        );
4158    }
4159
4160    #[test]
4161    fn context_layout_defaults_to_full_and_parses_each_variant() {
4162        assert_eq!(Config::default().context.layout, ContextLayout::Full);
4163        for (text, want) in [
4164            ("full", ContextLayout::Full),
4165            ("split", ContextLayout::Split),
4166            ("bottom", ContextLayout::Bottom),
4167        ] {
4168            let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
4169            assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
4170        }
4171        let file = write_toml("[context]\nlayout = \"floating\"\n");
4172        assert!(
4173            Config::load_from(file.path()).is_err(),
4174            "an unknown layout must be rejected, not silently defaulted"
4175        );
4176    }
4177
4178    #[test]
4179    fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
4180        assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
4181        for (text, want) in [
4182            ("sidebar", VendorBoxStyle::Sidebar),
4183            ("navbar", VendorBoxStyle::Navbar),
4184            ("none", VendorBoxStyle::None),
4185        ] {
4186            let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
4187            assert_eq!(
4188                Config::load_from(file.path()).unwrap().ui.vendor_box(),
4189                want
4190            );
4191        }
4192        let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
4193        assert!(
4194            Config::load_from(file.path()).is_err(),
4195            "an unknown vendor_box style must be rejected, not silently defaulted"
4196        );
4197    }
4198
4199    #[test]
4200    fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
4201        for source in [
4202            "[context]\ncontext_window_tokens = 0\n",
4203            "[context.model_context_window_tokens]\nclaude = 0\n",
4204            "[context.model_context_window_tokens]\n\" \" = 200000\n",
4205        ] {
4206            let file = write_toml(source);
4207            let error = Config::load_from(file.path()).unwrap_err().to_string();
4208            assert!(error.contains("context"), "{error}");
4209        }
4210    }
4211
4212    // serial guard for env-var manipulation tests so they don't race
4213    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
4214        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
4215        M.lock().unwrap_or_else(|p| p.into_inner())
4216    }
4217
4218    #[test]
4219    fn resolve_api_key_prefers_env_over_inline() {
4220        let _g = env_guard();
4221        // Use a unique env var name so we don't clobber test parallelism.
4222        let var = "AI_USAGEBAR_TEST_ENV_WINS";
4223        // SAFETY: tests are single-threaded under env_guard.
4224        unsafe { std::env::set_var(var, "from-env") };
4225        let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
4226        unsafe { std::env::remove_var(var) };
4227        assert_eq!(got, "from-env");
4228    }
4229
4230    #[test]
4231    fn resolve_api_key_falls_back_to_inline() {
4232        let _g = env_guard();
4233        let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
4234        unsafe { std::env::remove_var(var) };
4235        let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
4236        assert_eq!(got, "inline-key");
4237    }
4238
4239    #[test]
4240    fn copilot_token_prefers_explicit_environment_over_gh_cli() {
4241        struct NeverRun;
4242        impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
4243            fn run(
4244                &self,
4245                _: &crate::copilot::credentials::GhAuthTokenCommand,
4246            ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
4247                panic!("environment override must not invoke gh")
4248            }
4249        }
4250
4251        let token = CopilotConfig::default()
4252            .resolve_token_with(
4253                |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
4254                &NeverRun,
4255                None,
4256            )
4257            .unwrap();
4258        assert_eq!(token, "from-environment");
4259    }
4260
4261    /// `--account` names a `gh` login; `GITHUB_COPILOT_TOKEN` names nothing.
4262    /// Honoring both would label one account's quota with another's name, so
4263    /// the combination is refused rather than silently resolved either way.
4264    #[test]
4265    fn copilot_rejects_an_environment_override_combined_with_a_named_account() {
4266        struct NeverRun;
4267        impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
4268            fn run(
4269                &self,
4270                _: &crate::copilot::credentials::GhAuthTokenCommand,
4271            ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
4272                panic!("the conflict must be refused before gh runs")
4273            }
4274        }
4275        let config = CopilotConfig {
4276            accounts: vec![CopilotAccount {
4277                label: "work".into(),
4278                user: "octocat".into(),
4279            }],
4280            ..CopilotConfig::default()
4281        };
4282        let error = config
4283            .resolve_token_with(
4284                |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
4285                &NeverRun,
4286                Some("work"),
4287            )
4288            .unwrap_err()
4289            .to_string();
4290        assert!(error.contains("GITHUB_COPILOT_TOKEN is set"), "{error}");
4291        assert!(error.contains("work"), "{error}");
4292    }
4293
4294    /// An unknown label must not fall back to the active `gh` account: that
4295    /// would report some other account's quota under the requested name.
4296    #[test]
4297    fn copilot_unknown_account_label_errors_listing_the_known_ones() {
4298        let config = CopilotConfig {
4299            accounts: vec![CopilotAccount {
4300                label: "work".into(),
4301                user: "octocat".into(),
4302            }],
4303            ..CopilotConfig::default()
4304        };
4305        assert_eq!(config.resolve_user(None).unwrap(), None);
4306        assert_eq!(config.resolve_user(Some("work")).unwrap(), Some("octocat"));
4307        let error = config
4308            .resolve_user(Some("personal"))
4309            .unwrap_err()
4310            .to_string();
4311        assert!(error.contains("personal"), "{error}");
4312        assert!(error.contains("work"), "{error}");
4313
4314        // With no accounts array at all, say so instead of listing nothing.
4315        let error = CopilotConfig::default()
4316            .resolve_user(Some("work"))
4317            .unwrap_err()
4318            .to_string();
4319        assert!(error.contains("no accounts array"), "{error}");
4320    }
4321
4322    /// Back-compat: an existing single-account config keeps the default tab
4323    /// and resolves exactly as before.
4324    #[test]
4325    fn copilot_defaults_keep_the_unnamed_account_visible() {
4326        let config = CopilotConfig::default();
4327        assert!(config.show_default_account);
4328        assert!(config.accounts.is_empty());
4329        assert!(!config.enabled, "Copilot stays opt-in");
4330    }
4331
4332    #[test]
4333    fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
4334        struct FailedGh;
4335        impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
4336            fn run(
4337                &self,
4338                _: &crate::copilot::credentials::GhAuthTokenCommand,
4339            ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
4340                Ok(crate::copilot::credentials::GhAuthTokenOutput {
4341                    success: false,
4342                    stdout: b"never-echo-gh-output".to_vec(),
4343                })
4344            }
4345        }
4346        let error = CopilotConfig::default()
4347            .resolve_token_with(|_| None, &FailedGh, None)
4348            .unwrap_err()
4349            .to_string();
4350        assert!(error.contains("gh auth login --web"));
4351        assert!(!error.contains("never-echo-gh-output"));
4352    }
4353
4354    #[test]
4355    fn resolve_api_key_errors_when_both_missing() {
4356        let _g = env_guard();
4357        let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
4358        unsafe { std::env::remove_var(var) };
4359        let err = resolve_api_key("Zai", var, None).unwrap_err();
4360        match err {
4361            crate::error::AppError::Credentials(msg) => {
4362                assert!(
4363                    msg.contains("api_key"),
4364                    "error should suggest config field: {msg}"
4365                );
4366            }
4367            other => panic!("expected Credentials error, got {other:?}"),
4368        }
4369    }
4370
4371    #[test]
4372    fn resolve_api_key_uses_exact_opencode_go_section_name() {
4373        let _g = env_guard();
4374        unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
4375        let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
4376        let message = err.to_string();
4377        assert!(
4378            message.contains("[opencode-go]"),
4379            "wrong section hint: {message}"
4380        );
4381        assert!(
4382            !message.contains("[opencode go]"),
4383            "wrong section hint: {message}"
4384        );
4385    }
4386
4387    fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
4388        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
4389        M.lock().unwrap_or_else(|p| p.into_inner())
4390    }
4391
4392    /// Serializes the override tests *and* guarantees the process-wide
4393    /// override is dropped when the test ends — including via a panic, which
4394    /// a bare set/clear pair does not survive. A leaked override makes every
4395    /// later test in this process resolve a deleted temp file, turning one
4396    /// failure into a cascade of confusing sibling failures.
4397    struct ScopedPathOverride {
4398        _serial: std::sync::MutexGuard<'static, ()>,
4399    }
4400
4401    impl Drop for ScopedPathOverride {
4402        fn drop(&mut self) {
4403            clear_override_path();
4404        }
4405    }
4406
4407    fn scoped_path_override() -> ScopedPathOverride {
4408        ScopedPathOverride {
4409            _serial: path_override_guard(),
4410        }
4411    }
4412
4413    #[test]
4414    fn override_path_wins_over_canonical_and_legacy() {
4415        let _scoped = scoped_path_override();
4416        let file = NamedTempFile::new().unwrap();
4417        set_override_path(file.path());
4418        assert_eq!(resolved_path().as_deref(), Some(file.path()));
4419        assert_eq!(config_path_hint(), file.path().display().to_string());
4420        clear_override_path();
4421        // The usual locations decide again once the override is gone.
4422        let p = resolved_path().expect("a config path must resolve");
4423        assert!(p.ends_with("config.toml"));
4424    }
4425
4426    #[test]
4427    fn scoped_override_guard_clears_the_override_on_panic() {
4428        // Silence the simulated failure's hook output; the assertion below is
4429        // the real report.
4430        let hook = std::panic::take_hook();
4431        std::panic::set_hook(Box::new(|_| {}));
4432        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4433            let _scoped = scoped_path_override();
4434            set_override_path(std::path::Path::new("panicked-override.toml"));
4435            panic!("simulated mid-test failure");
4436        }))
4437        .is_err();
4438        std::panic::set_hook(hook);
4439        assert!(panicked, "the simulated failure must run");
4440        let _serial = path_override_guard();
4441        assert!(
4442            override_path().is_none(),
4443            "a panicking test must not leak the override into siblings"
4444        );
4445    }
4446
4447    #[test]
4448    fn config_path_hint_ends_with_config_toml() {
4449        let _g = path_override_guard();
4450        // Platform-resolved (Linux/macOS/Windows), but always ends in the
4451        // config filename — the trailing segment is what messages rely on.
4452        assert!(config_path_hint().ends_with("config.toml"));
4453    }
4454
4455    #[test]
4456    fn config_flag_value_splits_the_equals_form() {
4457        use std::ffi::OsStr;
4458        assert_eq!(
4459            config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
4460            Some(std::path::Path::new("work.toml"))
4461        );
4462        assert_eq!(
4463            config_flag_value(OsStr::new("--config=")).as_deref(),
4464            Some(std::path::Path::new(""))
4465        );
4466        assert_eq!(config_flag_value(OsStr::new("--config")), None);
4467        assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
4468        assert_eq!(config_flag_value(OsStr::new("account")), None);
4469    }
4470
4471    /// The `--config=PATH` form must preserve a path the platform can store
4472    /// but UTF-8 cannot represent — `to_string_lossy` would replace the bad
4473    /// bytes with U+FFFD and produce a false "config file not found".
4474    #[cfg(unix)]
4475    #[test]
4476    fn config_flag_value_keeps_undecodable_bytes_intact() {
4477        use std::ffi::OsString;
4478        use std::os::unix::ffi::{OsStrExt, OsStringExt};
4479        let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
4480        let value = config_flag_value(&raw).expect("prefix matches");
4481        assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
4482    }
4483
4484    #[cfg(windows)]
4485    #[test]
4486    fn config_flag_value_keeps_lone_surrogates_intact() {
4487        use std::ffi::OsString;
4488        use std::os::windows::ffi::{OsStrExt, OsStringExt};
4489        let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
4490        wide.push(0xDC00); // lone low surrogate: not valid Unicode
4491        wide.extend("x.toml".encode_utf16());
4492        let raw = OsString::from_wide(&wide);
4493        let value = config_flag_value(&raw).expect("prefix matches");
4494        let mut expected = vec![0xDC00u16];
4495        expected.extend("x.toml".encode_utf16());
4496        assert_eq!(
4497            value.as_os_str().encode_wide().collect::<Vec<_>>(),
4498            expected
4499        );
4500    }
4501
4502    #[test]
4503    fn resolve_api_key_treats_empty_env_as_unset() {
4504        let _g = env_guard();
4505        let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
4506        unsafe { std::env::set_var(var, "") };
4507        let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
4508        unsafe { std::env::remove_var(var) };
4509        assert_eq!(got, "inline");
4510    }
4511
4512    #[test]
4513    fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
4514        let _g = env_guard();
4515        // Simulates a user accidentally pasting the key into api_key_env.
4516        let bad = "sk-kimi-very-real-looking-pasted-secret";
4517        let err = resolve_api_key("Kimi", bad, None).unwrap_err();
4518        let msg = err.to_string();
4519        assert!(
4520            msg.contains("invalid") && msg.contains("api_key_env"),
4521            "error should explain misconfiguration: {msg}"
4522        );
4523        assert!(
4524            !msg.contains(bad),
4525            "error must not echo the misconfigured value: {msg}"
4526        );
4527        assert!(msg.contains("valid environment variable name"));
4528        assert!(
4529            msg.contains("[kimi]"),
4530            "error should point at the lowercase TOML section: {msg}"
4531        );
4532    }
4533
4534    #[test]
4535    fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
4536        let _g = env_guard();
4537        let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
4538        assert_eq!(got, "inline-key");
4539    }
4540
4541    #[test]
4542    fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
4543        let _g = env_guard();
4544        // This is syntactically a valid environment variable name, but could
4545        // be a pasted secret and must not be reflected in the error.
4546        let pasted_secret = "sk_pasted_secret";
4547        unsafe { std::env::remove_var(pasted_secret) };
4548        let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
4549        assert!(
4550            !err.to_string().contains(pasted_secret),
4551            "error must not echo configured api_key_env values"
4552        );
4553    }
4554
4555    #[test]
4556    fn is_valid_env_var_name_rules() {
4557        // Valid: alphabetic or underscore first, then alnum/underscore.
4558        for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
4559            assert!(is_valid_env_var_name(valid), "{valid} should be valid");
4560        }
4561        // Invalid: empty, digit-first, or shell-illegal characters.
4562        for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
4563            assert!(
4564                !is_valid_env_var_name(invalid),
4565                "{invalid} should be invalid"
4566            );
4567        }
4568    }
4569
4570    #[test]
4571    fn config_parses_with_inline_api_key_and_primary() {
4572        let f = write_toml(
4573            r#"
4574            [ui]
4575            primary = "openrouter"
4576
4577            [zai]
4578            enabled = true
4579            api_key_env = "MY_ZAI"
4580            api_key = "sk-zai-inline"
4581
4582            [openrouter]
4583            enabled = true
4584            api_key = "sk-or-inline"
4585            "#,
4586        );
4587        let c = Config::load_from(f.path()).unwrap();
4588        assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
4589        assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
4590        assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
4591    }
4592
4593    #[test]
4594    fn openrouter_named_accounts_preserve_the_default_contract() {
4595        let f = write_toml(
4596            r#"
4597            [openrouter]
4598            enabled = true
4599            api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
4600            api_key = "default-inline"
4601            show_default_account = false
4602
4603            [[openrouter.accounts]]
4604            label = "work"
4605            api_key_env = "OPENROUTER_WORK_API_KEY"
4606
4607            [[openrouter.accounts]]
4608            label = "personal"
4609            api_key = "personal-inline"
4610            "#,
4611        );
4612        let _g = env_guard();
4613        unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
4614        let config = Config::load_from(f.path()).unwrap();
4615        assert!(!config.openrouter.show_default_account);
4616        assert_eq!(config.openrouter.accounts.len(), 2);
4617        assert_eq!(
4618            config
4619                .resolve_account_api_key_for(VendorId::Openrouter, None)
4620                .unwrap(),
4621            "default-inline"
4622        );
4623        assert_eq!(
4624            config
4625                .resolve_account_api_key_for(VendorId::Openrouter, Some("personal"))
4626                .unwrap(),
4627            "personal-inline"
4628        );
4629    }
4630
4631    #[test]
4632    fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
4633        for source in [
4634            r#"
4635            [[openrouter.accounts]]
4636            label = "work"
4637            api_key = "one"
4638            [[openrouter.accounts]]
4639            label = "work"
4640            api_key = "two"
4641            "#,
4642            r#"
4643            [[openrouter.accounts]]
4644            label = "../work"
4645            api_key = "one"
4646            "#,
4647            r#"
4648            [[openrouter.accounts]]
4649            label = "work"
4650            "#,
4651        ] {
4652            let f = write_toml(source);
4653            assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
4654        }
4655    }
4656
4657    #[test]
4658    fn openrouter_unknown_account_never_falls_back_to_default_key() {
4659        let mut config = Config::default();
4660        config.openrouter.api_key = Some("default-secret".into());
4661        config.openrouter.accounts.push(ApiKeyAccount {
4662            label: "work".into(),
4663            api_key_env: None,
4664            api_key: Some("work-secret".into()),
4665            management_api_key_env: None,
4666        });
4667        let message = config
4668            .resolve_account_api_key_for(VendorId::Openrouter, Some("missing"))
4669            .unwrap_err()
4670            .to_string();
4671        assert!(message.contains("missing") && message.contains("work"));
4672        assert!(!message.contains("default-secret"));
4673        assert!(!message.contains("work-secret"));
4674    }
4675
4676    #[test]
4677    fn openrouter_account_key_errors_do_not_echo_configured_values() {
4678        let mut config = Config::default();
4679        config.openrouter.accounts.push(ApiKeyAccount {
4680            label: "work".into(),
4681            api_key_env: Some("sk_pasted_secret".into()),
4682            api_key: None,
4683            management_api_key_env: None,
4684        });
4685        let _g = env_guard();
4686        unsafe { std::env::remove_var("sk_pasted_secret") };
4687        let message = config
4688            .resolve_account_api_key_for(VendorId::Openrouter, Some("work"))
4689            .unwrap_err()
4690            .to_string();
4691        assert!(message.contains("[[openrouter.accounts]]"));
4692        assert!(!message.contains("sk_pasted_secret"));
4693    }
4694
4695    #[test]
4696    fn openrouter_management_key_resolves_per_account_and_never_inherits() {
4697        let mut config = Config::default();
4698        config.openrouter.accounts = vec![
4699            ApiKeyAccount {
4700                label: "work".into(),
4701                api_key_env: None,
4702                api_key: Some("work-key".into()),
4703                management_api_key_env: Some("AI_USAGEBAR_TEST_OR_MGMT_WORK".into()),
4704            },
4705            ApiKeyAccount {
4706                label: "plain".into(),
4707                api_key_env: None,
4708                api_key: Some("plain-key".into()),
4709                management_api_key_env: None,
4710            },
4711        ];
4712        let _g = env_guard();
4713        unsafe { std::env::remove_var("OPENROUTER_MANAGEMENT_API_KEY") };
4714        unsafe { std::env::set_var("AI_USAGEBAR_TEST_OR_MGMT_WORK", "mgmt-work") };
4715
4716        // The default account reads OPENROUTER_MANAGEMENT_API_KEY — unset here.
4717        assert_eq!(config.openrouter_management_key(None), None);
4718        // A named account with its own var resolves it.
4719        assert_eq!(
4720            config.openrouter_management_key(Some("work")).as_deref(),
4721            Some("mgmt-work")
4722        );
4723        // A named account without one resolves nothing, even when the default
4724        // account's var is set — accounts never inherit across identities.
4725        unsafe { std::env::set_var("OPENROUTER_MANAGEMENT_API_KEY", "mgmt-default") };
4726        assert_eq!(
4727            config.openrouter_management_key(None).as_deref(),
4728            Some("mgmt-default")
4729        );
4730        assert_eq!(config.openrouter_management_key(Some("plain")), None);
4731        // An unknown label resolves nothing rather than the default's key.
4732        assert_eq!(config.openrouter_management_key(Some("typo")), None);
4733
4734        // The [openrouter] override renames the default account's var.
4735        config.openrouter.management_api_key_env = "AI_USAGEBAR_TEST_OR_MGMT_DEFAULT".into();
4736        unsafe { std::env::set_var("AI_USAGEBAR_TEST_OR_MGMT_DEFAULT", "mgmt-renamed") };
4737        assert_eq!(
4738            config.openrouter_management_key(None).as_deref(),
4739            Some("mgmt-renamed")
4740        );
4741        unsafe { std::env::remove_var("OPENROUTER_MANAGEMENT_API_KEY") };
4742        unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_MGMT_WORK") };
4743        unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_MGMT_DEFAULT") };
4744    }
4745
4746    #[test]
4747    fn every_api_key_account_vendor_validates_its_array_like_openrouter() {
4748        for vendor in Config::API_KEY_ACCOUNT_VENDORS {
4749            let section = vendor.config_section();
4750            let parse = |accounts: &str| -> Result<Config> {
4751                let config: Config =
4752                    toml::from_str(&format!("[{section}]\nenabled = true\n{accounts}")).unwrap();
4753                config.validate().map(|()| config)
4754            };
4755            let config = parse(&format!(
4756                "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k\"\n"
4757            ))
4758            .unwrap();
4759            assert_eq!(
4760                config.api_key_accounts(vendor).unwrap().len(),
4761                1,
4762                "{vendor:?}"
4763            );
4764            assert!(config.show_default_api_key_account(vendor), "{vendor:?}");
4765
4766            let duplicate = parse(&format!(
4767                "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"a\"\n\
4768                 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"b\"\n"
4769            ))
4770            .unwrap_err()
4771            .to_string();
4772            assert!(
4773                duplicate.contains(&format!("duplicate {section} account label")),
4774                "{duplicate}"
4775            );
4776
4777            let keyless = parse(&format!("[[{section}.accounts]]\nlabel = \"work\"\n"))
4778                .unwrap_err()
4779                .to_string();
4780            assert!(
4781                keyless.contains("must set api_key_env or api_key"),
4782                "{keyless}"
4783            );
4784
4785            // A label becomes a cache subdirectory, so a path is refused.
4786            assert!(
4787                parse(&format!(
4788                    "[[{section}.accounts]]\nlabel = \"../x\"\napi_key = \"k\"\n"
4789                ))
4790                .is_err(),
4791                "{vendor:?}"
4792            );
4793        }
4794    }
4795
4796    #[test]
4797    fn named_api_key_accounts_resolve_their_own_key_and_never_the_default() {
4798        let mut config = Config::default();
4799        config.deepseek.api_key_env.clear();
4800        config.deepseek.api_key = Some("default-key".into());
4801        config.deepseek.accounts.push(ApiKeyAccount {
4802            label: "work".into(),
4803            api_key_env: Some("AI_USAGEBAR_TEST_DEEPSEEK_WORK".into()),
4804            api_key: Some("work-inline".into()),
4805            management_api_key_env: None,
4806        });
4807        let _g = env_guard();
4808        unsafe { std::env::set_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK", "work-env") };
4809        let resolve = |label| config.resolve_account_api_key_for(VendorId::Deepseek, label);
4810        assert_eq!(resolve(None).unwrap(), "default-key");
4811        assert_eq!(resolve(Some("work")).unwrap(), "work-env", "env wins");
4812        unsafe { std::env::remove_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK") };
4813        assert_eq!(resolve(Some("work")).unwrap(), "work-inline");
4814
4815        let unknown = resolve(Some("typo")).unwrap_err().to_string();
4816        assert!(unknown.contains("[[deepseek.accounts]]"), "{unknown}");
4817        assert!(
4818            unknown.contains("\"work\""),
4819            "lists known labels: {unknown}"
4820        );
4821        assert!(!unknown.contains("default-key"), "{unknown}");
4822    }
4823
4824    #[test]
4825    fn the_default_key_error_keeps_each_vendors_historic_wording() {
4826        let mut config = Config::default();
4827        config.zai.api_key_env.clear();
4828        config.minimax.api_key_env.clear();
4829        let zai = config
4830            .resolve_account_api_key_for(VendorId::Zai, None)
4831            .unwrap_err()
4832            .to_string();
4833        let expected = resolve_api_key("Zai", "", None).unwrap_err().to_string();
4834        assert_eq!(zai, expected);
4835        assert!(zai.contains("[zai]"), "{zai}");
4836        let minimax = config
4837            .resolve_account_api_key_for(VendorId::Minimax, None)
4838            .unwrap_err()
4839            .to_string();
4840        assert_eq!(
4841            minimax,
4842            resolve_api_key("MiniMax", "", None)
4843                .unwrap_err()
4844                .to_string()
4845        );
4846    }
4847
4848    #[test]
4849    fn enabled_vendors_preserves_canonical_order() {
4850        // DeepSeek and Kimi are disabled by default (require explicit API key
4851        // config), so they are absent from the enabled list unless enabled.
4852        let c = Config::default();
4853        assert_eq!(
4854            c.enabled_vendors(),
4855            vec![
4856                VendorId::Anthropic,
4857                VendorId::Openai,
4858                VendorId::Zai,
4859                VendorId::Openrouter,
4860            ]
4861        );
4862    }
4863
4864    #[test]
4865    fn deepseek_appears_when_enabled() {
4866        let f = write_toml(
4867            r#"
4868            [deepseek]
4869            enabled = true
4870            api_key = "sk-test"
4871            "#,
4872        );
4873        let c = Config::load_from(f.path()).unwrap();
4874        assert!(c.is_enabled(VendorId::Deepseek));
4875        assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
4876        assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
4877    }
4878
4879    #[test]
4880    fn tilde_paths_are_expanded_on_load() {
4881        // `PathBuf` keeps `~` literally, so the documented
4882        // `credentials_path = "~/..."` used to resolve to a directory named
4883        // `~` relative to the process's cwd.
4884        let f = write_toml(
4885            r#"
4886            [context]
4887            projects_path = "~/.claude/projects"
4888
4889            [anthropic]
4890            credentials_path = "~/.claude/.credentials.json"
4891
4892            [[anthropic.accounts]]
4893            label = "work"
4894            credentials_path = "~/work.json"
4895            "#,
4896        );
4897        let c = Config::load_from(f.path()).unwrap();
4898        let home = crate::cache::home_dir().unwrap();
4899
4900        assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
4901        let got = c.anthropic.credentials_path.unwrap();
4902        assert_eq!(got, home.join(".claude/.credentials.json"));
4903        assert!(!got.to_string_lossy().contains('~'));
4904        assert_eq!(
4905            c.anthropic.accounts[0].credentials_path,
4906            home.join("work.json")
4907        );
4908    }
4909
4910    #[test]
4911    fn absolute_and_relative_paths_are_left_alone() {
4912        let f = write_toml(
4913            r#"
4914            [anthropic]
4915            credentials_path = "/etc/creds.json"
4916            "#,
4917        );
4918        let c = Config::load_from(f.path()).unwrap();
4919        assert_eq!(
4920            c.anthropic.credentials_path.unwrap(),
4921            std::path::Path::new("/etc/creds.json")
4922        );
4923
4924        // `~user` is not ours to interpret.
4925        let f2 = write_toml(
4926            r#"
4927            [anthropic]
4928            credentials_path = "~someone/creds.json"
4929            "#,
4930        );
4931        let c2 = Config::load_from(f2.path()).unwrap();
4932        assert_eq!(
4933            c2.anthropic.credentials_path.unwrap(),
4934            std::path::Path::new("~someone/creds.json")
4935        );
4936    }
4937
4938    #[test]
4939    fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
4940        let _g = path_override_guard();
4941        // Hermetic: only asserts the shape, never which file happens to exist
4942        // on the machine running the tests.
4943        let p = resolved_path().expect("a config path must resolve");
4944        assert!(p.ends_with("config.toml"));
4945        let canonical = default_path().unwrap();
4946        let legacy = legacy_xdg_path().unwrap();
4947        assert!(
4948            p == canonical || p == legacy,
4949            "resolved to an unexpected location: {}",
4950            p.display()
4951        );
4952    }
4953
4954    #[test]
4955    fn misspelled_section_is_rejected_not_ignored() {
4956        // The regression this guards: `[openrouer]` used to parse fine, leave
4957        // OpenRouter on its defaults, and give the user no hint at all.
4958        let f = write_toml(
4959            r#"
4960            [openrouer]
4961            enabled = true
4962            api_key = "sk-or-v1-typo"
4963            "#,
4964        );
4965        let err = Config::load_from(f.path()).unwrap_err().to_string();
4966        assert!(
4967            err.contains("openrouer"),
4968            "error should name the typo: {err}"
4969        );
4970    }
4971
4972    #[test]
4973    fn invalid_toml_is_an_error_not_silent_defaults() {
4974        let f = write_toml("[zai\nenabled = true\n");
4975        assert!(Config::load_from(f.path()).is_err());
4976    }
4977
4978    /// A missing quote is the commonest way to break the file, and the line
4979    /// it breaks can be an inline key: the error must point at that line
4980    /// without repeating it.
4981    #[test]
4982    fn a_parse_error_names_the_line_without_quoting_it() {
4983        let f = write_toml("[openrouter]\nenabled = true\napi_key = sk-or-v1-unquoted\n");
4984        let err = Config::load_from(f.path()).unwrap_err().to_string();
4985        assert!(err.contains("line 3,"), "{err}");
4986        assert!(!err.contains("sk-or-v1-unquoted"), "{err}");
4987    }
4988
4989    /// The writers read the same file through `toml_edit`, whose errors quote
4990    /// the line too.
4991    #[test]
4992    fn a_writer_parse_error_names_the_line_without_quoting_it() {
4993        let (_dir, path) =
4994            crate::cache::closed_temp_file("config.toml", Some("[zai]\napi_key = zk-unquoted\n"));
4995        let err = read_config_document(&path).unwrap_err().to_string();
4996        assert!(err.contains("line 2,"), "{err}");
4997        assert!(!err.contains("zk-unquoted"), "{err}");
4998    }
4999
5000    #[test]
5001    fn toml_error_summary_keeps_the_position_and_the_message() {
5002        let input = "a = 1\nkey = sk-secret\n";
5003        assert_eq!(
5004            toml_error_summary(input, Some(12..21), "string values must be quoted\n"),
5005            "line 2, column 7: string values must be quoted"
5006        );
5007        assert_eq!(toml_error_summary(input, None, "bad"), "bad");
5008    }
5009
5010    #[test]
5011    fn a_missing_file_is_still_just_defaults() {
5012        // Absence stays the legitimate "use defaults" case — only real parse
5013        // and I/O failures are errors.
5014        let dir = tempfile::tempdir().unwrap();
5015        let missing = dir.path().join("nope").join("config.toml");
5016        let c = Config::load_from(&missing).unwrap();
5017        assert!(c.is_enabled(VendorId::Anthropic));
5018    }
5019
5020    #[test]
5021    fn kimi_appears_when_enabled() {
5022        let f = write_toml(
5023            r#"
5024            [kimi]
5025            enabled = true
5026            api_key = "sk-test"
5027            "#,
5028        );
5029        let c = Config::load_from(f.path()).unwrap();
5030        assert!(c.is_enabled(VendorId::Kimi));
5031        assert!(c.enabled_vendors().contains(&VendorId::Kimi));
5032        assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
5033    }
5034
5035    #[test]
5036    fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
5037        let f = write_toml(
5038            r#"
5039            [deepseek]
5040            enabled = true
5041            api_key = "sk-ds"
5042
5043            [kimi]
5044            enabled = true
5045            api_key = "sk-kimi"
5046            "#,
5047        );
5048        let c = Config::load_from(f.path()).unwrap();
5049        assert_eq!(
5050            c.enabled_vendors(),
5051            vec![
5052                VendorId::Anthropic,
5053                VendorId::Openai,
5054                VendorId::Zai,
5055                VendorId::Openrouter,
5056                VendorId::Deepseek,
5057                VendorId::Kimi,
5058            ]
5059        );
5060    }
5061
5062    #[test]
5063    fn parses_anthropic_accounts_and_looks_them_up() {
5064        let f = write_toml(
5065            r#"
5066            [anthropic]
5067            enabled = true
5068
5069            [[anthropic.accounts]]
5070            label = "personal"
5071            credentials_path = "/creds/personal.json"
5072
5073            [[anthropic.accounts]]
5074            label = "work"
5075            credentials_path = "/creds/work.json"
5076            "#,
5077        );
5078        let c = Config::load_from(f.path()).unwrap();
5079        assert_eq!(c.anthropic.accounts.len(), 2);
5080        let work = c.anthropic.account("work").unwrap();
5081        assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
5082        // A typo names the offending label and lists the known ones.
5083        let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
5084        assert!(err.contains("missing") && err.contains("work"), "{err}");
5085    }
5086
5087    #[test]
5088    fn duplicate_anthropic_account_labels_are_rejected_on_load() {
5089        let f = write_toml(
5090            r#"
5091            [[anthropic.accounts]]
5092            label = "work"
5093            credentials_path = "/creds/work-one.json"
5094
5095            [[anthropic.accounts]]
5096            label = "work"
5097            credentials_path = "/creds/work-two.json"
5098            "#,
5099        );
5100        let err = Config::load_from(f.path()).unwrap_err().to_string();
5101        assert!(
5102            err.contains("duplicate anthropic account label \"work\""),
5103            "{err}"
5104        );
5105    }
5106
5107    #[test]
5108    fn account_label_rejects_path_like_names() {
5109        let cfg = AnthropicConfig::default();
5110        for bad in [
5111            "",
5112            ".",
5113            "..",
5114            "a/b",
5115            r"a\b",
5116            "C:work",
5117            "line\nbreak",
5118            "tab\tname",
5119            "usage.json",
5120            ".stale",
5121            ".last_error",
5122            ".fetch.lock",
5123        ] {
5124            let err = cfg.account(bad).unwrap_err();
5125            assert!(
5126                format!("{err:?}").contains("invalid anthropic account label"),
5127                "{bad:?} should be rejected as a label"
5128            );
5129        }
5130    }
5131
5132    #[test]
5133    fn anthropic_accounts_default_to_empty() {
5134        // No [[anthropic.accounts]] → the single default account, empty list,
5135        // nothing to migrate (issue #14, back-compat rule 1).
5136        assert!(Config::default().anthropic.accounts.is_empty());
5137        assert!(Config::default().anthropic.accounts_dir.is_none());
5138    }
5139
5140    // --- accounts_dir: CLAUDE_CONFIG_DIR-style auto-discovery ----------------
5141    // All hermetic: discovery reads a TempDir, never the user's real config.
5142
5143    /// Create `<root>/<label>/.credentials.json` (contents irrelevant here —
5144    /// discovery keys on the file existing, the fetch path parses it).
5145    fn seed_account_dir(root: &std::path::Path, label: &str) {
5146        let dir = root.join(label);
5147        std::fs::create_dir_all(&dir).unwrap();
5148        std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
5149    }
5150
5151    #[test]
5152    fn discovers_account_dirs_in_claude_config_dir_layout() {
5153        let td = tempfile::tempdir().unwrap();
5154        seed_account_dir(td.path(), "work");
5155        seed_account_dir(td.path(), "personal");
5156        // Keychain-backed macOS logins may not write .credentials.json; their
5157        // config directories are still account entries.
5158        std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
5159        // A loose file (not a dir) is ignored.
5160        std::fs::write(td.path().join("stray.json"), "{}").unwrap();
5161
5162        let cfg = AnthropicConfig {
5163            accounts_dir: Some(td.path().to_path_buf()),
5164            ..Default::default()
5165        };
5166        let all = cfg.all_accounts();
5167        let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
5168        assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
5169        assert_eq!(
5170            all[2].credentials_path,
5171            td.path().join("work").join(".credentials.json")
5172        );
5173    }
5174
5175    #[test]
5176    fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
5177        let td = tempfile::tempdir().unwrap();
5178        seed_account_dir(td.path(), "work");
5179        let cfg = AnthropicConfig {
5180            accounts: vec![AnthropicAccount {
5181                label: "work".into(),
5182                credentials_path: "/explicit/work.json".into(),
5183            }],
5184            accounts_dir: Some(td.path().to_path_buf()),
5185            ..Default::default()
5186        };
5187        let all = cfg.all_accounts();
5188        assert_eq!(all.len(), 1, "no duplicate label");
5189        assert_eq!(
5190            all[0].credentials_path,
5191            std::path::Path::new("/explicit/work.json"),
5192            "explicit entry wins"
5193        );
5194        // A discovered account is still reachable through `account()`.
5195        seed_account_dir(td.path(), "other");
5196        assert_eq!(cfg.account("other").unwrap().label, "other");
5197    }
5198
5199    #[test]
5200    fn missing_accounts_dir_is_silently_empty_not_an_error() {
5201        let cfg = AnthropicConfig {
5202            accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
5203            ..Default::default()
5204        };
5205        assert!(cfg.all_accounts().is_empty());
5206    }
5207
5208    #[test]
5209    fn openai_account_auth_paths_are_tilde_expanded_on_load() {
5210        let f = write_toml(
5211            r#"
5212            [[openai.accounts]]
5213            label = "work"
5214            codex_auth_path = "~/.codex-work/auth.json"
5215            "#,
5216        );
5217        let c = Config::load_from(f.path()).unwrap();
5218        let home = crate::cache::home_dir().unwrap();
5219        assert_eq!(
5220            c.openai.accounts[0].codex_auth_path,
5221            home.join(".codex-work/auth.json")
5222        );
5223    }
5224
5225    #[test]
5226    fn accounts_dir_is_tilde_expanded_on_load() {
5227        let f = write_toml(
5228            r#"
5229            [anthropic]
5230            accounts_dir = "~/.config/ai-usagebar/accounts"
5231            "#,
5232        );
5233        let c = Config::load_from(f.path()).unwrap();
5234        let home = crate::cache::home_dir().unwrap();
5235        assert_eq!(
5236            c.anthropic.accounts_dir,
5237            Some(home.join(".config/ai-usagebar/accounts"))
5238        );
5239    }
5240
5241    #[test]
5242    fn desktop_profiles_dir_is_tilde_expanded_on_load() {
5243        let f = write_toml(
5244            r#"
5245            [anthropic]
5246            desktop_profiles_dir = "~/.claude-acc/profiles"
5247            "#,
5248        );
5249        let c = Config::load_from(f.path()).unwrap();
5250        let home = crate::cache::home_dir().unwrap();
5251        assert_eq!(
5252            c.anthropic.desktop_profiles_dir,
5253            Some(home.join(".claude-acc/profiles"))
5254        );
5255    }
5256
5257    #[test]
5258    fn the_live_cli_account_is_read_from_the_default_credential_slot() {
5259        let cfg = AnthropicConfig {
5260            accounts: vec![
5261                AnthropicAccount {
5262                    label: "work".into(),
5263                    credentials_path: "/tmp/accounts/work/.credentials.json".into(),
5264                },
5265                AnthropicAccount {
5266                    label: "personal".into(),
5267                    credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
5268                },
5269            ],
5270            ..Default::default()
5271        };
5272
5273        let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
5274        assert!(
5275            matches!(&idle, CredsTarget::Named { config_dir, .. }
5276                if config_dir == std::path::Path::new("/tmp/accounts/work")),
5277            "{idle:?}"
5278        );
5279
5280        // Same label, but it is the login `claude` itself is using: one lineage.
5281        let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
5282        assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
5283
5284        // The cache must not move, or a switch would silently orphan the tab's
5285        // usage history and show "Loading…" until the next fetch.
5286        assert_eq!(idle_cache.dir(), live_cache.dir());
5287    }
5288
5289    #[test]
5290    fn the_live_cli_account_keeps_its_own_slot_while_that_file_is_there() {
5291        // Two CLAUDE_CONFIG_DIRs can hold the same account, and each keeps its
5292        // own live credential — `resolve_active_label` matches the account, not
5293        // the lineage. Reading the default slot then hands back a credential
5294        // the user never logs into.
5295        let cfg = AnthropicConfig {
5296            accounts: vec![AnthropicAccount {
5297                label: "personal".into(),
5298                credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
5299            }],
5300            ..Default::default()
5301        };
5302
5303        let (present, _) = cfg
5304            .account_target_probing("personal", Some("personal"), |_| true)
5305            .unwrap();
5306        assert!(
5307            matches!(&present, CredsTarget::Named { path, .. }
5308                if path == Path::new("/tmp/accounts/personal/.credentials.json")),
5309            "{present:?}"
5310        );
5311
5312        // Emptied by `account switch`: the credential really did move.
5313        let (moved, _) = cfg
5314            .account_target_probing("personal", Some("personal"), |_| false)
5315            .unwrap();
5316        assert!(matches!(moved, CredsTarget::Default(_)), "{moved:?}");
5317    }
5318
5319    #[test]
5320    fn the_default_entry_reads_config_credentials_path_strictly() {
5321        let cfg = AnthropicConfig {
5322            credentials_path: Some("/tmp/claude-home/.credentials.json".into()),
5323            ..Default::default()
5324        };
5325
5326        assert!(
5327            matches!(&cfg.default_creds_target(), CredsTarget::Explicit(path)
5328                if path == Path::new("/tmp/claude-home/.credentials.json")),
5329        );
5330        // Without one, the platform default — the only target with a Keychain fallback.
5331        assert!(matches!(
5332            AnthropicConfig::default().default_creds_target(),
5333            CredsTarget::Default(_)
5334        ));
5335    }
5336
5337    #[test]
5338    fn the_live_cli_accounts_own_file_is_probed_on_disk() {
5339        // `account_target_probing` proves the decision; only the entry point
5340        // proves that the shipping caller probes at all. Fails on main, where
5341        // the live label is routed to the default slot unconditionally.
5342        let creds = NamedTempFile::new().unwrap();
5343        let cfg = AnthropicConfig {
5344            accounts: vec![AnthropicAccount {
5345                label: "personal".into(),
5346                credentials_path: creds.path().to_path_buf(),
5347            }],
5348            ..Default::default()
5349        };
5350        let (target, _) = cfg
5351            .account_target_with("personal", Some("personal"))
5352            .unwrap();
5353        assert!(
5354            matches!(&target, CredsTarget::Named { path, .. } if path == creds.path()),
5355            "read {target:?} instead of the account's own file"
5356        );
5357    }
5358
5359    #[test]
5360    fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
5361        let cfg = AnthropicConfig {
5362            accounts: vec![AnthropicAccount {
5363                label: "work".into(),
5364                credentials_path: "/tmp/accounts/work/.credentials.json".into(),
5365            }],
5366            ..Default::default()
5367        };
5368        let (target, _) = cfg.account_target_with("work", None).unwrap();
5369        assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
5370    }
5371
5372    /// The shipped example, which `make install` puts in
5373    /// `share/ai-usagebar/config.example.toml`. Repo-relative, so this stays
5374    /// hermetic — it never touches the user's real config.
5375    fn config_example() -> PathBuf {
5376        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
5377    }
5378
5379    #[test]
5380    fn shipped_example_parses_as_a_real_config() {
5381        // The example is documentation users copy verbatim, but nothing used
5382        // to parse it — so a renamed section or field could rot there
5383        // unnoticed, and `deny_unknown_fields` would reject the copy on the
5384        // user's machine instead of in CI.
5385        let c = Config::load_from(&config_example()).unwrap();
5386        assert!(!c.context.enabled);
5387        assert!(c.is_enabled(VendorId::Anthropic));
5388        assert!(c.is_enabled(VendorId::Openai));
5389        assert!(!c.is_enabled(VendorId::AnthropicApi));
5390        assert!(!c.is_enabled(VendorId::Deepseek));
5391        assert!(!c.is_enabled(VendorId::Kimi));
5392        assert!(!c.is_enabled(VendorId::Kilo));
5393        assert!(!c.is_enabled(VendorId::Novita));
5394        assert!(!c.is_enabled(VendorId::Moonshot));
5395        assert!(!c.is_enabled(VendorId::Grok));
5396        assert!(!c.is_enabled(VendorId::Cursor));
5397        assert!(!c.is_enabled(VendorId::Minimax));
5398    }
5399
5400    #[test]
5401    fn shipped_example_does_not_advertise_admin_key_env_as_working() {
5402        // The regression: the example shipped an *uncommented*
5403        // `admin_key_env = "OPENAI_ADMIN_KEY"`, indistinguishable from a live
5404        // setting. Nothing reads it, so a user could set it, skip
5405        // `codex login`, and wait for usage that never arrives.
5406        let text = std::fs::read_to_string(config_example()).unwrap();
5407        let live: Vec<&str> = text
5408            .lines()
5409            .map(str::trim)
5410            .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
5411            .collect();
5412        assert!(
5413            live.is_empty(),
5414            "admin_key_env must stay commented out while it is inert: {live:?}"
5415        );
5416        // Still documented, though — silently dropping it would leave users
5417        // who already set it with no explanation of why it does nothing.
5418        assert!(
5419            text.contains("admin_key_env") && text.contains("RESERVED"),
5420            "the example should keep describing admin_key_env as reserved"
5421        );
5422    }
5423
5424    #[test]
5425    fn admin_key_env_is_accepted_but_changes_nothing() {
5426        // The field survives because the API-key-only path is still intended.
5427        // What has to hold today is narrower: setting it loads without error
5428        // and moves nothing the code actually acts on.
5429        let f = write_toml(
5430            r#"
5431            [openai]
5432            admin_key_env = "SOME_ADMIN_KEY"
5433            "#,
5434        );
5435        let c = Config::load_from(f.path()).unwrap();
5436        assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
5437        // Nothing else moved: OpenAI still resolves through Codex OAuth only.
5438        let default = OpenAiConfig::default();
5439        assert_eq!(c.openai.enabled, default.enabled);
5440        assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
5441        assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
5442    }
5443
5444    #[test]
5445    fn config_example_documents_every_vendor_without_secrets() {
5446        let raw = std::fs::read_to_string(config_example()).unwrap();
5447        let cfg = Config::load_from(&config_example()).unwrap();
5448        // Every vendor the binary can dispatch needs a documented section, or
5449        // users have no way to discover how to turn it on.
5450        for id in VendorId::all() {
5451            let section = id.slug();
5452            assert!(
5453                raw.contains(&format!("[{section}]")),
5454                "config.example.toml has no [{section}] section"
5455            );
5456        }
5457
5458        // The example must not ship anything enabled-by-key-only, and must not
5459        // carry a real secret.
5460        assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
5461        assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
5462        assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
5463        assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
5464        assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
5465        assert!(!cfg.supergrok.enabled);
5466        assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
5467        assert_eq!(
5468            cfg.supergrok
5469                .grok_binary
5470                .file_name()
5471                .and_then(|p| p.to_str()),
5472            Some(if cfg!(windows) { "grok.exe" } else { "grok" })
5473        );
5474        assert!(cfg.supergrok.auth_path.is_none());
5475        assert!(cfg.supergrok.config_path.is_none());
5476        assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
5477        assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
5478    }
5479
5480    #[test]
5481    fn supergrok_binary_must_not_be_empty() {
5482        let file = write_toml(
5483            r#"
5484            [supergrok]
5485            enabled = true
5486            grok_binary = ""
5487            "#,
5488        );
5489        let error = Config::load_from(file.path()).unwrap_err().to_string();
5490        assert!(error.contains("grok_binary must not be empty"));
5491    }
5492
5493    #[test]
5494    fn supergrok_paths_are_tilde_expanded() {
5495        let file = write_toml(
5496            r#"
5497            [supergrok]
5498            grok_binary = "~/bin/grok"
5499            auth_path = "~/.grok/auth.json"
5500            config_path = "~/.grok/config.toml"
5501            "#,
5502        );
5503        let config = Config::load_from(file.path()).unwrap();
5504        let home = crate::cache::home_dir().unwrap();
5505        assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
5506        assert_eq!(
5507            config.supergrok.auth_path,
5508            Some(home.join(".grok/auth.json"))
5509        );
5510        assert_eq!(
5511            config.supergrok.config_path,
5512            Some(home.join(".grok/config.toml"))
5513        );
5514    }
5515
5516    /// `config.example.toml` documents `auth_paths = ["~/.commandcode/auth.json"]`;
5517    /// `gh_binary` is the Copilot counterpart of `grok_binary` above.
5518    #[test]
5519    fn commandcode_and_copilot_paths_are_tilde_expanded() {
5520        let file = write_toml(
5521            r#"
5522            [commandcode]
5523            auth_paths = ["~/.commandcode/auth.json", "/etc/commandcode/auth.json"]
5524
5525            [copilot]
5526            gh_binary = "~/bin/gh"
5527            "#,
5528        );
5529        let config = Config::load_from(file.path()).unwrap();
5530        let home = crate::cache::home_dir().unwrap();
5531        assert_eq!(
5532            config.commandcode.auth_paths,
5533            Some(vec![
5534                home.join(".commandcode/auth.json"),
5535                PathBuf::from("/etc/commandcode/auth.json"),
5536            ])
5537        );
5538        assert_eq!(config.copilot.gh_binary, Some(home.join("bin/gh")));
5539    }
5540
5541    #[test]
5542    fn opencode_go_and_droid_paths_are_tilde_expanded() {
5543        let file = write_toml(
5544            r#"
5545            [opencode-go]
5546            auth_paths = ["~/.local/share/opencode/opencode.db", "/etc/opencode/auth.json"]
5547
5548            [droid]
5549            cli_path = "~/.local/bin/droid"
5550            "#,
5551        );
5552        let config = Config::load_from(file.path()).unwrap();
5553        let home = crate::cache::home_dir().unwrap();
5554        assert_eq!(
5555            config.opencode_go.auth_paths,
5556            Some(vec![
5557                home.join(".local/share/opencode/opencode.db"),
5558                PathBuf::from("/etc/opencode/auth.json"),
5559            ])
5560        );
5561        assert_eq!(config.droid.cli_path, Some(home.join(".local/bin/droid")));
5562    }
5563
5564    #[test]
5565    fn kiro_db_path_is_tilde_expanded() {
5566        let f = write_toml(
5567            r#"
5568            [kiro]
5569            db_path = "~/kiro-data.sqlite3"
5570            "#,
5571        );
5572        let c = Config::load_from(f.path()).unwrap();
5573        let home = crate::cache::home_dir().unwrap();
5574        assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
5575    }
5576
5577    #[test]
5578    fn kiro_appears_when_enabled() {
5579        let f = write_toml(
5580            r#"
5581            [kiro]
5582            enabled = true
5583            "#,
5584        );
5585        let c = Config::load_from(f.path()).unwrap();
5586        assert!(c.is_enabled(VendorId::Kiro));
5587        assert!(c.enabled_vendors().contains(&VendorId::Kiro));
5588    }
5589
5590    #[test]
5591    fn cursor_db_path_is_tilde_expanded() {
5592        let f = write_toml(
5593            r#"
5594            [cursor]
5595            db_path = "~/cursor-state.vscdb"
5596            "#,
5597        );
5598        let c = Config::load_from(f.path()).unwrap();
5599        let home = crate::cache::home_dir().unwrap();
5600        assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
5601    }
5602
5603    #[test]
5604    fn cursor_agent_auth_path_is_tilde_expanded() {
5605        let f = write_toml(
5606            r#"
5607            [cursor]
5608            agent_auth_path = "~/cursor-agent-auth.json"
5609            "#,
5610        );
5611        let c = Config::load_from(f.path()).unwrap();
5612        let home = crate::cache::home_dir().unwrap();
5613        assert_eq!(
5614            c.cursor.agent_auth_path,
5615            Some(home.join("cursor-agent-auth.json"))
5616        );
5617    }
5618
5619    #[test]
5620    fn cursor_appears_when_enabled() {
5621        let f = write_toml(
5622            r#"
5623            [cursor]
5624            enabled = true
5625            "#,
5626        );
5627        let c = Config::load_from(f.path()).unwrap();
5628        assert!(c.is_enabled(VendorId::Cursor));
5629        assert!(c.enabled_vendors().contains(&VendorId::Cursor));
5630    }
5631
5632    #[test]
5633    fn add_account_appends_and_preserves_existing() {
5634        let mut doc: toml_edit::DocumentMut = r#"
5635# keep me
5636[anthropic]
5637enabled = true
5638
5639[[anthropic.accounts]]
5640label = "personal"
5641credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
5642"#
5643        .parse()
5644        .unwrap();
5645        add_anthropic_account_to_doc(
5646            &mut doc,
5647            "work",
5648            "~/.config/ai-usagebar/accounts/work/.credentials.json",
5649        )
5650        .unwrap();
5651        let rendered = doc.to_string();
5652        assert!(rendered.contains("# keep me"), "comment must survive");
5653        // Round-trips through the real loader with both accounts intact and ordered.
5654        let f = write_toml(&rendered);
5655        let c = Config::load_from(f.path()).unwrap();
5656        let labels: Vec<&str> = c
5657            .anthropic
5658            .accounts
5659            .iter()
5660            .map(|a| a.label.as_str())
5661            .collect();
5662        assert_eq!(labels, vec!["personal", "work"]);
5663    }
5664
5665    #[test]
5666    fn add_account_to_empty_doc_is_loadable() {
5667        let mut doc = toml_edit::DocumentMut::new();
5668        add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
5669        let f = write_toml(&doc.to_string());
5670        let c = Config::load_from(f.path()).unwrap();
5671        assert_eq!(c.anthropic.accounts.len(), 1);
5672        assert_eq!(c.anthropic.accounts[0].label, "solo");
5673    }
5674
5675    #[test]
5676    fn add_account_rejects_duplicate_label() {
5677        let mut doc: toml_edit::DocumentMut = r#"
5678[[anthropic.accounts]]
5679label = "work"
5680credentials_path = "~/w/.credentials.json"
5681"#
5682        .parse()
5683        .unwrap();
5684        assert!(
5685            add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
5686            "a duplicate label must be rejected, not appended"
5687        );
5688    }
5689
5690    #[test]
5691    fn add_account_rejects_bad_label() {
5692        let mut doc = toml_edit::DocumentMut::new();
5693        assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
5694        assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
5695    }
5696
5697    #[test]
5698    fn tildify_collapses_home_only() {
5699        let home = Path::new("/Users/me");
5700        assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
5701        assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
5702    }
5703
5704    #[test]
5705    fn default_account_credentials_path_nests_under_config_dir() {
5706        let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
5707        assert_eq!(
5708            default_account_credentials_path(cfg, "work"),
5709            Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
5710        );
5711    }
5712    // ----- [[custom]] providers -----
5713
5714    const CUSTOM_BLOCK: &str = r#"
5715[[custom]]
5716id = "mytool"
5717name = "My Tool"
5718short_name = "myt"
5719enabled = true
5720url = "https://api.example.test/v1/usage"
5721api_key_env = "MYTOOL_API_KEY"
5722auth_header = "Authorization"
5723auth_scheme = "Bearer"
5724plan = "Pro"
5725cache_ttl_secs = 120
5726[custom.headers]
5727X-Org = "org_1"
5728[[custom.metrics]]
5729label = "Requests"
5730used = "/requests/used"
5731limit = "/requests/limit"
5732resets_at = "/requests/reset"
5733window_secs = 3600
5734[[custom.texts]]
5735label = "Tier"
5736value = "/tier"
5737"#;
5738
5739    fn custom_with(from: &str, to: &str) -> String {
5740        assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
5741        CUSTOM_BLOCK.replace(from, to)
5742    }
5743
5744    fn custom_error(toml: &str) -> String {
5745        Config::load_from(write_toml(toml).path())
5746            .unwrap_err()
5747            .to_string()
5748    }
5749
5750    fn assert_custom_rejected(toml: &str, needle: &str) {
5751        let msg = custom_error(toml);
5752        assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
5753        assert!(
5754            msg.contains("[[custom]]"),
5755            "the error must locate the section: {msg}"
5756        );
5757    }
5758
5759    #[test]
5760    fn custom_block_parses_every_field() {
5761        let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
5762        assert_eq!(config.custom.len(), 1);
5763        let c = &config.custom[0];
5764        assert_eq!(c.id, "mytool");
5765        assert_eq!(c.name, "My Tool");
5766        assert_eq!(c.short_name, "myt");
5767        assert_eq!(
5768            c.brand, None,
5769            "a custom provider has no mark unless it asks"
5770        );
5771        assert!(c.enabled);
5772        assert_eq!(c.url, "https://api.example.test/v1/usage");
5773        assert!(!c.allow_http);
5774        assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
5775        assert_eq!(c.api_key, None);
5776        assert_eq!(c.auth_header, "Authorization");
5777        assert_eq!(c.auth_scheme, "Bearer");
5778        assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
5779        assert_eq!(c.plan.as_deref(), Some("Pro"));
5780        assert_eq!(c.plan_path, None);
5781        assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
5782        assert_eq!(c.metrics.len(), 1);
5783        assert_eq!(c.metrics[0].label, "Requests");
5784        assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
5785        assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
5786        assert_eq!(c.metrics[0].percent, None);
5787        assert_eq!(c.metrics[0].currency, None);
5788        assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
5789        assert_eq!(c.metrics[0].window_secs, Some(3600));
5790        assert_eq!(c.texts.len(), 1);
5791        assert_eq!(c.texts[0].label, "Tier");
5792        assert_eq!(c.texts[0].value, "/tier");
5793        assert_eq!(c.texts[0].currency, None);
5794        assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
5795    }
5796
5797    #[test]
5798    fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
5799        let config: Config = toml::from_str(
5800            r#"
5801            [[custom]]
5802            id = "bare"
5803            short_name = "bre"
5804            url = "https://example.test/u"
5805            [[custom.metrics]]
5806            label = "Q"
5807            percent = "/pct"
5808            "#,
5809        )
5810        .unwrap();
5811        let c = &config.custom[0];
5812        assert_eq!(c.name, "bare", "name must default to id on a plain parse");
5813        assert!(!c.enabled);
5814        assert!(!c.allow_http);
5815        assert_eq!(c.api_key_env, "");
5816        assert_eq!(c.auth_header, "Authorization");
5817        assert_eq!(c.auth_scheme, "Bearer");
5818        assert_eq!(c.cache_ttl_secs, 60);
5819        assert!(config.validate().is_ok());
5820        assert!(Config::default().custom.is_empty());
5821    }
5822
5823    #[test]
5824    fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
5825        let config = Config::load_from(
5826            write_toml(&custom_with(
5827                r#"short_name = "myt""#,
5828                "short_name = \"myt\"\nbrand = \"opencode-go\"",
5829            ))
5830            .path(),
5831        )
5832        .unwrap();
5833        assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
5834
5835        // The mark is borrowed from a vendor, so only a vendor can name one.
5836        // A free-form slug here would reach the frontend as artwork it does
5837        // not ship and draw nothing at all.
5838        for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
5839            assert_custom_rejected(
5840                &custom_with(
5841                    r#"short_name = "myt""#,
5842                    &format!("short_name = \"myt\"\nbrand = {brand:?}"),
5843                ),
5844                "must name a built-in vendor",
5845            );
5846        }
5847    }
5848
5849    #[test]
5850    fn custom_rejects_a_malformed_id() {
5851        let long = "a".repeat(33);
5852        for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
5853            let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
5854            assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
5855            assert!(msg.contains("must match"), "{id:?}: {msg}");
5856        }
5857    }
5858
5859    #[test]
5860    fn custom_rejects_a_builtin_slug_as_id() {
5861        assert_custom_rejected(
5862            &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
5863            "is a built-in vendor",
5864        );
5865        assert_custom_rejected(
5866            &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
5867            "is a built-in vendor",
5868        );
5869    }
5870
5871    #[test]
5872    fn custom_rejects_duplicate_ids() {
5873        let twice = format!(
5874            "{}{}",
5875            CUSTOM_BLOCK,
5876            custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
5877        );
5878        assert_custom_rejected(&twice, "duplicate id");
5879    }
5880
5881    #[test]
5882    fn custom_rejects_a_name_over_48_chars() {
5883        let long = "n".repeat(49);
5884        assert_custom_rejected(
5885            &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
5886            "name must be 1 to 48 characters",
5887        );
5888    }
5889
5890    #[test]
5891    fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
5892        for short in ["my", "myto", "MYT", "m1t"] {
5893            assert_custom_rejected(
5894                &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
5895                "exactly 3 lowercase ASCII letters",
5896            );
5897        }
5898    }
5899
5900    #[test]
5901    fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
5902        assert_custom_rejected(
5903            &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
5904            "already used by a built-in vendor",
5905        );
5906        let twice = format!(
5907            "{}{}",
5908            CUSTOM_BLOCK,
5909            custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
5910        );
5911        assert_custom_rejected(&twice, "already used by a built-in vendor");
5912    }
5913
5914    #[test]
5915    fn custom_rejects_http_unless_allowed() {
5916        let plain = custom_with(
5917            r#"url = "https://api.example.test/v1/usage""#,
5918            r#"url = "http://localhost:8080/usage""#,
5919        );
5920        assert_custom_rejected(&plain, "url must use https://");
5921        let allowed = plain.replace(
5922            r#"url = "http://localhost:8080/usage""#,
5923            "url = \"http://localhost:8080/usage\"\nallow_http = true",
5924        );
5925        assert!(
5926            Config::load_from(write_toml(&allowed).path()).is_ok(),
5927            "allow_http must permit http://"
5928        );
5929    }
5930
5931    #[test]
5932    fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
5933        assert_custom_rejected(
5934            &custom_with(
5935                r#"url = "https://api.example.test/v1/usage""#,
5936                r#"url = "https://user:pw@api.example.test/v1/usage""#,
5937            ),
5938            "must not carry credentials",
5939        );
5940        assert_custom_rejected(
5941            &custom_with(
5942                r#"url = "https://api.example.test/v1/usage""#,
5943                r#"url = "not a url""#,
5944            ),
5945            "is not a valid URL",
5946        );
5947        assert_custom_rejected(
5948            &custom_with(
5949                r#"url = "https://api.example.test/v1/usage""#,
5950                r#"url = "ftp://api.example.test/v1/usage""#,
5951            ),
5952            "is not http or https",
5953        );
5954    }
5955
5956    #[test]
5957    fn custom_rejects_an_invalid_api_key_env() {
5958        assert_custom_rejected(
5959            &custom_with(
5960                r#"api_key_env = "MYTOOL_API_KEY""#,
5961                r#"api_key_env = "1BAD-NAME""#,
5962            ),
5963            "is not a valid environment variable name",
5964        );
5965        let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
5966        assert!(
5967            Config::load_from(write_toml(&none).path()).is_ok(),
5968            "an empty api_key_env means inline-only and is valid"
5969        );
5970    }
5971
5972    /// A value that is not a variable name is most likely a key pasted into
5973    /// the wrong field, and this error fails the whole config load: it names
5974    /// the field and never repeats the value.
5975    #[test]
5976    fn custom_invalid_api_key_env_is_not_repeated() {
5977        let msg = custom_error(&custom_with(
5978            r#"api_key_env = "MYTOOL_API_KEY""#,
5979            r#"api_key_env = "sk-live-pasted-secret""#,
5980        ));
5981        assert!(msg.contains("api_key_env"), "{msg}");
5982        assert!(!msg.contains("sk-live-pasted-secret"), "{msg}");
5983    }
5984
5985    #[test]
5986    fn custom_rejects_an_invalid_auth_header_name() {
5987        assert_custom_rejected(
5988            &custom_with(
5989                r#"auth_header = "Authorization""#,
5990                r#"auth_header = "X Api Key""#,
5991            ),
5992            "auth_header \"X Api Key\" is not a valid HTTP header name",
5993        );
5994    }
5995
5996    #[test]
5997    fn custom_rejects_a_control_char_in_auth_scheme() {
5998        assert_custom_rejected(
5999            &custom_with(
6000                r#"auth_scheme = "Bearer""#,
6001                "auth_scheme = \"Bearer\\u0007\"",
6002            ),
6003            "auth_scheme contains characters that are not valid",
6004        );
6005        let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
6006        assert!(
6007            Config::load_from(write_toml(&bare).path()).is_ok(),
6008            "an empty scheme (bare key) is valid"
6009        );
6010    }
6011
6012    #[test]
6013    fn custom_rejects_a_bad_extra_header() {
6014        assert_custom_rejected(
6015            &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
6016            "headers must not repeat auth_header",
6017        );
6018        assert_custom_rejected(
6019            &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
6020            "is not a valid HTTP header name",
6021        );
6022        assert_custom_rejected(
6023            &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
6024            "has a value that is not valid in an HTTP header",
6025        );
6026    }
6027
6028    #[test]
6029    fn custom_rejects_cache_ttl_outside_10_to_3600() {
6030        for ttl in ["9", "3601"] {
6031            assert_custom_rejected(
6032                &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
6033                "cache_ttl_secs must be between 10 and 3600",
6034            );
6035        }
6036    }
6037
6038    #[test]
6039    fn custom_rejects_an_entry_with_no_metrics_or_texts() {
6040        let toml = r#"
6041[[custom]]
6042id = "empty"
6043short_name = "emp"
6044url = "https://example.test/u"
6045"#;
6046        assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
6047    }
6048
6049    #[test]
6050    fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
6051        assert_custom_rejected(
6052            &custom_with(
6053                r#"limit = "/requests/limit""#,
6054                "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
6055            ),
6056            "must set `percent`, or both `used` and `limit`",
6057        );
6058        assert_custom_rejected(
6059            &custom_with("limit = \"/requests/limit\"\n", ""),
6060            "must set `percent`, or both `used` and `limit`",
6061        );
6062    }
6063
6064    #[test]
6065    fn custom_rejects_a_pointer_without_a_leading_slash() {
6066        assert_custom_rejected(
6067            &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
6068            "used \"requests.used\" must be an RFC 6901 JSON Pointer",
6069        );
6070        assert_custom_rejected(
6071            &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
6072            "value \"tier\" must be an RFC 6901 JSON Pointer",
6073        );
6074        assert_custom_rejected(
6075            &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
6076            "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
6077        );
6078        assert_custom_rejected(
6079            &custom_with(
6080                r#"resets_at = "/requests/reset""#,
6081                "resets_at = \"/re\\u001bset\"",
6082            ),
6083            "resets_at",
6084        );
6085    }
6086
6087    #[test]
6088    fn custom_rejects_a_label_outside_1_to_64_chars() {
6089        let long = "l".repeat(65);
6090        assert_custom_rejected(
6091            &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
6092            "metric label",
6093        );
6094        assert_custom_rejected(
6095            &custom_with(r#"label = "Tier""#, r#"label = """#),
6096            "text label \"\" must be 1 to 64 characters",
6097        );
6098    }
6099
6100    #[test]
6101    fn custom_rejects_window_secs_under_60() {
6102        assert_custom_rejected(
6103            &custom_with("window_secs = 3600", "window_secs = 59"),
6104            "window_secs must be at least 60",
6105        );
6106    }
6107
6108    #[test]
6109    fn custom_rejects_duplicate_metric_and_text_labels() {
6110        let metric_twice = custom_with(
6111            "window_secs = 3600\n",
6112            "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
6113        );
6114        assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
6115        let text_twice =
6116            format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
6117        assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
6118    }
6119
6120    #[test]
6121    fn custom_metric_and_text_parse_currency() {
6122        let toml = custom_with(
6123            "window_secs = 3600\n",
6124            "window_secs = 3600\ncurrency = \"USD\"\n",
6125        )
6126        .replace(
6127            "value = \"/tier\"\n",
6128            "value = \"/tier\"\ncurrency = \"EUR\"\n",
6129        );
6130        let config = Config::load_from(write_toml(&toml).path()).unwrap();
6131        assert_eq!(config.custom[0].metrics[0].currency.as_deref(), Some("USD"));
6132        assert_eq!(config.custom[0].texts[0].currency.as_deref(), Some("EUR"));
6133    }
6134
6135    #[test]
6136    fn custom_rejects_an_invalid_currency_code() {
6137        let toml = custom_with(
6138            "window_secs = 3600\n",
6139            "window_secs = 3600\ncurrency = \"usd\"\n",
6140        );
6141        assert_custom_rejected(
6142            &toml,
6143            "metric \"Requests\" currency \"usd\" must be a 3-letter uppercase code",
6144        );
6145
6146        let toml = custom_with(
6147            "value = \"/tier\"\n",
6148            "value = \"/tier\"\ncurrency = \"USDT\"\n",
6149        );
6150        assert_custom_rejected(
6151            &toml,
6152            "text \"Tier\" currency \"USDT\" must be a 3-letter uppercase code",
6153        );
6154    }
6155
6156    #[test]
6157    fn custom_rejects_a_metric_mixing_currency_with_percent() {
6158        let toml = r#"
6159            [[custom]]
6160            id = "bare"
6161            short_name = "bre"
6162            url = "https://example.test/u"
6163            [[custom.metrics]]
6164            label = "Quota"
6165            percent = "/pct"
6166            currency = "USD"
6167        "#;
6168        assert_custom_rejected(
6169            toml,
6170            "metric \"Quota\" cannot combine `currency` with `percent`",
6171        );
6172    }
6173
6174    #[test]
6175    fn enabled_custom_and_custom_by_id_select_entries() {
6176        let two = format!(
6177            "{}{}",
6178            CUSTOM_BLOCK,
6179            custom_with(r#"id = "mytool""#, r#"id = "off""#)
6180                .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
6181                .replace("enabled = true", "enabled = false")
6182        );
6183        let config = Config::load_from(write_toml(&two).path()).unwrap();
6184        let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
6185        assert_eq!(enabled, ["mytool"]);
6186        assert_eq!(
6187            config.custom_by_id("off").map(|c| c.name.as_str()),
6188            Some("My Tool")
6189        );
6190        assert!(config.custom_by_id("nope").is_none());
6191    }
6192
6193    #[cfg(unix)]
6194    #[test]
6195    fn has_inline_secrets_sees_a_custom_inline_key() {
6196        let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
6197        assert!(!without.has_inline_secrets());
6198        let with: Config = toml::from_str(&custom_with(
6199            r#"api_key_env = "MYTOOL_API_KEY""#,
6200            "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
6201        ))
6202        .unwrap();
6203        assert!(with.has_inline_secrets());
6204    }
6205
6206    #[test]
6207    fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
6208        let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
6209        let mut spec = CustomProviderConfig {
6210            id: "mytool".into(),
6211            api_key_env: var.into(),
6212            api_key: Some("sk-inline-secret".into()),
6213            ..CustomProviderConfig::default()
6214        };
6215        unsafe { std::env::set_var(var, "sk-env-secret") };
6216        let from_env = spec.resolve_api_key();
6217        unsafe { std::env::remove_var(var) };
6218        assert_eq!(from_env.unwrap(), "sk-env-secret");
6219
6220        assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
6221
6222        spec.api_key = Some(String::new());
6223        let err = spec.resolve_api_key().unwrap_err();
6224        assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
6225        let msg = err.to_string();
6226        assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
6227        assert!(msg.contains(var), "{msg}");
6228        assert!(!msg.contains("secret"), "{msg}");
6229
6230        spec.api_key_env = String::new();
6231        let msg = spec.resolve_api_key().unwrap_err().to_string();
6232        assert!(msg.contains("set `api_key`"), "{msg}");
6233    }
6234
6235    #[test]
6236    fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
6237        let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
6238        assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
6239        let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
6240        Config::load_from(file.path()).unwrap();
6241        assert!(
6242            crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
6243            "a custom provider's env var must be scrubbed from subprocesses"
6244        );
6245    }
6246
6247    #[test]
6248    fn loading_a_config_registers_account_and_renamed_env_vars_for_scrubbing() {
6249        let account = "AI_USAGEBAR_ACCOUNT_SCRUB_TEST_4C2E";
6250        let renamed = "AI_USAGEBAR_RENAMED_SCRUB_TEST_7A3F";
6251        let before = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6252        assert!(!before.contains(&account));
6253        assert!(!before.contains(&renamed));
6254        let file = write_toml(&format!(
6255            "[zai]\napi_key_env = \"{renamed}\"\n\
6256             [[deepseek.accounts]]\nlabel = \"work\"\napi_key_env = \"{account}\"\n"
6257        ));
6258        Config::load_from(file.path()).unwrap();
6259        let after = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6260        assert!(after.contains(&account), "a named account's key variable");
6261        assert!(after.contains(&renamed), "a renamed api_key_env");
6262    }
6263
6264    #[test]
6265    fn loading_a_config_registers_openrouter_management_env_vars_for_scrubbing() {
6266        let renamed = "AI_USAGEBAR_MGMT_RENAMED_SCRUB_TEST_5D1B";
6267        let account = "AI_USAGEBAR_MGMT_ACCOUNT_SCRUB_TEST_8E6C";
6268        let before = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6269        assert!(!before.contains(&renamed));
6270        assert!(!before.contains(&account));
6271        let file = write_toml(&format!(
6272            "[openrouter]\nmanagement_api_key_env = \"{renamed}\"\n\
6273             [[openrouter.accounts]]\nlabel = \"work\"\napi_key_env = \"OR_WORK_KEY\"\n\
6274             management_api_key_env = \"{account}\"\n"
6275        ));
6276        Config::load_from(file.path()).unwrap();
6277        let after = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6278        assert!(after.contains(&renamed), "a renamed management_api_key_env");
6279        assert!(
6280            after.contains(&account),
6281            "an account's management_api_key_env"
6282        );
6283    }
6284
6285    /// `[cline] api_key_env` is read by the fetch, so the shared resolver has
6286    /// to answer the same name: the catalog reports and probes it, and the
6287    /// scrub list is built from it. Cline's arm sat with the fixed-name
6288    /// providers, so a renamed variable configured the fetch alone.
6289    #[test]
6290    fn loading_a_config_resolves_and_scrubs_a_renamed_cline_env_var() {
6291        let renamed = "AI_USAGEBAR_CLINE_RENAMED_SCRUB_TEST_2F8A";
6292        assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&renamed));
6293        let file = write_toml(&format!("[cline]\napi_key_env = \"{renamed}\"\n"));
6294        let config = Config::load_from(file.path()).unwrap();
6295        assert_eq!(config.api_key_env_for(VendorId::Cline), renamed);
6296        assert!(
6297            crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&renamed),
6298            "a renamed Cline key variable must be scrubbed from subprocesses"
6299        );
6300    }
6301
6302    /// `VendorId::config_section` is what every by-name config writer uses;
6303    /// this proves each section name is one the parser actually recognizes
6304    /// (the `deny_unknown_fields` on `Config` makes a misspelling fail loudly)
6305    /// and lands on that vendor's `enabled` switch.
6306    #[test]
6307    fn every_config_section_parses_to_its_vendors_enabled_switch() {
6308        for vendor in VendorId::all() {
6309            let text = format!(
6310                "[{}]
6311enabled = true
6312",
6313                vendor.config_section()
6314            );
6315            let config: Config = toml::from_str(&text)
6316                .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
6317            assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
6318            let others = VendorId::all()
6319                .iter()
6320                .filter(|other| *other != vendor && config.is_enabled(**other))
6321                .count();
6322            assert_eq!(
6323                others,
6324                Config::default().enabled_vendors().len()
6325                    - usize::from(Config::default().is_enabled(*vendor)),
6326                "[{}] enabled a different vendor",
6327                vendor.config_section()
6328            );
6329        }
6330    }
6331
6332    #[test]
6333    fn tray_section_parses_and_defaults_to_notify() {
6334        let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
6335        let config = Config::load_from(file.path()).unwrap();
6336        assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
6337        assert_eq!(config.tray.updates(), UpdateMode::Auto);
6338
6339        let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
6340        assert_eq!(empty.tray, TrayConfig::default());
6341        assert_eq!(empty.tray.updates(), UpdateMode::Notify);
6342        assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
6343        assert_eq!(UpdateMode::parse("weekly"), None);
6344        assert_eq!(UpdateMode::Auto.as_str(), "auto");
6345    }
6346
6347    #[test]
6348    fn tray_ignores_removed_menu_bar_keys_for_back_compatibility() {
6349        let legacy = write_toml(
6350            "[tray]\nmenu_bar_show_all = false\nmenu_bar_hide_value = true\nmenu_bar_names = \"short\"\nmenu_bar_provider = \"anthropic\"\nmenu_bar_window = \"weekly\"\n",
6351        );
6352        let config = Config::load_from(legacy.path()).unwrap();
6353        assert_eq!(config.tray, TrayConfig::default());
6354    }
6355
6356    #[test]
6357    fn tray_section_rejects_a_misspelled_mode() {
6358        let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
6359        assert!(Config::load_from(file.path()).is_err());
6360    }
6361
6362    #[test]
6363    fn tray_refresh_minutes_defaults_to_five_and_parses() {
6364        let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
6365        assert_eq!(empty.tray.refresh_minutes, None);
6366        assert_eq!(empty.tray.refresh_minutes(), 5);
6367
6368        let file = write_toml("[tray]\nrefresh_minutes = 10\n");
6369        let config = Config::load_from(file.path()).unwrap();
6370        assert_eq!(config.tray.refresh_minutes(), 10);
6371    }
6372
6373    #[test]
6374    fn tray_refresh_minutes_rejects_values_outside_the_menu() {
6375        for minutes in ["3", "0", "120"] {
6376            let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
6377            let error = Config::load_from(file.path()).unwrap_err().to_string();
6378            assert!(error.contains("[tray] refresh_minutes"), "{error}");
6379            assert!(error.contains("1, 5, 10, 15, 30 or 60"), "{error}");
6380        }
6381    }
6382
6383    #[test]
6384    fn notifications_default_on_at_97_and_parse_overrides() {
6385        let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
6386        assert!(empty.notifications.enabled);
6387        assert_eq!(empty.notifications.threshold, 97);
6388
6389        let file = write_toml("[notifications]\nenabled = false\nthreshold = 100\n");
6390        let config = Config::load_from(file.path()).unwrap();
6391        assert!(!config.notifications.enabled);
6392        assert_eq!(config.notifications.threshold, 100);
6393    }
6394
6395    #[test]
6396    fn notifications_threshold_rejects_values_outside_1_to_100() {
6397        for threshold in ["0", "101", "255"] {
6398            let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
6399            let error = Config::load_from(file.path()).unwrap_err().to_string();
6400            assert!(
6401                error.contains("[notifications] threshold must be between 1 and 100"),
6402                "threshold {threshold}: {error}"
6403            );
6404        }
6405        // The boundaries themselves are valid.
6406        for threshold in ["1", "50", "100"] {
6407            let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
6408            let config = Config::load_from(file.path()).unwrap();
6409            assert_eq!(config.notifications.threshold.to_string(), threshold);
6410        }
6411    }
6412
6413    #[test]
6414    fn notification_preferences_round_trip_without_changing_other_sections() {
6415        let dir = tempfile::tempdir().unwrap();
6416        let path = dir.path().join("config.toml");
6417        std::fs::write(&path, "# keep this\n[tray]\nrefresh_minutes = 10\n").unwrap();
6418        set_notification_value(&path, "enabled", false.into()).unwrap();
6419        set_notification_value(&path, "threshold", 85i64.into()).unwrap();
6420        let text = std::fs::read_to_string(&path).unwrap();
6421        assert!(text.contains("# keep this\n[tray]\nrefresh_minutes = 10"));
6422        let config = Config::load_from(&path).unwrap();
6423        assert!(!config.notifications.enabled);
6424        assert_eq!(config.notifications.threshold, 85);
6425        assert!(set_notification_value(&path, "threshold", 101i64.into()).is_err());
6426        assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
6427    }
6428
6429    /// #244's whitelist: a real slug round-trips onto that vendor's `enabled`
6430    /// switch (creating the section when the config never had one) while the
6431    /// rest of the file is untouched, and a slug that names no built-in vendor
6432    /// is refused without writing anything.
6433    #[test]
6434    fn vendor_enabled_round_trips_and_rejects_unknown_slugs() {
6435        let dir = tempfile::tempdir().unwrap();
6436        let path = dir.path().join("config.toml");
6437        std::fs::write(&path, "# keep\n[zai]\nenabled = true # mine\n").unwrap();
6438
6439        set_vendor_enabled(&path, "zai", false).unwrap();
6440        let text = std::fs::read_to_string(&path).unwrap();
6441        assert!(text.starts_with("# keep\n"), "{text}");
6442        assert!(text.contains("enabled = false # mine"), "{text}");
6443        assert!(!config_enabled(&path, VendorId::Zai));
6444
6445        set_vendor_enabled(&path, "grok", true).unwrap();
6446        let config = Config::load_from(&path).unwrap();
6447        assert!(config.is_enabled(VendorId::Grok));
6448        assert!(
6449            !config.is_enabled(VendorId::Deepseek),
6450            "only the named vendor moves; untouched opt-in vendors stay off"
6451        );
6452        assert!(
6453            config.is_enabled(VendorId::Openai),
6454            "an untouched default-on vendor is not switched off either"
6455        );
6456
6457        for bad in ["", "custom", "not-a-vendor", "anthropic "] {
6458            let error = set_vendor_enabled(&path, bad, true)
6459                .unwrap_err()
6460                .to_string();
6461            assert!(error.contains("unknown provider"), "{bad}: {error}");
6462        }
6463        // A refused write leaves the file byte-for-byte alone.
6464        assert!(
6465            std::fs::read_to_string(&path)
6466                .unwrap()
6467                .contains("enabled = false # mine")
6468        );
6469    }
6470
6471    /// The doc-level core only ever lands on the vendor's own section, and an
6472    /// idempotent call does not rewrite the file.
6473    #[test]
6474    fn set_vendor_enabled_in_doc_targets_the_section_and_stays_idempotent() {
6475        let dir = tempfile::tempdir().unwrap();
6476        let path = dir.path().join("config.toml");
6477        std::fs::write(&path, "[grok]\nenabled = true\napi_key = \"k\"\n").unwrap();
6478        let before = std::fs::metadata(&path).unwrap().modified().unwrap();
6479
6480        set_vendor_enabled(&path, "grok", true).unwrap();
6481
6482        assert_eq!(
6483            std::fs::read_to_string(&path).unwrap(),
6484            "[grok]\nenabled = true\napi_key = \"k\"\n"
6485        );
6486        assert_eq!(
6487            std::fs::metadata(&path).unwrap().modified().unwrap(),
6488            before
6489        );
6490
6491        let mut doc = toml_edit::DocumentMut::new();
6492        set_vendor_enabled_in_doc(&mut doc, "opencode-go", true).unwrap();
6493        assert_eq!(doc.to_string(), "[opencode-go]\nenabled = true\n");
6494        assert!(set_vendor_enabled_in_doc(&mut doc, "mytool", true).is_err());
6495    }
6496
6497    fn config_enabled(path: &std::path::Path, vendor: VendorId) -> bool {
6498        Config::load_from(path).unwrap().is_enabled(vendor)
6499    }
6500
6501    #[test]
6502    fn menu_bar_short_name_defaults_on_and_persists_as_a_bool() {
6503        let dir = tempfile::tempdir().unwrap();
6504        let path = dir.path().join("config.toml");
6505        std::fs::write(&path, "[tray]\n").unwrap();
6506        assert!(Config::load_from(&path).unwrap().tray.menu_bar_short_name());
6507
6508        set_tray_value(&path, "menu_bar_short_name", Some(false.into())).unwrap();
6509        let text = std::fs::read_to_string(&path).unwrap();
6510        assert_eq!(text, "[tray]\nmenu_bar_short_name = false\n");
6511        assert!(!Config::load_from(&path).unwrap().tray.menu_bar_short_name());
6512    }
6513
6514    #[test]
6515    fn set_tray_value_writes_refresh_minutes_as_an_integer() {
6516        let dir = tempfile::tempdir().unwrap();
6517        let path = dir.path().join("config.toml");
6518        std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
6519
6520        set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
6521        let text = std::fs::read_to_string(&path).unwrap();
6522        assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
6523        assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
6524
6525        set_tray_value(&path, "refresh_minutes", None).unwrap();
6526        assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
6527    }
6528
6529    #[test]
6530    fn set_tray_value_creates_replaces_and_removes_keys() {
6531        let dir = tempfile::tempdir().unwrap();
6532        let path = dir.path().join("config.toml");
6533        std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
6534
6535        set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
6536        let text = std::fs::read_to_string(&path).unwrap();
6537        assert!(text.contains("# primary = \"anthropic\""), "{text}");
6538        assert!(
6539            text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
6540            "{text}"
6541        );
6542
6543        set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
6544        set_tray_value(&path, "updates", Some("off".into())).unwrap();
6545        let config = Config::load_from(&path).unwrap();
6546        assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
6547        assert_eq!(config.tray.updates(), UpdateMode::Off);
6548
6549        set_tray_value(&path, "shortcut", None).unwrap();
6550        let text = std::fs::read_to_string(&path).unwrap();
6551        assert!(!text.contains("shortcut"), "{text}");
6552        assert!(text.contains("updates = \"off\""), "{text}");
6553
6554        // Idempotent removal does not rewrite the file.
6555        let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
6556        set_tray_value(&path, "shortcut", None).unwrap();
6557        assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
6558    }
6559
6560    #[test]
6561    fn set_value_keeps_the_trailing_comment_when_replacing() {
6562        let mut doc: toml_edit::DocumentMut =
6563            "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
6564        set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
6565        assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
6566    }
6567
6568    #[test]
6569    fn set_value_updates_fields_in_a_toml_inline_table() {
6570        let mut doc: toml_edit::DocumentMut =
6571            "tray = { shortcut = \"Ctrl+U\" } # mine\n".parse().unwrap();
6572        set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
6573        assert_eq!(doc.to_string(), "tray = { shortcut = \"Alt+U\" } # mine\n");
6574    }
6575
6576    #[test]
6577    fn enable_vendors_in_creates_a_missing_config() {
6578        let dir = tempfile::TempDir::new().unwrap();
6579        let path = dir.path().join("sub").join("config.toml");
6580
6581        enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6582
6583        assert_eq!(
6584            std::fs::read_to_string(&path).unwrap(),
6585            "[grok]
6586enabled = true
6587"
6588        );
6589        assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
6590    }
6591
6592    #[test]
6593    fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
6594        let dir = tempfile::TempDir::new().unwrap();
6595        let path = dir.path().join("config.toml");
6596        let original = "# my settings
6597[zai]
6598api_key = \"x\" # keep
6599enabled = false
6600";
6601        std::fs::write(&path, original).unwrap();
6602
6603        enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
6604
6605        let text = std::fs::read_to_string(&path).unwrap();
6606        assert!(
6607            text.starts_with(
6608                "# my settings
6609"
6610            ),
6611            "{text}"
6612        );
6613        assert!(
6614            text.contains(
6615                "api_key = \"x\" # keep
6616"
6617            ),
6618            "{text}"
6619        );
6620        assert!(
6621            text.contains(
6622                "[grok]
6623enabled = true
6624"
6625            ),
6626            "{text}"
6627        );
6628        assert!(
6629            text.contains(
6630                "[opencode-go]
6631enabled = true
6632"
6633            ),
6634            "{text}"
6635        );
6636        let config = Config::load_from(&path).unwrap();
6637        assert!(
6638            !config.is_enabled(VendorId::Zai),
6639            "never widens to false, never flips others"
6640        );
6641        assert!(config.is_enabled(VendorId::Grok));
6642        assert!(config.is_enabled(VendorId::OpenCodeGo));
6643    }
6644
6645    #[test]
6646    fn enable_vendors_in_leaves_an_explicit_false_alone() {
6647        let dir = tempfile::TempDir::new().unwrap();
6648        let path = dir.path().join("config.toml");
6649        let original = "[grok]
6650enabled = false # off
6651api_key = \"k\"
6652";
6653        std::fs::write(&path, original).unwrap();
6654
6655        // `enabled = false` in the file is the user having said no. Only the
6656        // automatic path goes through here — the Settings overlay writes with
6657        // `set_bool` — so nothing a person does by hand is blocked by this.
6658        let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6659
6660        assert!(written.is_empty(), "{written:?}");
6661        assert_eq!(
6662            std::fs::read_to_string(&path).unwrap(),
6663            original,
6664            "the file must not be rewritten at all"
6665        );
6666    }
6667
6668    #[test]
6669    fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
6670        let dir = tempfile::TempDir::new().unwrap();
6671        let path = dir.path().join("config.toml");
6672        std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
6673
6674        let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6675
6676        assert_eq!(written, vec![VendorId::Grok]);
6677        assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
6678    }
6679
6680    #[test]
6681    fn enable_vendors_in_enables_a_toml_inline_table() {
6682        let dir = tempfile::TempDir::new().unwrap();
6683        let path = dir.path().join("config.toml");
6684        std::fs::write(&path, "grok = { api_key = \"k\" }\n").unwrap();
6685
6686        let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6687
6688        assert_eq!(written, vec![VendorId::Grok]);
6689        assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
6690        let raw = std::fs::read_to_string(&path).unwrap();
6691        assert!(raw.contains("grok = {"));
6692    }
6693
6694    #[test]
6695    fn enable_vendors_in_is_textually_idempotent() {
6696        let dir = tempfile::TempDir::new().unwrap();
6697        let path = dir.path().join("config.toml");
6698        let original = "[grok]
6699enabled = true
6700
6701# trailing
6702";
6703        std::fs::write(&path, original).unwrap();
6704        let before = std::fs::metadata(&path).unwrap().modified().unwrap();
6705
6706        enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6707
6708        assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
6709        assert_eq!(
6710            std::fs::metadata(&path).unwrap().modified().unwrap(),
6711            before,
6712            "an unchanged document must not be rewritten"
6713        );
6714    }
6715
6716    #[test]
6717    fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
6718        let dir = tempfile::TempDir::new().unwrap();
6719        let path = dir.path().join("config.toml");
6720
6721        enable_vendors_in(&path, &[]).unwrap();
6722
6723        assert!(!path.exists());
6724    }
6725}