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