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