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