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    const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
331    let bad = label.is_empty()
332        || label == "."
333        || label == ".."
334        || label.contains(['/', '\\'])
335        || label.chars().any(char::is_control)
336        || RESERVED.contains(&label);
337    if bad {
338        return Err(AppError::Credentials(format!(
339            "invalid anthropic account label {label:?}: must be a non-empty name \
340             without path separators, control characters, or reserved cache names"
341        )));
342    }
343    Ok(())
344}
345
346/// Discover accounts under `accounts_dir` in the `CLAUDE_CONFIG_DIR` layout:
347/// each immediate subdirectory becomes an account labeled by the subdirectory
348/// name. Best-effort: an unreadable directory or unusable label is skipped
349/// silently rather than failing the whole config — discovery is convenience,
350/// while an explicit `[[anthropic.accounts]]` entry stays authoritative. The
351/// fetch path resolves credentials from either `.credentials.json` or the macOS
352/// Keychain. Sorted by label so the tab order is stable across runs.
353fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
354    let Ok(entries) = std::fs::read_dir(accounts_dir) else {
355        return Vec::new();
356    };
357    let mut found: Vec<AnthropicAccount> = entries
358        .flatten()
359        .filter_map(|entry| {
360            let path = entry.path();
361            if !path.is_dir() {
362                return None;
363            }
364            let label = path.file_name()?.to_str()?.to_string();
365            validate_account_label(&label).ok()?;
366            Some(AnthropicAccount {
367                label,
368                credentials_path: path.join(".credentials.json"),
369            })
370        })
371        .collect();
372    found.sort_by(|a, b| a.label.cmp(&b.label));
373    found
374}
375
376/// Render a path with `$HOME` collapsed back to `~`, matching the style the docs
377/// and existing `[[anthropic.accounts]]` entries use. Pure so it's testable;
378/// paths outside home are returned verbatim.
379pub fn tildify(path: &Path, home: &Path) -> String {
380    path.strip_prefix(home)
381        .map(|rest| {
382            let rendered = rest.display().to_string();
383            // Config paths use the same portable `~/...` spelling on every
384            // platform. A Windows `~\...` would not be expanded by the loader.
385            #[cfg(windows)]
386            let rendered = rendered.replace('\\', "/");
387            format!("~/{rendered}")
388        })
389        .unwrap_or_else(|_| path.display().to_string())
390}
391
392/// Where a newly-registered account's credentials file lives by default: next
393/// to `config.toml`, under `accounts/<label>/.credentials.json`. Returns the
394/// absolute path (for `mkdir`) — tilde-render it with [`tildify`] for display
395/// and for the value written into config.
396pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
397    let base = config_path.parent().unwrap_or_else(|| Path::new("."));
398    base.join("accounts").join(label).join(".credentials.json")
399}
400
401/// Append a `[[anthropic.accounts]]` entry to a parsed config document, in
402/// place. Pure over a `toml_edit` document so the validation, duplicate check,
403/// and formatting are testable without disk. Preserves the rest of the file
404/// (comments, key order, other sections) — only the new array-of-tables entry
405/// is added. Errors on an invalid label or a label that already exists.
406pub fn add_anthropic_account_to_doc(
407    doc: &mut toml_edit::DocumentMut,
408    label: &str,
409    credentials_path: &str,
410) -> Result<()> {
411    use toml_edit::{Item, Table, value};
412
413    validate_account_label(label)?;
414
415    let anthropic = doc
416        .entry("anthropic")
417        .or_insert_with(|| Item::Table(Table::new()));
418    let anthropic = anthropic
419        .as_table_mut()
420        .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
421
422    let accounts = anthropic
423        .entry("accounts")
424        .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
425    let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
426        AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
427    })?;
428
429    let exists = accounts
430        .iter()
431        .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
432    if exists {
433        return Err(AppError::Credentials(format!(
434            "anthropic account {label:?} already exists in config.toml"
435        )));
436    }
437
438    let mut table = Table::new();
439    table["label"] = value(label);
440    table["credentials_path"] = value(credentials_path);
441    accounts.push(table);
442    Ok(())
443}
444
445#[derive(Debug, Clone, Deserialize, Serialize)]
446#[serde(default)]
447pub struct OpenAiConfig {
448    pub enabled: bool,
449    /// Override the Codex auth file path (defaults to `~/.codex/auth.json`).
450    pub codex_auth_path: Option<PathBuf>,
451    /// Reserved, and inert: names the env var an API-key-only path *would*
452    /// read (admin key → `/v1/organization/costs`). Nothing consumes it —
453    /// OpenAI usage comes solely from Codex OAuth. Kept because that path is
454    /// still intended, not for back-compat: `[openai]` doesn't deny unknown
455    /// fields, so an existing `admin_key_env` would load either way. See
456    /// `config.example.toml`, which ships it commented out so nobody sets it
457    /// expecting an effect.
458    pub admin_key_env: String,
459}
460
461impl Default for OpenAiConfig {
462    fn default() -> Self {
463        Self {
464            enabled: true,
465            codex_auth_path: None,
466            admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
467        }
468    }
469}
470
471#[derive(Debug, Clone, Default, Deserialize, Serialize)]
472#[serde(default)]
473pub struct NousConfig {
474    pub enabled: bool,
475}
476
477#[derive(Debug, Clone, Deserialize, Serialize)]
478#[serde(default)]
479pub struct OpenCodeGoConfig {
480    pub enabled: bool,
481    pub api_key_env: String,
482    pub api_key: Option<String>,
483}
484
485impl Default for OpenCodeGoConfig {
486    fn default() -> Self {
487        Self {
488            enabled: false,
489            api_key_env: "OPENCODE_GO_API_KEY".to_string(),
490            api_key: None,
491        }
492    }
493}
494
495#[derive(Debug, Clone, Deserialize, Serialize)]
496#[serde(default)]
497pub struct ZaiConfig {
498    pub enabled: bool,
499    /// Env var name to read the key from (env wins over `api_key`).
500    pub api_key_env: String,
501    /// Inline key (fallback when the env var is unset). Chmod 600 your
502    /// config file if you put a real key here.
503    pub api_key: Option<String>,
504    /// Optional plan tier label (lite/pro/max) — display-only.
505    pub plan_tier: Option<String>,
506}
507
508impl Default for ZaiConfig {
509    fn default() -> Self {
510        Self {
511            enabled: true,
512            api_key_env: "ZAI_API_KEY".to_string(),
513            api_key: None,
514            plan_tier: None,
515        }
516    }
517}
518
519#[derive(Debug, Clone, Deserialize, Serialize)]
520#[serde(default)]
521pub struct OpenRouterConfig {
522    pub enabled: bool,
523    pub api_key_env: String,
524    pub api_key: Option<String>,
525}
526
527impl Default for OpenRouterConfig {
528    fn default() -> Self {
529        Self {
530            enabled: true,
531            api_key_env: "OPENROUTER_API_KEY".to_string(),
532            api_key: None,
533        }
534    }
535}
536
537#[derive(Debug, Clone, Deserialize, Serialize)]
538#[serde(default)]
539pub struct DeepseekConfig {
540    pub enabled: bool,
541    pub api_key_env: String,
542    pub api_key: Option<String>,
543}
544
545impl Default for DeepseekConfig {
546    fn default() -> Self {
547        Self {
548            enabled: false,
549            api_key_env: "DEEPSEEK_API_KEY".to_string(),
550            api_key: None,
551        }
552    }
553}
554
555#[derive(Debug, Clone, Deserialize, Serialize)]
556#[serde(default)]
557pub struct KimiConfig {
558    pub enabled: bool,
559    pub api_key_env: String,
560    pub api_key: Option<String>,
561}
562
563impl Default for KimiConfig {
564    fn default() -> Self {
565        Self {
566            enabled: false,
567            api_key_env: "KIMI_API_KEY".to_string(),
568            api_key: None,
569        }
570    }
571}
572
573#[derive(Debug, Clone, Deserialize, Serialize)]
574#[serde(default)]
575pub struct KiloConfig {
576    pub enabled: bool,
577    pub api_key_env: String,
578    pub api_key: Option<String>,
579    /// Optional Kilo organization id — scopes the balance to a team via the
580    /// `x-kilocode-organizationid` header. Omit for the personal balance.
581    pub organization_id: Option<String>,
582}
583
584impl Default for KiloConfig {
585    fn default() -> Self {
586        // Opt-in like DeepSeek: requires an explicit API key, so it defaults to
587        // disabled and never affects existing installs.
588        Self {
589            enabled: false,
590            api_key_env: "KILO_API_KEY".to_string(),
591            api_key: None,
592            organization_id: None,
593        }
594    }
595}
596
597#[derive(Debug, Clone, Deserialize, Serialize)]
598#[serde(default)]
599pub struct NovitaConfig {
600    pub enabled: bool,
601    pub api_key_env: String,
602    pub api_key: Option<String>,
603}
604
605impl Default for NovitaConfig {
606    fn default() -> Self {
607        // Opt-in like DeepSeek/Kilo: needs an explicit API key.
608        Self {
609            enabled: false,
610            api_key_env: "NOVITA_API_KEY".to_string(),
611            api_key: None,
612        }
613    }
614}
615
616#[derive(Debug, Clone, Deserialize, Serialize)]
617#[serde(default)]
618pub struct MinimaxConfig {
619    pub enabled: bool,
620    pub api_key_env: String,
621    pub api_key: Option<String>,
622    /// `"global"` → api.minimax.io; `"cn"` → api.minimaxi.com. Unlike
623    /// Moonshot's, this does not change the unit — MiniMax reports quota as a
624    /// percentage either way. It picks the *instance*: a key issued for one
625    /// host is rejected by the other (`status_code 2049`), so pointing this at
626    /// the wrong region reads as an invalid key rather than an empty plan.
627    pub region: String,
628}
629
630impl Default for MinimaxConfig {
631    fn default() -> Self {
632        // Opt-in like the other API-key vendors: needs an explicit key.
633        Self {
634            enabled: false,
635            api_key_env: "MINIMAX_API_KEY".to_string(),
636            api_key: None,
637            region: "global".to_string(),
638        }
639    }
640}
641
642#[derive(Debug, Clone, Deserialize, Serialize)]
643#[serde(default)]
644pub struct MoonshotConfig {
645    pub enabled: bool,
646    pub api_key_env: String,
647    pub api_key: Option<String>,
648    /// `"global"` → api.moonshot.ai (USD); `"cn"` → api.moonshot.cn (CNY).
649    pub region: String,
650}
651
652impl Default for MoonshotConfig {
653    fn default() -> Self {
654        // Opt-in like DeepSeek/Kilo/Novita: needs an explicit API key.
655        Self {
656            enabled: false,
657            api_key_env: "MOONSHOT_API_KEY".to_string(),
658            api_key: None,
659            region: "global".to_string(),
660        }
661    }
662}
663
664#[derive(Debug, Clone, Deserialize, Serialize)]
665#[serde(default)]
666pub struct GrokConfig {
667    pub enabled: bool,
668    /// Env var for the xAI **Management** key (distinct from the inference key).
669    pub api_key_env: String,
670    pub api_key: Option<String>,
671    /// Optional team id. When absent, it's auto-resolved from the management
672    /// key via `/auth/management-keys/validation`.
673    pub team_id: Option<String>,
674}
675
676impl Default for GrokConfig {
677    fn default() -> Self {
678        // Opt-in: needs a management key (and, for prepaid, a team).
679        Self {
680            enabled: false,
681            api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
682            api_key: None,
683            team_id: None,
684        }
685    }
686}
687
688/// SuperGrok subscription auth — no API key. Asks the official Grok Build
689/// CLI for billing through its `x.ai/billing` ACP extension, leaving every
690/// credential, issuer, proxy, and token-rotation decision inside Grok Build.
691///
692/// Opt-in like Cursor/Kiro (`enabled` defaults to `false`): it requires a
693/// separate official executable and signed-in session, so it stays off until
694/// the user explicitly turns it on.
695#[derive(Debug, Clone, Deserialize, Serialize)]
696#[serde(default)]
697pub struct SuperGrokConfig {
698    pub enabled: bool,
699    /// Trusted official Grok Build executable. Defaults to its canonical
700    /// `$GROK_HOME/bin/grok` (or `~/.grok/bin/grok`) installation path instead
701    /// of searching PATH, where unrelated programs can share the name.
702    pub grok_binary: PathBuf,
703    /// Opaque auth/config files used only to fingerprint the active cache
704    /// scope. Their contents are never parsed or copied to the cache.
705    pub auth_path: Option<PathBuf>,
706    pub config_path: Option<PathBuf>,
707}
708
709impl Default for SuperGrokConfig {
710    fn default() -> Self {
711        Self {
712            enabled: false,
713            grok_binary: default_grok_binary(),
714            auth_path: None,
715            config_path: None,
716        }
717    }
718}
719
720fn default_grok_binary() -> PathBuf {
721    let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
722    let grok_home = std::env::var_os("GROK_HOME")
723        .filter(|value| !value.is_empty())
724        .map(PathBuf::from)
725        .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
726    grok_home
727        .map(|home| home.join("bin").join(executable))
728        .unwrap_or_else(|| PathBuf::from(executable))
729}
730
731/// Antigravity reads its quota from whichever local Antigravity product is
732/// running, so it needs no credentials — only an on/off switch.
733#[derive(Debug, Clone, Default, Deserialize, Serialize)]
734#[serde(default)]
735pub struct AntigravityConfig {
736    pub enabled: bool,
737}
738
739/// Cursor reads its quota through a session token the Cursor IDE already
740/// wrote to its local `state.vscdb` — no API key, but (unlike Antigravity)
741/// there is a real on-disk path that can need overriding (e.g. a portable or
742/// non-default Cursor install), mirroring `openai.codex_auth_path`.
743///
744/// Opt-in like DeepSeek/Kilo/etc (`enabled` defaults to `false`, matching
745/// `bool::default()`): reads an undocumented endpoint via a session token
746/// scraped from a local IDE file, so it stays off until the user explicitly
747/// turns it on.
748#[derive(Debug, Clone, Default, Deserialize, Serialize)]
749#[serde(default)]
750pub struct CursorConfig {
751    pub enabled: bool,
752    /// Override Cursor's local state database path (defaults to the
753    /// platform-standard `.../User/globalStorage/state.vscdb` — see
754    /// `cursor::db::default_db_path`).
755    pub db_path: Option<PathBuf>,
756    /// Override the headless `cursor-agent` CLI's own login file (defaults to
757    /// `.../cursor/auth.json` — see `cursor::db::default_agent_auth_path`).
758    /// Used as a fallback when `db_path` doesn't exist, so a text-only
759    /// machine that never runs the desktop IDE still gets usage.
760    pub agent_auth_path: Option<PathBuf>,
761}
762
763/// Kiro CLI reads its quota through the AWS SSO OIDC session kiro-cli already
764/// wrote to its own local `data.sqlite3` — no API key, but (like Cursor) a
765/// real on-disk path that can need overriding.
766///
767/// Opt-in like Cursor/DeepSeek/Kilo/etc (`enabled` defaults to `false`):
768/// calls a reverse-engineered CodeWhisperer endpoint via a session token
769/// scraped from a local CLI database, so it stays off until the user
770/// explicitly turns it on.
771#[derive(Debug, Clone, Default, Deserialize, Serialize)]
772#[serde(default)]
773pub struct KiroConfig {
774    pub enabled: bool,
775    /// Override kiro-cli's local database path (defaults to the
776    /// platform-standard `.../kiro-cli/data.sqlite3` — see
777    /// `kiro::db::default_db_path`).
778    pub db_path: Option<PathBuf>,
779}
780
781#[derive(Debug, Clone, Deserialize, Serialize)]
782#[serde(default)]
783pub struct AnthropicApiConfig {
784    pub enabled: bool,
785    /// Env var for the Console **Admin key** (`sk-ant-admin01-…`), distinct from
786    /// an inference key and from the Claude Code OAuth login.
787    pub api_key_env: String,
788    pub api_key: Option<String>,
789    /// Monthly USD spend limit, used only for the spend-vs-limit % display. The
790    /// API exposes neither this limit nor the remaining prepaid balance.
791    pub monthly_limit: Option<f64>,
792}
793
794impl Default for AnthropicApiConfig {
795    fn default() -> Self {
796        // Opt-in: needs an explicit Admin key.
797        Self {
798            enabled: false,
799            api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
800            api_key: None,
801            monthly_limit: None,
802        }
803    }
804}
805
806/// Resolve an API key for a vendor: a valid env-var name wins, then inline
807/// config, then a clear error naming both fields. Used by every API-key vendor.
808pub fn resolve_api_key(
809    vendor_label: &str,
810    env_var_name: &str,
811    inline: Option<&str>,
812) -> crate::error::Result<String> {
813    let valid_env_name = is_valid_env_var_name(env_var_name);
814    if valid_env_name
815        && let Ok(v) = std::env::var(env_var_name)
816        && !v.is_empty()
817    {
818        return Ok(v);
819    }
820    if let Some(v) = inline
821        && !v.is_empty()
822    {
823        return Ok(v.to_string());
824    }
825    let advice = if valid_env_name {
826        "set an API key in a valid environment variable or set `api_key`"
827    } else {
828        "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
829    };
830    let section = match vendor_label {
831        "OpenCode Go" => "opencode-go".to_string(),
832        _ => vendor_label.to_lowercase(),
833    };
834    Err(crate::error::AppError::Credentials(format!(
835        "{vendor_label}: no API key. Either {advice} under [{section}] in {}.",
836        config_path_hint()
837    )))
838}
839
840fn is_valid_env_var_name(name: &str) -> bool {
841    let mut chars = name.chars();
842    let Some(first) = chars.next() else {
843        return false;
844    };
845    (first.is_ascii_alphabetic() || first == '_')
846        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
847}
848
849impl Config {
850    /// Load from `~/.config/ai-usagebar/config.toml`. Returns defaults if the
851    /// file doesn't exist; errors only on actual parse failures.
852    pub fn load() -> Result<Self> {
853        let Some(path) = resolved_path() else {
854            return Ok(Self::default());
855        };
856        Self::load_from(&path)
857    }
858
859    pub fn load_from(path: &std::path::Path) -> Result<Self> {
860        match std::fs::read_to_string(path) {
861            Ok(s) => {
862                let mut config: Self = toml::from_str(&s)?;
863                // `~` is shell syntax, not path syntax: `PathBuf` keeps it
864                // literally, so a documented `credentials_path = "~/..."`
865                // silently pointed at a directory named `~`.
866                config.expand_paths();
867                config.validate()?;
868                #[cfg(unix)]
869                config.protect_inline_api_keys(path)?;
870                Ok(config)
871            }
872            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
873            Err(e) => Err(AppError::io_at(path, e)),
874        }
875    }
876
877    fn expand_paths(&mut self) {
878        expand_tilde_opt(&mut self.context.projects_path);
879        expand_tilde_opt(&mut self.anthropic.credentials_path);
880        expand_tilde_opt(&mut self.anthropic.accounts_dir);
881        expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
882        expand_tilde_opt(&mut self.openai.codex_auth_path);
883        expand_tilde_opt(&mut self.cursor.db_path);
884        expand_tilde_opt(&mut self.cursor.agent_auth_path);
885        expand_tilde_opt(&mut self.kiro.db_path);
886        self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
887        expand_tilde_opt(&mut self.supergrok.auth_path);
888        expand_tilde_opt(&mut self.supergrok.config_path);
889        for account in &mut self.anthropic.accounts {
890            account.credentials_path = expand_tilde(&account.credentials_path);
891        }
892    }
893
894    /// Explicitly enumerate every inline API-key field. Adding a new API-key
895    /// vendor must add it here so its config file receives the same protection.
896    #[cfg(unix)]
897    fn has_inline_api_keys(&self) -> bool {
898        [
899            self.zai.api_key.as_deref(),
900            self.openrouter.api_key.as_deref(),
901            self.deepseek.api_key.as_deref(),
902            self.kimi.api_key.as_deref(),
903            self.kilo.api_key.as_deref(),
904            self.novita.api_key.as_deref(),
905            self.minimax.api_key.as_deref(),
906            self.moonshot.api_key.as_deref(),
907            self.grok.api_key.as_deref(),
908            self.anthropic_api.api_key.as_deref(),
909            self.opencode_go.api_key.as_deref(),
910        ]
911        .into_iter()
912        .any(|key| key.is_some_and(|key| !key.is_empty()))
913    }
914
915    #[cfg(unix)]
916    fn protect_inline_api_keys(&self, path: &Path) -> Result<()> {
917        if !self.has_inline_api_keys() {
918            return Ok(());
919        }
920
921        let metadata = std::fs::metadata(path).map_err(|_| {
922            AppError::Credentials(format!(
923                "config at {} contains inline api_key values but its permissions could not be checked; fix permissions or move keys to environment variables",
924                path.display()
925            ))
926        })?;
927        if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
928            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
929                AppError::Credentials(format!(
930                    "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",
931                    path.display()
932                ))
933            })?;
934        }
935        Ok(())
936    }
937
938    pub fn is_enabled(&self, id: VendorId) -> bool {
939        match id {
940            VendorId::Anthropic => self.anthropic.enabled,
941            VendorId::AnthropicApi => self.anthropic_api.enabled,
942            VendorId::Openai => self.openai.enabled,
943            VendorId::Zai => self.zai.enabled,
944            VendorId::Openrouter => self.openrouter.enabled,
945            VendorId::Deepseek => self.deepseek.enabled,
946            VendorId::Kimi => self.kimi.enabled,
947            VendorId::Kilo => self.kilo.enabled,
948            VendorId::Novita => self.novita.enabled,
949            VendorId::Moonshot => self.moonshot.enabled,
950            VendorId::Grok => self.grok.enabled,
951            VendorId::Supergrok => self.supergrok.enabled,
952            VendorId::Antigravity => self.antigravity.enabled,
953            VendorId::Cursor => self.cursor.enabled,
954            VendorId::Minimax => self.minimax.enabled,
955            VendorId::Kiro => self.kiro.enabled,
956            VendorId::NousResearch => self.nous.enabled,
957            VendorId::OpenCodeGo => self.opencode_go.enabled,
958        }
959    }
960
961    pub fn enabled_vendors(&self) -> Vec<VendorId> {
962        VendorId::all()
963            .iter()
964            .copied()
965            .filter(|id| self.is_enabled(*id))
966            .collect()
967    }
968
969    /// Validate cross-entry constraints that serde cannot express. Account
970    /// labels are both CLI selectors and TUI tab identities, so duplicates
971    /// would make either destination ambiguous.
972    pub fn validate(&self) -> Result<()> {
973        if self.context.context_window_tokens == Some(0) {
974            return Err(AppError::Other(
975                "[context] context_window_tokens must be greater than zero".into(),
976            ));
977        }
978        for (model, tokens) in &self.context.model_context_window_tokens {
979            if model.trim().is_empty() {
980                return Err(AppError::Other(
981                    "[context] model_context_window_tokens keys must not be empty".into(),
982                ));
983            }
984            if *tokens == 0 {
985                return Err(AppError::Other(format!(
986                    "[context] model_context_window_tokens entry {model:?} must be greater than zero"
987                )));
988            }
989        }
990        if let Some(limit) = self.anthropic_api.monthly_limit
991            && (!limit.is_finite() || limit <= 0.0)
992        {
993            return Err(AppError::Other(
994                "[anthropic_api] monthly_limit must be finite and greater than zero; \
995                 remove it to show spend without a limit"
996                    .into(),
997            ));
998        }
999        if !self.minimax.region.eq_ignore_ascii_case("global")
1000            && !self.minimax.region.eq_ignore_ascii_case("cn")
1001        {
1002            return Err(AppError::Other(format!(
1003                "[minimax] region must be \"global\" or \"cn\", got {:?}",
1004                self.minimax.region
1005            )));
1006        }
1007        if self.supergrok.grok_binary.as_os_str().is_empty() {
1008            return Err(AppError::Other(
1009                "[supergrok] grok_binary must not be empty".into(),
1010            ));
1011        }
1012        let mut labels = HashSet::new();
1013        for account in &self.anthropic.accounts {
1014            validate_account_label(&account.label)?;
1015            if !labels.insert(&account.label) {
1016                return Err(AppError::Credentials(format!(
1017                    "duplicate anthropic account label {:?}",
1018                    account.label
1019                )));
1020            }
1021        }
1022        Ok(())
1023    }
1024}
1025
1026#[cfg(unix)]
1027#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1028enum InlineKeyPermissionDecision {
1029    Ok,
1030    Tighten,
1031}
1032
1033#[cfg(unix)]
1034fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
1035    if mode & 0o077 == 0 {
1036        InlineKeyPermissionDecision::Ok
1037    } else {
1038        InlineKeyPermissionDecision::Tighten
1039    }
1040}
1041
1042pub fn default_path() -> Option<PathBuf> {
1043    let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
1044    Some(proj.config_dir().join("config.toml"))
1045}
1046
1047/// The Unix-conventional location, which is what every doc, the config
1048/// example, and both desktop integrations have always pointed at. On Linux it
1049/// *is* [`default_path`]; on macOS `ProjectDirs` resolves to
1050/// `~/Library/Application Support/…` instead, so the two diverge.
1051fn legacy_xdg_path() -> Option<PathBuf> {
1052    let home = crate::cache::home_dir().ok()?;
1053    Some(home.join(".config").join("ai-usagebar").join("config.toml"))
1054}
1055
1056/// The config file actually in effect.
1057///
1058/// [`default_path`] stays canonical, but on macOS a file at the documented
1059/// `~/.config/ai-usagebar/config.toml` is honored when the canonical one does
1060/// not exist — otherwise everyone who followed the README (and both desktop
1061/// integrations, which read that path) silently got defaults. The legacy file
1062/// is never moved or rewritten: it may hold API keys, and relocating a secret
1063/// behind the user's back is not this tool's business.
1064pub fn resolved_path() -> Option<PathBuf> {
1065    let canonical = default_path();
1066    if let Some(p) = &canonical
1067        && p.exists()
1068    {
1069        return canonical;
1070    }
1071    if let Some(legacy) = legacy_xdg_path()
1072        && legacy.exists()
1073    {
1074        return Some(legacy);
1075    }
1076    canonical
1077}
1078
1079/// Expand a leading `~` (or `~/`) against the user's home directory. Anything
1080/// else — including `~user` — is left untouched.
1081fn expand_tilde(p: &std::path::Path) -> PathBuf {
1082    let Some(s) = p.to_str() else {
1083        return p.to_path_buf();
1084    };
1085    let rest = if s == "~" {
1086        ""
1087    } else if let Some(r) = s.strip_prefix("~/") {
1088        r
1089    } else {
1090        return p.to_path_buf();
1091    };
1092    match crate::cache::home_dir() {
1093        Ok(home) if rest.is_empty() => home,
1094        Ok(home) => home.join(rest),
1095        Err(_) => p.to_path_buf(),
1096    }
1097}
1098
1099fn expand_tilde_opt(p: &mut Option<PathBuf>) {
1100    if let Some(inner) = p.as_ref() {
1101        *p = Some(expand_tilde(inner));
1102    }
1103}
1104
1105/// Resolved `config.toml` path as a string for user-facing messages. Uses the
1106/// platform's config dir (`directories::ProjectDirs`), so it reads correctly on
1107/// Linux, macOS, and Windows instead of hard-coding the Unix `~/.config` path.
1108/// Falls back to the bare filename if the path can't be resolved.
1109pub fn config_path_hint() -> String {
1110    resolved_path()
1111        .map(|p| p.display().to_string())
1112        .unwrap_or_else(|| "config.toml".to_string())
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117    use super::*;
1118    use std::io::Write;
1119    use tempfile::NamedTempFile;
1120
1121    #[cfg(unix)]
1122    use std::os::unix::fs::{MetadataExt, PermissionsExt};
1123
1124    fn write_toml(s: &str) -> NamedTempFile {
1125        let mut f = NamedTempFile::new().unwrap();
1126        f.write_all(s.as_bytes()).unwrap();
1127        f.flush().unwrap();
1128        f
1129    }
1130
1131    #[test]
1132    fn defaults_enable_only_the_four_core_vendors() {
1133        let c = Config::default();
1134        assert!(c.is_enabled(VendorId::Anthropic));
1135        assert!(c.is_enabled(VendorId::Openai));
1136        assert!(c.is_enabled(VendorId::Zai));
1137        assert!(c.is_enabled(VendorId::Openrouter));
1138        for opt_in in [
1139            VendorId::AnthropicApi,
1140            VendorId::Deepseek,
1141            VendorId::Kimi,
1142            VendorId::Kilo,
1143            VendorId::Novita,
1144            VendorId::Moonshot,
1145            VendorId::Grok,
1146            VendorId::Supergrok,
1147            VendorId::Cursor,
1148            VendorId::Minimax,
1149            VendorId::Kiro,
1150        ] {
1151            assert!(!c.is_enabled(opt_in), "{opt_in:?}");
1152        }
1153        assert_eq!(c.enabled_vendors().len(), 4);
1154    }
1155
1156    #[test]
1157    fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
1158        let config = Config::default();
1159        assert!(!config.is_enabled(VendorId::NousResearch));
1160        assert!(!config.is_enabled(VendorId::OpenCodeGo));
1161        assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
1162        assert!(config.opencode_go.api_key.is_none());
1163    }
1164
1165    #[cfg(unix)]
1166    #[test]
1167    fn opencode_go_inline_key_is_protected_like_other_api_keys() {
1168        let mut config = Config::default();
1169        config.opencode_go.api_key = Some("<redacted>".to_string());
1170        assert!(config.has_inline_api_keys());
1171    }
1172
1173    #[test]
1174    fn missing_file_uses_defaults() {
1175        let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
1176        let c = Config::load_from(path).unwrap();
1177        assert!(c.is_enabled(VendorId::Anthropic));
1178    }
1179
1180    #[test]
1181    fn parses_full_config() {
1182        let f = write_toml(
1183            r#"
1184            [anthropic]
1185            enabled = true
1186
1187            [openai]
1188            enabled = false
1189            admin_key_env = "MY_ADMIN_KEY"
1190
1191            [zai]
1192            enabled = true
1193            api_key_env = "MY_ZAI"
1194            plan_tier = "pro"
1195
1196            [openrouter]
1197            enabled = false
1198            "#,
1199        );
1200        let c = Config::load_from(f.path()).unwrap();
1201        assert!(c.is_enabled(VendorId::Anthropic));
1202        assert!(!c.is_enabled(VendorId::Openai));
1203        assert!(c.is_enabled(VendorId::Zai));
1204        assert!(!c.is_enabled(VendorId::Openrouter));
1205        assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
1206        assert_eq!(c.zai.api_key_env, "MY_ZAI");
1207        assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
1208    }
1209
1210    #[test]
1211    fn partial_config_falls_back_to_defaults() {
1212        let f = write_toml(
1213            r#"[openai]
1214enabled = false
1215"#,
1216        );
1217        let c = Config::load_from(f.path()).unwrap();
1218        assert!(!c.is_enabled(VendorId::Openai));
1219        // Other vendors keep their defaults.
1220        assert!(c.is_enabled(VendorId::Anthropic));
1221        assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
1222    }
1223
1224    #[test]
1225    fn malformed_toml_returns_error() {
1226        let f = write_toml("this is not = = valid");
1227        assert!(Config::load_from(f.path()).is_err());
1228    }
1229
1230    #[cfg(unix)]
1231    #[test]
1232    fn load_from_tightens_world_readable_config_with_inline_api_key() {
1233        let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
1234        std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1235
1236        Config::load_from(file.path()).unwrap();
1237
1238        assert_eq!(
1239            std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1240            0o600
1241        );
1242    }
1243
1244    #[cfg(unix)]
1245    #[test]
1246    fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
1247        let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
1248        std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1249
1250        Config::load_from(file.path()).unwrap();
1251
1252        assert_eq!(
1253            std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1254            0o644
1255        );
1256    }
1257
1258    #[cfg(unix)]
1259    #[test]
1260    fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
1261        assert_eq!(
1262            inline_key_permission_decision(0o600),
1263            InlineKeyPermissionDecision::Ok
1264        );
1265        assert_eq!(
1266            inline_key_permission_decision(0o640),
1267            InlineKeyPermissionDecision::Tighten
1268        );
1269        assert_eq!(
1270            inline_key_permission_decision(0o604),
1271            InlineKeyPermissionDecision::Tighten
1272        );
1273    }
1274
1275    #[test]
1276    fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
1277        for value in ["0", "-1", "inf", "nan"] {
1278            let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
1279            let error = Config::load_from(file.path()).unwrap_err().to_string();
1280            assert!(error.contains("monthly_limit"), "value {value}: {error}");
1281        }
1282
1283        let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
1284        assert_eq!(
1285            Config::load_from(file.path())
1286                .unwrap()
1287                .anthropic_api
1288                .monthly_limit,
1289            Some(1000.0)
1290        );
1291    }
1292
1293    #[test]
1294    fn minimax_region_accepts_only_known_instances() {
1295        for region in ["global", "GLOBAL", "cn", "CN"] {
1296            let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1297            assert_eq!(
1298                Config::load_from(file.path()).unwrap().minimax.region,
1299                region
1300            );
1301        }
1302
1303        for region in ["", "china", "us"] {
1304            let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1305            let error = Config::load_from(file.path()).unwrap_err().to_string();
1306            assert!(error.contains("[minimax] region"), "{error}");
1307        }
1308    }
1309
1310    #[test]
1311    fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
1312        let defaults = Config::default();
1313        assert!(!defaults.context.enabled);
1314        assert_eq!(
1315            defaults.context.window_tokens_for(Some("claude-test")),
1316            None
1317        );
1318
1319        let file = write_toml(
1320            r#"
1321            [context]
1322            enabled = true
1323            context_window_tokens = 200000
1324
1325            [context.model_context_window_tokens]
1326            claude-opus-1m = 1000000
1327            "claude exact id" = 300000
1328            "#,
1329        );
1330        let config = Config::load_from(file.path()).unwrap();
1331        assert!(config.context.enabled);
1332        assert_eq!(
1333            config.context.window_tokens_for(Some("claude-opus-1m")),
1334            Some(1_000_000)
1335        );
1336        assert_eq!(
1337            config.context.window_tokens_for(Some("claude exact id")),
1338            Some(300_000)
1339        );
1340        assert_eq!(
1341            config.context.window_tokens_for(Some("another-model")),
1342            Some(200_000)
1343        );
1344    }
1345
1346    #[test]
1347    fn context_layout_defaults_to_full_and_parses_each_variant() {
1348        assert_eq!(Config::default().context.layout, ContextLayout::Full);
1349        for (text, want) in [
1350            ("full", ContextLayout::Full),
1351            ("split", ContextLayout::Split),
1352            ("bottom", ContextLayout::Bottom),
1353        ] {
1354            let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
1355            assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
1356        }
1357        let file = write_toml("[context]\nlayout = \"floating\"\n");
1358        assert!(
1359            Config::load_from(file.path()).is_err(),
1360            "an unknown layout must be rejected, not silently defaulted"
1361        );
1362    }
1363
1364    #[test]
1365    fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
1366        assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
1367        for (text, want) in [
1368            ("sidebar", VendorBoxStyle::Sidebar),
1369            ("navbar", VendorBoxStyle::Navbar),
1370            ("none", VendorBoxStyle::None),
1371        ] {
1372            let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
1373            assert_eq!(
1374                Config::load_from(file.path()).unwrap().ui.vendor_box(),
1375                want
1376            );
1377        }
1378        let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
1379        assert!(
1380            Config::load_from(file.path()).is_err(),
1381            "an unknown vendor_box style must be rejected, not silently defaulted"
1382        );
1383    }
1384
1385    #[test]
1386    fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
1387        for source in [
1388            "[context]\ncontext_window_tokens = 0\n",
1389            "[context.model_context_window_tokens]\nclaude = 0\n",
1390            "[context.model_context_window_tokens]\n\" \" = 200000\n",
1391        ] {
1392            let file = write_toml(source);
1393            let error = Config::load_from(file.path()).unwrap_err().to_string();
1394            assert!(error.contains("context"), "{error}");
1395        }
1396    }
1397
1398    // serial guard for env-var manipulation tests so they don't race
1399    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
1400        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
1401        M.lock().unwrap_or_else(|p| p.into_inner())
1402    }
1403
1404    #[test]
1405    fn resolve_api_key_prefers_env_over_inline() {
1406        let _g = env_guard();
1407        // Use a unique env var name so we don't clobber test parallelism.
1408        let var = "AI_USAGEBAR_TEST_ENV_WINS";
1409        // SAFETY: tests are single-threaded under env_guard.
1410        unsafe { std::env::set_var(var, "from-env") };
1411        let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
1412        unsafe { std::env::remove_var(var) };
1413        assert_eq!(got, "from-env");
1414    }
1415
1416    #[test]
1417    fn resolve_api_key_falls_back_to_inline() {
1418        let _g = env_guard();
1419        let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
1420        unsafe { std::env::remove_var(var) };
1421        let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
1422        assert_eq!(got, "inline-key");
1423    }
1424
1425    #[test]
1426    fn resolve_api_key_errors_when_both_missing() {
1427        let _g = env_guard();
1428        let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
1429        unsafe { std::env::remove_var(var) };
1430        let err = resolve_api_key("Zai", var, None).unwrap_err();
1431        match err {
1432            crate::error::AppError::Credentials(msg) => {
1433                assert!(
1434                    msg.contains("api_key"),
1435                    "error should suggest config field: {msg}"
1436                );
1437            }
1438            other => panic!("expected Credentials error, got {other:?}"),
1439        }
1440    }
1441
1442    #[test]
1443    fn resolve_api_key_uses_exact_opencode_go_section_name() {
1444        let _g = env_guard();
1445        unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
1446        let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
1447        let message = err.to_string();
1448        assert!(
1449            message.contains("[opencode-go]"),
1450            "wrong section hint: {message}"
1451        );
1452        assert!(
1453            !message.contains("[opencode go]"),
1454            "wrong section hint: {message}"
1455        );
1456    }
1457
1458    #[test]
1459    fn config_path_hint_ends_with_config_toml() {
1460        // Platform-resolved (Linux/macOS/Windows), but always ends in the
1461        // config filename — the trailing segment is what messages rely on.
1462        assert!(config_path_hint().ends_with("config.toml"));
1463    }
1464
1465    #[test]
1466    fn resolve_api_key_treats_empty_env_as_unset() {
1467        let _g = env_guard();
1468        let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
1469        unsafe { std::env::set_var(var, "") };
1470        let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
1471        unsafe { std::env::remove_var(var) };
1472        assert_eq!(got, "inline");
1473    }
1474
1475    #[test]
1476    fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
1477        let _g = env_guard();
1478        // Simulates a user accidentally pasting the key into api_key_env.
1479        let bad = "sk-kimi-very-real-looking-pasted-secret";
1480        let err = resolve_api_key("Kimi", bad, None).unwrap_err();
1481        let msg = err.to_string();
1482        assert!(
1483            msg.contains("invalid") && msg.contains("api_key_env"),
1484            "error should explain misconfiguration: {msg}"
1485        );
1486        assert!(
1487            !msg.contains(bad),
1488            "error must not echo the misconfigured value: {msg}"
1489        );
1490        assert!(msg.contains("valid environment variable name"));
1491        assert!(
1492            msg.contains("[kimi]"),
1493            "error should point at the lowercase TOML section: {msg}"
1494        );
1495    }
1496
1497    #[test]
1498    fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
1499        let _g = env_guard();
1500        let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
1501        assert_eq!(got, "inline-key");
1502    }
1503
1504    #[test]
1505    fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
1506        let _g = env_guard();
1507        // This is syntactically a valid environment variable name, but could
1508        // be a pasted secret and must not be reflected in the error.
1509        let pasted_secret = "sk_pasted_secret";
1510        unsafe { std::env::remove_var(pasted_secret) };
1511        let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
1512        assert!(
1513            !err.to_string().contains(pasted_secret),
1514            "error must not echo configured api_key_env values"
1515        );
1516    }
1517
1518    #[test]
1519    fn is_valid_env_var_name_rules() {
1520        // Valid: alphabetic or underscore first, then alnum/underscore.
1521        for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
1522            assert!(is_valid_env_var_name(valid), "{valid} should be valid");
1523        }
1524        // Invalid: empty, digit-first, or shell-illegal characters.
1525        for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
1526            assert!(
1527                !is_valid_env_var_name(invalid),
1528                "{invalid} should be invalid"
1529            );
1530        }
1531    }
1532
1533    #[test]
1534    fn config_parses_with_inline_api_key_and_primary() {
1535        let f = write_toml(
1536            r#"
1537            [ui]
1538            primary = "openrouter"
1539
1540            [zai]
1541            enabled = true
1542            api_key_env = "MY_ZAI"
1543            api_key = "sk-zai-inline"
1544
1545            [openrouter]
1546            enabled = true
1547            api_key = "sk-or-inline"
1548            "#,
1549        );
1550        let c = Config::load_from(f.path()).unwrap();
1551        assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
1552        assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
1553        assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
1554    }
1555
1556    #[test]
1557    fn enabled_vendors_preserves_canonical_order() {
1558        // DeepSeek and Kimi are disabled by default (require explicit API key
1559        // config), so they are absent from the enabled list unless enabled.
1560        let c = Config::default();
1561        assert_eq!(
1562            c.enabled_vendors(),
1563            vec![
1564                VendorId::Anthropic,
1565                VendorId::Openai,
1566                VendorId::Zai,
1567                VendorId::Openrouter,
1568            ]
1569        );
1570    }
1571
1572    #[test]
1573    fn deepseek_appears_when_enabled() {
1574        let f = write_toml(
1575            r#"
1576            [deepseek]
1577            enabled = true
1578            api_key = "sk-test"
1579            "#,
1580        );
1581        let c = Config::load_from(f.path()).unwrap();
1582        assert!(c.is_enabled(VendorId::Deepseek));
1583        assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
1584        assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
1585    }
1586
1587    #[test]
1588    fn tilde_paths_are_expanded_on_load() {
1589        // `PathBuf` keeps `~` literally, so the documented
1590        // `credentials_path = "~/..."` used to resolve to a directory named
1591        // `~` relative to the process's cwd.
1592        let f = write_toml(
1593            r#"
1594            [context]
1595            projects_path = "~/.claude/projects"
1596
1597            [anthropic]
1598            credentials_path = "~/.claude/.credentials.json"
1599
1600            [[anthropic.accounts]]
1601            label = "work"
1602            credentials_path = "~/work.json"
1603            "#,
1604        );
1605        let c = Config::load_from(f.path()).unwrap();
1606        let home = crate::cache::home_dir().unwrap();
1607
1608        assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
1609        let got = c.anthropic.credentials_path.unwrap();
1610        assert_eq!(got, home.join(".claude/.credentials.json"));
1611        assert!(!got.to_string_lossy().contains('~'));
1612        assert_eq!(
1613            c.anthropic.accounts[0].credentials_path,
1614            home.join("work.json")
1615        );
1616    }
1617
1618    #[test]
1619    fn absolute_and_relative_paths_are_left_alone() {
1620        let f = write_toml(
1621            r#"
1622            [anthropic]
1623            credentials_path = "/etc/creds.json"
1624            "#,
1625        );
1626        let c = Config::load_from(f.path()).unwrap();
1627        assert_eq!(
1628            c.anthropic.credentials_path.unwrap(),
1629            std::path::Path::new("/etc/creds.json")
1630        );
1631
1632        // `~user` is not ours to interpret.
1633        let f2 = write_toml(
1634            r#"
1635            [anthropic]
1636            credentials_path = "~someone/creds.json"
1637            "#,
1638        );
1639        let c2 = Config::load_from(f2.path()).unwrap();
1640        assert_eq!(
1641            c2.anthropic.credentials_path.unwrap(),
1642            std::path::Path::new("~someone/creds.json")
1643        );
1644    }
1645
1646    #[test]
1647    fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
1648        // Hermetic: only asserts the shape, never which file happens to exist
1649        // on the machine running the tests.
1650        let p = resolved_path().expect("a config path must resolve");
1651        assert!(p.ends_with("config.toml"));
1652        let canonical = default_path().unwrap();
1653        let legacy = legacy_xdg_path().unwrap();
1654        assert!(
1655            p == canonical || p == legacy,
1656            "resolved to an unexpected location: {}",
1657            p.display()
1658        );
1659    }
1660
1661    #[test]
1662    fn misspelled_section_is_rejected_not_ignored() {
1663        // The regression this guards: `[openrouer]` used to parse fine, leave
1664        // OpenRouter on its defaults, and give the user no hint at all.
1665        let f = write_toml(
1666            r#"
1667            [openrouer]
1668            enabled = true
1669            api_key = "sk-or-v1-typo"
1670            "#,
1671        );
1672        let err = Config::load_from(f.path()).unwrap_err().to_string();
1673        assert!(
1674            err.contains("openrouer"),
1675            "error should name the typo: {err}"
1676        );
1677    }
1678
1679    #[test]
1680    fn invalid_toml_is_an_error_not_silent_defaults() {
1681        let f = write_toml("[zai\nenabled = true\n");
1682        assert!(Config::load_from(f.path()).is_err());
1683    }
1684
1685    #[test]
1686    fn a_missing_file_is_still_just_defaults() {
1687        // Absence stays the legitimate "use defaults" case — only real parse
1688        // and I/O failures are errors.
1689        let dir = tempfile::tempdir().unwrap();
1690        let missing = dir.path().join("nope").join("config.toml");
1691        let c = Config::load_from(&missing).unwrap();
1692        assert!(c.is_enabled(VendorId::Anthropic));
1693    }
1694
1695    #[test]
1696    fn kimi_appears_when_enabled() {
1697        let f = write_toml(
1698            r#"
1699            [kimi]
1700            enabled = true
1701            api_key = "sk-test"
1702            "#,
1703        );
1704        let c = Config::load_from(f.path()).unwrap();
1705        assert!(c.is_enabled(VendorId::Kimi));
1706        assert!(c.enabled_vendors().contains(&VendorId::Kimi));
1707        assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
1708    }
1709
1710    #[test]
1711    fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
1712        let f = write_toml(
1713            r#"
1714            [deepseek]
1715            enabled = true
1716            api_key = "sk-ds"
1717
1718            [kimi]
1719            enabled = true
1720            api_key = "sk-kimi"
1721            "#,
1722        );
1723        let c = Config::load_from(f.path()).unwrap();
1724        assert_eq!(
1725            c.enabled_vendors(),
1726            vec![
1727                VendorId::Anthropic,
1728                VendorId::Openai,
1729                VendorId::Zai,
1730                VendorId::Openrouter,
1731                VendorId::Deepseek,
1732                VendorId::Kimi,
1733            ]
1734        );
1735    }
1736
1737    #[test]
1738    fn parses_anthropic_accounts_and_looks_them_up() {
1739        let f = write_toml(
1740            r#"
1741            [anthropic]
1742            enabled = true
1743
1744            [[anthropic.accounts]]
1745            label = "personal"
1746            credentials_path = "/creds/personal.json"
1747
1748            [[anthropic.accounts]]
1749            label = "work"
1750            credentials_path = "/creds/work.json"
1751            "#,
1752        );
1753        let c = Config::load_from(f.path()).unwrap();
1754        assert_eq!(c.anthropic.accounts.len(), 2);
1755        let work = c.anthropic.account("work").unwrap();
1756        assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
1757        // A typo names the offending label and lists the known ones.
1758        let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
1759        assert!(err.contains("missing") && err.contains("work"), "{err}");
1760    }
1761
1762    #[test]
1763    fn duplicate_anthropic_account_labels_are_rejected_on_load() {
1764        let f = write_toml(
1765            r#"
1766            [[anthropic.accounts]]
1767            label = "work"
1768            credentials_path = "/creds/work-one.json"
1769
1770            [[anthropic.accounts]]
1771            label = "work"
1772            credentials_path = "/creds/work-two.json"
1773            "#,
1774        );
1775        let err = Config::load_from(f.path()).unwrap_err().to_string();
1776        assert!(
1777            err.contains("duplicate anthropic account label \"work\""),
1778            "{err}"
1779        );
1780    }
1781
1782    #[test]
1783    fn account_label_rejects_path_like_names() {
1784        let cfg = AnthropicConfig::default();
1785        for bad in [
1786            "",
1787            ".",
1788            "..",
1789            "a/b",
1790            r"a\b",
1791            "line\nbreak",
1792            "tab\tname",
1793            "usage.json",
1794            ".stale",
1795            ".last_error",
1796            ".fetch.lock",
1797        ] {
1798            let err = cfg.account(bad).unwrap_err();
1799            assert!(
1800                format!("{err:?}").contains("invalid anthropic account label"),
1801                "{bad:?} should be rejected as a label"
1802            );
1803        }
1804    }
1805
1806    #[test]
1807    fn anthropic_accounts_default_to_empty() {
1808        // No [[anthropic.accounts]] → the single default account, empty list,
1809        // nothing to migrate (issue #14, back-compat rule 1).
1810        assert!(Config::default().anthropic.accounts.is_empty());
1811        assert!(Config::default().anthropic.accounts_dir.is_none());
1812    }
1813
1814    // --- accounts_dir: CLAUDE_CONFIG_DIR-style auto-discovery ----------------
1815    // All hermetic: discovery reads a TempDir, never the user's real config.
1816
1817    /// Create `<root>/<label>/.credentials.json` (contents irrelevant here —
1818    /// discovery keys on the file existing, the fetch path parses it).
1819    fn seed_account_dir(root: &std::path::Path, label: &str) {
1820        let dir = root.join(label);
1821        std::fs::create_dir_all(&dir).unwrap();
1822        std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1823    }
1824
1825    #[test]
1826    fn discovers_account_dirs_in_claude_config_dir_layout() {
1827        let td = tempfile::tempdir().unwrap();
1828        seed_account_dir(td.path(), "work");
1829        seed_account_dir(td.path(), "personal");
1830        // Keychain-backed macOS logins may not write .credentials.json; their
1831        // config directories are still account entries.
1832        std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
1833        // A loose file (not a dir) is ignored.
1834        std::fs::write(td.path().join("stray.json"), "{}").unwrap();
1835
1836        let cfg = AnthropicConfig {
1837            accounts_dir: Some(td.path().to_path_buf()),
1838            ..Default::default()
1839        };
1840        let all = cfg.all_accounts();
1841        let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
1842        assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
1843        assert_eq!(
1844            all[2].credentials_path,
1845            td.path().join("work").join(".credentials.json")
1846        );
1847    }
1848
1849    #[test]
1850    fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
1851        let td = tempfile::tempdir().unwrap();
1852        seed_account_dir(td.path(), "work");
1853        let cfg = AnthropicConfig {
1854            accounts: vec![AnthropicAccount {
1855                label: "work".into(),
1856                credentials_path: "/explicit/work.json".into(),
1857            }],
1858            accounts_dir: Some(td.path().to_path_buf()),
1859            ..Default::default()
1860        };
1861        let all = cfg.all_accounts();
1862        assert_eq!(all.len(), 1, "no duplicate label");
1863        assert_eq!(
1864            all[0].credentials_path,
1865            std::path::Path::new("/explicit/work.json"),
1866            "explicit entry wins"
1867        );
1868        // A discovered account is still reachable through `account()`.
1869        seed_account_dir(td.path(), "other");
1870        assert_eq!(cfg.account("other").unwrap().label, "other");
1871    }
1872
1873    #[test]
1874    fn missing_accounts_dir_is_silently_empty_not_an_error() {
1875        let cfg = AnthropicConfig {
1876            accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
1877            ..Default::default()
1878        };
1879        assert!(cfg.all_accounts().is_empty());
1880    }
1881
1882    #[test]
1883    fn accounts_dir_is_tilde_expanded_on_load() {
1884        let f = write_toml(
1885            r#"
1886            [anthropic]
1887            accounts_dir = "~/.config/ai-usagebar/accounts"
1888            "#,
1889        );
1890        let c = Config::load_from(f.path()).unwrap();
1891        let home = crate::cache::home_dir().unwrap();
1892        assert_eq!(
1893            c.anthropic.accounts_dir,
1894            Some(home.join(".config/ai-usagebar/accounts"))
1895        );
1896    }
1897
1898    #[test]
1899    fn desktop_profiles_dir_is_tilde_expanded_on_load() {
1900        let f = write_toml(
1901            r#"
1902            [anthropic]
1903            desktop_profiles_dir = "~/.claude-acc/profiles"
1904            "#,
1905        );
1906        let c = Config::load_from(f.path()).unwrap();
1907        let home = crate::cache::home_dir().unwrap();
1908        assert_eq!(
1909            c.anthropic.desktop_profiles_dir,
1910            Some(home.join(".claude-acc/profiles"))
1911        );
1912    }
1913
1914    #[test]
1915    fn the_live_cli_account_is_read_from_the_default_credential_slot() {
1916        let cfg = AnthropicConfig {
1917            accounts: vec![
1918                AnthropicAccount {
1919                    label: "work".into(),
1920                    credentials_path: "/tmp/accounts/work/.credentials.json".into(),
1921                },
1922                AnthropicAccount {
1923                    label: "personal".into(),
1924                    credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
1925                },
1926            ],
1927            ..Default::default()
1928        };
1929
1930        let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
1931        assert!(
1932            matches!(&idle, CredsTarget::Named { config_dir, .. }
1933                if config_dir == std::path::Path::new("/tmp/accounts/work")),
1934            "{idle:?}"
1935        );
1936
1937        // Same label, but it is the login `claude` itself is using: one lineage.
1938        let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
1939        assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
1940
1941        // The cache must not move, or a switch would silently orphan the tab's
1942        // usage history and show "Loading…" until the next fetch.
1943        assert_eq!(idle_cache.dir(), live_cache.dir());
1944    }
1945
1946    #[test]
1947    fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
1948        let cfg = AnthropicConfig {
1949            accounts: vec![AnthropicAccount {
1950                label: "work".into(),
1951                credentials_path: "/tmp/accounts/work/.credentials.json".into(),
1952            }],
1953            ..Default::default()
1954        };
1955        let (target, _) = cfg.account_target_with("work", None).unwrap();
1956        assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
1957    }
1958
1959    /// The shipped example, which `make install` puts in
1960    /// `share/ai-usagebar/config.example.toml`. Repo-relative, so this stays
1961    /// hermetic — it never touches the user's real config.
1962    fn config_example() -> PathBuf {
1963        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
1964    }
1965
1966    #[test]
1967    fn shipped_example_parses_as_a_real_config() {
1968        // The example is documentation users copy verbatim, but nothing used
1969        // to parse it — so a renamed section or field could rot there
1970        // unnoticed, and `deny_unknown_fields` would reject the copy on the
1971        // user's machine instead of in CI.
1972        let c = Config::load_from(&config_example()).unwrap();
1973        assert!(!c.context.enabled);
1974        assert!(c.is_enabled(VendorId::Anthropic));
1975        assert!(c.is_enabled(VendorId::Openai));
1976        assert!(!c.is_enabled(VendorId::AnthropicApi));
1977        assert!(!c.is_enabled(VendorId::Deepseek));
1978        assert!(!c.is_enabled(VendorId::Kimi));
1979        assert!(!c.is_enabled(VendorId::Kilo));
1980        assert!(!c.is_enabled(VendorId::Novita));
1981        assert!(!c.is_enabled(VendorId::Moonshot));
1982        assert!(!c.is_enabled(VendorId::Grok));
1983        assert!(!c.is_enabled(VendorId::Cursor));
1984        assert!(!c.is_enabled(VendorId::Minimax));
1985    }
1986
1987    #[test]
1988    fn shipped_example_does_not_advertise_admin_key_env_as_working() {
1989        // The regression: the example shipped an *uncommented*
1990        // `admin_key_env = "OPENAI_ADMIN_KEY"`, indistinguishable from a live
1991        // setting. Nothing reads it, so a user could set it, skip
1992        // `codex login`, and wait for usage that never arrives.
1993        let text = std::fs::read_to_string(config_example()).unwrap();
1994        let live: Vec<&str> = text
1995            .lines()
1996            .map(str::trim)
1997            .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
1998            .collect();
1999        assert!(
2000            live.is_empty(),
2001            "admin_key_env must stay commented out while it is inert: {live:?}"
2002        );
2003        // Still documented, though — silently dropping it would leave users
2004        // who already set it with no explanation of why it does nothing.
2005        assert!(
2006            text.contains("admin_key_env") && text.contains("RESERVED"),
2007            "the example should keep describing admin_key_env as reserved"
2008        );
2009    }
2010
2011    #[test]
2012    fn admin_key_env_is_accepted_but_changes_nothing() {
2013        // The field survives because the API-key-only path is still intended.
2014        // What has to hold today is narrower: setting it loads without error
2015        // and moves nothing the code actually acts on.
2016        let f = write_toml(
2017            r#"
2018            [openai]
2019            admin_key_env = "SOME_ADMIN_KEY"
2020            "#,
2021        );
2022        let c = Config::load_from(f.path()).unwrap();
2023        assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
2024        // Nothing else moved: OpenAI still resolves through Codex OAuth only.
2025        let default = OpenAiConfig::default();
2026        assert_eq!(c.openai.enabled, default.enabled);
2027        assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
2028        assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
2029    }
2030
2031    #[test]
2032    fn config_example_documents_every_vendor_without_secrets() {
2033        let raw = std::fs::read_to_string(config_example()).unwrap();
2034        let cfg = Config::load_from(&config_example()).unwrap();
2035        // Every vendor the binary can dispatch needs a documented section, or
2036        // users have no way to discover how to turn it on.
2037        for id in VendorId::all() {
2038            let section = id.slug();
2039            assert!(
2040                raw.contains(&format!("[{section}]")),
2041                "config.example.toml has no [{section}] section"
2042            );
2043        }
2044
2045        // The example must not ship anything enabled-by-key-only, and must not
2046        // carry a real secret.
2047        assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
2048        assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
2049        assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
2050        assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
2051        assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
2052        assert!(!cfg.supergrok.enabled);
2053        assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
2054        assert_eq!(
2055            cfg.supergrok
2056                .grok_binary
2057                .file_name()
2058                .and_then(|p| p.to_str()),
2059            Some(if cfg!(windows) { "grok.exe" } else { "grok" })
2060        );
2061        assert!(cfg.supergrok.auth_path.is_none());
2062        assert!(cfg.supergrok.config_path.is_none());
2063        assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
2064        assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
2065    }
2066
2067    #[test]
2068    fn supergrok_binary_must_not_be_empty() {
2069        let file = write_toml(
2070            r#"
2071            [supergrok]
2072            enabled = true
2073            grok_binary = ""
2074            "#,
2075        );
2076        let error = Config::load_from(file.path()).unwrap_err().to_string();
2077        assert!(error.contains("grok_binary must not be empty"));
2078    }
2079
2080    #[test]
2081    fn supergrok_paths_are_tilde_expanded() {
2082        let file = write_toml(
2083            r#"
2084            [supergrok]
2085            grok_binary = "~/bin/grok"
2086            auth_path = "~/.grok/auth.json"
2087            config_path = "~/.grok/config.toml"
2088            "#,
2089        );
2090        let config = Config::load_from(file.path()).unwrap();
2091        let home = crate::cache::home_dir().unwrap();
2092        assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
2093        assert_eq!(
2094            config.supergrok.auth_path,
2095            Some(home.join(".grok/auth.json"))
2096        );
2097        assert_eq!(
2098            config.supergrok.config_path,
2099            Some(home.join(".grok/config.toml"))
2100        );
2101    }
2102
2103    #[test]
2104    fn kiro_db_path_is_tilde_expanded() {
2105        let f = write_toml(
2106            r#"
2107            [kiro]
2108            db_path = "~/kiro-data.sqlite3"
2109            "#,
2110        );
2111        let c = Config::load_from(f.path()).unwrap();
2112        let home = crate::cache::home_dir().unwrap();
2113        assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
2114    }
2115
2116    #[test]
2117    fn kiro_appears_when_enabled() {
2118        let f = write_toml(
2119            r#"
2120            [kiro]
2121            enabled = true
2122            "#,
2123        );
2124        let c = Config::load_from(f.path()).unwrap();
2125        assert!(c.is_enabled(VendorId::Kiro));
2126        assert!(c.enabled_vendors().contains(&VendorId::Kiro));
2127    }
2128
2129    #[test]
2130    fn cursor_db_path_is_tilde_expanded() {
2131        let f = write_toml(
2132            r#"
2133            [cursor]
2134            db_path = "~/cursor-state.vscdb"
2135            "#,
2136        );
2137        let c = Config::load_from(f.path()).unwrap();
2138        let home = crate::cache::home_dir().unwrap();
2139        assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
2140    }
2141
2142    #[test]
2143    fn cursor_agent_auth_path_is_tilde_expanded() {
2144        let f = write_toml(
2145            r#"
2146            [cursor]
2147            agent_auth_path = "~/cursor-agent-auth.json"
2148            "#,
2149        );
2150        let c = Config::load_from(f.path()).unwrap();
2151        let home = crate::cache::home_dir().unwrap();
2152        assert_eq!(
2153            c.cursor.agent_auth_path,
2154            Some(home.join("cursor-agent-auth.json"))
2155        );
2156    }
2157
2158    #[test]
2159    fn cursor_appears_when_enabled() {
2160        let f = write_toml(
2161            r#"
2162            [cursor]
2163            enabled = true
2164            "#,
2165        );
2166        let c = Config::load_from(f.path()).unwrap();
2167        assert!(c.is_enabled(VendorId::Cursor));
2168        assert!(c.enabled_vendors().contains(&VendorId::Cursor));
2169    }
2170
2171    #[test]
2172    fn add_account_appends_and_preserves_existing() {
2173        let mut doc: toml_edit::DocumentMut = r#"
2174# keep me
2175[anthropic]
2176enabled = true
2177
2178[[anthropic.accounts]]
2179label = "personal"
2180credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
2181"#
2182        .parse()
2183        .unwrap();
2184        add_anthropic_account_to_doc(
2185            &mut doc,
2186            "work",
2187            "~/.config/ai-usagebar/accounts/work/.credentials.json",
2188        )
2189        .unwrap();
2190        let rendered = doc.to_string();
2191        assert!(rendered.contains("# keep me"), "comment must survive");
2192        // Round-trips through the real loader with both accounts intact and ordered.
2193        let f = write_toml(&rendered);
2194        let c = Config::load_from(f.path()).unwrap();
2195        let labels: Vec<&str> = c
2196            .anthropic
2197            .accounts
2198            .iter()
2199            .map(|a| a.label.as_str())
2200            .collect();
2201        assert_eq!(labels, vec!["personal", "work"]);
2202    }
2203
2204    #[test]
2205    fn add_account_to_empty_doc_is_loadable() {
2206        let mut doc = toml_edit::DocumentMut::new();
2207        add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
2208        let f = write_toml(&doc.to_string());
2209        let c = Config::load_from(f.path()).unwrap();
2210        assert_eq!(c.anthropic.accounts.len(), 1);
2211        assert_eq!(c.anthropic.accounts[0].label, "solo");
2212    }
2213
2214    #[test]
2215    fn add_account_rejects_duplicate_label() {
2216        let mut doc: toml_edit::DocumentMut = r#"
2217[[anthropic.accounts]]
2218label = "work"
2219credentials_path = "~/w/.credentials.json"
2220"#
2221        .parse()
2222        .unwrap();
2223        assert!(
2224            add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
2225            "a duplicate label must be rejected, not appended"
2226        );
2227    }
2228
2229    #[test]
2230    fn add_account_rejects_bad_label() {
2231        let mut doc = toml_edit::DocumentMut::new();
2232        assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
2233        assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
2234    }
2235
2236    #[test]
2237    fn tildify_collapses_home_only() {
2238        let home = Path::new("/Users/me");
2239        assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
2240        assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
2241    }
2242
2243    #[test]
2244    fn default_account_credentials_path_nests_under_config_dir() {
2245        let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
2246        assert_eq!(
2247            default_account_credentials_path(cfg, "work"),
2248            Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
2249        );
2250    }
2251}