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