Skip to main content

ai_usagebar/
config.rs

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