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