Skip to main content

ai_usagebar/
config.rs

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