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