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    pub fn enabled_vendors(&self) -> Vec<VendorId> {
1172        VendorId::all()
1173            .iter()
1174            .copied()
1175            .filter(|id| self.is_enabled(*id))
1176            .collect()
1177    }
1178
1179    /// Validate cross-entry constraints that serde cannot express. Account
1180    /// labels are both CLI selectors and TUI tab identities, so duplicates
1181    /// would make either destination ambiguous.
1182    pub fn validate(&self) -> Result<()> {
1183        if self.context.context_window_tokens == Some(0) {
1184            return Err(AppError::Other(
1185                "[context] context_window_tokens must be greater than zero".into(),
1186            ));
1187        }
1188        for (model, tokens) in &self.context.model_context_window_tokens {
1189            if model.trim().is_empty() {
1190                return Err(AppError::Other(
1191                    "[context] model_context_window_tokens keys must not be empty".into(),
1192                ));
1193            }
1194            if *tokens == 0 {
1195                return Err(AppError::Other(format!(
1196                    "[context] model_context_window_tokens entry {model:?} must be greater than zero"
1197                )));
1198            }
1199        }
1200        if let Some(limit) = self.anthropic_api.monthly_limit
1201            && (!limit.is_finite() || limit <= 0.0)
1202        {
1203            return Err(AppError::Other(
1204                "[anthropic_api] monthly_limit must be finite and greater than zero; \
1205                 remove it to show spend without a limit"
1206                    .into(),
1207            ));
1208        }
1209        if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
1210            && !self.kimi.region.eq_ignore_ascii_case("auto")
1211        {
1212            return Err(AppError::Other(format!(
1213                "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
1214                self.kimi.region
1215            )));
1216        }
1217        if !self.minimax.region.eq_ignore_ascii_case("global")
1218            && !self.minimax.region.eq_ignore_ascii_case("cn")
1219        {
1220            return Err(AppError::Other(format!(
1221                "[minimax] region must be \"global\" or \"cn\", got {:?}",
1222                self.minimax.region
1223            )));
1224        }
1225        if self.supergrok.grok_binary.as_os_str().is_empty() {
1226            return Err(AppError::Other(
1227                "[supergrok] grok_binary must not be empty".into(),
1228            ));
1229        }
1230        let mut labels = HashSet::new();
1231        for account in &self.anthropic.accounts {
1232            validate_account_label(&account.label)?;
1233            if !labels.insert(&account.label) {
1234                return Err(AppError::Credentials(format!(
1235                    "duplicate anthropic account label {:?}",
1236                    account.label
1237                )));
1238            }
1239        }
1240        let mut openai_labels = HashSet::new();
1241        for account in &self.openai.accounts {
1242            validate_account_label_for("openai", &account.label)?;
1243            if !openai_labels.insert(&account.label) {
1244                return Err(AppError::Credentials(format!(
1245                    "duplicate openai account label {:?}",
1246                    account.label
1247                )));
1248            }
1249        }
1250        let mut openrouter_labels = HashSet::new();
1251        for account in &self.openrouter.accounts {
1252            validate_account_label_for("openrouter", &account.label)?;
1253            if !openrouter_labels.insert(&account.label) {
1254                return Err(AppError::Credentials(format!(
1255                    "duplicate openrouter account label {:?}",
1256                    account.label
1257                )));
1258            }
1259            let has_env = account
1260                .api_key_env
1261                .as_deref()
1262                .is_some_and(|name| !name.is_empty());
1263            let has_inline = account
1264                .api_key
1265                .as_deref()
1266                .is_some_and(|key| !key.is_empty());
1267            if !has_env && !has_inline {
1268                return Err(AppError::Credentials(format!(
1269                    "openrouter account {:?} must set api_key_env or api_key",
1270                    account.label
1271                )));
1272            }
1273        }
1274        Ok(())
1275    }
1276}
1277
1278#[cfg(unix)]
1279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1280enum InlineKeyPermissionDecision {
1281    Ok,
1282    Tighten,
1283}
1284
1285#[cfg(unix)]
1286fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
1287    if mode & 0o077 == 0 {
1288        InlineKeyPermissionDecision::Ok
1289    } else {
1290        InlineKeyPermissionDecision::Tighten
1291    }
1292}
1293
1294pub fn default_path() -> Option<PathBuf> {
1295    let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
1296    Some(proj.config_dir().join("config.toml"))
1297}
1298
1299/// The Unix-conventional location, which is what every doc, the config
1300/// example, and both desktop integrations have always pointed at. On Linux it
1301/// *is* [`default_path`]; on macOS `ProjectDirs` resolves to
1302/// `~/Library/Application Support/…` instead, so the two diverge.
1303fn legacy_xdg_path() -> Option<PathBuf> {
1304    let home = crate::cache::home_dir().ok()?;
1305    Some(home.join(".config").join("ai-usagebar").join("config.toml"))
1306}
1307
1308/// The config file actually in effect.
1309///
1310/// [`default_path`] stays canonical, but on macOS a file at the documented
1311/// `~/.config/ai-usagebar/config.toml` is honored when the canonical one does
1312/// not exist — otherwise everyone who followed the README (and both desktop
1313/// integrations, which read that path) silently got defaults. The legacy file
1314/// is never moved or rewritten: it may hold API keys, and relocating a secret
1315/// behind the user's back is not this tool's business.
1316pub fn resolved_path() -> Option<PathBuf> {
1317    let canonical = default_path();
1318    if let Some(p) = &canonical
1319        && p.exists()
1320    {
1321        return canonical;
1322    }
1323    if let Some(legacy) = legacy_xdg_path()
1324        && legacy.exists()
1325    {
1326        return Some(legacy);
1327    }
1328    canonical
1329}
1330
1331/// Expand a leading `~` (or `~/`) against the user's home directory. Anything
1332/// else — including `~user` — is left untouched.
1333fn expand_tilde(p: &std::path::Path) -> PathBuf {
1334    let Some(s) = p.to_str() else {
1335        return p.to_path_buf();
1336    };
1337    let rest = if s == "~" {
1338        ""
1339    } else if let Some(r) = s.strip_prefix("~/") {
1340        r
1341    } else {
1342        return p.to_path_buf();
1343    };
1344    match crate::cache::home_dir() {
1345        Ok(home) if rest.is_empty() => home,
1346        Ok(home) => home.join(rest),
1347        Err(_) => p.to_path_buf(),
1348    }
1349}
1350
1351fn expand_tilde_opt(p: &mut Option<PathBuf>) {
1352    if let Some(inner) = p.as_ref() {
1353        *p = Some(expand_tilde(inner));
1354    }
1355}
1356
1357/// Resolved `config.toml` path as a string for user-facing messages. Uses the
1358/// platform's config dir (`directories::ProjectDirs`), so it reads correctly on
1359/// Linux, macOS, and Windows instead of hard-coding the Unix `~/.config` path.
1360/// Falls back to the bare filename if the path can't be resolved.
1361pub fn config_path_hint() -> String {
1362    resolved_path()
1363        .map(|p| p.display().to_string())
1364        .unwrap_or_else(|| "config.toml".to_string())
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369    use super::*;
1370    use std::io::Write;
1371    use tempfile::NamedTempFile;
1372
1373    #[cfg(unix)]
1374    use std::os::unix::fs::{MetadataExt, PermissionsExt};
1375
1376    fn write_toml(s: &str) -> NamedTempFile {
1377        let mut f = NamedTempFile::new().unwrap();
1378        f.write_all(s.as_bytes()).unwrap();
1379        f.flush().unwrap();
1380        f
1381    }
1382
1383    /// The back-compat guarantee #134 asks for: a config with no
1384    /// `[[openai.accounts]]` resolves exactly what it resolved before, whether
1385    /// it sets `codex_auth_path` or leaves it to the default.
1386    #[test]
1387    fn openai_without_accounts_resolves_the_singular_path() {
1388        let explicit = OpenAiConfig {
1389            codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
1390            ..OpenAiConfig::default()
1391        };
1392        assert_eq!(
1393            explicit.resolve_auth_path(None).unwrap(),
1394            PathBuf::from("/tmp/codex/auth.json")
1395        );
1396
1397        let bare = OpenAiConfig::default();
1398        assert_eq!(
1399            bare.resolve_auth_path(None).unwrap(),
1400            crate::openai::creds::default_path().unwrap(),
1401            "no codex_auth_path must still mean ~/.codex/auth.json"
1402        );
1403    }
1404
1405    /// Each named account resolves its own file, and the default login is still
1406    /// reachable alongside them.
1407    #[test]
1408    fn openai_named_accounts_resolve_their_own_auth_file() {
1409        let config: Config = toml::from_str(
1410            r#"
1411            [openai]
1412            codex_auth_path = "/tmp/personal/auth.json"
1413            [[openai.accounts]]
1414            label = "work"
1415            codex_auth_path = "/tmp/work/auth.json"
1416            "#,
1417        )
1418        .unwrap();
1419
1420        assert_eq!(
1421            config.openai.resolve_auth_path(Some("work")).unwrap(),
1422            PathBuf::from("/tmp/work/auth.json")
1423        );
1424        assert_eq!(
1425            config.openai.resolve_auth_path(None).unwrap(),
1426            PathBuf::from("/tmp/personal/auth.json")
1427        );
1428    }
1429
1430    /// An unknown label must fail rather than quietly fall back to the default
1431    /// login — reporting the wrong subscription's usage is worse than an error.
1432    #[test]
1433    fn an_unknown_openai_account_is_an_error_not_a_fallback() {
1434        let config = OpenAiConfig {
1435            codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
1436            accounts: vec![OpenAiAccount {
1437                label: "work".into(),
1438                codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
1439            }],
1440            ..OpenAiConfig::default()
1441        };
1442        let err = config
1443            .resolve_auth_path(Some("nope"))
1444            .unwrap_err()
1445            .to_string();
1446        assert!(err.contains("nope"), "{err}");
1447        assert!(err.contains("[[openai.accounts]]"), "{err}");
1448    }
1449
1450    #[test]
1451    fn defaults_enable_only_the_four_core_vendors() {
1452        let c = Config::default();
1453        assert!(c.is_enabled(VendorId::Anthropic));
1454        assert!(c.is_enabled(VendorId::Openai));
1455        assert!(c.is_enabled(VendorId::Zai));
1456        assert!(c.is_enabled(VendorId::Openrouter));
1457        for opt_in in [
1458            VendorId::AnthropicApi,
1459            VendorId::Copilot,
1460            VendorId::Deepseek,
1461            VendorId::Kimi,
1462            VendorId::Kilo,
1463            VendorId::Novita,
1464            VendorId::Moonshot,
1465            VendorId::Grok,
1466            VendorId::Supergrok,
1467            VendorId::Cursor,
1468            VendorId::Minimax,
1469            VendorId::Kiro,
1470        ] {
1471            assert!(!c.is_enabled(opt_in), "{opt_in:?}");
1472        }
1473        assert_eq!(c.enabled_vendors().len(), 4);
1474    }
1475
1476    #[test]
1477    fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
1478        let config = Config::default();
1479        assert!(!config.is_enabled(VendorId::NousResearch));
1480        assert!(!config.is_enabled(VendorId::OpenCodeGo));
1481        assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
1482        assert!(config.opencode_go.api_key.is_none());
1483        assert!(!config.is_enabled(VendorId::Copilot));
1484    }
1485
1486    #[cfg(unix)]
1487    #[test]
1488    fn inline_credentials_are_protected() {
1489        let mut config = Config::default();
1490        config.opencode_go.api_key = Some("<redacted>".to_string());
1491        assert!(config.has_inline_secrets());
1492    }
1493
1494    #[cfg(unix)]
1495    #[test]
1496    fn openrouter_named_inline_keys_receive_config_file_protection() {
1497        let mut config = Config::default();
1498        config.openrouter.accounts.push(OpenRouterAccount {
1499            label: "work".into(),
1500            api_key_env: None,
1501            api_key: Some("<redacted>".into()),
1502        });
1503        assert!(config.has_inline_secrets());
1504    }
1505
1506    #[test]
1507    fn missing_file_uses_defaults() {
1508        let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
1509        let c = Config::load_from(path).unwrap();
1510        assert!(c.is_enabled(VendorId::Anthropic));
1511    }
1512
1513    #[test]
1514    fn parses_full_config() {
1515        let f = write_toml(
1516            r#"
1517            [anthropic]
1518            enabled = true
1519
1520            [openai]
1521            enabled = false
1522            admin_key_env = "MY_ADMIN_KEY"
1523
1524            [zai]
1525            enabled = true
1526            api_key_env = "MY_ZAI"
1527            plan_tier = "pro"
1528
1529            [openrouter]
1530            enabled = false
1531            "#,
1532        );
1533        let c = Config::load_from(f.path()).unwrap();
1534        assert!(c.is_enabled(VendorId::Anthropic));
1535        assert!(!c.is_enabled(VendorId::Openai));
1536        assert!(c.is_enabled(VendorId::Zai));
1537        assert!(!c.is_enabled(VendorId::Openrouter));
1538        assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
1539        assert_eq!(c.zai.api_key_env, "MY_ZAI");
1540        assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
1541        assert!(c.openrouter.accounts.is_empty());
1542        assert!(c.openrouter.show_default_account);
1543    }
1544
1545    #[test]
1546    fn partial_config_falls_back_to_defaults() {
1547        let f = write_toml(
1548            r#"[openai]
1549enabled = false
1550"#,
1551        );
1552        let c = Config::load_from(f.path()).unwrap();
1553        assert!(!c.is_enabled(VendorId::Openai));
1554        // Other vendors keep their defaults.
1555        assert!(c.is_enabled(VendorId::Anthropic));
1556        assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
1557    }
1558
1559    #[test]
1560    fn malformed_toml_returns_error() {
1561        let f = write_toml("this is not = = valid");
1562        assert!(Config::load_from(f.path()).is_err());
1563    }
1564
1565    #[cfg(unix)]
1566    #[test]
1567    fn load_from_tightens_world_readable_config_with_inline_api_key() {
1568        let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
1569        std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1570
1571        Config::load_from(file.path()).unwrap();
1572
1573        assert_eq!(
1574            std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1575            0o600
1576        );
1577    }
1578
1579    #[cfg(unix)]
1580    #[test]
1581    fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
1582        let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
1583        std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1584
1585        Config::load_from(file.path()).unwrap();
1586
1587        assert_eq!(
1588            std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1589            0o644
1590        );
1591    }
1592
1593    #[cfg(unix)]
1594    #[test]
1595    fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
1596        assert_eq!(
1597            inline_key_permission_decision(0o600),
1598            InlineKeyPermissionDecision::Ok
1599        );
1600        assert_eq!(
1601            inline_key_permission_decision(0o640),
1602            InlineKeyPermissionDecision::Tighten
1603        );
1604        assert_eq!(
1605            inline_key_permission_decision(0o604),
1606            InlineKeyPermissionDecision::Tighten
1607        );
1608    }
1609
1610    #[test]
1611    fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
1612        for value in ["0", "-1", "inf", "nan"] {
1613            let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
1614            let error = Config::load_from(file.path()).unwrap_err().to_string();
1615            assert!(error.contains("monthly_limit"), "value {value}: {error}");
1616        }
1617
1618        let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
1619        assert_eq!(
1620            Config::load_from(file.path())
1621                .unwrap()
1622                .anthropic_api
1623                .monthly_limit,
1624            Some(1000.0)
1625        );
1626    }
1627
1628    #[test]
1629    fn minimax_region_accepts_only_known_instances() {
1630        for region in ["global", "GLOBAL", "cn", "CN"] {
1631            let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1632            assert_eq!(
1633                Config::load_from(file.path()).unwrap().minimax.region,
1634                region
1635            );
1636        }
1637
1638        for region in ["", "china", "us"] {
1639            let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1640            let error = Config::load_from(file.path()).unwrap_err().to_string();
1641            assert!(error.contains("[minimax] region"), "{error}");
1642        }
1643    }
1644
1645    #[test]
1646    fn kimi_region_accepts_auto_and_both_deployments() {
1647        for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
1648            let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
1649            assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
1650        }
1651
1652        for region in ["", "us", "oversea"] {
1653            let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
1654            let error = Config::load_from(file.path()).unwrap_err().to_string();
1655            assert!(error.contains("[kimi] region"), "{error}");
1656        }
1657    }
1658
1659    #[test]
1660    fn kimi_defaults_to_auto_region_and_no_credential_override() {
1661        let defaults = KimiConfig::default();
1662        assert_eq!(defaults.region, "auto");
1663        assert_eq!(defaults.credentials_path, None);
1664        assert!(!defaults.enabled);
1665    }
1666
1667    #[test]
1668    fn kimi_credentials_path_expands_a_tilde() {
1669        let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
1670        let path = Config::load_from(file.path())
1671            .unwrap()
1672            .kimi
1673            .credentials_path
1674            .unwrap();
1675        assert!(!path.starts_with("~"), "{}", path.display());
1676        assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
1677    }
1678
1679    #[test]
1680    fn optional_api_key_reports_absence_instead_of_failing() {
1681        assert_eq!(
1682            optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", Some("inline")),
1683            Some("inline".to_string())
1684        );
1685        assert_eq!(
1686            optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", None),
1687            None
1688        );
1689        assert_eq!(optional_api_key("KIMI_API_KEY_UNSET", Some("")), None);
1690        // An unusable `api_key_env` still lets an inline key through, exactly
1691        // as `resolve_api_key` does.
1692        assert_eq!(
1693            optional_api_key("9INVALID", Some("inline")),
1694            Some("inline".to_string())
1695        );
1696    }
1697
1698    #[test]
1699    fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
1700        let defaults = Config::default();
1701        assert!(!defaults.context.enabled);
1702        assert_eq!(
1703            defaults.context.window_tokens_for(Some("claude-test")),
1704            None
1705        );
1706
1707        let file = write_toml(
1708            r#"
1709            [context]
1710            enabled = true
1711            context_window_tokens = 200000
1712
1713            [context.model_context_window_tokens]
1714            claude-opus-1m = 1000000
1715            "claude exact id" = 300000
1716            "#,
1717        );
1718        let config = Config::load_from(file.path()).unwrap();
1719        assert!(config.context.enabled);
1720        assert_eq!(
1721            config.context.window_tokens_for(Some("claude-opus-1m")),
1722            Some(1_000_000)
1723        );
1724        assert_eq!(
1725            config.context.window_tokens_for(Some("claude exact id")),
1726            Some(300_000)
1727        );
1728        assert_eq!(
1729            config.context.window_tokens_for(Some("another-model")),
1730            Some(200_000)
1731        );
1732    }
1733
1734    #[test]
1735    fn context_layout_defaults_to_full_and_parses_each_variant() {
1736        assert_eq!(Config::default().context.layout, ContextLayout::Full);
1737        for (text, want) in [
1738            ("full", ContextLayout::Full),
1739            ("split", ContextLayout::Split),
1740            ("bottom", ContextLayout::Bottom),
1741        ] {
1742            let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
1743            assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
1744        }
1745        let file = write_toml("[context]\nlayout = \"floating\"\n");
1746        assert!(
1747            Config::load_from(file.path()).is_err(),
1748            "an unknown layout must be rejected, not silently defaulted"
1749        );
1750    }
1751
1752    #[test]
1753    fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
1754        assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
1755        for (text, want) in [
1756            ("sidebar", VendorBoxStyle::Sidebar),
1757            ("navbar", VendorBoxStyle::Navbar),
1758            ("none", VendorBoxStyle::None),
1759        ] {
1760            let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
1761            assert_eq!(
1762                Config::load_from(file.path()).unwrap().ui.vendor_box(),
1763                want
1764            );
1765        }
1766        let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
1767        assert!(
1768            Config::load_from(file.path()).is_err(),
1769            "an unknown vendor_box style must be rejected, not silently defaulted"
1770        );
1771    }
1772
1773    #[test]
1774    fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
1775        for source in [
1776            "[context]\ncontext_window_tokens = 0\n",
1777            "[context.model_context_window_tokens]\nclaude = 0\n",
1778            "[context.model_context_window_tokens]\n\" \" = 200000\n",
1779        ] {
1780            let file = write_toml(source);
1781            let error = Config::load_from(file.path()).unwrap_err().to_string();
1782            assert!(error.contains("context"), "{error}");
1783        }
1784    }
1785
1786    // serial guard for env-var manipulation tests so they don't race
1787    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
1788        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
1789        M.lock().unwrap_or_else(|p| p.into_inner())
1790    }
1791
1792    #[test]
1793    fn resolve_api_key_prefers_env_over_inline() {
1794        let _g = env_guard();
1795        // Use a unique env var name so we don't clobber test parallelism.
1796        let var = "AI_USAGEBAR_TEST_ENV_WINS";
1797        // SAFETY: tests are single-threaded under env_guard.
1798        unsafe { std::env::set_var(var, "from-env") };
1799        let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
1800        unsafe { std::env::remove_var(var) };
1801        assert_eq!(got, "from-env");
1802    }
1803
1804    #[test]
1805    fn resolve_api_key_falls_back_to_inline() {
1806        let _g = env_guard();
1807        let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
1808        unsafe { std::env::remove_var(var) };
1809        let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
1810        assert_eq!(got, "inline-key");
1811    }
1812
1813    #[test]
1814    fn copilot_token_prefers_explicit_environment_over_gh_cli() {
1815        struct NeverRun;
1816        impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
1817            fn run(
1818                &self,
1819                _: &crate::copilot::credentials::GhAuthTokenCommand,
1820            ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
1821                panic!("environment override must not invoke gh")
1822            }
1823        }
1824
1825        let token = CopilotConfig::default()
1826            .resolve_token_with(
1827                |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
1828                &NeverRun,
1829            )
1830            .unwrap();
1831        assert_eq!(token, "from-environment");
1832    }
1833
1834    #[test]
1835    fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
1836        struct FailedGh;
1837        impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
1838            fn run(
1839                &self,
1840                _: &crate::copilot::credentials::GhAuthTokenCommand,
1841            ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
1842                Ok(crate::copilot::credentials::GhAuthTokenOutput {
1843                    success: false,
1844                    stdout: b"never-echo-gh-output".to_vec(),
1845                })
1846            }
1847        }
1848        let error = CopilotConfig::default()
1849            .resolve_token_with(|_| None, &FailedGh)
1850            .unwrap_err()
1851            .to_string();
1852        assert!(error.contains("gh auth login --web"));
1853        assert!(!error.contains("never-echo-gh-output"));
1854    }
1855
1856    #[test]
1857    fn resolve_api_key_errors_when_both_missing() {
1858        let _g = env_guard();
1859        let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
1860        unsafe { std::env::remove_var(var) };
1861        let err = resolve_api_key("Zai", var, None).unwrap_err();
1862        match err {
1863            crate::error::AppError::Credentials(msg) => {
1864                assert!(
1865                    msg.contains("api_key"),
1866                    "error should suggest config field: {msg}"
1867                );
1868            }
1869            other => panic!("expected Credentials error, got {other:?}"),
1870        }
1871    }
1872
1873    #[test]
1874    fn resolve_api_key_uses_exact_opencode_go_section_name() {
1875        let _g = env_guard();
1876        unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
1877        let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
1878        let message = err.to_string();
1879        assert!(
1880            message.contains("[opencode-go]"),
1881            "wrong section hint: {message}"
1882        );
1883        assert!(
1884            !message.contains("[opencode go]"),
1885            "wrong section hint: {message}"
1886        );
1887    }
1888
1889    #[test]
1890    fn config_path_hint_ends_with_config_toml() {
1891        // Platform-resolved (Linux/macOS/Windows), but always ends in the
1892        // config filename — the trailing segment is what messages rely on.
1893        assert!(config_path_hint().ends_with("config.toml"));
1894    }
1895
1896    #[test]
1897    fn resolve_api_key_treats_empty_env_as_unset() {
1898        let _g = env_guard();
1899        let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
1900        unsafe { std::env::set_var(var, "") };
1901        let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
1902        unsafe { std::env::remove_var(var) };
1903        assert_eq!(got, "inline");
1904    }
1905
1906    #[test]
1907    fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
1908        let _g = env_guard();
1909        // Simulates a user accidentally pasting the key into api_key_env.
1910        let bad = "sk-kimi-very-real-looking-pasted-secret";
1911        let err = resolve_api_key("Kimi", bad, None).unwrap_err();
1912        let msg = err.to_string();
1913        assert!(
1914            msg.contains("invalid") && msg.contains("api_key_env"),
1915            "error should explain misconfiguration: {msg}"
1916        );
1917        assert!(
1918            !msg.contains(bad),
1919            "error must not echo the misconfigured value: {msg}"
1920        );
1921        assert!(msg.contains("valid environment variable name"));
1922        assert!(
1923            msg.contains("[kimi]"),
1924            "error should point at the lowercase TOML section: {msg}"
1925        );
1926    }
1927
1928    #[test]
1929    fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
1930        let _g = env_guard();
1931        let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
1932        assert_eq!(got, "inline-key");
1933    }
1934
1935    #[test]
1936    fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
1937        let _g = env_guard();
1938        // This is syntactically a valid environment variable name, but could
1939        // be a pasted secret and must not be reflected in the error.
1940        let pasted_secret = "sk_pasted_secret";
1941        unsafe { std::env::remove_var(pasted_secret) };
1942        let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
1943        assert!(
1944            !err.to_string().contains(pasted_secret),
1945            "error must not echo configured api_key_env values"
1946        );
1947    }
1948
1949    #[test]
1950    fn is_valid_env_var_name_rules() {
1951        // Valid: alphabetic or underscore first, then alnum/underscore.
1952        for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
1953            assert!(is_valid_env_var_name(valid), "{valid} should be valid");
1954        }
1955        // Invalid: empty, digit-first, or shell-illegal characters.
1956        for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
1957            assert!(
1958                !is_valid_env_var_name(invalid),
1959                "{invalid} should be invalid"
1960            );
1961        }
1962    }
1963
1964    #[test]
1965    fn config_parses_with_inline_api_key_and_primary() {
1966        let f = write_toml(
1967            r#"
1968            [ui]
1969            primary = "openrouter"
1970
1971            [zai]
1972            enabled = true
1973            api_key_env = "MY_ZAI"
1974            api_key = "sk-zai-inline"
1975
1976            [openrouter]
1977            enabled = true
1978            api_key = "sk-or-inline"
1979            "#,
1980        );
1981        let c = Config::load_from(f.path()).unwrap();
1982        assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
1983        assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
1984        assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
1985    }
1986
1987    #[test]
1988    fn openrouter_named_accounts_preserve_the_default_contract() {
1989        let f = write_toml(
1990            r#"
1991            [openrouter]
1992            enabled = true
1993            api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
1994            api_key = "default-inline"
1995            show_default_account = false
1996
1997            [[openrouter.accounts]]
1998            label = "work"
1999            api_key_env = "OPENROUTER_WORK_API_KEY"
2000
2001            [[openrouter.accounts]]
2002            label = "personal"
2003            api_key = "personal-inline"
2004            "#,
2005        );
2006        let _g = env_guard();
2007        unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
2008        let config = Config::load_from(f.path()).unwrap();
2009        assert!(!config.openrouter.show_default_account);
2010        assert_eq!(config.openrouter.accounts.len(), 2);
2011        assert_eq!(
2012            config.openrouter.resolve_api_key(None).unwrap(),
2013            "default-inline"
2014        );
2015        assert_eq!(
2016            config.openrouter.resolve_api_key(Some("personal")).unwrap(),
2017            "personal-inline"
2018        );
2019    }
2020
2021    #[test]
2022    fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
2023        for source in [
2024            r#"
2025            [[openrouter.accounts]]
2026            label = "work"
2027            api_key = "one"
2028            [[openrouter.accounts]]
2029            label = "work"
2030            api_key = "two"
2031            "#,
2032            r#"
2033            [[openrouter.accounts]]
2034            label = "../work"
2035            api_key = "one"
2036            "#,
2037            r#"
2038            [[openrouter.accounts]]
2039            label = "work"
2040            "#,
2041        ] {
2042            let f = write_toml(source);
2043            assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
2044        }
2045    }
2046
2047    #[test]
2048    fn openrouter_unknown_account_never_falls_back_to_default_key() {
2049        let mut config = OpenRouterConfig {
2050            api_key: Some("default-secret".into()),
2051            ..OpenRouterConfig::default()
2052        };
2053        config.accounts.push(OpenRouterAccount {
2054            label: "work".into(),
2055            api_key_env: None,
2056            api_key: Some("work-secret".into()),
2057        });
2058        let message = config
2059            .resolve_api_key(Some("missing"))
2060            .unwrap_err()
2061            .to_string();
2062        assert!(message.contains("missing") && message.contains("work"));
2063        assert!(!message.contains("default-secret"));
2064        assert!(!message.contains("work-secret"));
2065    }
2066
2067    #[test]
2068    fn openrouter_account_key_errors_do_not_echo_configured_values() {
2069        let config = OpenRouterConfig {
2070            accounts: vec![OpenRouterAccount {
2071                label: "work".into(),
2072                api_key_env: Some("sk_pasted_secret".into()),
2073                api_key: None,
2074            }],
2075            ..OpenRouterConfig::default()
2076        };
2077        let _g = env_guard();
2078        unsafe { std::env::remove_var("sk_pasted_secret") };
2079        let message = config
2080            .resolve_api_key(Some("work"))
2081            .unwrap_err()
2082            .to_string();
2083        assert!(message.contains("[[openrouter.accounts]]"));
2084        assert!(!message.contains("sk_pasted_secret"));
2085    }
2086
2087    #[test]
2088    fn enabled_vendors_preserves_canonical_order() {
2089        // DeepSeek and Kimi are disabled by default (require explicit API key
2090        // config), so they are absent from the enabled list unless enabled.
2091        let c = Config::default();
2092        assert_eq!(
2093            c.enabled_vendors(),
2094            vec![
2095                VendorId::Anthropic,
2096                VendorId::Openai,
2097                VendorId::Zai,
2098                VendorId::Openrouter,
2099            ]
2100        );
2101    }
2102
2103    #[test]
2104    fn deepseek_appears_when_enabled() {
2105        let f = write_toml(
2106            r#"
2107            [deepseek]
2108            enabled = true
2109            api_key = "sk-test"
2110            "#,
2111        );
2112        let c = Config::load_from(f.path()).unwrap();
2113        assert!(c.is_enabled(VendorId::Deepseek));
2114        assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
2115        assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
2116    }
2117
2118    #[test]
2119    fn tilde_paths_are_expanded_on_load() {
2120        // `PathBuf` keeps `~` literally, so the documented
2121        // `credentials_path = "~/..."` used to resolve to a directory named
2122        // `~` relative to the process's cwd.
2123        let f = write_toml(
2124            r#"
2125            [context]
2126            projects_path = "~/.claude/projects"
2127
2128            [anthropic]
2129            credentials_path = "~/.claude/.credentials.json"
2130
2131            [[anthropic.accounts]]
2132            label = "work"
2133            credentials_path = "~/work.json"
2134            "#,
2135        );
2136        let c = Config::load_from(f.path()).unwrap();
2137        let home = crate::cache::home_dir().unwrap();
2138
2139        assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
2140        let got = c.anthropic.credentials_path.unwrap();
2141        assert_eq!(got, home.join(".claude/.credentials.json"));
2142        assert!(!got.to_string_lossy().contains('~'));
2143        assert_eq!(
2144            c.anthropic.accounts[0].credentials_path,
2145            home.join("work.json")
2146        );
2147    }
2148
2149    #[test]
2150    fn absolute_and_relative_paths_are_left_alone() {
2151        let f = write_toml(
2152            r#"
2153            [anthropic]
2154            credentials_path = "/etc/creds.json"
2155            "#,
2156        );
2157        let c = Config::load_from(f.path()).unwrap();
2158        assert_eq!(
2159            c.anthropic.credentials_path.unwrap(),
2160            std::path::Path::new("/etc/creds.json")
2161        );
2162
2163        // `~user` is not ours to interpret.
2164        let f2 = write_toml(
2165            r#"
2166            [anthropic]
2167            credentials_path = "~someone/creds.json"
2168            "#,
2169        );
2170        let c2 = Config::load_from(f2.path()).unwrap();
2171        assert_eq!(
2172            c2.anthropic.credentials_path.unwrap(),
2173            std::path::Path::new("~someone/creds.json")
2174        );
2175    }
2176
2177    #[test]
2178    fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
2179        // Hermetic: only asserts the shape, never which file happens to exist
2180        // on the machine running the tests.
2181        let p = resolved_path().expect("a config path must resolve");
2182        assert!(p.ends_with("config.toml"));
2183        let canonical = default_path().unwrap();
2184        let legacy = legacy_xdg_path().unwrap();
2185        assert!(
2186            p == canonical || p == legacy,
2187            "resolved to an unexpected location: {}",
2188            p.display()
2189        );
2190    }
2191
2192    #[test]
2193    fn misspelled_section_is_rejected_not_ignored() {
2194        // The regression this guards: `[openrouer]` used to parse fine, leave
2195        // OpenRouter on its defaults, and give the user no hint at all.
2196        let f = write_toml(
2197            r#"
2198            [openrouer]
2199            enabled = true
2200            api_key = "sk-or-v1-typo"
2201            "#,
2202        );
2203        let err = Config::load_from(f.path()).unwrap_err().to_string();
2204        assert!(
2205            err.contains("openrouer"),
2206            "error should name the typo: {err}"
2207        );
2208    }
2209
2210    #[test]
2211    fn invalid_toml_is_an_error_not_silent_defaults() {
2212        let f = write_toml("[zai\nenabled = true\n");
2213        assert!(Config::load_from(f.path()).is_err());
2214    }
2215
2216    #[test]
2217    fn a_missing_file_is_still_just_defaults() {
2218        // Absence stays the legitimate "use defaults" case — only real parse
2219        // and I/O failures are errors.
2220        let dir = tempfile::tempdir().unwrap();
2221        let missing = dir.path().join("nope").join("config.toml");
2222        let c = Config::load_from(&missing).unwrap();
2223        assert!(c.is_enabled(VendorId::Anthropic));
2224    }
2225
2226    #[test]
2227    fn kimi_appears_when_enabled() {
2228        let f = write_toml(
2229            r#"
2230            [kimi]
2231            enabled = true
2232            api_key = "sk-test"
2233            "#,
2234        );
2235        let c = Config::load_from(f.path()).unwrap();
2236        assert!(c.is_enabled(VendorId::Kimi));
2237        assert!(c.enabled_vendors().contains(&VendorId::Kimi));
2238        assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
2239    }
2240
2241    #[test]
2242    fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
2243        let f = write_toml(
2244            r#"
2245            [deepseek]
2246            enabled = true
2247            api_key = "sk-ds"
2248
2249            [kimi]
2250            enabled = true
2251            api_key = "sk-kimi"
2252            "#,
2253        );
2254        let c = Config::load_from(f.path()).unwrap();
2255        assert_eq!(
2256            c.enabled_vendors(),
2257            vec![
2258                VendorId::Anthropic,
2259                VendorId::Openai,
2260                VendorId::Zai,
2261                VendorId::Openrouter,
2262                VendorId::Deepseek,
2263                VendorId::Kimi,
2264            ]
2265        );
2266    }
2267
2268    #[test]
2269    fn parses_anthropic_accounts_and_looks_them_up() {
2270        let f = write_toml(
2271            r#"
2272            [anthropic]
2273            enabled = true
2274
2275            [[anthropic.accounts]]
2276            label = "personal"
2277            credentials_path = "/creds/personal.json"
2278
2279            [[anthropic.accounts]]
2280            label = "work"
2281            credentials_path = "/creds/work.json"
2282            "#,
2283        );
2284        let c = Config::load_from(f.path()).unwrap();
2285        assert_eq!(c.anthropic.accounts.len(), 2);
2286        let work = c.anthropic.account("work").unwrap();
2287        assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
2288        // A typo names the offending label and lists the known ones.
2289        let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
2290        assert!(err.contains("missing") && err.contains("work"), "{err}");
2291    }
2292
2293    #[test]
2294    fn duplicate_anthropic_account_labels_are_rejected_on_load() {
2295        let f = write_toml(
2296            r#"
2297            [[anthropic.accounts]]
2298            label = "work"
2299            credentials_path = "/creds/work-one.json"
2300
2301            [[anthropic.accounts]]
2302            label = "work"
2303            credentials_path = "/creds/work-two.json"
2304            "#,
2305        );
2306        let err = Config::load_from(f.path()).unwrap_err().to_string();
2307        assert!(
2308            err.contains("duplicate anthropic account label \"work\""),
2309            "{err}"
2310        );
2311    }
2312
2313    #[test]
2314    fn account_label_rejects_path_like_names() {
2315        let cfg = AnthropicConfig::default();
2316        for bad in [
2317            "",
2318            ".",
2319            "..",
2320            "a/b",
2321            r"a\b",
2322            "C:work",
2323            "line\nbreak",
2324            "tab\tname",
2325            "usage.json",
2326            ".stale",
2327            ".last_error",
2328            ".fetch.lock",
2329        ] {
2330            let err = cfg.account(bad).unwrap_err();
2331            assert!(
2332                format!("{err:?}").contains("invalid anthropic account label"),
2333                "{bad:?} should be rejected as a label"
2334            );
2335        }
2336    }
2337
2338    #[test]
2339    fn anthropic_accounts_default_to_empty() {
2340        // No [[anthropic.accounts]] → the single default account, empty list,
2341        // nothing to migrate (issue #14, back-compat rule 1).
2342        assert!(Config::default().anthropic.accounts.is_empty());
2343        assert!(Config::default().anthropic.accounts_dir.is_none());
2344    }
2345
2346    // --- accounts_dir: CLAUDE_CONFIG_DIR-style auto-discovery ----------------
2347    // All hermetic: discovery reads a TempDir, never the user's real config.
2348
2349    /// Create `<root>/<label>/.credentials.json` (contents irrelevant here —
2350    /// discovery keys on the file existing, the fetch path parses it).
2351    fn seed_account_dir(root: &std::path::Path, label: &str) {
2352        let dir = root.join(label);
2353        std::fs::create_dir_all(&dir).unwrap();
2354        std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
2355    }
2356
2357    #[test]
2358    fn discovers_account_dirs_in_claude_config_dir_layout() {
2359        let td = tempfile::tempdir().unwrap();
2360        seed_account_dir(td.path(), "work");
2361        seed_account_dir(td.path(), "personal");
2362        // Keychain-backed macOS logins may not write .credentials.json; their
2363        // config directories are still account entries.
2364        std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
2365        // A loose file (not a dir) is ignored.
2366        std::fs::write(td.path().join("stray.json"), "{}").unwrap();
2367
2368        let cfg = AnthropicConfig {
2369            accounts_dir: Some(td.path().to_path_buf()),
2370            ..Default::default()
2371        };
2372        let all = cfg.all_accounts();
2373        let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
2374        assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
2375        assert_eq!(
2376            all[2].credentials_path,
2377            td.path().join("work").join(".credentials.json")
2378        );
2379    }
2380
2381    #[test]
2382    fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
2383        let td = tempfile::tempdir().unwrap();
2384        seed_account_dir(td.path(), "work");
2385        let cfg = AnthropicConfig {
2386            accounts: vec![AnthropicAccount {
2387                label: "work".into(),
2388                credentials_path: "/explicit/work.json".into(),
2389            }],
2390            accounts_dir: Some(td.path().to_path_buf()),
2391            ..Default::default()
2392        };
2393        let all = cfg.all_accounts();
2394        assert_eq!(all.len(), 1, "no duplicate label");
2395        assert_eq!(
2396            all[0].credentials_path,
2397            std::path::Path::new("/explicit/work.json"),
2398            "explicit entry wins"
2399        );
2400        // A discovered account is still reachable through `account()`.
2401        seed_account_dir(td.path(), "other");
2402        assert_eq!(cfg.account("other").unwrap().label, "other");
2403    }
2404
2405    #[test]
2406    fn missing_accounts_dir_is_silently_empty_not_an_error() {
2407        let cfg = AnthropicConfig {
2408            accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
2409            ..Default::default()
2410        };
2411        assert!(cfg.all_accounts().is_empty());
2412    }
2413
2414    #[test]
2415    fn openai_account_auth_paths_are_tilde_expanded_on_load() {
2416        let f = write_toml(
2417            r#"
2418            [[openai.accounts]]
2419            label = "work"
2420            codex_auth_path = "~/.codex-work/auth.json"
2421            "#,
2422        );
2423        let c = Config::load_from(f.path()).unwrap();
2424        let home = crate::cache::home_dir().unwrap();
2425        assert_eq!(
2426            c.openai.accounts[0].codex_auth_path,
2427            home.join(".codex-work/auth.json")
2428        );
2429    }
2430
2431    #[test]
2432    fn accounts_dir_is_tilde_expanded_on_load() {
2433        let f = write_toml(
2434            r#"
2435            [anthropic]
2436            accounts_dir = "~/.config/ai-usagebar/accounts"
2437            "#,
2438        );
2439        let c = Config::load_from(f.path()).unwrap();
2440        let home = crate::cache::home_dir().unwrap();
2441        assert_eq!(
2442            c.anthropic.accounts_dir,
2443            Some(home.join(".config/ai-usagebar/accounts"))
2444        );
2445    }
2446
2447    #[test]
2448    fn desktop_profiles_dir_is_tilde_expanded_on_load() {
2449        let f = write_toml(
2450            r#"
2451            [anthropic]
2452            desktop_profiles_dir = "~/.claude-acc/profiles"
2453            "#,
2454        );
2455        let c = Config::load_from(f.path()).unwrap();
2456        let home = crate::cache::home_dir().unwrap();
2457        assert_eq!(
2458            c.anthropic.desktop_profiles_dir,
2459            Some(home.join(".claude-acc/profiles"))
2460        );
2461    }
2462
2463    #[test]
2464    fn the_live_cli_account_is_read_from_the_default_credential_slot() {
2465        let cfg = AnthropicConfig {
2466            accounts: vec![
2467                AnthropicAccount {
2468                    label: "work".into(),
2469                    credentials_path: "/tmp/accounts/work/.credentials.json".into(),
2470                },
2471                AnthropicAccount {
2472                    label: "personal".into(),
2473                    credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
2474                },
2475            ],
2476            ..Default::default()
2477        };
2478
2479        let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
2480        assert!(
2481            matches!(&idle, CredsTarget::Named { config_dir, .. }
2482                if config_dir == std::path::Path::new("/tmp/accounts/work")),
2483            "{idle:?}"
2484        );
2485
2486        // Same label, but it is the login `claude` itself is using: one lineage.
2487        let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
2488        assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
2489
2490        // The cache must not move, or a switch would silently orphan the tab's
2491        // usage history and show "Loading…" until the next fetch.
2492        assert_eq!(idle_cache.dir(), live_cache.dir());
2493    }
2494
2495    #[test]
2496    fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
2497        let cfg = AnthropicConfig {
2498            accounts: vec![AnthropicAccount {
2499                label: "work".into(),
2500                credentials_path: "/tmp/accounts/work/.credentials.json".into(),
2501            }],
2502            ..Default::default()
2503        };
2504        let (target, _) = cfg.account_target_with("work", None).unwrap();
2505        assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
2506    }
2507
2508    /// The shipped example, which `make install` puts in
2509    /// `share/ai-usagebar/config.example.toml`. Repo-relative, so this stays
2510    /// hermetic — it never touches the user's real config.
2511    fn config_example() -> PathBuf {
2512        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
2513    }
2514
2515    #[test]
2516    fn shipped_example_parses_as_a_real_config() {
2517        // The example is documentation users copy verbatim, but nothing used
2518        // to parse it — so a renamed section or field could rot there
2519        // unnoticed, and `deny_unknown_fields` would reject the copy on the
2520        // user's machine instead of in CI.
2521        let c = Config::load_from(&config_example()).unwrap();
2522        assert!(!c.context.enabled);
2523        assert!(c.is_enabled(VendorId::Anthropic));
2524        assert!(c.is_enabled(VendorId::Openai));
2525        assert!(!c.is_enabled(VendorId::AnthropicApi));
2526        assert!(!c.is_enabled(VendorId::Deepseek));
2527        assert!(!c.is_enabled(VendorId::Kimi));
2528        assert!(!c.is_enabled(VendorId::Kilo));
2529        assert!(!c.is_enabled(VendorId::Novita));
2530        assert!(!c.is_enabled(VendorId::Moonshot));
2531        assert!(!c.is_enabled(VendorId::Grok));
2532        assert!(!c.is_enabled(VendorId::Cursor));
2533        assert!(!c.is_enabled(VendorId::Minimax));
2534    }
2535
2536    #[test]
2537    fn shipped_example_does_not_advertise_admin_key_env_as_working() {
2538        // The regression: the example shipped an *uncommented*
2539        // `admin_key_env = "OPENAI_ADMIN_KEY"`, indistinguishable from a live
2540        // setting. Nothing reads it, so a user could set it, skip
2541        // `codex login`, and wait for usage that never arrives.
2542        let text = std::fs::read_to_string(config_example()).unwrap();
2543        let live: Vec<&str> = text
2544            .lines()
2545            .map(str::trim)
2546            .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
2547            .collect();
2548        assert!(
2549            live.is_empty(),
2550            "admin_key_env must stay commented out while it is inert: {live:?}"
2551        );
2552        // Still documented, though — silently dropping it would leave users
2553        // who already set it with no explanation of why it does nothing.
2554        assert!(
2555            text.contains("admin_key_env") && text.contains("RESERVED"),
2556            "the example should keep describing admin_key_env as reserved"
2557        );
2558    }
2559
2560    #[test]
2561    fn admin_key_env_is_accepted_but_changes_nothing() {
2562        // The field survives because the API-key-only path is still intended.
2563        // What has to hold today is narrower: setting it loads without error
2564        // and moves nothing the code actually acts on.
2565        let f = write_toml(
2566            r#"
2567            [openai]
2568            admin_key_env = "SOME_ADMIN_KEY"
2569            "#,
2570        );
2571        let c = Config::load_from(f.path()).unwrap();
2572        assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
2573        // Nothing else moved: OpenAI still resolves through Codex OAuth only.
2574        let default = OpenAiConfig::default();
2575        assert_eq!(c.openai.enabled, default.enabled);
2576        assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
2577        assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
2578    }
2579
2580    #[test]
2581    fn config_example_documents_every_vendor_without_secrets() {
2582        let raw = std::fs::read_to_string(config_example()).unwrap();
2583        let cfg = Config::load_from(&config_example()).unwrap();
2584        // Every vendor the binary can dispatch needs a documented section, or
2585        // users have no way to discover how to turn it on.
2586        for id in VendorId::all() {
2587            let section = id.slug();
2588            assert!(
2589                raw.contains(&format!("[{section}]")),
2590                "config.example.toml has no [{section}] section"
2591            );
2592        }
2593
2594        // The example must not ship anything enabled-by-key-only, and must not
2595        // carry a real secret.
2596        assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
2597        assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
2598        assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
2599        assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
2600        assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
2601        assert!(!cfg.supergrok.enabled);
2602        assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
2603        assert_eq!(
2604            cfg.supergrok
2605                .grok_binary
2606                .file_name()
2607                .and_then(|p| p.to_str()),
2608            Some(if cfg!(windows) { "grok.exe" } else { "grok" })
2609        );
2610        assert!(cfg.supergrok.auth_path.is_none());
2611        assert!(cfg.supergrok.config_path.is_none());
2612        assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
2613        assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
2614    }
2615
2616    #[test]
2617    fn supergrok_binary_must_not_be_empty() {
2618        let file = write_toml(
2619            r#"
2620            [supergrok]
2621            enabled = true
2622            grok_binary = ""
2623            "#,
2624        );
2625        let error = Config::load_from(file.path()).unwrap_err().to_string();
2626        assert!(error.contains("grok_binary must not be empty"));
2627    }
2628
2629    #[test]
2630    fn supergrok_paths_are_tilde_expanded() {
2631        let file = write_toml(
2632            r#"
2633            [supergrok]
2634            grok_binary = "~/bin/grok"
2635            auth_path = "~/.grok/auth.json"
2636            config_path = "~/.grok/config.toml"
2637            "#,
2638        );
2639        let config = Config::load_from(file.path()).unwrap();
2640        let home = crate::cache::home_dir().unwrap();
2641        assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
2642        assert_eq!(
2643            config.supergrok.auth_path,
2644            Some(home.join(".grok/auth.json"))
2645        );
2646        assert_eq!(
2647            config.supergrok.config_path,
2648            Some(home.join(".grok/config.toml"))
2649        );
2650    }
2651
2652    #[test]
2653    fn kiro_db_path_is_tilde_expanded() {
2654        let f = write_toml(
2655            r#"
2656            [kiro]
2657            db_path = "~/kiro-data.sqlite3"
2658            "#,
2659        );
2660        let c = Config::load_from(f.path()).unwrap();
2661        let home = crate::cache::home_dir().unwrap();
2662        assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
2663    }
2664
2665    #[test]
2666    fn kiro_appears_when_enabled() {
2667        let f = write_toml(
2668            r#"
2669            [kiro]
2670            enabled = true
2671            "#,
2672        );
2673        let c = Config::load_from(f.path()).unwrap();
2674        assert!(c.is_enabled(VendorId::Kiro));
2675        assert!(c.enabled_vendors().contains(&VendorId::Kiro));
2676    }
2677
2678    #[test]
2679    fn cursor_db_path_is_tilde_expanded() {
2680        let f = write_toml(
2681            r#"
2682            [cursor]
2683            db_path = "~/cursor-state.vscdb"
2684            "#,
2685        );
2686        let c = Config::load_from(f.path()).unwrap();
2687        let home = crate::cache::home_dir().unwrap();
2688        assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
2689    }
2690
2691    #[test]
2692    fn cursor_agent_auth_path_is_tilde_expanded() {
2693        let f = write_toml(
2694            r#"
2695            [cursor]
2696            agent_auth_path = "~/cursor-agent-auth.json"
2697            "#,
2698        );
2699        let c = Config::load_from(f.path()).unwrap();
2700        let home = crate::cache::home_dir().unwrap();
2701        assert_eq!(
2702            c.cursor.agent_auth_path,
2703            Some(home.join("cursor-agent-auth.json"))
2704        );
2705    }
2706
2707    #[test]
2708    fn cursor_appears_when_enabled() {
2709        let f = write_toml(
2710            r#"
2711            [cursor]
2712            enabled = true
2713            "#,
2714        );
2715        let c = Config::load_from(f.path()).unwrap();
2716        assert!(c.is_enabled(VendorId::Cursor));
2717        assert!(c.enabled_vendors().contains(&VendorId::Cursor));
2718    }
2719
2720    #[test]
2721    fn add_account_appends_and_preserves_existing() {
2722        let mut doc: toml_edit::DocumentMut = r#"
2723# keep me
2724[anthropic]
2725enabled = true
2726
2727[[anthropic.accounts]]
2728label = "personal"
2729credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
2730"#
2731        .parse()
2732        .unwrap();
2733        add_anthropic_account_to_doc(
2734            &mut doc,
2735            "work",
2736            "~/.config/ai-usagebar/accounts/work/.credentials.json",
2737        )
2738        .unwrap();
2739        let rendered = doc.to_string();
2740        assert!(rendered.contains("# keep me"), "comment must survive");
2741        // Round-trips through the real loader with both accounts intact and ordered.
2742        let f = write_toml(&rendered);
2743        let c = Config::load_from(f.path()).unwrap();
2744        let labels: Vec<&str> = c
2745            .anthropic
2746            .accounts
2747            .iter()
2748            .map(|a| a.label.as_str())
2749            .collect();
2750        assert_eq!(labels, vec!["personal", "work"]);
2751    }
2752
2753    #[test]
2754    fn add_account_to_empty_doc_is_loadable() {
2755        let mut doc = toml_edit::DocumentMut::new();
2756        add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
2757        let f = write_toml(&doc.to_string());
2758        let c = Config::load_from(f.path()).unwrap();
2759        assert_eq!(c.anthropic.accounts.len(), 1);
2760        assert_eq!(c.anthropic.accounts[0].label, "solo");
2761    }
2762
2763    #[test]
2764    fn add_account_rejects_duplicate_label() {
2765        let mut doc: toml_edit::DocumentMut = r#"
2766[[anthropic.accounts]]
2767label = "work"
2768credentials_path = "~/w/.credentials.json"
2769"#
2770        .parse()
2771        .unwrap();
2772        assert!(
2773            add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
2774            "a duplicate label must be rejected, not appended"
2775        );
2776    }
2777
2778    #[test]
2779    fn add_account_rejects_bad_label() {
2780        let mut doc = toml_edit::DocumentMut::new();
2781        assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
2782        assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
2783    }
2784
2785    #[test]
2786    fn tildify_collapses_home_only() {
2787        let home = Path::new("/Users/me");
2788        assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
2789        assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
2790    }
2791
2792    #[test]
2793    fn default_account_credentials_path_nests_under_config_dir() {
2794        let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
2795        assert_eq!(
2796            default_account_credentials_path(cfg, "work"),
2797            Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
2798        );
2799    }
2800}