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