Skip to main content

ai_usagebar/
config.rs

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