Skip to main content

ai_usagebar/
config.rs

1//! Config file at `~/.config/ai-usagebar/config.toml`.
2//!
3//! Layout:
4//! ```toml
5//! [anthropic]  enabled = true
6//! [openai]     enabled = true   # Codex OAuth from ~/.codex/auth.json
7//! [copilot]    enabled = false  # GitHub CLI OAuth, or an explicit env override
8//! [zai]        enabled = true
9//! [openrouter] enabled = true
10//! [deepseek]   enabled = false
11//! [kimi]       enabled = false
12//! ```
13//!
14//! Every field is optional with sensible defaults — missing config file is
15//! treated as "use defaults". API keys are read from env vars (the relevant
16//! `*_api_key_env` field lets the user override which env var name).
17
18use std::collections::{BTreeMap, HashSet};
19use std::path::{Path, PathBuf};
20
21#[cfg(unix)]
22use std::os::unix::fs::{MetadataExt, PermissionsExt};
23
24use serde::{Deserialize, Serialize};
25
26use crate::anthropic::creds::CredsTarget;
27use crate::cache::Cache;
28use crate::error::{AppError, Result};
29use crate::vendor::VendorId;
30
31/// A misspelled section name is silently ignored without this: `[openrouer]`
32/// leaves OpenRouter on its defaults and the user sees the wrong vendor set
33/// with no diagnostic. Denying unknown keys is deliberately applied at the
34/// *section* level only — the set of sections is small and stable, whereas
35/// denying unknown keys inside every section would hard-fail configs that
36/// carry a field from a future or removed version.
37#[derive(Debug, Clone, Default, Deserialize, Serialize)]
38#[serde(default, deny_unknown_fields)]
39pub struct Config {
40    pub ui: UiConfig,
41    pub context: ContextConfig,
42    pub anthropic: AnthropicConfig,
43    pub anthropic_api: AnthropicApiConfig,
44    pub openai: OpenAiConfig,
45    pub copilot: CopilotConfig,
46    pub zai: ZaiConfig,
47    pub openrouter: OpenRouterConfig,
48    pub deepseek: DeepseekConfig,
49    pub kimi: KimiConfig,
50    pub kilo: KiloConfig,
51    pub novita: NovitaConfig,
52    pub moonshot: MoonshotConfig,
53    pub grok: GrokConfig,
54    pub supergrok: SuperGrokConfig,
55    pub antigravity: AntigravityConfig,
56    pub cursor: CursorConfig,
57    pub minimax: MinimaxConfig,
58    pub kiro: KiroConfig,
59    pub nous: NousConfig,
60    #[serde(rename = "opencode-go")]
61    pub opencode_go: OpenCodeGoConfig,
62    pub commandcode: CommandCodeConfig,
63}
64
65/// UI / dispatch preferences. Currently just `primary` — which vendor the
66/// widget shows when `--vendor` is omitted, and which TUI tab is selected
67/// at startup.
68#[derive(Debug, Clone, Default, Deserialize, Serialize)]
69#[serde(default)]
70pub struct UiConfig {
71    /// `None` → fall back to anthropic for backward compatibility.
72    pub primary: Option<VendorId>,
73    /// Which vendors the Overview shows (the TUI's first tab and the macOS
74    /// menu-bar's top section), in this order. `None` → every enabled vendor,
75    /// in the canonical order.
76    pub overview_vendors: Option<Vec<VendorId>>,
77    /// Layout style for vendor navigation in the TUI: sidebar | navbar | none.
78    pub vendor_box: Option<VendorBoxStyle>,
79}
80
81impl UiConfig {
82    pub fn vendor_box(&self) -> VendorBoxStyle {
83        self.vendor_box.unwrap_or_default()
84    }
85}
86
87/// Presentation style of the TUI vendor navigation box.
88#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
89#[serde(rename_all = "lowercase")]
90pub enum VendorBoxStyle {
91    /// Vertical sidebar box on wide terminals; falls back to top navbar on narrow terminals.
92    #[default]
93    Sidebar,
94    /// Horizontal navbar strip above the dashboard detail panel.
95    Navbar,
96    /// Completely hide vendor navigation (dashboards expand to fill full width).
97    None,
98}
99
100/// Where the context view docks in the dashboard body. `v` cycles it while the
101/// overlay is open; the config value is what it opens with.
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
103#[serde(rename_all = "lowercase")]
104pub enum ContextLayout {
105    /// Takes the whole body, the way a vendor panel does.
106    #[default]
107    Full,
108    /// Beside the dashboard.
109    Split,
110    /// Below the dashboard.
111    Bottom,
112}
113
114impl ContextLayout {
115    pub fn next(self) -> Self {
116        match self {
117            ContextLayout::Full => ContextLayout::Split,
118            ContextLayout::Split => ContextLayout::Bottom,
119            ContextLayout::Bottom => ContextLayout::Full,
120        }
121    }
122
123    pub fn label(self) -> &'static str {
124        match self {
125            ContextLayout::Full => "full",
126            ContextLayout::Split => "split",
127            ContextLayout::Bottom => "bottom",
128        }
129    }
130}
131
132/// Optional local Claude Code context-window monitor. This is deliberately
133/// separate from vendors: sessions are discovered from local transcripts and
134/// change while the TUI is running, whereas vendor tabs are config-declared
135/// account identities.
136#[derive(Debug, Clone, Default, Deserialize, Serialize)]
137#[serde(default)]
138pub struct ContextConfig {
139    /// Keep the filesystem scanner completely dormant unless explicitly
140    /// enabled. The `c` key and its footer hint are hidden while disabled.
141    pub enabled: bool,
142    /// Override Claude Code's normal `~/.claude/projects` transcript root.
143    pub projects_path: Option<PathBuf>,
144    /// Optional fallback denominator. When absent, sessions without an exact
145    /// model override show their input-token count without inventing a %.
146    pub context_window_tokens: Option<u64>,
147    /// Exact Claude model id -> context-window size. This takes precedence
148    /// over `context_window_tokens`, which keeps mixed 200K/1M histories safe.
149    pub model_context_window_tokens: BTreeMap<String, u64>,
150    /// Where the view opens: full | split | bottom.
151    pub layout: ContextLayout,
152}
153
154impl ContextConfig {
155    pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
156        model
157            .and_then(|model| self.model_context_window_tokens.get(model).copied())
158            .filter(|tokens| *tokens > 0)
159            .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
160    }
161}
162
163#[derive(Debug, Clone, Deserialize, Serialize)]
164#[serde(default)]
165pub struct AnthropicConfig {
166    pub enabled: bool,
167    /// Override the credentials file path (defaults to `~/.claude/.credentials.json`).
168    /// This is the *default* account; extra subscriptions go in `accounts`.
169    pub credentials_path: Option<PathBuf>,
170    /// Extra Anthropic accounts beyond the default, each selected on the CLI
171    /// with `--account <label>` (issue #14). Empty by default, so existing
172    /// single-account configs are byte-for-byte unchanged.
173    pub accounts: Vec<AnthropicAccount>,
174    /// Directory to auto-discover extra accounts from, in Claude Code's own
175    /// `CLAUDE_CONFIG_DIR` layout: each immediate subdirectory becomes an
176    /// account labeled by the subdirectory name. The credentials may live in
177    /// that directory's `.credentials.json` or in the macOS Keychain, so
178    /// discovery intentionally does not probe for the credentials file.
179    /// Merged with `accounts` (explicit wins on a label clash); each is
180    /// refreshed independently.
181    pub accounts_dir: Option<PathBuf>,
182    /// Whether the default (unnamed) Claude account gets its own tab. Defaults
183    /// to `true` for back-compat. Set `false` when every account is managed
184    /// explicitly (via `accounts`/`accounts_dir`) so the ambient
185    /// Keychain/`~/.claude` login doesn't add a redundant "Claude" tab. Ignored
186    /// when there are no named accounts, so Anthropic never loses its only tab.
187    pub show_default_account: bool,
188    /// Where the Claude **Desktop app**'s saved account profiles live. Defaults
189    /// to `~/.claude-acc/profiles`, the store claude-acc
190    /// (<https://github.com/ohmaseclaro/claude-acc>) creates — `account switch`
191    /// reads and writes that layout so the two tools stay interchangeable.
192    /// Unrelated to `accounts_dir`, which is the `claude` CLI's own accounts.
193    pub desktop_profiles_dir: Option<PathBuf>,
194}
195
196impl Default for AnthropicConfig {
197    fn default() -> Self {
198        Self {
199            enabled: true,
200            credentials_path: None,
201            accounts: Vec::new(),
202            accounts_dir: None,
203            show_default_account: true,
204            desktop_profiles_dir: None,
205        }
206    }
207}
208
209/// One extra Anthropic account beyond the default (issue #14). The default
210/// account stays the singular `[anthropic] credentials_path`; each entry here
211/// is an additional subscription selected on the CLI with `--account <label>`.
212///
213/// ```toml
214/// [[anthropic.accounts]]
215/// label = "work"
216/// credentials_path = "~/.config/ai-usagebar/accounts/work/.credentials.json"
217/// ```
218#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
219pub struct AnthropicAccount {
220    /// Stable name used on the CLI (`--account <label>`) and as the cache
221    /// subdir (`~/.cache/ai-usagebar/anthropic/<label>`).
222    pub label: String,
223    /// OAuth credentials file for this account (same JSON shape Claude Code
224    /// writes). Token refreshes are written back here, so each account keeps
225    /// itself alive independently.
226    pub credentials_path: PathBuf,
227}
228
229impl AnthropicAccount {
230    /// The `CLAUDE_CONFIG_DIR` this account occupies — the credential file's
231    /// own directory. Claude Code hashes exactly this path for the account's
232    /// Keychain item, so it is also the account's identity for
233    /// [`crate::anthropic::keychain`].
234    pub fn config_dir(&self) -> PathBuf {
235        self.credentials_path
236            .parent()
237            .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
238    }
239}
240
241impl AnthropicConfig {
242    /// Every extra account: the explicit `[[anthropic.accounts]]` entries plus
243    /// any auto-discovered under [`accounts_dir`](AnthropicConfig::accounts_dir).
244    /// Explicit entries take precedence on a label clash. This is what tabs and
245    /// `--account` enumerate, so a discovered account behaves exactly like a
246    /// hand-written one (own cache subdir, independent refresh).
247    pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
248        let mut out = self.accounts.clone();
249        if let Some(dir) = &self.accounts_dir {
250            for acct in discover_accounts(dir) {
251                if !out.iter().any(|a| a.label == acct.label) {
252                    out.push(acct);
253                }
254            }
255        }
256        out
257    }
258
259    /// Find an extra account by label (explicit or discovered), or error listing
260    /// the known labels so a typo fails loudly instead of silently hitting the
261    /// default. Returns an owned account because discovered entries are
262    /// synthesized, not stored.
263    pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
264        validate_account_label(label)?;
265        let all = self.all_accounts();
266        all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
267            let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
268            AppError::Credentials(format!(
269                "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
270                 known labels: {known:?}"
271            ))
272        })
273    }
274
275    /// Resolve a named account to the credentials target + isolated cache it
276    /// fetches through: [`CredsTarget::Named`], which on macOS prefers the
277    /// Keychain item scoped to the file's own directory (that is where
278    /// `CLAUDE_CONFIG_DIR=<dir> claude` actually writes) and falls back to
279    /// the file elsewhere — never a *different* account's item, since the
280    /// hash is per-directory, so issue #15's cross-account concern doesn't
281    /// apply. Plus an `anthropic/<label>` cache subdir. Shared by the widget
282    /// (`--account`) and the TUI's per-account tab (#14, #17) so both resolve
283    /// accounts identically; the widget layers its `--cache-dir` override on
284    /// top of the cache returned here.
285    pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
286        let active = crate::anthropic::cli_account::home_claude_json()
287            .ok()
288            .and_then(|path| {
289                crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
290            });
291        self.account_target_with(label, active.as_deref())
292    }
293
294    /// The pure half of [`account_target`](AnthropicConfig::account_target),
295    /// with "which account the `claude` CLI is signed into" injected — the same
296    /// shape as `Cli::resolve_vendor_with`.
297    ///
298    /// When `label` *is* the live CLI login, its credential has been moved into
299    /// the default slot and removed from its named slot. Reading the default
300    /// one keeps exactly one live lineage, so a refresh here cannot invalidate
301    /// the credential `claude` is using (or the other way round). The cache directory
302    /// is unchanged either way, so the tab keeps its identity and its cached
303    /// usage across a switch.
304    pub fn account_target_with(
305        &self,
306        label: &str,
307        cli_active: Option<&str>,
308    ) -> Result<(CredsTarget, Cache)> {
309        let account = self.account(label)?;
310        let cache = Cache::for_vendor_account("anthropic", label)?;
311        if cli_active == Some(label) {
312            return Ok((
313                CredsTarget::Default(crate::anthropic::creds::default_path()?),
314                cache,
315            ));
316        }
317        Ok((
318            CredsTarget::Named {
319                config_dir: account.config_dir(),
320                path: account.credentials_path,
321            },
322            cache,
323        ))
324    }
325}
326
327/// The label doubles as a cache subdirectory name
328/// (`~/.cache/ai-usagebar/anthropic/<label>/`), which nests inside the default
329/// account's cache dir — so path separators, control characters, or reserved
330/// cache sidecar names would escape, spoof terminal output, or collide with the
331/// cache layout (`usage.json`, `.stale`, …).
332pub fn validate_account_label(label: &str) -> Result<()> {
333    validate_account_label_for("anthropic", label)
334}
335
336fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
337    const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
338    let bad = label.is_empty()
339        || label == "."
340        || label == ".."
341        || label.contains(['/', '\\'])
342        || label.contains(':')
343        || label.chars().any(char::is_control)
344        || RESERVED.contains(&label);
345    if bad {
346        return Err(AppError::Credentials(format!(
347            "invalid {vendor} account label {label:?}: must be a non-empty name \
348             without path separators, drive prefixes, control characters, or reserved cache names"
349        )));
350    }
351    Ok(())
352}
353
354/// Discover accounts under `accounts_dir` in the `CLAUDE_CONFIG_DIR` layout:
355/// each immediate subdirectory becomes an account labeled by the subdirectory
356/// name. Best-effort: an unreadable directory or unusable label is skipped
357/// silently rather than failing the whole config — discovery is convenience,
358/// while an explicit `[[anthropic.accounts]]` entry stays authoritative. The
359/// fetch path resolves credentials from either `.credentials.json` or the macOS
360/// Keychain. Sorted by label so the tab order is stable across runs.
361fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
362    let Ok(entries) = std::fs::read_dir(accounts_dir) else {
363        return Vec::new();
364    };
365    let mut found: Vec<AnthropicAccount> = entries
366        .flatten()
367        .filter_map(|entry| {
368            let path = entry.path();
369            if !path.is_dir() {
370                return None;
371            }
372            let label = path.file_name()?.to_str()?.to_string();
373            validate_account_label(&label).ok()?;
374            Some(AnthropicAccount {
375                label,
376                credentials_path: path.join(".credentials.json"),
377            })
378        })
379        .collect();
380    found.sort_by(|a, b| a.label.cmp(&b.label));
381    found
382}
383
384/// Render a path with `$HOME` collapsed back to `~`, matching the style the docs
385/// and existing `[[anthropic.accounts]]` entries use. Pure so it's testable;
386/// paths outside home are returned verbatim.
387pub fn tildify(path: &Path, home: &Path) -> String {
388    path.strip_prefix(home)
389        .map(|rest| {
390            let rendered = rest.display().to_string();
391            // Config paths use the same portable `~/...` spelling on every
392            // platform. A Windows `~\...` would not be expanded by the loader.
393            #[cfg(windows)]
394            let rendered = rendered.replace('\\', "/");
395            format!("~/{rendered}")
396        })
397        .unwrap_or_else(|_| path.display().to_string())
398}
399
400/// Where a newly-registered account's credentials file lives by default: next
401/// to `config.toml`, under `accounts/<label>/.credentials.json`. Returns the
402/// absolute path (for `mkdir`) — tilde-render it with [`tildify`] for display
403/// and for the value written into config.
404pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
405    let base = config_path.parent().unwrap_or_else(|| Path::new("."));
406    base.join("accounts").join(label).join(".credentials.json")
407}
408
409/// Append a `[[anthropic.accounts]]` entry to a parsed config document, in
410/// place. Pure over a `toml_edit` document so the validation, duplicate check,
411/// and formatting are testable without disk. Preserves the rest of the file
412/// (comments, key order, other sections) — only the new array-of-tables entry
413/// is added. Errors on an invalid label or a label that already exists.
414pub fn add_anthropic_account_to_doc(
415    doc: &mut toml_edit::DocumentMut,
416    label: &str,
417    credentials_path: &str,
418) -> Result<()> {
419    use toml_edit::{Item, Table, value};
420
421    validate_account_label(label)?;
422
423    let anthropic = doc
424        .entry("anthropic")
425        .or_insert_with(|| Item::Table(Table::new()));
426    let anthropic = anthropic
427        .as_table_mut()
428        .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
429
430    let accounts = anthropic
431        .entry("accounts")
432        .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
433    let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
434        AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
435    })?;
436
437    let exists = accounts
438        .iter()
439        .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
440    if exists {
441        return Err(AppError::Credentials(format!(
442            "anthropic account {label:?} already exists in config.toml"
443        )));
444    }
445
446    let mut table = Table::new();
447    table["label"] = value(label);
448    table["credentials_path"] = value(credentials_path);
449    accounts.push(table);
450    Ok(())
451}
452
453#[derive(Debug, Clone, Deserialize, Serialize)]
454#[serde(default)]
455pub struct OpenAiConfig {
456    pub enabled: bool,
457    /// Override the Codex auth file path (defaults to `~/.codex/auth.json`).
458    pub codex_auth_path: Option<PathBuf>,
459    /// Extra Codex logins, each its own `auth.json`. Same shape as
460    /// [`AnthropicAccount`] and for the same reason: Codex is an OAuth vendor,
461    /// so an account *is* a credential file, and `openai::creds::write_back`
462    /// refreshes into whichever one it read.
463    #[serde(default)]
464    pub accounts: Vec<OpenAiAccount>,
465    /// Reserved, and inert: names the env var an API-key-only path *would*
466    /// read (admin key → `/v1/organization/costs`). Nothing consumes it —
467    /// OpenAI usage comes solely from Codex OAuth. Kept because that path is
468    /// still intended, not for back-compat: `[openai]` doesn't deny unknown
469    /// fields, so an existing `admin_key_env` would load either way. See
470    /// `config.example.toml`, which ships it commented out so nobody sets it
471    /// expecting an effect.
472    pub admin_key_env: String,
473}
474
475/// One extra Codex login.
476///
477/// ```toml
478/// [[openai.accounts]]
479/// label = "work"
480/// codex_auth_path = "~/.config/ai-usagebar/accounts/work-codex/auth.json"
481/// ```
482///
483/// A second login is made with `CODEX_HOME=~/.codex-work codex login`; point
484/// `codex_auth_path` at the `auth.json` it writes.
485#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
486pub struct OpenAiAccount {
487    /// Stable name used on the CLI (`--account <label>`) and as the cache
488    /// subdir (`~/.cache/ai-usagebar/openai/<label>`).
489    pub label: String,
490    /// Codex OAuth file for this account. Refreshed tokens are written back
491    /// here, so each account keeps itself alive independently.
492    pub codex_auth_path: PathBuf,
493}
494
495impl OpenAiConfig {
496    /// The auth file for `label`, or the singular/default one when `label` is
497    /// `None`. An unknown label is an error rather than a silent fall back to
498    /// the default account, which would report the wrong login's usage.
499    pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
500        let Some(label) = label else {
501            return match &self.codex_auth_path {
502                Some(path) => Ok(path.clone()),
503                None => crate::openai::creds::default_path(),
504            };
505        };
506        self.accounts
507            .iter()
508            .find(|account| account.label == label)
509            .map(|account| account.codex_auth_path.clone())
510            .ok_or_else(|| {
511                AppError::Credentials(format!(
512                    "no OpenAI account named {label:?}. Add it under \
513                     [[openai.accounts]], or drop --account to use the default login."
514                ))
515            })
516    }
517}
518
519impl Default for OpenAiConfig {
520    fn default() -> Self {
521        Self {
522            enabled: true,
523            codex_auth_path: None,
524            accounts: Vec::new(),
525            admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
526        }
527    }
528}
529
530/// GitHub Copilot quota from the private endpoint used by VS Code. The token
531/// comes from an explicit environment override or the official GitHub CLI;
532/// this app never reads, copies, or writes GitHub credential stores.
533#[derive(Debug, Clone, Default, Deserialize, Serialize)]
534#[serde(default)]
535pub struct CopilotConfig {
536    pub enabled: bool,
537    /// Path to the official GitHub CLI. Unset looks `gh` up on `PATH`, which
538    /// is how `gh` is normally installed; set it to pin the executable.
539    pub gh_binary: Option<PathBuf>,
540}
541
542impl CopilotConfig {
543    pub fn resolve_token(&self) -> Result<String> {
544        self.resolve_token_with(
545            |name| std::env::var_os(name),
546            &crate::copilot::credentials::SystemGhAuthTokenRunner,
547        )
548    }
549
550    fn resolve_token_with(
551        &self,
552        environment: impl Fn(&str) -> Option<std::ffi::OsString>,
553        runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
554    ) -> Result<String> {
555        if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
556            let token = value.into_string().map_err(|_| {
557                AppError::Credentials(
558                    "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
559                )
560            })?;
561            if !token.is_empty() {
562                return Ok(token);
563            }
564        }
565        crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref())
566    }
567}
568
569#[derive(Debug, Clone, Default, Deserialize, Serialize)]
570#[serde(default)]
571pub struct NousConfig {
572    pub enabled: bool,
573}
574
575#[derive(Debug, Clone, Deserialize, Serialize)]
576#[serde(default)]
577pub struct OpenCodeGoConfig {
578    pub enabled: bool,
579    pub api_key_env: String,
580    pub api_key: Option<String>,
581}
582
583/// Command Code reads the OAuth credential from the official CLI or pi, so it
584/// has no API key of its own. `auth_paths` overrides that search list for a
585/// non-standard install.
586#[derive(Debug, Clone, Default, Deserialize, Serialize)]
587#[serde(default)]
588pub struct CommandCodeConfig {
589    pub enabled: bool,
590    pub auth_paths: Option<Vec<PathBuf>>,
591}
592
593impl Default for OpenCodeGoConfig {
594    fn default() -> Self {
595        Self {
596            enabled: false,
597            api_key_env: "OPENCODE_GO_API_KEY".to_string(),
598            api_key: None,
599        }
600    }
601}
602
603#[derive(Debug, Clone, Deserialize, Serialize)]
604#[serde(default)]
605pub struct ZaiConfig {
606    pub enabled: bool,
607    /// Env var name to read the key from (env wins over `api_key`).
608    pub api_key_env: String,
609    /// Inline key (fallback when the env var is unset). Chmod 600 your
610    /// config file if you put a real key here.
611    pub api_key: Option<String>,
612    /// Optional plan tier label (lite/pro/max) — display-only.
613    pub plan_tier: Option<String>,
614}
615
616impl Default for ZaiConfig {
617    fn default() -> Self {
618        Self {
619            enabled: true,
620            api_key_env: "ZAI_API_KEY".to_string(),
621            api_key: None,
622            plan_tier: None,
623        }
624    }
625}
626
627#[derive(Debug, Clone, Deserialize, Serialize)]
628#[serde(default)]
629pub struct OpenRouterConfig {
630    pub enabled: bool,
631    /// Extra OpenRouter accounts beyond the default key. Each account gets a
632    /// separate aggregate-view entry and cache directory.
633    pub accounts: Vec<OpenRouterAccount>,
634    /// Whether aggregate views include the default (unnamed) key when named
635    /// accounts exist. Ignored when `accounts` is empty so OpenRouter never
636    /// loses its only tab.
637    pub show_default_account: bool,
638    pub api_key_env: String,
639    pub api_key: Option<String>,
640}
641
642impl Default for OpenRouterConfig {
643    fn default() -> Self {
644        Self {
645            enabled: true,
646            accounts: Vec::new(),
647            show_default_account: true,
648            api_key_env: "OPENROUTER_API_KEY".to_string(),
649            api_key: None,
650        }
651    }
652}
653
654/// One named OpenRouter account. The default account continues to use the
655/// singular `api_key_env` / `api_key` fields under `[openrouter]`.
656#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
657pub struct OpenRouterAccount {
658    /// Stable CLI/report label and account-scoped cache subdirectory.
659    pub label: String,
660    /// Optional environment variable containing this account's key.
661    #[serde(default)]
662    pub api_key_env: Option<String>,
663    /// Inline fallback when the account environment variable is unset.
664    #[serde(default)]
665    pub api_key: Option<String>,
666}
667
668impl OpenRouterConfig {
669    /// Find a named account or fail loudly instead of falling back to the
670    /// default key (which would show the wrong account's usage).
671    pub fn account(&self, label: &str) -> Result<&OpenRouterAccount> {
672        validate_account_label_for("openrouter", label)?;
673        self.accounts
674            .iter()
675            .find(|account| account.label == label)
676            .ok_or_else(|| {
677                let known: Vec<&str> = self
678                    .accounts
679                    .iter()
680                    .map(|account| account.label.as_str())
681                    .collect();
682                AppError::Credentials(format!(
683                    "openrouter account {label:?} not found in [[openrouter.accounts]]; \
684                     known labels: {known:?}"
685                ))
686            })
687    }
688
689    /// Resolve either the backward-compatible default key or one named
690    /// account. Configured values are never included in an error message.
691    pub fn resolve_api_key(&self, label: Option<&str>) -> Result<String> {
692        match label {
693            None => resolve_api_key("OpenRouter", &self.api_key_env, self.api_key.as_deref()),
694            Some(label) => {
695                let account = self.account(label)?;
696                resolve_api_key_in_section(
697                    &format!("OpenRouter account {label:?}"),
698                    "[[openrouter.accounts]]",
699                    account.api_key_env.as_deref().unwrap_or(""),
700                    account.api_key.as_deref(),
701                )
702            }
703        }
704    }
705}
706
707#[derive(Debug, Clone, Deserialize, Serialize)]
708#[serde(default)]
709pub struct DeepseekConfig {
710    pub enabled: bool,
711    pub api_key_env: String,
712    pub api_key: Option<String>,
713}
714
715impl Default for DeepseekConfig {
716    fn default() -> Self {
717        Self {
718            enabled: false,
719            api_key_env: "DEEPSEEK_API_KEY".to_string(),
720            api_key: None,
721        }
722    }
723}
724
725#[derive(Debug, Clone, Deserialize, Serialize)]
726#[serde(default)]
727pub struct KimiConfig {
728    pub enabled: bool,
729    pub api_key_env: String,
730    /// Optional: with no key set, the vendor falls back to the Kimi Code CLI's
731    /// own OAuth login, which is what a subscriber already has locally.
732    pub api_key: Option<String>,
733    /// Override for kimi-code's credential file (default
734    /// `~/.kimi-code/credentials/kimi-code.json`), mirroring `[cursor] db_path`
735    /// and `[kiro] db_path`. Useful with a relocated `KIMI_CODE_HOME`.
736    pub credentials_path: Option<PathBuf>,
737    /// `"auto"` follows kimi-code's own install marker (`~/.kimi-code/region`);
738    /// `"cn"` pins `api.kimi.com` / `auth.kimi.com`, `"global"` pins
739    /// `api.kimi.ai` / `auth.kimi.ai`. A token minted by one deployment means
740    /// nothing to the other, so this picks the instance, not a currency.
741    pub region: String,
742}
743
744impl Default for KimiConfig {
745    fn default() -> Self {
746        Self {
747            enabled: false,
748            api_key_env: "KIMI_API_KEY".to_string(),
749            api_key: None,
750            credentials_path: None,
751            region: "auto".to_string(),
752        }
753    }
754}
755
756#[derive(Debug, Clone, Deserialize, Serialize)]
757#[serde(default)]
758pub struct KiloConfig {
759    pub enabled: bool,
760    pub api_key_env: String,
761    pub api_key: Option<String>,
762    /// Optional Kilo organization id — scopes the balance to a team via the
763    /// `x-kilocode-organizationid` header. Omit for the personal balance.
764    pub organization_id: Option<String>,
765}
766
767impl Default for KiloConfig {
768    fn default() -> Self {
769        // Opt-in like DeepSeek: requires an explicit API key, so it defaults to
770        // disabled and never affects existing installs.
771        Self {
772            enabled: false,
773            api_key_env: "KILO_API_KEY".to_string(),
774            api_key: None,
775            organization_id: None,
776        }
777    }
778}
779
780#[derive(Debug, Clone, Deserialize, Serialize)]
781#[serde(default)]
782pub struct NovitaConfig {
783    pub enabled: bool,
784    pub api_key_env: String,
785    pub api_key: Option<String>,
786}
787
788impl Default for NovitaConfig {
789    fn default() -> Self {
790        // Opt-in like DeepSeek/Kilo: needs an explicit API key.
791        Self {
792            enabled: false,
793            api_key_env: "NOVITA_API_KEY".to_string(),
794            api_key: None,
795        }
796    }
797}
798
799#[derive(Debug, Clone, Deserialize, Serialize)]
800#[serde(default)]
801pub struct MinimaxConfig {
802    pub enabled: bool,
803    pub api_key_env: String,
804    pub api_key: Option<String>,
805    /// `"global"` → api.minimax.io; `"cn"` → api.minimaxi.com. Unlike
806    /// Moonshot's, this does not change the unit — MiniMax reports quota as a
807    /// percentage either way. It picks the *instance*: a key issued for one
808    /// host is rejected by the other (`status_code 2049`), so pointing this at
809    /// the wrong region reads as an invalid key rather than an empty plan.
810    pub region: String,
811}
812
813impl Default for MinimaxConfig {
814    fn default() -> Self {
815        // Opt-in like the other API-key vendors: needs an explicit key.
816        Self {
817            enabled: false,
818            api_key_env: "MINIMAX_API_KEY".to_string(),
819            api_key: None,
820            region: "global".to_string(),
821        }
822    }
823}
824
825#[derive(Debug, Clone, Deserialize, Serialize)]
826#[serde(default)]
827pub struct MoonshotConfig {
828    pub enabled: bool,
829    pub api_key_env: String,
830    pub api_key: Option<String>,
831    /// `"global"` → api.moonshot.ai (USD); `"cn"` → api.moonshot.cn (CNY).
832    pub region: String,
833}
834
835impl Default for MoonshotConfig {
836    fn default() -> Self {
837        // Opt-in like DeepSeek/Kilo/Novita: needs an explicit API key.
838        Self {
839            enabled: false,
840            api_key_env: "MOONSHOT_API_KEY".to_string(),
841            api_key: None,
842            region: "global".to_string(),
843        }
844    }
845}
846
847#[derive(Debug, Clone, Deserialize, Serialize)]
848#[serde(default)]
849pub struct GrokConfig {
850    pub enabled: bool,
851    /// Env var for the xAI **Management** key (distinct from the inference key).
852    pub api_key_env: String,
853    pub api_key: Option<String>,
854    /// Optional team id. When absent, it's auto-resolved from the management
855    /// key via `/auth/management-keys/validation`.
856    pub team_id: Option<String>,
857}
858
859impl Default for GrokConfig {
860    fn default() -> Self {
861        // Opt-in: needs a management key (and, for prepaid, a team).
862        Self {
863            enabled: false,
864            api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
865            api_key: None,
866            team_id: None,
867        }
868    }
869}
870
871/// SuperGrok subscription auth — no API key of its own. Billing and banked
872/// resets use the `key` already in Grok Build's `auth.json` (read-only).
873/// Login, issuer, proxy, and token rotation stay inside Grok Build.
874///
875/// Opt-in like Cursor/Kiro (`enabled` defaults to `false`): it requires a
876/// separate official executable and signed-in session, so it stays off until
877/// the user explicitly turns it on.
878#[derive(Debug, Clone, Deserialize, Serialize)]
879#[serde(default)]
880pub struct SuperGrokConfig {
881    pub enabled: bool,
882    /// Trusted official Grok Build executable. Defaults to its canonical
883    /// `$GROK_HOME/bin/grok` (or `~/.grok/bin/grok`) installation path instead
884    /// of searching PATH, where unrelated programs can share the name.
885    pub grok_binary: PathBuf,
886    /// Opaque auth/config files used only to fingerprint the active cache
887    /// scope. Their contents are never parsed or copied to the cache.
888    pub auth_path: Option<PathBuf>,
889    pub config_path: Option<PathBuf>,
890}
891
892impl Default for SuperGrokConfig {
893    fn default() -> Self {
894        Self {
895            enabled: false,
896            grok_binary: default_grok_binary(),
897            auth_path: None,
898            config_path: None,
899        }
900    }
901}
902
903fn default_grok_binary() -> PathBuf {
904    let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
905    let grok_home = std::env::var_os("GROK_HOME")
906        .filter(|value| !value.is_empty())
907        .map(PathBuf::from)
908        .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
909    grok_home
910        .map(|home| home.join("bin").join(executable))
911        .unwrap_or_else(|| PathBuf::from(executable))
912}
913
914/// Antigravity reads its quota from whichever local Antigravity product is
915/// running, so it needs no credentials — only an on/off switch.
916#[derive(Debug, Clone, Default, Deserialize, Serialize)]
917#[serde(default)]
918pub struct AntigravityConfig {
919    pub enabled: bool,
920}
921
922/// Cursor reads its quota through a session token the Cursor IDE already
923/// wrote to its local `state.vscdb` — no API key, but (unlike Antigravity)
924/// there is a real on-disk path that can need overriding (e.g. a portable or
925/// non-default Cursor install), mirroring `openai.codex_auth_path`.
926///
927/// Opt-in like DeepSeek/Kilo/etc (`enabled` defaults to `false`, matching
928/// `bool::default()`): reads an undocumented endpoint via a session token
929/// scraped from a local IDE file, so it stays off until the user explicitly
930/// turns it on.
931#[derive(Debug, Clone, Default, Deserialize, Serialize)]
932#[serde(default)]
933pub struct CursorConfig {
934    pub enabled: bool,
935    /// Override Cursor's local state database path (defaults to the
936    /// platform-standard `.../User/globalStorage/state.vscdb` — see
937    /// `cursor::db::default_db_path`).
938    pub db_path: Option<PathBuf>,
939    /// Override the headless `cursor-agent` CLI's own login file (defaults to
940    /// `.../cursor/auth.json` — see `cursor::db::default_agent_auth_path`).
941    /// Used as a fallback when `db_path` doesn't exist, so a text-only
942    /// machine that never runs the desktop IDE still gets usage.
943    pub agent_auth_path: Option<PathBuf>,
944}
945
946/// Kiro CLI reads its quota through the AWS SSO OIDC session kiro-cli already
947/// wrote to its own local `data.sqlite3` — no API key, but (like Cursor) a
948/// real on-disk path that can need overriding.
949///
950/// Opt-in like Cursor/DeepSeek/Kilo/etc (`enabled` defaults to `false`):
951/// calls a reverse-engineered CodeWhisperer endpoint via a session token
952/// scraped from a local CLI database, so it stays off until the user
953/// explicitly turns it on.
954#[derive(Debug, Clone, Default, Deserialize, Serialize)]
955#[serde(default)]
956pub struct KiroConfig {
957    pub enabled: bool,
958    /// Override kiro-cli's local database path (defaults to the
959    /// platform-standard `.../kiro-cli/data.sqlite3` — see
960    /// `kiro::db::default_db_path`).
961    pub db_path: Option<PathBuf>,
962}
963
964#[derive(Debug, Clone, Deserialize, Serialize)]
965#[serde(default)]
966pub struct AnthropicApiConfig {
967    pub enabled: bool,
968    /// Env var for the Console **Admin key** (`sk-ant-admin01-…`), distinct from
969    /// an inference key and from the Claude Code OAuth login.
970    pub api_key_env: String,
971    pub api_key: Option<String>,
972    /// Monthly USD spend limit, used only for the spend-vs-limit % display. The
973    /// API exposes neither this limit nor the remaining prepaid balance.
974    pub monthly_limit: Option<f64>,
975}
976
977impl Default for AnthropicApiConfig {
978    fn default() -> Self {
979        // Opt-in: needs an explicit Admin key.
980        Self {
981            enabled: false,
982            api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
983            api_key: None,
984            monthly_limit: None,
985        }
986    }
987}
988
989/// Resolve an API key for a vendor: a valid env-var name wins, then inline
990/// config, then a clear error naming both fields. Used by every API-key vendor.
991pub fn resolve_api_key(
992    vendor_label: &str,
993    env_var_name: &str,
994    inline: Option<&str>,
995) -> crate::error::Result<String> {
996    let section = match vendor_label {
997        "OpenCode Go" => "[opencode-go]".to_string(),
998        _ => format!("[{}]", vendor_label.to_lowercase()),
999    };
1000    resolve_api_key_in_section(vendor_label, &section, env_var_name, inline)
1001}
1002
1003/// The env-then-inline lookup without the "or fail" ending, for vendors where
1004/// an absent API key is a legitimate state rather than an error — Kimi accepts
1005/// a Kimi Code CLI subscription login instead.
1006pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
1007    if is_valid_env_var_name(env_var_name)
1008        && let Ok(v) = std::env::var(env_var_name)
1009        && !v.is_empty()
1010    {
1011        return Some(v);
1012    }
1013    inline.filter(|v| !v.is_empty()).map(str::to_string)
1014}
1015
1016fn resolve_api_key_in_section(
1017    vendor_label: &str,
1018    section: &str,
1019    env_var_name: &str,
1020    inline: Option<&str>,
1021) -> crate::error::Result<String> {
1022    if let Some(key) = optional_api_key(env_var_name, inline) {
1023        return Ok(key);
1024    }
1025    let valid_env_name = is_valid_env_var_name(env_var_name);
1026    let advice = if valid_env_name {
1027        "set an API key in a valid environment variable or set `api_key`"
1028    } else {
1029        "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
1030    };
1031    Err(crate::error::AppError::Credentials(format!(
1032        "{vendor_label}: no API key. Either {advice} under {section} in {}.",
1033        config_path_hint()
1034    )))
1035}
1036
1037fn is_valid_env_var_name(name: &str) -> bool {
1038    let mut chars = name.chars();
1039    let Some(first) = chars.next() else {
1040        return false;
1041    };
1042    (first.is_ascii_alphabetic() || first == '_')
1043        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1044}
1045
1046impl Config {
1047    /// Load from `~/.config/ai-usagebar/config.toml`. Returns defaults if the
1048    /// file doesn't exist; errors only on actual parse failures.
1049    pub fn load() -> Result<Self> {
1050        let Some(path) = resolved_path() else {
1051            return Ok(Self::default());
1052        };
1053        Self::load_from(&path)
1054    }
1055
1056    pub fn load_from(path: &std::path::Path) -> Result<Self> {
1057        match std::fs::read_to_string(path) {
1058            Ok(s) => {
1059                let mut config: Self = toml::from_str(&s)?;
1060                // `~` is shell syntax, not path syntax: `PathBuf` keeps it
1061                // literally, so a documented `credentials_path = "~/..."`
1062                // silently pointed at a directory named `~`.
1063                config.expand_paths();
1064                config.validate()?;
1065                #[cfg(unix)]
1066                config.protect_inline_secrets(path)?;
1067                Ok(config)
1068            }
1069            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1070            Err(e) => Err(AppError::io_at(path, e)),
1071        }
1072    }
1073
1074    fn expand_paths(&mut self) {
1075        expand_tilde_opt(&mut self.context.projects_path);
1076        expand_tilde_opt(&mut self.anthropic.credentials_path);
1077        expand_tilde_opt(&mut self.anthropic.accounts_dir);
1078        expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
1079        expand_tilde_opt(&mut self.openai.codex_auth_path);
1080        expand_tilde_opt(&mut self.cursor.db_path);
1081        expand_tilde_opt(&mut self.cursor.agent_auth_path);
1082        expand_tilde_opt(&mut self.kiro.db_path);
1083        expand_tilde_opt(&mut self.kimi.credentials_path);
1084        self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
1085        expand_tilde_opt(&mut self.supergrok.auth_path);
1086        expand_tilde_opt(&mut self.supergrok.config_path);
1087        for account in &mut self.anthropic.accounts {
1088            account.credentials_path = expand_tilde(&account.credentials_path);
1089        }
1090        for account in &mut self.openai.accounts {
1091            account.codex_auth_path = expand_tilde(&account.codex_auth_path);
1092        }
1093    }
1094
1095    /// Explicitly enumerate every inline credential field. Adding a new
1096    /// credential vendor must add it here so its config receives the same
1097    /// protection.
1098    #[cfg(unix)]
1099    fn has_inline_secrets(&self) -> bool {
1100        [
1101            self.zai.api_key.as_deref(),
1102            self.openrouter.api_key.as_deref(),
1103            self.deepseek.api_key.as_deref(),
1104            self.kimi.api_key.as_deref(),
1105            self.kilo.api_key.as_deref(),
1106            self.novita.api_key.as_deref(),
1107            self.minimax.api_key.as_deref(),
1108            self.moonshot.api_key.as_deref(),
1109            self.grok.api_key.as_deref(),
1110            self.anthropic_api.api_key.as_deref(),
1111            self.opencode_go.api_key.as_deref(),
1112        ]
1113        .into_iter()
1114        .chain(
1115            self.openrouter
1116                .accounts
1117                .iter()
1118                .map(|account| account.api_key.as_deref()),
1119        )
1120        .any(|key| key.is_some_and(|key| !key.is_empty()))
1121    }
1122
1123    #[cfg(unix)]
1124    fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
1125        if !self.has_inline_secrets() {
1126            return Ok(());
1127        }
1128
1129        let metadata = std::fs::metadata(path).map_err(|_| {
1130            AppError::Credentials(format!(
1131                "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
1132                path.display()
1133            ))
1134        })?;
1135        if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
1136            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
1137                AppError::Credentials(format!(
1138                    "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",
1139                    path.display()
1140                ))
1141            })?;
1142        }
1143        Ok(())
1144    }
1145
1146    pub fn is_enabled(&self, id: VendorId) -> bool {
1147        match id {
1148            VendorId::Anthropic => self.anthropic.enabled,
1149            VendorId::AnthropicApi => self.anthropic_api.enabled,
1150            VendorId::Openai => self.openai.enabled,
1151            VendorId::Copilot => self.copilot.enabled,
1152            VendorId::Zai => self.zai.enabled,
1153            VendorId::Openrouter => self.openrouter.enabled,
1154            VendorId::Deepseek => self.deepseek.enabled,
1155            VendorId::Kimi => self.kimi.enabled,
1156            VendorId::Kilo => self.kilo.enabled,
1157            VendorId::Novita => self.novita.enabled,
1158            VendorId::Moonshot => self.moonshot.enabled,
1159            VendorId::Grok => self.grok.enabled,
1160            VendorId::Supergrok => self.supergrok.enabled,
1161            VendorId::Antigravity => self.antigravity.enabled,
1162            VendorId::Cursor => self.cursor.enabled,
1163            VendorId::Minimax => self.minimax.enabled,
1164            VendorId::Kiro => self.kiro.enabled,
1165            VendorId::NousResearch => self.nous.enabled,
1166            VendorId::OpenCodeGo => self.opencode_go.enabled,
1167            VendorId::CommandCode => self.commandcode.enabled,
1168        }
1169    }
1170
1171    /// The environment variable this provider's API key is read from, honoring
1172    /// a per-vendor `api_key_env` override; `""` for a provider that takes no
1173    /// key. Matching on [`VendorId`] rather than on a section name is
1174    /// deliberate: a new key vendor that nobody adds here fails to compile,
1175    /// where a `_ =>` arm over `&str` sections would silently hand back the
1176    /// wrong default and report the provider as unconfigured for ever.
1177    pub fn api_key_env_for(&self, id: VendorId) -> &str {
1178        match id {
1179            VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
1180            VendorId::Zai => &self.zai.api_key_env,
1181            VendorId::Openrouter => &self.openrouter.api_key_env,
1182            VendorId::Deepseek => &self.deepseek.api_key_env,
1183            VendorId::Kimi => &self.kimi.api_key_env,
1184            VendorId::Kilo => &self.kilo.api_key_env,
1185            VendorId::Novita => &self.novita.api_key_env,
1186            VendorId::Moonshot => &self.moonshot.api_key_env,
1187            VendorId::Grok => &self.grok.api_key_env,
1188            VendorId::Minimax => &self.minimax.api_key_env,
1189            VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
1190            // Fixed names: OAuth-first providers whose environment override is
1191            // not user-renameable, and the providers with no key at all.
1192            VendorId::Anthropic
1193            | VendorId::Openai
1194            | VendorId::Copilot
1195            | VendorId::Supergrok
1196            | VendorId::Antigravity
1197            | VendorId::Cursor
1198            | VendorId::Kiro
1199            | VendorId::NousResearch
1200            | VendorId::CommandCode => id.api_key_env(),
1201        }
1202    }
1203
1204    /// A non-empty inline `api_key` from this provider's config section. An
1205    /// empty string counts as unset, the same way the vendors' own
1206    /// `resolve_api_key` treats it.
1207    pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
1208        let raw = match id {
1209            VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
1210            VendorId::Zai => self.zai.api_key.as_deref(),
1211            VendorId::Openrouter => self.openrouter.api_key.as_deref(),
1212            VendorId::Deepseek => self.deepseek.api_key.as_deref(),
1213            VendorId::Kimi => self.kimi.api_key.as_deref(),
1214            VendorId::Kilo => self.kilo.api_key.as_deref(),
1215            VendorId::Novita => self.novita.api_key.as_deref(),
1216            VendorId::Moonshot => self.moonshot.api_key.as_deref(),
1217            VendorId::Grok => self.grok.api_key.as_deref(),
1218            VendorId::Minimax => self.minimax.api_key.as_deref(),
1219            VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
1220            VendorId::Anthropic
1221            | VendorId::Openai
1222            | VendorId::Copilot
1223            | VendorId::Supergrok
1224            | VendorId::Antigravity
1225            | VendorId::Cursor
1226            | VendorId::Kiro
1227            | VendorId::NousResearch
1228            | VendorId::CommandCode => None,
1229        };
1230        raw.filter(|key| !key.is_empty())
1231    }
1232
1233    pub fn enabled_vendors(&self) -> Vec<VendorId> {
1234        VendorId::all()
1235            .iter()
1236            .copied()
1237            .filter(|id| self.is_enabled(*id))
1238            .collect()
1239    }
1240
1241    /// Validate cross-entry constraints that serde cannot express. Account
1242    /// labels are both CLI selectors and TUI tab identities, so duplicates
1243    /// would make either destination ambiguous.
1244    pub fn validate(&self) -> Result<()> {
1245        if self.context.context_window_tokens == Some(0) {
1246            return Err(AppError::Other(
1247                "[context] context_window_tokens must be greater than zero".into(),
1248            ));
1249        }
1250        for (model, tokens) in &self.context.model_context_window_tokens {
1251            if model.trim().is_empty() {
1252                return Err(AppError::Other(
1253                    "[context] model_context_window_tokens keys must not be empty".into(),
1254                ));
1255            }
1256            if *tokens == 0 {
1257                return Err(AppError::Other(format!(
1258                    "[context] model_context_window_tokens entry {model:?} must be greater than zero"
1259                )));
1260            }
1261        }
1262        if let Some(limit) = self.anthropic_api.monthly_limit
1263            && (!limit.is_finite() || limit <= 0.0)
1264        {
1265            return Err(AppError::Other(
1266                "[anthropic_api] monthly_limit must be finite and greater than zero; \
1267                 remove it to show spend without a limit"
1268                    .into(),
1269            ));
1270        }
1271        if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
1272            && !self.kimi.region.eq_ignore_ascii_case("auto")
1273        {
1274            return Err(AppError::Other(format!(
1275                "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
1276                self.kimi.region
1277            )));
1278        }
1279        if !self.minimax.region.eq_ignore_ascii_case("global")
1280            && !self.minimax.region.eq_ignore_ascii_case("cn")
1281        {
1282            return Err(AppError::Other(format!(
1283                "[minimax] region must be \"global\" or \"cn\", got {:?}",
1284                self.minimax.region
1285            )));
1286        }
1287        if self.supergrok.grok_binary.as_os_str().is_empty() {
1288            return Err(AppError::Other(
1289                "[supergrok] grok_binary must not be empty".into(),
1290            ));
1291        }
1292        let mut labels = HashSet::new();
1293        for account in &self.anthropic.accounts {
1294            validate_account_label(&account.label)?;
1295            if !labels.insert(&account.label) {
1296                return Err(AppError::Credentials(format!(
1297                    "duplicate anthropic account label {:?}",
1298                    account.label
1299                )));
1300            }
1301        }
1302        let mut openai_labels = HashSet::new();
1303        for account in &self.openai.accounts {
1304            validate_account_label_for("openai", &account.label)?;
1305            if !openai_labels.insert(&account.label) {
1306                return Err(AppError::Credentials(format!(
1307                    "duplicate openai account label {:?}",
1308                    account.label
1309                )));
1310            }
1311        }
1312        let mut openrouter_labels = HashSet::new();
1313        for account in &self.openrouter.accounts {
1314            validate_account_label_for("openrouter", &account.label)?;
1315            if !openrouter_labels.insert(&account.label) {
1316                return Err(AppError::Credentials(format!(
1317                    "duplicate openrouter account label {:?}",
1318                    account.label
1319                )));
1320            }
1321            let has_env = account
1322                .api_key_env
1323                .as_deref()
1324                .is_some_and(|name| !name.is_empty());
1325            let has_inline = account
1326                .api_key
1327                .as_deref()
1328                .is_some_and(|key| !key.is_empty());
1329            if !has_env && !has_inline {
1330                return Err(AppError::Credentials(format!(
1331                    "openrouter account {:?} must set api_key_env or api_key",
1332                    account.label
1333                )));
1334            }
1335        }
1336        Ok(())
1337    }
1338}
1339
1340#[cfg(unix)]
1341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1342enum InlineKeyPermissionDecision {
1343    Ok,
1344    Tighten,
1345}
1346
1347#[cfg(unix)]
1348fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
1349    if mode & 0o077 == 0 {
1350        InlineKeyPermissionDecision::Ok
1351    } else {
1352        InlineKeyPermissionDecision::Tighten
1353    }
1354}
1355
1356pub fn default_path() -> Option<PathBuf> {
1357    let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
1358    Some(proj.config_dir().join("config.toml"))
1359}
1360
1361/// The Unix-conventional location, which is what every doc, the config
1362/// example, and both desktop integrations have always pointed at. On Linux it
1363/// *is* [`default_path`]; on macOS `ProjectDirs` resolves to
1364/// `~/Library/Application Support/…` instead, so the two diverge.
1365fn legacy_xdg_path() -> Option<PathBuf> {
1366    let home = crate::cache::home_dir().ok()?;
1367    Some(home.join(".config").join("ai-usagebar").join("config.toml"))
1368}
1369
1370/// The config file actually in effect.
1371///
1372/// A `--config` override (see [`set_override_path`]) wins outright so a test
1373/// run never touches the real file. Otherwise [`default_path`] stays
1374/// canonical, but on macOS a file at the documented
1375/// `~/.config/ai-usagebar/config.toml` is honored when the canonical one does
1376/// not exist — otherwise everyone who followed the README (and both desktop
1377/// integrations, which read that path) silently got defaults. The legacy file
1378/// is never moved or rewritten: it may hold API keys, and relocating a secret
1379/// behind the user's back is not this tool's business.
1380pub fn resolved_path() -> Option<PathBuf> {
1381    if let Some(path) = override_path() {
1382        return Some(path);
1383    }
1384    let canonical = default_path();
1385    if let Some(p) = &canonical
1386        && p.exists()
1387    {
1388        return canonical;
1389    }
1390    if let Some(legacy) = legacy_xdg_path()
1391        && legacy.exists()
1392    {
1393        return Some(legacy);
1394    }
1395    canonical
1396}
1397
1398static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
1399
1400/// Point every config load, save, and hint at one explicit file — the
1401/// `--config` flag. Takes precedence over the canonical and legacy locations.
1402/// The file does not have to exist yet: loads treat it as defaults while
1403/// Settings saves create it. Process-wide, so call it once at startup before
1404/// any config is read.
1405pub fn set_override_path(path: &std::path::Path) {
1406    if let Ok(mut slot) = PATH_OVERRIDE.lock() {
1407        *slot = Some(path.to_path_buf());
1408    }
1409}
1410
1411/// Drop the override again. Used only by tests so they can restore the
1412/// process-wide state they changed.
1413#[doc(hidden)]
1414pub fn clear_override_path() {
1415    if let Ok(mut slot) = PATH_OVERRIDE.lock() {
1416        *slot = None;
1417    }
1418}
1419
1420fn override_path() -> Option<PathBuf> {
1421    PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
1422}
1423
1424/// Value of a `--config=PATH` argument, split at the OS-string level so a
1425/// path with bytes Windows/Unix can store but UTF-8 cannot represent (an
1426/// undecodable filename on Unix, a lone surrogate on Windows) survives
1427/// intact instead of being mangled by `to_string_lossy`. `None` when the
1428/// argument is not in that form. Used by both binaries' argv pre-parsers.
1429#[doc(hidden)]
1430pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
1431    #[cfg(unix)]
1432    {
1433        use std::os::unix::ffi::{OsStrExt, OsStringExt};
1434        let rest = arg.as_bytes().strip_prefix(b"--config=")?;
1435        Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
1436    }
1437    #[cfg(windows)]
1438    {
1439        use std::os::windows::ffi::{OsStrExt, OsStringExt};
1440        const PREFIX: &[u16] = &[
1441            b'-' as u16,
1442            b'-' as u16,
1443            b'c' as u16,
1444            b'o' as u16,
1445            b'n' as u16,
1446            b'f' as u16,
1447            b'i' as u16,
1448            b'g' as u16,
1449            b'=' as u16,
1450        ];
1451        let wide: Vec<u16> = arg.encode_wide().collect();
1452        let rest = wide.strip_prefix(PREFIX)?;
1453        Some(std::ffi::OsString::from_wide(rest).into())
1454    }
1455    #[cfg(not(any(unix, windows)))]
1456    {
1457        Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
1458    }
1459}
1460
1461/// Expand a leading `~` (or `~/`) against the user's home directory. Anything
1462/// else — including `~user` — is left untouched.
1463fn expand_tilde(p: &std::path::Path) -> PathBuf {
1464    let Some(s) = p.to_str() else {
1465        return p.to_path_buf();
1466    };
1467    let rest = if s == "~" {
1468        ""
1469    } else if let Some(r) = s.strip_prefix("~/") {
1470        r
1471    } else {
1472        return p.to_path_buf();
1473    };
1474    match crate::cache::home_dir() {
1475        Ok(home) if rest.is_empty() => home,
1476        Ok(home) => home.join(rest),
1477        Err(_) => p.to_path_buf(),
1478    }
1479}
1480
1481fn expand_tilde_opt(p: &mut Option<PathBuf>) {
1482    if let Some(inner) = p.as_ref() {
1483        *p = Some(expand_tilde(inner));
1484    }
1485}
1486
1487/// Resolved `config.toml` path as a string for user-facing messages. Uses the
1488/// platform's config dir (`directories::ProjectDirs`), so it reads correctly on
1489/// Linux, macOS, and Windows instead of hard-coding the Unix `~/.config` path.
1490/// Falls back to the bare filename if the path can't be resolved.
1491pub fn config_path_hint() -> String {
1492    resolved_path()
1493        .map(|p| p.display().to_string())
1494        .unwrap_or_else(|| "config.toml".to_string())
1495}
1496
1497#[cfg(test)]
1498mod tests {
1499    use super::*;
1500    use std::io::Write;
1501    use tempfile::NamedTempFile;
1502
1503    #[cfg(unix)]
1504    use std::os::unix::fs::{MetadataExt, PermissionsExt};
1505
1506    fn write_toml(s: &str) -> NamedTempFile {
1507        let mut f = NamedTempFile::new().unwrap();
1508        f.write_all(s.as_bytes()).unwrap();
1509        f.flush().unwrap();
1510        f
1511    }
1512
1513    /// The back-compat guarantee #134 asks for: a config with no
1514    /// `[[openai.accounts]]` resolves exactly what it resolved before, whether
1515    /// it sets `codex_auth_path` or leaves it to the default.
1516    #[test]
1517    fn openai_without_accounts_resolves_the_singular_path() {
1518        let explicit = OpenAiConfig {
1519            codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
1520            ..OpenAiConfig::default()
1521        };
1522        assert_eq!(
1523            explicit.resolve_auth_path(None).unwrap(),
1524            PathBuf::from("/tmp/codex/auth.json")
1525        );
1526
1527        let bare = OpenAiConfig::default();
1528        assert_eq!(
1529            bare.resolve_auth_path(None).unwrap(),
1530            crate::openai::creds::default_path().unwrap(),
1531            "no codex_auth_path must still mean ~/.codex/auth.json"
1532        );
1533    }
1534
1535    /// Each named account resolves its own file, and the default login is still
1536    /// reachable alongside them.
1537    #[test]
1538    fn openai_named_accounts_resolve_their_own_auth_file() {
1539        let config: Config = toml::from_str(
1540            r#"
1541            [openai]
1542            codex_auth_path = "/tmp/personal/auth.json"
1543            [[openai.accounts]]
1544            label = "work"
1545            codex_auth_path = "/tmp/work/auth.json"
1546            "#,
1547        )
1548        .unwrap();
1549
1550        assert_eq!(
1551            config.openai.resolve_auth_path(Some("work")).unwrap(),
1552            PathBuf::from("/tmp/work/auth.json")
1553        );
1554        assert_eq!(
1555            config.openai.resolve_auth_path(None).unwrap(),
1556            PathBuf::from("/tmp/personal/auth.json")
1557        );
1558    }
1559
1560    /// An unknown label must fail rather than quietly fall back to the default
1561    /// login — reporting the wrong subscription's usage is worse than an error.
1562    #[test]
1563    fn an_unknown_openai_account_is_an_error_not_a_fallback() {
1564        let config = OpenAiConfig {
1565            codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
1566            accounts: vec![OpenAiAccount {
1567                label: "work".into(),
1568                codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
1569            }],
1570            ..OpenAiConfig::default()
1571        };
1572        let err = config
1573            .resolve_auth_path(Some("nope"))
1574            .unwrap_err()
1575            .to_string();
1576        assert!(err.contains("nope"), "{err}");
1577        assert!(err.contains("[[openai.accounts]]"), "{err}");
1578    }
1579
1580    #[test]
1581    fn defaults_enable_only_the_four_core_vendors() {
1582        let c = Config::default();
1583        assert!(c.is_enabled(VendorId::Anthropic));
1584        assert!(c.is_enabled(VendorId::Openai));
1585        assert!(c.is_enabled(VendorId::Zai));
1586        assert!(c.is_enabled(VendorId::Openrouter));
1587        for opt_in in [
1588            VendorId::AnthropicApi,
1589            VendorId::Copilot,
1590            VendorId::Deepseek,
1591            VendorId::Kimi,
1592            VendorId::Kilo,
1593            VendorId::Novita,
1594            VendorId::Moonshot,
1595            VendorId::Grok,
1596            VendorId::Supergrok,
1597            VendorId::Cursor,
1598            VendorId::Minimax,
1599            VendorId::Kiro,
1600        ] {
1601            assert!(!c.is_enabled(opt_in), "{opt_in:?}");
1602        }
1603        assert_eq!(c.enabled_vendors().len(), 4);
1604    }
1605
1606    #[test]
1607    fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
1608        let config = Config::default();
1609        assert!(!config.is_enabled(VendorId::NousResearch));
1610        assert!(!config.is_enabled(VendorId::OpenCodeGo));
1611        assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
1612        assert!(config.opencode_go.api_key.is_none());
1613        assert!(!config.is_enabled(VendorId::Copilot));
1614    }
1615
1616    #[cfg(unix)]
1617    #[test]
1618    fn inline_credentials_are_protected() {
1619        let mut config = Config::default();
1620        config.opencode_go.api_key = Some("<redacted>".to_string());
1621        assert!(config.has_inline_secrets());
1622    }
1623
1624    #[cfg(unix)]
1625    #[test]
1626    fn openrouter_named_inline_keys_receive_config_file_protection() {
1627        let mut config = Config::default();
1628        config.openrouter.accounts.push(OpenRouterAccount {
1629            label: "work".into(),
1630            api_key_env: None,
1631            api_key: Some("<redacted>".into()),
1632        });
1633        assert!(config.has_inline_secrets());
1634    }
1635
1636    #[test]
1637    fn missing_file_uses_defaults() {
1638        let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
1639        let c = Config::load_from(path).unwrap();
1640        assert!(c.is_enabled(VendorId::Anthropic));
1641    }
1642
1643    #[test]
1644    fn parses_full_config() {
1645        let f = write_toml(
1646            r#"
1647            [anthropic]
1648            enabled = true
1649
1650            [openai]
1651            enabled = false
1652            admin_key_env = "MY_ADMIN_KEY"
1653
1654            [zai]
1655            enabled = true
1656            api_key_env = "MY_ZAI"
1657            plan_tier = "pro"
1658
1659            [openrouter]
1660            enabled = false
1661            "#,
1662        );
1663        let c = Config::load_from(f.path()).unwrap();
1664        assert!(c.is_enabled(VendorId::Anthropic));
1665        assert!(!c.is_enabled(VendorId::Openai));
1666        assert!(c.is_enabled(VendorId::Zai));
1667        assert!(!c.is_enabled(VendorId::Openrouter));
1668        assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
1669        assert_eq!(c.zai.api_key_env, "MY_ZAI");
1670        assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
1671        assert!(c.openrouter.accounts.is_empty());
1672        assert!(c.openrouter.show_default_account);
1673    }
1674
1675    #[test]
1676    fn partial_config_falls_back_to_defaults() {
1677        let f = write_toml(
1678            r#"[openai]
1679enabled = false
1680"#,
1681        );
1682        let c = Config::load_from(f.path()).unwrap();
1683        assert!(!c.is_enabled(VendorId::Openai));
1684        // Other vendors keep their defaults.
1685        assert!(c.is_enabled(VendorId::Anthropic));
1686        assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
1687    }
1688
1689    #[test]
1690    fn malformed_toml_returns_error() {
1691        let f = write_toml("this is not = = valid");
1692        assert!(Config::load_from(f.path()).is_err());
1693    }
1694
1695    #[cfg(unix)]
1696    #[test]
1697    fn load_from_tightens_world_readable_config_with_inline_api_key() {
1698        let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
1699        std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1700
1701        Config::load_from(file.path()).unwrap();
1702
1703        assert_eq!(
1704            std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1705            0o600
1706        );
1707    }
1708
1709    #[cfg(unix)]
1710    #[test]
1711    fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
1712        let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
1713        std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1714
1715        Config::load_from(file.path()).unwrap();
1716
1717        assert_eq!(
1718            std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1719            0o644
1720        );
1721    }
1722
1723    #[cfg(unix)]
1724    #[test]
1725    fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
1726        assert_eq!(
1727            inline_key_permission_decision(0o600),
1728            InlineKeyPermissionDecision::Ok
1729        );
1730        assert_eq!(
1731            inline_key_permission_decision(0o640),
1732            InlineKeyPermissionDecision::Tighten
1733        );
1734        assert_eq!(
1735            inline_key_permission_decision(0o604),
1736            InlineKeyPermissionDecision::Tighten
1737        );
1738    }
1739
1740    #[test]
1741    fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
1742        for value in ["0", "-1", "inf", "nan"] {
1743            let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
1744            let error = Config::load_from(file.path()).unwrap_err().to_string();
1745            assert!(error.contains("monthly_limit"), "value {value}: {error}");
1746        }
1747
1748        let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
1749        assert_eq!(
1750            Config::load_from(file.path())
1751                .unwrap()
1752                .anthropic_api
1753                .monthly_limit,
1754            Some(1000.0)
1755        );
1756    }
1757
1758    #[test]
1759    fn minimax_region_accepts_only_known_instances() {
1760        for region in ["global", "GLOBAL", "cn", "CN"] {
1761            let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1762            assert_eq!(
1763                Config::load_from(file.path()).unwrap().minimax.region,
1764                region
1765            );
1766        }
1767
1768        for region in ["", "china", "us"] {
1769            let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1770            let error = Config::load_from(file.path()).unwrap_err().to_string();
1771            assert!(error.contains("[minimax] region"), "{error}");
1772        }
1773    }
1774
1775    #[test]
1776    fn kimi_region_accepts_auto_and_both_deployments() {
1777        for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
1778            let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
1779            assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
1780        }
1781
1782        for region in ["", "us", "oversea"] {
1783            let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
1784            let error = Config::load_from(file.path()).unwrap_err().to_string();
1785            assert!(error.contains("[kimi] region"), "{error}");
1786        }
1787    }
1788
1789    #[test]
1790    fn kimi_defaults_to_auto_region_and_no_credential_override() {
1791        let defaults = KimiConfig::default();
1792        assert_eq!(defaults.region, "auto");
1793        assert_eq!(defaults.credentials_path, None);
1794        assert!(!defaults.enabled);
1795    }
1796
1797    #[test]
1798    fn kimi_credentials_path_expands_a_tilde() {
1799        let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
1800        let path = Config::load_from(file.path())
1801            .unwrap()
1802            .kimi
1803            .credentials_path
1804            .unwrap();
1805        assert!(!path.starts_with("~"), "{}", path.display());
1806        assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
1807    }
1808
1809    #[test]
1810    fn optional_api_key_reports_absence_instead_of_failing() {
1811        assert_eq!(
1812            optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", Some("inline")),
1813            Some("inline".to_string())
1814        );
1815        assert_eq!(
1816            optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", None),
1817            None
1818        );
1819        assert_eq!(optional_api_key("KIMI_API_KEY_UNSET", Some("")), None);
1820        // An unusable `api_key_env` still lets an inline key through, exactly
1821        // as `resolve_api_key` does.
1822        assert_eq!(
1823            optional_api_key("9INVALID", Some("inline")),
1824            Some("inline".to_string())
1825        );
1826    }
1827
1828    #[test]
1829    fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
1830        let defaults = Config::default();
1831        assert!(!defaults.context.enabled);
1832        assert_eq!(
1833            defaults.context.window_tokens_for(Some("claude-test")),
1834            None
1835        );
1836
1837        let file = write_toml(
1838            r#"
1839            [context]
1840            enabled = true
1841            context_window_tokens = 200000
1842
1843            [context.model_context_window_tokens]
1844            claude-opus-1m = 1000000
1845            "claude exact id" = 300000
1846            "#,
1847        );
1848        let config = Config::load_from(file.path()).unwrap();
1849        assert!(config.context.enabled);
1850        assert_eq!(
1851            config.context.window_tokens_for(Some("claude-opus-1m")),
1852            Some(1_000_000)
1853        );
1854        assert_eq!(
1855            config.context.window_tokens_for(Some("claude exact id")),
1856            Some(300_000)
1857        );
1858        assert_eq!(
1859            config.context.window_tokens_for(Some("another-model")),
1860            Some(200_000)
1861        );
1862    }
1863
1864    #[test]
1865    fn context_layout_defaults_to_full_and_parses_each_variant() {
1866        assert_eq!(Config::default().context.layout, ContextLayout::Full);
1867        for (text, want) in [
1868            ("full", ContextLayout::Full),
1869            ("split", ContextLayout::Split),
1870            ("bottom", ContextLayout::Bottom),
1871        ] {
1872            let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
1873            assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
1874        }
1875        let file = write_toml("[context]\nlayout = \"floating\"\n");
1876        assert!(
1877            Config::load_from(file.path()).is_err(),
1878            "an unknown layout must be rejected, not silently defaulted"
1879        );
1880    }
1881
1882    #[test]
1883    fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
1884        assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
1885        for (text, want) in [
1886            ("sidebar", VendorBoxStyle::Sidebar),
1887            ("navbar", VendorBoxStyle::Navbar),
1888            ("none", VendorBoxStyle::None),
1889        ] {
1890            let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
1891            assert_eq!(
1892                Config::load_from(file.path()).unwrap().ui.vendor_box(),
1893                want
1894            );
1895        }
1896        let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
1897        assert!(
1898            Config::load_from(file.path()).is_err(),
1899            "an unknown vendor_box style must be rejected, not silently defaulted"
1900        );
1901    }
1902
1903    #[test]
1904    fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
1905        for source in [
1906            "[context]\ncontext_window_tokens = 0\n",
1907            "[context.model_context_window_tokens]\nclaude = 0\n",
1908            "[context.model_context_window_tokens]\n\" \" = 200000\n",
1909        ] {
1910            let file = write_toml(source);
1911            let error = Config::load_from(file.path()).unwrap_err().to_string();
1912            assert!(error.contains("context"), "{error}");
1913        }
1914    }
1915
1916    // serial guard for env-var manipulation tests so they don't race
1917    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
1918        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
1919        M.lock().unwrap_or_else(|p| p.into_inner())
1920    }
1921
1922    #[test]
1923    fn resolve_api_key_prefers_env_over_inline() {
1924        let _g = env_guard();
1925        // Use a unique env var name so we don't clobber test parallelism.
1926        let var = "AI_USAGEBAR_TEST_ENV_WINS";
1927        // SAFETY: tests are single-threaded under env_guard.
1928        unsafe { std::env::set_var(var, "from-env") };
1929        let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
1930        unsafe { std::env::remove_var(var) };
1931        assert_eq!(got, "from-env");
1932    }
1933
1934    #[test]
1935    fn resolve_api_key_falls_back_to_inline() {
1936        let _g = env_guard();
1937        let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
1938        unsafe { std::env::remove_var(var) };
1939        let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
1940        assert_eq!(got, "inline-key");
1941    }
1942
1943    #[test]
1944    fn copilot_token_prefers_explicit_environment_over_gh_cli() {
1945        struct NeverRun;
1946        impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
1947            fn run(
1948                &self,
1949                _: &crate::copilot::credentials::GhAuthTokenCommand,
1950            ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
1951                panic!("environment override must not invoke gh")
1952            }
1953        }
1954
1955        let token = CopilotConfig::default()
1956            .resolve_token_with(
1957                |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
1958                &NeverRun,
1959            )
1960            .unwrap();
1961        assert_eq!(token, "from-environment");
1962    }
1963
1964    #[test]
1965    fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
1966        struct FailedGh;
1967        impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
1968            fn run(
1969                &self,
1970                _: &crate::copilot::credentials::GhAuthTokenCommand,
1971            ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
1972                Ok(crate::copilot::credentials::GhAuthTokenOutput {
1973                    success: false,
1974                    stdout: b"never-echo-gh-output".to_vec(),
1975                })
1976            }
1977        }
1978        let error = CopilotConfig::default()
1979            .resolve_token_with(|_| None, &FailedGh)
1980            .unwrap_err()
1981            .to_string();
1982        assert!(error.contains("gh auth login --web"));
1983        assert!(!error.contains("never-echo-gh-output"));
1984    }
1985
1986    #[test]
1987    fn resolve_api_key_errors_when_both_missing() {
1988        let _g = env_guard();
1989        let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
1990        unsafe { std::env::remove_var(var) };
1991        let err = resolve_api_key("Zai", var, None).unwrap_err();
1992        match err {
1993            crate::error::AppError::Credentials(msg) => {
1994                assert!(
1995                    msg.contains("api_key"),
1996                    "error should suggest config field: {msg}"
1997                );
1998            }
1999            other => panic!("expected Credentials error, got {other:?}"),
2000        }
2001    }
2002
2003    #[test]
2004    fn resolve_api_key_uses_exact_opencode_go_section_name() {
2005        let _g = env_guard();
2006        unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
2007        let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
2008        let message = err.to_string();
2009        assert!(
2010            message.contains("[opencode-go]"),
2011            "wrong section hint: {message}"
2012        );
2013        assert!(
2014            !message.contains("[opencode go]"),
2015            "wrong section hint: {message}"
2016        );
2017    }
2018
2019    fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
2020        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
2021        M.lock().unwrap_or_else(|p| p.into_inner())
2022    }
2023
2024    /// Serializes the override tests *and* guarantees the process-wide
2025    /// override is dropped when the test ends — including via a panic, which
2026    /// a bare set/clear pair does not survive. A leaked override makes every
2027    /// later test in this process resolve a deleted temp file, turning one
2028    /// failure into a cascade of confusing sibling failures.
2029    struct ScopedPathOverride {
2030        _serial: std::sync::MutexGuard<'static, ()>,
2031    }
2032
2033    impl Drop for ScopedPathOverride {
2034        fn drop(&mut self) {
2035            clear_override_path();
2036        }
2037    }
2038
2039    fn scoped_path_override() -> ScopedPathOverride {
2040        ScopedPathOverride {
2041            _serial: path_override_guard(),
2042        }
2043    }
2044
2045    #[test]
2046    fn override_path_wins_over_canonical_and_legacy() {
2047        let _scoped = scoped_path_override();
2048        let file = NamedTempFile::new().unwrap();
2049        set_override_path(file.path());
2050        assert_eq!(resolved_path().as_deref(), Some(file.path()));
2051        assert_eq!(config_path_hint(), file.path().display().to_string());
2052        clear_override_path();
2053        // The usual locations decide again once the override is gone.
2054        let p = resolved_path().expect("a config path must resolve");
2055        assert!(p.ends_with("config.toml"));
2056    }
2057
2058    #[test]
2059    fn scoped_override_guard_clears_the_override_on_panic() {
2060        // Silence the simulated failure's hook output; the assertion below is
2061        // the real report.
2062        let hook = std::panic::take_hook();
2063        std::panic::set_hook(Box::new(|_| {}));
2064        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2065            let _scoped = scoped_path_override();
2066            set_override_path(std::path::Path::new("panicked-override.toml"));
2067            panic!("simulated mid-test failure");
2068        }))
2069        .is_err();
2070        std::panic::set_hook(hook);
2071        assert!(panicked, "the simulated failure must run");
2072        let _serial = path_override_guard();
2073        assert!(
2074            override_path().is_none(),
2075            "a panicking test must not leak the override into siblings"
2076        );
2077    }
2078
2079    #[test]
2080    fn config_path_hint_ends_with_config_toml() {
2081        let _g = path_override_guard();
2082        // Platform-resolved (Linux/macOS/Windows), but always ends in the
2083        // config filename — the trailing segment is what messages rely on.
2084        assert!(config_path_hint().ends_with("config.toml"));
2085    }
2086
2087    #[test]
2088    fn config_flag_value_splits_the_equals_form() {
2089        use std::ffi::OsStr;
2090        assert_eq!(
2091            config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
2092            Some(std::path::Path::new("work.toml"))
2093        );
2094        assert_eq!(
2095            config_flag_value(OsStr::new("--config=")).as_deref(),
2096            Some(std::path::Path::new(""))
2097        );
2098        assert_eq!(config_flag_value(OsStr::new("--config")), None);
2099        assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
2100        assert_eq!(config_flag_value(OsStr::new("account")), None);
2101    }
2102
2103    /// The `--config=PATH` form must preserve a path the platform can store
2104    /// but UTF-8 cannot represent — `to_string_lossy` would replace the bad
2105    /// bytes with U+FFFD and produce a false "config file not found".
2106    #[cfg(unix)]
2107    #[test]
2108    fn config_flag_value_keeps_undecodable_bytes_intact() {
2109        use std::ffi::OsString;
2110        use std::os::unix::ffi::{OsStrExt, OsStringExt};
2111        let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
2112        let value = config_flag_value(&raw).expect("prefix matches");
2113        assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
2114    }
2115
2116    #[cfg(windows)]
2117    #[test]
2118    fn config_flag_value_keeps_lone_surrogates_intact() {
2119        use std::ffi::OsString;
2120        use std::os::windows::ffi::{OsStrExt, OsStringExt};
2121        let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
2122        wide.push(0xDC00); // lone low surrogate: not valid Unicode
2123        wide.extend("x.toml".encode_utf16());
2124        let raw = OsString::from_wide(&wide);
2125        let value = config_flag_value(&raw).expect("prefix matches");
2126        let mut expected = vec![0xDC00u16];
2127        expected.extend("x.toml".encode_utf16());
2128        assert_eq!(
2129            value.as_os_str().encode_wide().collect::<Vec<_>>(),
2130            expected
2131        );
2132    }
2133
2134    #[test]
2135    fn resolve_api_key_treats_empty_env_as_unset() {
2136        let _g = env_guard();
2137        let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
2138        unsafe { std::env::set_var(var, "") };
2139        let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
2140        unsafe { std::env::remove_var(var) };
2141        assert_eq!(got, "inline");
2142    }
2143
2144    #[test]
2145    fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
2146        let _g = env_guard();
2147        // Simulates a user accidentally pasting the key into api_key_env.
2148        let bad = "sk-kimi-very-real-looking-pasted-secret";
2149        let err = resolve_api_key("Kimi", bad, None).unwrap_err();
2150        let msg = err.to_string();
2151        assert!(
2152            msg.contains("invalid") && msg.contains("api_key_env"),
2153            "error should explain misconfiguration: {msg}"
2154        );
2155        assert!(
2156            !msg.contains(bad),
2157            "error must not echo the misconfigured value: {msg}"
2158        );
2159        assert!(msg.contains("valid environment variable name"));
2160        assert!(
2161            msg.contains("[kimi]"),
2162            "error should point at the lowercase TOML section: {msg}"
2163        );
2164    }
2165
2166    #[test]
2167    fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
2168        let _g = env_guard();
2169        let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
2170        assert_eq!(got, "inline-key");
2171    }
2172
2173    #[test]
2174    fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
2175        let _g = env_guard();
2176        // This is syntactically a valid environment variable name, but could
2177        // be a pasted secret and must not be reflected in the error.
2178        let pasted_secret = "sk_pasted_secret";
2179        unsafe { std::env::remove_var(pasted_secret) };
2180        let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
2181        assert!(
2182            !err.to_string().contains(pasted_secret),
2183            "error must not echo configured api_key_env values"
2184        );
2185    }
2186
2187    #[test]
2188    fn is_valid_env_var_name_rules() {
2189        // Valid: alphabetic or underscore first, then alnum/underscore.
2190        for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
2191            assert!(is_valid_env_var_name(valid), "{valid} should be valid");
2192        }
2193        // Invalid: empty, digit-first, or shell-illegal characters.
2194        for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
2195            assert!(
2196                !is_valid_env_var_name(invalid),
2197                "{invalid} should be invalid"
2198            );
2199        }
2200    }
2201
2202    #[test]
2203    fn config_parses_with_inline_api_key_and_primary() {
2204        let f = write_toml(
2205            r#"
2206            [ui]
2207            primary = "openrouter"
2208
2209            [zai]
2210            enabled = true
2211            api_key_env = "MY_ZAI"
2212            api_key = "sk-zai-inline"
2213
2214            [openrouter]
2215            enabled = true
2216            api_key = "sk-or-inline"
2217            "#,
2218        );
2219        let c = Config::load_from(f.path()).unwrap();
2220        assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
2221        assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
2222        assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
2223    }
2224
2225    #[test]
2226    fn openrouter_named_accounts_preserve_the_default_contract() {
2227        let f = write_toml(
2228            r#"
2229            [openrouter]
2230            enabled = true
2231            api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
2232            api_key = "default-inline"
2233            show_default_account = false
2234
2235            [[openrouter.accounts]]
2236            label = "work"
2237            api_key_env = "OPENROUTER_WORK_API_KEY"
2238
2239            [[openrouter.accounts]]
2240            label = "personal"
2241            api_key = "personal-inline"
2242            "#,
2243        );
2244        let _g = env_guard();
2245        unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
2246        let config = Config::load_from(f.path()).unwrap();
2247        assert!(!config.openrouter.show_default_account);
2248        assert_eq!(config.openrouter.accounts.len(), 2);
2249        assert_eq!(
2250            config.openrouter.resolve_api_key(None).unwrap(),
2251            "default-inline"
2252        );
2253        assert_eq!(
2254            config.openrouter.resolve_api_key(Some("personal")).unwrap(),
2255            "personal-inline"
2256        );
2257    }
2258
2259    #[test]
2260    fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
2261        for source in [
2262            r#"
2263            [[openrouter.accounts]]
2264            label = "work"
2265            api_key = "one"
2266            [[openrouter.accounts]]
2267            label = "work"
2268            api_key = "two"
2269            "#,
2270            r#"
2271            [[openrouter.accounts]]
2272            label = "../work"
2273            api_key = "one"
2274            "#,
2275            r#"
2276            [[openrouter.accounts]]
2277            label = "work"
2278            "#,
2279        ] {
2280            let f = write_toml(source);
2281            assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
2282        }
2283    }
2284
2285    #[test]
2286    fn openrouter_unknown_account_never_falls_back_to_default_key() {
2287        let mut config = OpenRouterConfig {
2288            api_key: Some("default-secret".into()),
2289            ..OpenRouterConfig::default()
2290        };
2291        config.accounts.push(OpenRouterAccount {
2292            label: "work".into(),
2293            api_key_env: None,
2294            api_key: Some("work-secret".into()),
2295        });
2296        let message = config
2297            .resolve_api_key(Some("missing"))
2298            .unwrap_err()
2299            .to_string();
2300        assert!(message.contains("missing") && message.contains("work"));
2301        assert!(!message.contains("default-secret"));
2302        assert!(!message.contains("work-secret"));
2303    }
2304
2305    #[test]
2306    fn openrouter_account_key_errors_do_not_echo_configured_values() {
2307        let config = OpenRouterConfig {
2308            accounts: vec![OpenRouterAccount {
2309                label: "work".into(),
2310                api_key_env: Some("sk_pasted_secret".into()),
2311                api_key: None,
2312            }],
2313            ..OpenRouterConfig::default()
2314        };
2315        let _g = env_guard();
2316        unsafe { std::env::remove_var("sk_pasted_secret") };
2317        let message = config
2318            .resolve_api_key(Some("work"))
2319            .unwrap_err()
2320            .to_string();
2321        assert!(message.contains("[[openrouter.accounts]]"));
2322        assert!(!message.contains("sk_pasted_secret"));
2323    }
2324
2325    #[test]
2326    fn enabled_vendors_preserves_canonical_order() {
2327        // DeepSeek and Kimi are disabled by default (require explicit API key
2328        // config), so they are absent from the enabled list unless enabled.
2329        let c = Config::default();
2330        assert_eq!(
2331            c.enabled_vendors(),
2332            vec![
2333                VendorId::Anthropic,
2334                VendorId::Openai,
2335                VendorId::Zai,
2336                VendorId::Openrouter,
2337            ]
2338        );
2339    }
2340
2341    #[test]
2342    fn deepseek_appears_when_enabled() {
2343        let f = write_toml(
2344            r#"
2345            [deepseek]
2346            enabled = true
2347            api_key = "sk-test"
2348            "#,
2349        );
2350        let c = Config::load_from(f.path()).unwrap();
2351        assert!(c.is_enabled(VendorId::Deepseek));
2352        assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
2353        assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
2354    }
2355
2356    #[test]
2357    fn tilde_paths_are_expanded_on_load() {
2358        // `PathBuf` keeps `~` literally, so the documented
2359        // `credentials_path = "~/..."` used to resolve to a directory named
2360        // `~` relative to the process's cwd.
2361        let f = write_toml(
2362            r#"
2363            [context]
2364            projects_path = "~/.claude/projects"
2365
2366            [anthropic]
2367            credentials_path = "~/.claude/.credentials.json"
2368
2369            [[anthropic.accounts]]
2370            label = "work"
2371            credentials_path = "~/work.json"
2372            "#,
2373        );
2374        let c = Config::load_from(f.path()).unwrap();
2375        let home = crate::cache::home_dir().unwrap();
2376
2377        assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
2378        let got = c.anthropic.credentials_path.unwrap();
2379        assert_eq!(got, home.join(".claude/.credentials.json"));
2380        assert!(!got.to_string_lossy().contains('~'));
2381        assert_eq!(
2382            c.anthropic.accounts[0].credentials_path,
2383            home.join("work.json")
2384        );
2385    }
2386
2387    #[test]
2388    fn absolute_and_relative_paths_are_left_alone() {
2389        let f = write_toml(
2390            r#"
2391            [anthropic]
2392            credentials_path = "/etc/creds.json"
2393            "#,
2394        );
2395        let c = Config::load_from(f.path()).unwrap();
2396        assert_eq!(
2397            c.anthropic.credentials_path.unwrap(),
2398            std::path::Path::new("/etc/creds.json")
2399        );
2400
2401        // `~user` is not ours to interpret.
2402        let f2 = write_toml(
2403            r#"
2404            [anthropic]
2405            credentials_path = "~someone/creds.json"
2406            "#,
2407        );
2408        let c2 = Config::load_from(f2.path()).unwrap();
2409        assert_eq!(
2410            c2.anthropic.credentials_path.unwrap(),
2411            std::path::Path::new("~someone/creds.json")
2412        );
2413    }
2414
2415    #[test]
2416    fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
2417        let _g = path_override_guard();
2418        // Hermetic: only asserts the shape, never which file happens to exist
2419        // on the machine running the tests.
2420        let p = resolved_path().expect("a config path must resolve");
2421        assert!(p.ends_with("config.toml"));
2422        let canonical = default_path().unwrap();
2423        let legacy = legacy_xdg_path().unwrap();
2424        assert!(
2425            p == canonical || p == legacy,
2426            "resolved to an unexpected location: {}",
2427            p.display()
2428        );
2429    }
2430
2431    #[test]
2432    fn misspelled_section_is_rejected_not_ignored() {
2433        // The regression this guards: `[openrouer]` used to parse fine, leave
2434        // OpenRouter on its defaults, and give the user no hint at all.
2435        let f = write_toml(
2436            r#"
2437            [openrouer]
2438            enabled = true
2439            api_key = "sk-or-v1-typo"
2440            "#,
2441        );
2442        let err = Config::load_from(f.path()).unwrap_err().to_string();
2443        assert!(
2444            err.contains("openrouer"),
2445            "error should name the typo: {err}"
2446        );
2447    }
2448
2449    #[test]
2450    fn invalid_toml_is_an_error_not_silent_defaults() {
2451        let f = write_toml("[zai\nenabled = true\n");
2452        assert!(Config::load_from(f.path()).is_err());
2453    }
2454
2455    #[test]
2456    fn a_missing_file_is_still_just_defaults() {
2457        // Absence stays the legitimate "use defaults" case — only real parse
2458        // and I/O failures are errors.
2459        let dir = tempfile::tempdir().unwrap();
2460        let missing = dir.path().join("nope").join("config.toml");
2461        let c = Config::load_from(&missing).unwrap();
2462        assert!(c.is_enabled(VendorId::Anthropic));
2463    }
2464
2465    #[test]
2466    fn kimi_appears_when_enabled() {
2467        let f = write_toml(
2468            r#"
2469            [kimi]
2470            enabled = true
2471            api_key = "sk-test"
2472            "#,
2473        );
2474        let c = Config::load_from(f.path()).unwrap();
2475        assert!(c.is_enabled(VendorId::Kimi));
2476        assert!(c.enabled_vendors().contains(&VendorId::Kimi));
2477        assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
2478    }
2479
2480    #[test]
2481    fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
2482        let f = write_toml(
2483            r#"
2484            [deepseek]
2485            enabled = true
2486            api_key = "sk-ds"
2487
2488            [kimi]
2489            enabled = true
2490            api_key = "sk-kimi"
2491            "#,
2492        );
2493        let c = Config::load_from(f.path()).unwrap();
2494        assert_eq!(
2495            c.enabled_vendors(),
2496            vec![
2497                VendorId::Anthropic,
2498                VendorId::Openai,
2499                VendorId::Zai,
2500                VendorId::Openrouter,
2501                VendorId::Deepseek,
2502                VendorId::Kimi,
2503            ]
2504        );
2505    }
2506
2507    #[test]
2508    fn parses_anthropic_accounts_and_looks_them_up() {
2509        let f = write_toml(
2510            r#"
2511            [anthropic]
2512            enabled = true
2513
2514            [[anthropic.accounts]]
2515            label = "personal"
2516            credentials_path = "/creds/personal.json"
2517
2518            [[anthropic.accounts]]
2519            label = "work"
2520            credentials_path = "/creds/work.json"
2521            "#,
2522        );
2523        let c = Config::load_from(f.path()).unwrap();
2524        assert_eq!(c.anthropic.accounts.len(), 2);
2525        let work = c.anthropic.account("work").unwrap();
2526        assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
2527        // A typo names the offending label and lists the known ones.
2528        let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
2529        assert!(err.contains("missing") && err.contains("work"), "{err}");
2530    }
2531
2532    #[test]
2533    fn duplicate_anthropic_account_labels_are_rejected_on_load() {
2534        let f = write_toml(
2535            r#"
2536            [[anthropic.accounts]]
2537            label = "work"
2538            credentials_path = "/creds/work-one.json"
2539
2540            [[anthropic.accounts]]
2541            label = "work"
2542            credentials_path = "/creds/work-two.json"
2543            "#,
2544        );
2545        let err = Config::load_from(f.path()).unwrap_err().to_string();
2546        assert!(
2547            err.contains("duplicate anthropic account label \"work\""),
2548            "{err}"
2549        );
2550    }
2551
2552    #[test]
2553    fn account_label_rejects_path_like_names() {
2554        let cfg = AnthropicConfig::default();
2555        for bad in [
2556            "",
2557            ".",
2558            "..",
2559            "a/b",
2560            r"a\b",
2561            "C:work",
2562            "line\nbreak",
2563            "tab\tname",
2564            "usage.json",
2565            ".stale",
2566            ".last_error",
2567            ".fetch.lock",
2568        ] {
2569            let err = cfg.account(bad).unwrap_err();
2570            assert!(
2571                format!("{err:?}").contains("invalid anthropic account label"),
2572                "{bad:?} should be rejected as a label"
2573            );
2574        }
2575    }
2576
2577    #[test]
2578    fn anthropic_accounts_default_to_empty() {
2579        // No [[anthropic.accounts]] → the single default account, empty list,
2580        // nothing to migrate (issue #14, back-compat rule 1).
2581        assert!(Config::default().anthropic.accounts.is_empty());
2582        assert!(Config::default().anthropic.accounts_dir.is_none());
2583    }
2584
2585    // --- accounts_dir: CLAUDE_CONFIG_DIR-style auto-discovery ----------------
2586    // All hermetic: discovery reads a TempDir, never the user's real config.
2587
2588    /// Create `<root>/<label>/.credentials.json` (contents irrelevant here —
2589    /// discovery keys on the file existing, the fetch path parses it).
2590    fn seed_account_dir(root: &std::path::Path, label: &str) {
2591        let dir = root.join(label);
2592        std::fs::create_dir_all(&dir).unwrap();
2593        std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
2594    }
2595
2596    #[test]
2597    fn discovers_account_dirs_in_claude_config_dir_layout() {
2598        let td = tempfile::tempdir().unwrap();
2599        seed_account_dir(td.path(), "work");
2600        seed_account_dir(td.path(), "personal");
2601        // Keychain-backed macOS logins may not write .credentials.json; their
2602        // config directories are still account entries.
2603        std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
2604        // A loose file (not a dir) is ignored.
2605        std::fs::write(td.path().join("stray.json"), "{}").unwrap();
2606
2607        let cfg = AnthropicConfig {
2608            accounts_dir: Some(td.path().to_path_buf()),
2609            ..Default::default()
2610        };
2611        let all = cfg.all_accounts();
2612        let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
2613        assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
2614        assert_eq!(
2615            all[2].credentials_path,
2616            td.path().join("work").join(".credentials.json")
2617        );
2618    }
2619
2620    #[test]
2621    fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
2622        let td = tempfile::tempdir().unwrap();
2623        seed_account_dir(td.path(), "work");
2624        let cfg = AnthropicConfig {
2625            accounts: vec![AnthropicAccount {
2626                label: "work".into(),
2627                credentials_path: "/explicit/work.json".into(),
2628            }],
2629            accounts_dir: Some(td.path().to_path_buf()),
2630            ..Default::default()
2631        };
2632        let all = cfg.all_accounts();
2633        assert_eq!(all.len(), 1, "no duplicate label");
2634        assert_eq!(
2635            all[0].credentials_path,
2636            std::path::Path::new("/explicit/work.json"),
2637            "explicit entry wins"
2638        );
2639        // A discovered account is still reachable through `account()`.
2640        seed_account_dir(td.path(), "other");
2641        assert_eq!(cfg.account("other").unwrap().label, "other");
2642    }
2643
2644    #[test]
2645    fn missing_accounts_dir_is_silently_empty_not_an_error() {
2646        let cfg = AnthropicConfig {
2647            accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
2648            ..Default::default()
2649        };
2650        assert!(cfg.all_accounts().is_empty());
2651    }
2652
2653    #[test]
2654    fn openai_account_auth_paths_are_tilde_expanded_on_load() {
2655        let f = write_toml(
2656            r#"
2657            [[openai.accounts]]
2658            label = "work"
2659            codex_auth_path = "~/.codex-work/auth.json"
2660            "#,
2661        );
2662        let c = Config::load_from(f.path()).unwrap();
2663        let home = crate::cache::home_dir().unwrap();
2664        assert_eq!(
2665            c.openai.accounts[0].codex_auth_path,
2666            home.join(".codex-work/auth.json")
2667        );
2668    }
2669
2670    #[test]
2671    fn accounts_dir_is_tilde_expanded_on_load() {
2672        let f = write_toml(
2673            r#"
2674            [anthropic]
2675            accounts_dir = "~/.config/ai-usagebar/accounts"
2676            "#,
2677        );
2678        let c = Config::load_from(f.path()).unwrap();
2679        let home = crate::cache::home_dir().unwrap();
2680        assert_eq!(
2681            c.anthropic.accounts_dir,
2682            Some(home.join(".config/ai-usagebar/accounts"))
2683        );
2684    }
2685
2686    #[test]
2687    fn desktop_profiles_dir_is_tilde_expanded_on_load() {
2688        let f = write_toml(
2689            r#"
2690            [anthropic]
2691            desktop_profiles_dir = "~/.claude-acc/profiles"
2692            "#,
2693        );
2694        let c = Config::load_from(f.path()).unwrap();
2695        let home = crate::cache::home_dir().unwrap();
2696        assert_eq!(
2697            c.anthropic.desktop_profiles_dir,
2698            Some(home.join(".claude-acc/profiles"))
2699        );
2700    }
2701
2702    #[test]
2703    fn the_live_cli_account_is_read_from_the_default_credential_slot() {
2704        let cfg = AnthropicConfig {
2705            accounts: vec![
2706                AnthropicAccount {
2707                    label: "work".into(),
2708                    credentials_path: "/tmp/accounts/work/.credentials.json".into(),
2709                },
2710                AnthropicAccount {
2711                    label: "personal".into(),
2712                    credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
2713                },
2714            ],
2715            ..Default::default()
2716        };
2717
2718        let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
2719        assert!(
2720            matches!(&idle, CredsTarget::Named { config_dir, .. }
2721                if config_dir == std::path::Path::new("/tmp/accounts/work")),
2722            "{idle:?}"
2723        );
2724
2725        // Same label, but it is the login `claude` itself is using: one lineage.
2726        let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
2727        assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
2728
2729        // The cache must not move, or a switch would silently orphan the tab's
2730        // usage history and show "Loading…" until the next fetch.
2731        assert_eq!(idle_cache.dir(), live_cache.dir());
2732    }
2733
2734    #[test]
2735    fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
2736        let cfg = AnthropicConfig {
2737            accounts: vec![AnthropicAccount {
2738                label: "work".into(),
2739                credentials_path: "/tmp/accounts/work/.credentials.json".into(),
2740            }],
2741            ..Default::default()
2742        };
2743        let (target, _) = cfg.account_target_with("work", None).unwrap();
2744        assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
2745    }
2746
2747    /// The shipped example, which `make install` puts in
2748    /// `share/ai-usagebar/config.example.toml`. Repo-relative, so this stays
2749    /// hermetic — it never touches the user's real config.
2750    fn config_example() -> PathBuf {
2751        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
2752    }
2753
2754    #[test]
2755    fn shipped_example_parses_as_a_real_config() {
2756        // The example is documentation users copy verbatim, but nothing used
2757        // to parse it — so a renamed section or field could rot there
2758        // unnoticed, and `deny_unknown_fields` would reject the copy on the
2759        // user's machine instead of in CI.
2760        let c = Config::load_from(&config_example()).unwrap();
2761        assert!(!c.context.enabled);
2762        assert!(c.is_enabled(VendorId::Anthropic));
2763        assert!(c.is_enabled(VendorId::Openai));
2764        assert!(!c.is_enabled(VendorId::AnthropicApi));
2765        assert!(!c.is_enabled(VendorId::Deepseek));
2766        assert!(!c.is_enabled(VendorId::Kimi));
2767        assert!(!c.is_enabled(VendorId::Kilo));
2768        assert!(!c.is_enabled(VendorId::Novita));
2769        assert!(!c.is_enabled(VendorId::Moonshot));
2770        assert!(!c.is_enabled(VendorId::Grok));
2771        assert!(!c.is_enabled(VendorId::Cursor));
2772        assert!(!c.is_enabled(VendorId::Minimax));
2773    }
2774
2775    #[test]
2776    fn shipped_example_does_not_advertise_admin_key_env_as_working() {
2777        // The regression: the example shipped an *uncommented*
2778        // `admin_key_env = "OPENAI_ADMIN_KEY"`, indistinguishable from a live
2779        // setting. Nothing reads it, so a user could set it, skip
2780        // `codex login`, and wait for usage that never arrives.
2781        let text = std::fs::read_to_string(config_example()).unwrap();
2782        let live: Vec<&str> = text
2783            .lines()
2784            .map(str::trim)
2785            .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
2786            .collect();
2787        assert!(
2788            live.is_empty(),
2789            "admin_key_env must stay commented out while it is inert: {live:?}"
2790        );
2791        // Still documented, though — silently dropping it would leave users
2792        // who already set it with no explanation of why it does nothing.
2793        assert!(
2794            text.contains("admin_key_env") && text.contains("RESERVED"),
2795            "the example should keep describing admin_key_env as reserved"
2796        );
2797    }
2798
2799    #[test]
2800    fn admin_key_env_is_accepted_but_changes_nothing() {
2801        // The field survives because the API-key-only path is still intended.
2802        // What has to hold today is narrower: setting it loads without error
2803        // and moves nothing the code actually acts on.
2804        let f = write_toml(
2805            r#"
2806            [openai]
2807            admin_key_env = "SOME_ADMIN_KEY"
2808            "#,
2809        );
2810        let c = Config::load_from(f.path()).unwrap();
2811        assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
2812        // Nothing else moved: OpenAI still resolves through Codex OAuth only.
2813        let default = OpenAiConfig::default();
2814        assert_eq!(c.openai.enabled, default.enabled);
2815        assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
2816        assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
2817    }
2818
2819    #[test]
2820    fn config_example_documents_every_vendor_without_secrets() {
2821        let raw = std::fs::read_to_string(config_example()).unwrap();
2822        let cfg = Config::load_from(&config_example()).unwrap();
2823        // Every vendor the binary can dispatch needs a documented section, or
2824        // users have no way to discover how to turn it on.
2825        for id in VendorId::all() {
2826            let section = id.slug();
2827            assert!(
2828                raw.contains(&format!("[{section}]")),
2829                "config.example.toml has no [{section}] section"
2830            );
2831        }
2832
2833        // The example must not ship anything enabled-by-key-only, and must not
2834        // carry a real secret.
2835        assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
2836        assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
2837        assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
2838        assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
2839        assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
2840        assert!(!cfg.supergrok.enabled);
2841        assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
2842        assert_eq!(
2843            cfg.supergrok
2844                .grok_binary
2845                .file_name()
2846                .and_then(|p| p.to_str()),
2847            Some(if cfg!(windows) { "grok.exe" } else { "grok" })
2848        );
2849        assert!(cfg.supergrok.auth_path.is_none());
2850        assert!(cfg.supergrok.config_path.is_none());
2851        assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
2852        assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
2853    }
2854
2855    #[test]
2856    fn supergrok_binary_must_not_be_empty() {
2857        let file = write_toml(
2858            r#"
2859            [supergrok]
2860            enabled = true
2861            grok_binary = ""
2862            "#,
2863        );
2864        let error = Config::load_from(file.path()).unwrap_err().to_string();
2865        assert!(error.contains("grok_binary must not be empty"));
2866    }
2867
2868    #[test]
2869    fn supergrok_paths_are_tilde_expanded() {
2870        let file = write_toml(
2871            r#"
2872            [supergrok]
2873            grok_binary = "~/bin/grok"
2874            auth_path = "~/.grok/auth.json"
2875            config_path = "~/.grok/config.toml"
2876            "#,
2877        );
2878        let config = Config::load_from(file.path()).unwrap();
2879        let home = crate::cache::home_dir().unwrap();
2880        assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
2881        assert_eq!(
2882            config.supergrok.auth_path,
2883            Some(home.join(".grok/auth.json"))
2884        );
2885        assert_eq!(
2886            config.supergrok.config_path,
2887            Some(home.join(".grok/config.toml"))
2888        );
2889    }
2890
2891    #[test]
2892    fn kiro_db_path_is_tilde_expanded() {
2893        let f = write_toml(
2894            r#"
2895            [kiro]
2896            db_path = "~/kiro-data.sqlite3"
2897            "#,
2898        );
2899        let c = Config::load_from(f.path()).unwrap();
2900        let home = crate::cache::home_dir().unwrap();
2901        assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
2902    }
2903
2904    #[test]
2905    fn kiro_appears_when_enabled() {
2906        let f = write_toml(
2907            r#"
2908            [kiro]
2909            enabled = true
2910            "#,
2911        );
2912        let c = Config::load_from(f.path()).unwrap();
2913        assert!(c.is_enabled(VendorId::Kiro));
2914        assert!(c.enabled_vendors().contains(&VendorId::Kiro));
2915    }
2916
2917    #[test]
2918    fn cursor_db_path_is_tilde_expanded() {
2919        let f = write_toml(
2920            r#"
2921            [cursor]
2922            db_path = "~/cursor-state.vscdb"
2923            "#,
2924        );
2925        let c = Config::load_from(f.path()).unwrap();
2926        let home = crate::cache::home_dir().unwrap();
2927        assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
2928    }
2929
2930    #[test]
2931    fn cursor_agent_auth_path_is_tilde_expanded() {
2932        let f = write_toml(
2933            r#"
2934            [cursor]
2935            agent_auth_path = "~/cursor-agent-auth.json"
2936            "#,
2937        );
2938        let c = Config::load_from(f.path()).unwrap();
2939        let home = crate::cache::home_dir().unwrap();
2940        assert_eq!(
2941            c.cursor.agent_auth_path,
2942            Some(home.join("cursor-agent-auth.json"))
2943        );
2944    }
2945
2946    #[test]
2947    fn cursor_appears_when_enabled() {
2948        let f = write_toml(
2949            r#"
2950            [cursor]
2951            enabled = true
2952            "#,
2953        );
2954        let c = Config::load_from(f.path()).unwrap();
2955        assert!(c.is_enabled(VendorId::Cursor));
2956        assert!(c.enabled_vendors().contains(&VendorId::Cursor));
2957    }
2958
2959    #[test]
2960    fn add_account_appends_and_preserves_existing() {
2961        let mut doc: toml_edit::DocumentMut = r#"
2962# keep me
2963[anthropic]
2964enabled = true
2965
2966[[anthropic.accounts]]
2967label = "personal"
2968credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
2969"#
2970        .parse()
2971        .unwrap();
2972        add_anthropic_account_to_doc(
2973            &mut doc,
2974            "work",
2975            "~/.config/ai-usagebar/accounts/work/.credentials.json",
2976        )
2977        .unwrap();
2978        let rendered = doc.to_string();
2979        assert!(rendered.contains("# keep me"), "comment must survive");
2980        // Round-trips through the real loader with both accounts intact and ordered.
2981        let f = write_toml(&rendered);
2982        let c = Config::load_from(f.path()).unwrap();
2983        let labels: Vec<&str> = c
2984            .anthropic
2985            .accounts
2986            .iter()
2987            .map(|a| a.label.as_str())
2988            .collect();
2989        assert_eq!(labels, vec!["personal", "work"]);
2990    }
2991
2992    #[test]
2993    fn add_account_to_empty_doc_is_loadable() {
2994        let mut doc = toml_edit::DocumentMut::new();
2995        add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
2996        let f = write_toml(&doc.to_string());
2997        let c = Config::load_from(f.path()).unwrap();
2998        assert_eq!(c.anthropic.accounts.len(), 1);
2999        assert_eq!(c.anthropic.accounts[0].label, "solo");
3000    }
3001
3002    #[test]
3003    fn add_account_rejects_duplicate_label() {
3004        let mut doc: toml_edit::DocumentMut = r#"
3005[[anthropic.accounts]]
3006label = "work"
3007credentials_path = "~/w/.credentials.json"
3008"#
3009        .parse()
3010        .unwrap();
3011        assert!(
3012            add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
3013            "a duplicate label must be rejected, not appended"
3014        );
3015    }
3016
3017    #[test]
3018    fn add_account_rejects_bad_label() {
3019        let mut doc = toml_edit::DocumentMut::new();
3020        assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
3021        assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
3022    }
3023
3024    #[test]
3025    fn tildify_collapses_home_only() {
3026        let home = Path::new("/Users/me");
3027        assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
3028        assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
3029    }
3030
3031    #[test]
3032    fn default_account_credentials_path_nests_under_config_dir() {
3033        let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
3034        assert_eq!(
3035            default_account_credentials_path(cfg, "work"),
3036            Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
3037        );
3038    }
3039}