Skip to main content

ai_usagebar/
config.rs

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