Skip to main content

ai_usagebar/
config.rs

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