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