Skip to main content

ai_usagebar/
config.rs

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