Skip to main content

ai_usagebar/
config.rs

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