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::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.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 or dot-dirs would escape or
253/// collide with the cache layout (`usage.json`, `.stale`, …). Reject anything
254/// that isn't a plain single-segment name.
255fn validate_account_label(label: &str) -> Result<()> {
256    let bad = label.is_empty()
257        || label == "."
258        || label == ".."
259        || label.contains(['/', '\\'])
260        || label == "usage.json";
261    if bad {
262        return Err(AppError::Credentials(format!(
263            "invalid anthropic account label {label:?}: must be a non-empty name \
264             without path separators (it becomes a cache subdirectory)"
265        )));
266    }
267    Ok(())
268}
269
270/// Discover accounts under `accounts_dir` in the `CLAUDE_CONFIG_DIR` layout:
271/// each immediate subdirectory becomes an account labeled by the subdirectory
272/// name. Best-effort: an unreadable directory or unusable label is skipped
273/// silently rather than failing the whole config — discovery is convenience,
274/// while an explicit `[[anthropic.accounts]]` entry stays authoritative. The
275/// fetch path resolves credentials from either `.credentials.json` or the macOS
276/// Keychain. Sorted by label so the tab order is stable across runs.
277fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
278    let Ok(entries) = std::fs::read_dir(accounts_dir) else {
279        return Vec::new();
280    };
281    let mut found: Vec<AnthropicAccount> = entries
282        .flatten()
283        .filter_map(|entry| {
284            let path = entry.path();
285            if !path.is_dir() {
286                return None;
287            }
288            let label = path.file_name()?.to_str()?.to_string();
289            validate_account_label(&label).ok()?;
290            Some(AnthropicAccount {
291                label,
292                credentials_path: path.join(".credentials.json"),
293            })
294        })
295        .collect();
296    found.sort_by(|a, b| a.label.cmp(&b.label));
297    found
298}
299
300#[derive(Debug, Clone, Deserialize, Serialize)]
301#[serde(default)]
302pub struct OpenAiConfig {
303    pub enabled: bool,
304    /// Override the Codex auth file path (defaults to `~/.codex/auth.json`).
305    pub codex_auth_path: Option<PathBuf>,
306    /// Reserved, and inert: names the env var an API-key-only path *would*
307    /// read (admin key → `/v1/organization/costs`). Nothing consumes it —
308    /// OpenAI usage comes solely from Codex OAuth. Kept because that path is
309    /// still intended, not for back-compat: `[openai]` doesn't deny unknown
310    /// fields, so an existing `admin_key_env` would load either way. See
311    /// `config.example.toml`, which ships it commented out so nobody sets it
312    /// expecting an effect.
313    pub admin_key_env: String,
314}
315
316impl Default for OpenAiConfig {
317    fn default() -> Self {
318        Self {
319            enabled: true,
320            codex_auth_path: None,
321            admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
322        }
323    }
324}
325
326#[derive(Debug, Clone, Deserialize, Serialize)]
327#[serde(default)]
328pub struct ZaiConfig {
329    pub enabled: bool,
330    /// Env var name to read the key from (env wins over `api_key`).
331    pub api_key_env: String,
332    /// Inline key (fallback when the env var is unset). Chmod 600 your
333    /// config file if you put a real key here.
334    pub api_key: Option<String>,
335    /// Optional plan tier label (lite/pro/max) — display-only.
336    pub plan_tier: Option<String>,
337}
338
339impl Default for ZaiConfig {
340    fn default() -> Self {
341        Self {
342            enabled: true,
343            api_key_env: "ZAI_API_KEY".to_string(),
344            api_key: None,
345            plan_tier: None,
346        }
347    }
348}
349
350#[derive(Debug, Clone, Deserialize, Serialize)]
351#[serde(default)]
352pub struct OpenRouterConfig {
353    pub enabled: bool,
354    pub api_key_env: String,
355    pub api_key: Option<String>,
356}
357
358impl Default for OpenRouterConfig {
359    fn default() -> Self {
360        Self {
361            enabled: true,
362            api_key_env: "OPENROUTER_API_KEY".to_string(),
363            api_key: None,
364        }
365    }
366}
367
368#[derive(Debug, Clone, Deserialize, Serialize)]
369#[serde(default)]
370pub struct DeepseekConfig {
371    pub enabled: bool,
372    pub api_key_env: String,
373    pub api_key: Option<String>,
374}
375
376impl Default for DeepseekConfig {
377    fn default() -> Self {
378        Self {
379            enabled: false,
380            api_key_env: "DEEPSEEK_API_KEY".to_string(),
381            api_key: None,
382        }
383    }
384}
385
386#[derive(Debug, Clone, Deserialize, Serialize)]
387#[serde(default)]
388pub struct KimiConfig {
389    pub enabled: bool,
390    pub api_key_env: String,
391    pub api_key: Option<String>,
392}
393
394impl Default for KimiConfig {
395    fn default() -> Self {
396        Self {
397            enabled: false,
398            api_key_env: "KIMI_API_KEY".to_string(),
399            api_key: None,
400        }
401    }
402}
403
404#[derive(Debug, Clone, Deserialize, Serialize)]
405#[serde(default)]
406pub struct KiloConfig {
407    pub enabled: bool,
408    pub api_key_env: String,
409    pub api_key: Option<String>,
410    /// Optional Kilo organization id — scopes the balance to a team via the
411    /// `x-kilocode-organizationid` header. Omit for the personal balance.
412    pub organization_id: Option<String>,
413}
414
415impl Default for KiloConfig {
416    fn default() -> Self {
417        // Opt-in like DeepSeek: requires an explicit API key, so it defaults to
418        // disabled and never affects existing installs.
419        Self {
420            enabled: false,
421            api_key_env: "KILO_API_KEY".to_string(),
422            api_key: None,
423            organization_id: None,
424        }
425    }
426}
427
428#[derive(Debug, Clone, Deserialize, Serialize)]
429#[serde(default)]
430pub struct NovitaConfig {
431    pub enabled: bool,
432    pub api_key_env: String,
433    pub api_key: Option<String>,
434}
435
436impl Default for NovitaConfig {
437    fn default() -> Self {
438        // Opt-in like DeepSeek/Kilo: needs an explicit API key.
439        Self {
440            enabled: false,
441            api_key_env: "NOVITA_API_KEY".to_string(),
442            api_key: None,
443        }
444    }
445}
446
447#[derive(Debug, Clone, Deserialize, Serialize)]
448#[serde(default)]
449pub struct MoonshotConfig {
450    pub enabled: bool,
451    pub api_key_env: String,
452    pub api_key: Option<String>,
453    /// `"global"` → api.moonshot.ai (USD); `"cn"` → api.moonshot.cn (CNY).
454    pub region: String,
455}
456
457impl Default for MoonshotConfig {
458    fn default() -> Self {
459        // Opt-in like DeepSeek/Kilo/Novita: needs an explicit API key.
460        Self {
461            enabled: false,
462            api_key_env: "MOONSHOT_API_KEY".to_string(),
463            api_key: None,
464            region: "global".to_string(),
465        }
466    }
467}
468
469#[derive(Debug, Clone, Deserialize, Serialize)]
470#[serde(default)]
471pub struct GrokConfig {
472    pub enabled: bool,
473    /// Env var for the xAI **Management** key (distinct from the inference key).
474    pub api_key_env: String,
475    pub api_key: Option<String>,
476    /// Optional team id. When absent, it's auto-resolved from the management
477    /// key via `/auth/management-keys/validation`.
478    pub team_id: Option<String>,
479}
480
481impl Default for GrokConfig {
482    fn default() -> Self {
483        // Opt-in: needs a management key (and, for prepaid, a team).
484        Self {
485            enabled: false,
486            api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
487            api_key: None,
488            team_id: None,
489        }
490    }
491}
492
493/// Antigravity reads its quota from whichever local Antigravity product is
494/// running, so it needs no credentials — only an on/off switch.
495#[derive(Debug, Clone, Default, Deserialize, Serialize)]
496#[serde(default)]
497pub struct AntigravityConfig {
498    pub enabled: bool,
499}
500
501/// Cursor reads its quota through a session token the Cursor IDE already
502/// wrote to its local `state.vscdb` — no API key, but (unlike Antigravity)
503/// there is a real on-disk path that can need overriding (e.g. a portable or
504/// non-default Cursor install), mirroring `openai.codex_auth_path`.
505///
506/// Opt-in like DeepSeek/Kilo/etc (`enabled` defaults to `false`, matching
507/// `bool::default()`): reads an undocumented endpoint via a session token
508/// scraped from a local IDE file, so it stays off until the user explicitly
509/// turns it on.
510#[derive(Debug, Clone, Default, Deserialize, Serialize)]
511#[serde(default)]
512pub struct CursorConfig {
513    pub enabled: bool,
514    /// Override Cursor's local state database path (defaults to the
515    /// platform-standard `.../User/globalStorage/state.vscdb` — see
516    /// `cursor::db::default_db_path`).
517    pub db_path: Option<PathBuf>,
518}
519
520#[derive(Debug, Clone, Deserialize, Serialize)]
521#[serde(default)]
522pub struct AnthropicApiConfig {
523    pub enabled: bool,
524    /// Env var for the Console **Admin key** (`sk-ant-admin01-…`), distinct from
525    /// an inference key and from the Claude Code OAuth login.
526    pub api_key_env: String,
527    pub api_key: Option<String>,
528    /// Monthly USD spend limit, used only for the spend-vs-limit % display. The
529    /// API exposes neither this limit nor the remaining prepaid balance.
530    pub monthly_limit: Option<f64>,
531}
532
533impl Default for AnthropicApiConfig {
534    fn default() -> Self {
535        // Opt-in: needs an explicit Admin key.
536        Self {
537            enabled: false,
538            api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
539            api_key: None,
540            monthly_limit: None,
541        }
542    }
543}
544
545/// Resolve an API key for a vendor: a valid env-var name wins, then inline
546/// config, then a clear error naming both fields. Used by every API-key vendor.
547pub fn resolve_api_key(
548    vendor_label: &str,
549    env_var_name: &str,
550    inline: Option<&str>,
551) -> crate::error::Result<String> {
552    let valid_env_name = is_valid_env_var_name(env_var_name);
553    if valid_env_name
554        && let Ok(v) = std::env::var(env_var_name)
555        && !v.is_empty()
556    {
557        return Ok(v);
558    }
559    if let Some(v) = inline
560        && !v.is_empty()
561    {
562        return Ok(v.to_string());
563    }
564    let advice = if valid_env_name {
565        "set an API key in a valid environment variable or set `api_key`"
566    } else {
567        "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
568    };
569    Err(crate::error::AppError::Credentials(format!(
570        "{vendor_label}: no API key. Either {advice} under [{}] in {}.",
571        vendor_label.to_lowercase(),
572        config_path_hint()
573    )))
574}
575
576fn is_valid_env_var_name(name: &str) -> bool {
577    let mut chars = name.chars();
578    let Some(first) = chars.next() else {
579        return false;
580    };
581    (first.is_ascii_alphabetic() || first == '_')
582        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
583}
584
585impl Config {
586    /// Load from `~/.config/ai-usagebar/config.toml`. Returns defaults if the
587    /// file doesn't exist; errors only on actual parse failures.
588    pub fn load() -> Result<Self> {
589        let Some(path) = resolved_path() else {
590            return Ok(Self::default());
591        };
592        Self::load_from(&path)
593    }
594
595    pub fn load_from(path: &std::path::Path) -> Result<Self> {
596        match std::fs::read_to_string(path) {
597            Ok(s) => {
598                let mut config: Self = toml::from_str(&s)?;
599                // `~` is shell syntax, not path syntax: `PathBuf` keeps it
600                // literally, so a documented `credentials_path = "~/..."`
601                // silently pointed at a directory named `~`.
602                config.expand_paths();
603                config.validate()?;
604                Ok(config)
605            }
606            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
607            Err(e) => Err(AppError::io_at(path, e)),
608        }
609    }
610
611    fn expand_paths(&mut self) {
612        expand_tilde_opt(&mut self.context.projects_path);
613        expand_tilde_opt(&mut self.anthropic.credentials_path);
614        expand_tilde_opt(&mut self.anthropic.accounts_dir);
615        expand_tilde_opt(&mut self.openai.codex_auth_path);
616        expand_tilde_opt(&mut self.cursor.db_path);
617        for account in &mut self.anthropic.accounts {
618            account.credentials_path = expand_tilde(&account.credentials_path);
619        }
620    }
621
622    pub fn is_enabled(&self, id: VendorId) -> bool {
623        match id {
624            VendorId::Anthropic => self.anthropic.enabled,
625            VendorId::AnthropicApi => self.anthropic_api.enabled,
626            VendorId::Openai => self.openai.enabled,
627            VendorId::Zai => self.zai.enabled,
628            VendorId::Openrouter => self.openrouter.enabled,
629            VendorId::Deepseek => self.deepseek.enabled,
630            VendorId::Kimi => self.kimi.enabled,
631            VendorId::Kilo => self.kilo.enabled,
632            VendorId::Novita => self.novita.enabled,
633            VendorId::Moonshot => self.moonshot.enabled,
634            VendorId::Grok => self.grok.enabled,
635            VendorId::Antigravity => self.antigravity.enabled,
636            VendorId::Cursor => self.cursor.enabled,
637        }
638    }
639
640    pub fn enabled_vendors(&self) -> Vec<VendorId> {
641        VendorId::all()
642            .iter()
643            .copied()
644            .filter(|id| self.is_enabled(*id))
645            .collect()
646    }
647
648    /// Validate cross-entry constraints that serde cannot express. Account
649    /// labels are both CLI selectors and TUI tab identities, so duplicates
650    /// would make either destination ambiguous.
651    pub fn validate(&self) -> Result<()> {
652        if self.context.context_window_tokens == Some(0) {
653            return Err(AppError::Other(
654                "[context] context_window_tokens must be greater than zero".into(),
655            ));
656        }
657        for (model, tokens) in &self.context.model_context_window_tokens {
658            if model.trim().is_empty() {
659                return Err(AppError::Other(
660                    "[context] model_context_window_tokens keys must not be empty".into(),
661                ));
662            }
663            if *tokens == 0 {
664                return Err(AppError::Other(format!(
665                    "[context] model_context_window_tokens entry {model:?} must be greater than zero"
666                )));
667            }
668        }
669        if let Some(limit) = self.anthropic_api.monthly_limit
670            && (!limit.is_finite() || limit <= 0.0)
671        {
672            return Err(AppError::Other(
673                "[anthropic_api] monthly_limit must be finite and greater than zero; \
674                 remove it to show spend without a limit"
675                    .into(),
676            ));
677        }
678        let mut labels = HashSet::new();
679        for account in &self.anthropic.accounts {
680            validate_account_label(&account.label)?;
681            if !labels.insert(&account.label) {
682                return Err(AppError::Credentials(format!(
683                    "duplicate anthropic account label {:?}",
684                    account.label
685                )));
686            }
687        }
688        Ok(())
689    }
690}
691
692pub fn default_path() -> Option<PathBuf> {
693    let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
694    Some(proj.config_dir().join("config.toml"))
695}
696
697/// The Unix-conventional location, which is what every doc, the config
698/// example, and both desktop integrations have always pointed at. On Linux it
699/// *is* [`default_path`]; on macOS `ProjectDirs` resolves to
700/// `~/Library/Application Support/…` instead, so the two diverge.
701fn legacy_xdg_path() -> Option<PathBuf> {
702    let home = crate::cache::home_dir().ok()?;
703    Some(home.join(".config").join("ai-usagebar").join("config.toml"))
704}
705
706/// The config file actually in effect.
707///
708/// [`default_path`] stays canonical, but on macOS a file at the documented
709/// `~/.config/ai-usagebar/config.toml` is honored when the canonical one does
710/// not exist — otherwise everyone who followed the README (and both desktop
711/// integrations, which read that path) silently got defaults. The legacy file
712/// is never moved or rewritten: it may hold API keys, and relocating a secret
713/// behind the user's back is not this tool's business.
714pub fn resolved_path() -> Option<PathBuf> {
715    let canonical = default_path();
716    if let Some(p) = &canonical
717        && p.exists()
718    {
719        return canonical;
720    }
721    if let Some(legacy) = legacy_xdg_path()
722        && legacy.exists()
723    {
724        return Some(legacy);
725    }
726    canonical
727}
728
729/// Expand a leading `~` (or `~/`) against the user's home directory. Anything
730/// else — including `~user` — is left untouched.
731fn expand_tilde(p: &std::path::Path) -> PathBuf {
732    let Some(s) = p.to_str() else {
733        return p.to_path_buf();
734    };
735    let rest = if s == "~" {
736        ""
737    } else if let Some(r) = s.strip_prefix("~/") {
738        r
739    } else {
740        return p.to_path_buf();
741    };
742    match crate::cache::home_dir() {
743        Ok(home) if rest.is_empty() => home,
744        Ok(home) => home.join(rest),
745        Err(_) => p.to_path_buf(),
746    }
747}
748
749fn expand_tilde_opt(p: &mut Option<PathBuf>) {
750    if let Some(inner) = p.as_ref() {
751        *p = Some(expand_tilde(inner));
752    }
753}
754
755/// Resolved `config.toml` path as a string for user-facing messages. Uses the
756/// platform's config dir (`directories::ProjectDirs`), so it reads correctly on
757/// Linux, macOS, and Windows instead of hard-coding the Unix `~/.config` path.
758/// Falls back to the bare filename if the path can't be resolved.
759pub fn config_path_hint() -> String {
760    resolved_path()
761        .map(|p| p.display().to_string())
762        .unwrap_or_else(|| "config.toml".to_string())
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768    use std::io::Write;
769    use tempfile::NamedTempFile;
770
771    fn write_toml(s: &str) -> NamedTempFile {
772        let mut f = NamedTempFile::new().unwrap();
773        f.write_all(s.as_bytes()).unwrap();
774        f.flush().unwrap();
775        f
776    }
777
778    #[test]
779    fn defaults_enable_only_the_four_core_vendors() {
780        let c = Config::default();
781        assert!(c.is_enabled(VendorId::Anthropic));
782        assert!(c.is_enabled(VendorId::Openai));
783        assert!(c.is_enabled(VendorId::Zai));
784        assert!(c.is_enabled(VendorId::Openrouter));
785        for opt_in in [
786            VendorId::AnthropicApi,
787            VendorId::Deepseek,
788            VendorId::Kimi,
789            VendorId::Kilo,
790            VendorId::Novita,
791            VendorId::Moonshot,
792            VendorId::Grok,
793            VendorId::Cursor,
794        ] {
795            assert!(!c.is_enabled(opt_in), "{opt_in:?}");
796        }
797        assert_eq!(c.enabled_vendors().len(), 4);
798    }
799
800    #[test]
801    fn missing_file_uses_defaults() {
802        let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
803        let c = Config::load_from(path).unwrap();
804        assert!(c.is_enabled(VendorId::Anthropic));
805    }
806
807    #[test]
808    fn parses_full_config() {
809        let f = write_toml(
810            r#"
811            [anthropic]
812            enabled = true
813
814            [openai]
815            enabled = false
816            admin_key_env = "MY_ADMIN_KEY"
817
818            [zai]
819            enabled = true
820            api_key_env = "MY_ZAI"
821            plan_tier = "pro"
822
823            [openrouter]
824            enabled = false
825            "#,
826        );
827        let c = Config::load_from(f.path()).unwrap();
828        assert!(c.is_enabled(VendorId::Anthropic));
829        assert!(!c.is_enabled(VendorId::Openai));
830        assert!(c.is_enabled(VendorId::Zai));
831        assert!(!c.is_enabled(VendorId::Openrouter));
832        assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
833        assert_eq!(c.zai.api_key_env, "MY_ZAI");
834        assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
835    }
836
837    #[test]
838    fn partial_config_falls_back_to_defaults() {
839        let f = write_toml(
840            r#"[openai]
841enabled = false
842"#,
843        );
844        let c = Config::load_from(f.path()).unwrap();
845        assert!(!c.is_enabled(VendorId::Openai));
846        // Other vendors keep their defaults.
847        assert!(c.is_enabled(VendorId::Anthropic));
848        assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
849    }
850
851    #[test]
852    fn malformed_toml_returns_error() {
853        let f = write_toml("this is not = = valid");
854        assert!(Config::load_from(f.path()).is_err());
855    }
856
857    #[test]
858    fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
859        for value in ["0", "-1", "inf", "nan"] {
860            let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
861            let error = Config::load_from(file.path()).unwrap_err().to_string();
862            assert!(error.contains("monthly_limit"), "value {value}: {error}");
863        }
864
865        let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
866        assert_eq!(
867            Config::load_from(file.path())
868                .unwrap()
869                .anthropic_api
870                .monthly_limit,
871            Some(1000.0)
872        );
873    }
874
875    #[test]
876    fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
877        let defaults = Config::default();
878        assert!(!defaults.context.enabled);
879        assert_eq!(
880            defaults.context.window_tokens_for(Some("claude-test")),
881            None
882        );
883
884        let file = write_toml(
885            r#"
886            [context]
887            enabled = true
888            context_window_tokens = 200000
889
890            [context.model_context_window_tokens]
891            claude-opus-1m = 1000000
892            "claude exact id" = 300000
893            "#,
894        );
895        let config = Config::load_from(file.path()).unwrap();
896        assert!(config.context.enabled);
897        assert_eq!(
898            config.context.window_tokens_for(Some("claude-opus-1m")),
899            Some(1_000_000)
900        );
901        assert_eq!(
902            config.context.window_tokens_for(Some("claude exact id")),
903            Some(300_000)
904        );
905        assert_eq!(
906            config.context.window_tokens_for(Some("another-model")),
907            Some(200_000)
908        );
909    }
910
911    #[test]
912    fn context_layout_defaults_to_full_and_parses_each_variant() {
913        assert_eq!(Config::default().context.layout, ContextLayout::Full);
914        for (text, want) in [
915            ("full", ContextLayout::Full),
916            ("split", ContextLayout::Split),
917            ("bottom", ContextLayout::Bottom),
918        ] {
919            let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
920            assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
921        }
922        let file = write_toml("[context]\nlayout = \"floating\"\n");
923        assert!(
924            Config::load_from(file.path()).is_err(),
925            "an unknown layout must be rejected, not silently defaulted"
926        );
927    }
928
929    #[test]
930    fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
931        for source in [
932            "[context]\ncontext_window_tokens = 0\n",
933            "[context.model_context_window_tokens]\nclaude = 0\n",
934            "[context.model_context_window_tokens]\n\" \" = 200000\n",
935        ] {
936            let file = write_toml(source);
937            let error = Config::load_from(file.path()).unwrap_err().to_string();
938            assert!(error.contains("context"), "{error}");
939        }
940    }
941
942    // serial guard for env-var manipulation tests so they don't race
943    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
944        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
945        M.lock().unwrap_or_else(|p| p.into_inner())
946    }
947
948    #[test]
949    fn resolve_api_key_prefers_env_over_inline() {
950        let _g = env_guard();
951        // Use a unique env var name so we don't clobber test parallelism.
952        let var = "AI_USAGEBAR_TEST_ENV_WINS";
953        // SAFETY: tests are single-threaded under env_guard.
954        unsafe { std::env::set_var(var, "from-env") };
955        let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
956        unsafe { std::env::remove_var(var) };
957        assert_eq!(got, "from-env");
958    }
959
960    #[test]
961    fn resolve_api_key_falls_back_to_inline() {
962        let _g = env_guard();
963        let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
964        unsafe { std::env::remove_var(var) };
965        let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
966        assert_eq!(got, "inline-key");
967    }
968
969    #[test]
970    fn resolve_api_key_errors_when_both_missing() {
971        let _g = env_guard();
972        let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
973        unsafe { std::env::remove_var(var) };
974        let err = resolve_api_key("Zai", var, None).unwrap_err();
975        match err {
976            crate::error::AppError::Credentials(msg) => {
977                assert!(
978                    msg.contains("api_key"),
979                    "error should suggest config field: {msg}"
980                );
981            }
982            other => panic!("expected Credentials error, got {other:?}"),
983        }
984    }
985
986    #[test]
987    fn config_path_hint_ends_with_config_toml() {
988        // Platform-resolved (Linux/macOS/Windows), but always ends in the
989        // config filename — the trailing segment is what messages rely on.
990        assert!(config_path_hint().ends_with("config.toml"));
991    }
992
993    #[test]
994    fn resolve_api_key_treats_empty_env_as_unset() {
995        let _g = env_guard();
996        let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
997        unsafe { std::env::set_var(var, "") };
998        let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
999        unsafe { std::env::remove_var(var) };
1000        assert_eq!(got, "inline");
1001    }
1002
1003    #[test]
1004    fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
1005        let _g = env_guard();
1006        // Simulates a user accidentally pasting the key into api_key_env.
1007        let bad = "sk-kimi-very-real-looking-pasted-secret";
1008        let err = resolve_api_key("Kimi", bad, None).unwrap_err();
1009        let msg = err.to_string();
1010        assert!(
1011            msg.contains("invalid") && msg.contains("api_key_env"),
1012            "error should explain misconfiguration: {msg}"
1013        );
1014        assert!(
1015            !msg.contains(bad),
1016            "error must not echo the misconfigured value: {msg}"
1017        );
1018        assert!(msg.contains("valid environment variable name"));
1019        assert!(
1020            msg.contains("[kimi]"),
1021            "error should point at the lowercase TOML section: {msg}"
1022        );
1023    }
1024
1025    #[test]
1026    fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
1027        let _g = env_guard();
1028        let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
1029        assert_eq!(got, "inline-key");
1030    }
1031
1032    #[test]
1033    fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
1034        let _g = env_guard();
1035        // This is syntactically a valid environment variable name, but could
1036        // be a pasted secret and must not be reflected in the error.
1037        let pasted_secret = "sk_pasted_secret";
1038        unsafe { std::env::remove_var(pasted_secret) };
1039        let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
1040        assert!(
1041            !err.to_string().contains(pasted_secret),
1042            "error must not echo configured api_key_env values"
1043        );
1044    }
1045
1046    #[test]
1047    fn is_valid_env_var_name_rules() {
1048        // Valid: alphabetic or underscore first, then alnum/underscore.
1049        for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
1050            assert!(is_valid_env_var_name(valid), "{valid} should be valid");
1051        }
1052        // Invalid: empty, digit-first, or shell-illegal characters.
1053        for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
1054            assert!(
1055                !is_valid_env_var_name(invalid),
1056                "{invalid} should be invalid"
1057            );
1058        }
1059    }
1060
1061    #[test]
1062    fn config_parses_with_inline_api_key_and_primary() {
1063        let f = write_toml(
1064            r#"
1065            [ui]
1066            primary = "openrouter"
1067
1068            [zai]
1069            enabled = true
1070            api_key_env = "MY_ZAI"
1071            api_key = "sk-zai-inline"
1072
1073            [openrouter]
1074            enabled = true
1075            api_key = "sk-or-inline"
1076            "#,
1077        );
1078        let c = Config::load_from(f.path()).unwrap();
1079        assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
1080        assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
1081        assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
1082    }
1083
1084    #[test]
1085    fn enabled_vendors_preserves_canonical_order() {
1086        // DeepSeek and Kimi are disabled by default (require explicit API key
1087        // config), so they are absent from the enabled list unless enabled.
1088        let c = Config::default();
1089        assert_eq!(
1090            c.enabled_vendors(),
1091            vec![
1092                VendorId::Anthropic,
1093                VendorId::Openai,
1094                VendorId::Zai,
1095                VendorId::Openrouter,
1096            ]
1097        );
1098    }
1099
1100    #[test]
1101    fn deepseek_appears_when_enabled() {
1102        let f = write_toml(
1103            r#"
1104            [deepseek]
1105            enabled = true
1106            api_key = "sk-test"
1107            "#,
1108        );
1109        let c = Config::load_from(f.path()).unwrap();
1110        assert!(c.is_enabled(VendorId::Deepseek));
1111        assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
1112        assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
1113    }
1114
1115    #[test]
1116    fn tilde_paths_are_expanded_on_load() {
1117        // `PathBuf` keeps `~` literally, so the documented
1118        // `credentials_path = "~/..."` used to resolve to a directory named
1119        // `~` relative to the process's cwd.
1120        let f = write_toml(
1121            r#"
1122            [context]
1123            projects_path = "~/.claude/projects"
1124
1125            [anthropic]
1126            credentials_path = "~/.claude/.credentials.json"
1127
1128            [[anthropic.accounts]]
1129            label = "work"
1130            credentials_path = "~/work.json"
1131            "#,
1132        );
1133        let c = Config::load_from(f.path()).unwrap();
1134        let home = crate::cache::home_dir().unwrap();
1135
1136        assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
1137        let got = c.anthropic.credentials_path.unwrap();
1138        assert_eq!(got, home.join(".claude/.credentials.json"));
1139        assert!(!got.to_string_lossy().contains('~'));
1140        assert_eq!(
1141            c.anthropic.accounts[0].credentials_path,
1142            home.join("work.json")
1143        );
1144    }
1145
1146    #[test]
1147    fn absolute_and_relative_paths_are_left_alone() {
1148        let f = write_toml(
1149            r#"
1150            [anthropic]
1151            credentials_path = "/etc/creds.json"
1152            "#,
1153        );
1154        let c = Config::load_from(f.path()).unwrap();
1155        assert_eq!(
1156            c.anthropic.credentials_path.unwrap(),
1157            std::path::Path::new("/etc/creds.json")
1158        );
1159
1160        // `~user` is not ours to interpret.
1161        let f2 = write_toml(
1162            r#"
1163            [anthropic]
1164            credentials_path = "~someone/creds.json"
1165            "#,
1166        );
1167        let c2 = Config::load_from(f2.path()).unwrap();
1168        assert_eq!(
1169            c2.anthropic.credentials_path.unwrap(),
1170            std::path::Path::new("~someone/creds.json")
1171        );
1172    }
1173
1174    #[test]
1175    fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
1176        // Hermetic: only asserts the shape, never which file happens to exist
1177        // on the machine running the tests.
1178        let p = resolved_path().expect("a config path must resolve");
1179        assert!(p.ends_with("config.toml"));
1180        let canonical = default_path().unwrap();
1181        let legacy = legacy_xdg_path().unwrap();
1182        assert!(
1183            p == canonical || p == legacy,
1184            "resolved to an unexpected location: {}",
1185            p.display()
1186        );
1187    }
1188
1189    #[test]
1190    fn misspelled_section_is_rejected_not_ignored() {
1191        // The regression this guards: `[openrouer]` used to parse fine, leave
1192        // OpenRouter on its defaults, and give the user no hint at all.
1193        let f = write_toml(
1194            r#"
1195            [openrouer]
1196            enabled = true
1197            api_key = "sk-or-v1-typo"
1198            "#,
1199        );
1200        let err = Config::load_from(f.path()).unwrap_err().to_string();
1201        assert!(
1202            err.contains("openrouer"),
1203            "error should name the typo: {err}"
1204        );
1205    }
1206
1207    #[test]
1208    fn invalid_toml_is_an_error_not_silent_defaults() {
1209        let f = write_toml("[zai\nenabled = true\n");
1210        assert!(Config::load_from(f.path()).is_err());
1211    }
1212
1213    #[test]
1214    fn a_missing_file_is_still_just_defaults() {
1215        // Absence stays the legitimate "use defaults" case — only real parse
1216        // and I/O failures are errors.
1217        let dir = tempfile::tempdir().unwrap();
1218        let missing = dir.path().join("nope").join("config.toml");
1219        let c = Config::load_from(&missing).unwrap();
1220        assert!(c.is_enabled(VendorId::Anthropic));
1221    }
1222
1223    #[test]
1224    fn kimi_appears_when_enabled() {
1225        let f = write_toml(
1226            r#"
1227            [kimi]
1228            enabled = true
1229            api_key = "sk-test"
1230            "#,
1231        );
1232        let c = Config::load_from(f.path()).unwrap();
1233        assert!(c.is_enabled(VendorId::Kimi));
1234        assert!(c.enabled_vendors().contains(&VendorId::Kimi));
1235        assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
1236    }
1237
1238    #[test]
1239    fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
1240        let f = write_toml(
1241            r#"
1242            [deepseek]
1243            enabled = true
1244            api_key = "sk-ds"
1245
1246            [kimi]
1247            enabled = true
1248            api_key = "sk-kimi"
1249            "#,
1250        );
1251        let c = Config::load_from(f.path()).unwrap();
1252        assert_eq!(
1253            c.enabled_vendors(),
1254            vec![
1255                VendorId::Anthropic,
1256                VendorId::Openai,
1257                VendorId::Zai,
1258                VendorId::Openrouter,
1259                VendorId::Deepseek,
1260                VendorId::Kimi,
1261            ]
1262        );
1263    }
1264
1265    #[test]
1266    fn parses_anthropic_accounts_and_looks_them_up() {
1267        let f = write_toml(
1268            r#"
1269            [anthropic]
1270            enabled = true
1271
1272            [[anthropic.accounts]]
1273            label = "personal"
1274            credentials_path = "/creds/personal.json"
1275
1276            [[anthropic.accounts]]
1277            label = "work"
1278            credentials_path = "/creds/work.json"
1279            "#,
1280        );
1281        let c = Config::load_from(f.path()).unwrap();
1282        assert_eq!(c.anthropic.accounts.len(), 2);
1283        let work = c.anthropic.account("work").unwrap();
1284        assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
1285        // A typo names the offending label and lists the known ones.
1286        let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
1287        assert!(err.contains("missing") && err.contains("work"), "{err}");
1288    }
1289
1290    #[test]
1291    fn duplicate_anthropic_account_labels_are_rejected_on_load() {
1292        let f = write_toml(
1293            r#"
1294            [[anthropic.accounts]]
1295            label = "work"
1296            credentials_path = "/creds/work-one.json"
1297
1298            [[anthropic.accounts]]
1299            label = "work"
1300            credentials_path = "/creds/work-two.json"
1301            "#,
1302        );
1303        let err = Config::load_from(f.path()).unwrap_err().to_string();
1304        assert!(
1305            err.contains("duplicate anthropic account label \"work\""),
1306            "{err}"
1307        );
1308    }
1309
1310    #[test]
1311    fn account_label_rejects_path_like_names() {
1312        let cfg = AnthropicConfig::default();
1313        for bad in ["", ".", "..", "a/b", r"a\b", "usage.json"] {
1314            let err = cfg.account(bad).unwrap_err();
1315            assert!(
1316                format!("{err:?}").contains("invalid anthropic account label"),
1317                "{bad:?} should be rejected as a label"
1318            );
1319        }
1320    }
1321
1322    #[test]
1323    fn anthropic_accounts_default_to_empty() {
1324        // No [[anthropic.accounts]] → the single default account, empty list,
1325        // nothing to migrate (issue #14, back-compat rule 1).
1326        assert!(Config::default().anthropic.accounts.is_empty());
1327        assert!(Config::default().anthropic.accounts_dir.is_none());
1328    }
1329
1330    // --- accounts_dir: CLAUDE_CONFIG_DIR-style auto-discovery ----------------
1331    // All hermetic: discovery reads a TempDir, never the user's real config.
1332
1333    /// Create `<root>/<label>/.credentials.json` (contents irrelevant here —
1334    /// discovery keys on the file existing, the fetch path parses it).
1335    fn seed_account_dir(root: &std::path::Path, label: &str) {
1336        let dir = root.join(label);
1337        std::fs::create_dir_all(&dir).unwrap();
1338        std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1339    }
1340
1341    #[test]
1342    fn discovers_account_dirs_in_claude_config_dir_layout() {
1343        let td = tempfile::tempdir().unwrap();
1344        seed_account_dir(td.path(), "work");
1345        seed_account_dir(td.path(), "personal");
1346        // Keychain-backed macOS logins may not write .credentials.json; their
1347        // config directories are still account entries.
1348        std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
1349        // A loose file (not a dir) is ignored.
1350        std::fs::write(td.path().join("stray.json"), "{}").unwrap();
1351
1352        let cfg = AnthropicConfig {
1353            accounts_dir: Some(td.path().to_path_buf()),
1354            ..Default::default()
1355        };
1356        let all = cfg.all_accounts();
1357        let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
1358        assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
1359        assert_eq!(
1360            all[2].credentials_path,
1361            td.path().join("work").join(".credentials.json")
1362        );
1363    }
1364
1365    #[test]
1366    fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
1367        let td = tempfile::tempdir().unwrap();
1368        seed_account_dir(td.path(), "work");
1369        let cfg = AnthropicConfig {
1370            accounts: vec![AnthropicAccount {
1371                label: "work".into(),
1372                credentials_path: "/explicit/work.json".into(),
1373            }],
1374            accounts_dir: Some(td.path().to_path_buf()),
1375            ..Default::default()
1376        };
1377        let all = cfg.all_accounts();
1378        assert_eq!(all.len(), 1, "no duplicate label");
1379        assert_eq!(
1380            all[0].credentials_path,
1381            std::path::Path::new("/explicit/work.json"),
1382            "explicit entry wins"
1383        );
1384        // A discovered account is still reachable through `account()`.
1385        seed_account_dir(td.path(), "other");
1386        assert_eq!(cfg.account("other").unwrap().label, "other");
1387    }
1388
1389    #[test]
1390    fn missing_accounts_dir_is_silently_empty_not_an_error() {
1391        let cfg = AnthropicConfig {
1392            accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
1393            ..Default::default()
1394        };
1395        assert!(cfg.all_accounts().is_empty());
1396    }
1397
1398    #[test]
1399    fn accounts_dir_is_tilde_expanded_on_load() {
1400        let f = write_toml(
1401            r#"
1402            [anthropic]
1403            accounts_dir = "~/.config/ai-usagebar/accounts"
1404            "#,
1405        );
1406        let c = Config::load_from(f.path()).unwrap();
1407        let home = crate::cache::home_dir().unwrap();
1408        assert_eq!(
1409            c.anthropic.accounts_dir,
1410            Some(home.join(".config/ai-usagebar/accounts"))
1411        );
1412    }
1413
1414    /// The shipped example, which `make install` puts in
1415    /// `share/ai-usagebar/config.example.toml`. Repo-relative, so this stays
1416    /// hermetic — it never touches the user's real config.
1417    fn config_example() -> PathBuf {
1418        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
1419    }
1420
1421    #[test]
1422    fn shipped_example_parses_as_a_real_config() {
1423        // The example is documentation users copy verbatim, but nothing used
1424        // to parse it — so a renamed section or field could rot there
1425        // unnoticed, and `deny_unknown_fields` would reject the copy on the
1426        // user's machine instead of in CI.
1427        let c = Config::load_from(&config_example()).unwrap();
1428        assert!(!c.context.enabled);
1429        assert!(c.is_enabled(VendorId::Anthropic));
1430        assert!(c.is_enabled(VendorId::Openai));
1431        assert!(!c.is_enabled(VendorId::AnthropicApi));
1432        assert!(!c.is_enabled(VendorId::Deepseek));
1433        assert!(!c.is_enabled(VendorId::Kimi));
1434        assert!(!c.is_enabled(VendorId::Kilo));
1435        assert!(!c.is_enabled(VendorId::Novita));
1436        assert!(!c.is_enabled(VendorId::Moonshot));
1437        assert!(!c.is_enabled(VendorId::Grok));
1438        assert!(!c.is_enabled(VendorId::Cursor));
1439    }
1440
1441    #[test]
1442    fn shipped_example_does_not_advertise_admin_key_env_as_working() {
1443        // The regression: the example shipped an *uncommented*
1444        // `admin_key_env = "OPENAI_ADMIN_KEY"`, indistinguishable from a live
1445        // setting. Nothing reads it, so a user could set it, skip
1446        // `codex login`, and wait for usage that never arrives.
1447        let text = std::fs::read_to_string(config_example()).unwrap();
1448        let live: Vec<&str> = text
1449            .lines()
1450            .map(str::trim)
1451            .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
1452            .collect();
1453        assert!(
1454            live.is_empty(),
1455            "admin_key_env must stay commented out while it is inert: {live:?}"
1456        );
1457        // Still documented, though — silently dropping it would leave users
1458        // who already set it with no explanation of why it does nothing.
1459        assert!(
1460            text.contains("admin_key_env") && text.contains("RESERVED"),
1461            "the example should keep describing admin_key_env as reserved"
1462        );
1463    }
1464
1465    #[test]
1466    fn admin_key_env_is_accepted_but_changes_nothing() {
1467        // The field survives because the API-key-only path is still intended.
1468        // What has to hold today is narrower: setting it loads without error
1469        // and moves nothing the code actually acts on.
1470        let f = write_toml(
1471            r#"
1472            [openai]
1473            admin_key_env = "SOME_ADMIN_KEY"
1474            "#,
1475        );
1476        let c = Config::load_from(f.path()).unwrap();
1477        assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
1478        // Nothing else moved: OpenAI still resolves through Codex OAuth only.
1479        let default = OpenAiConfig::default();
1480        assert_eq!(c.openai.enabled, default.enabled);
1481        assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
1482        assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
1483    }
1484
1485    #[test]
1486    fn config_example_documents_every_vendor_without_secrets() {
1487        let raw = std::fs::read_to_string(config_example()).unwrap();
1488        let cfg = Config::load_from(&config_example()).unwrap();
1489        // Every vendor the binary can dispatch needs a documented section, or
1490        // users have no way to discover how to turn it on.
1491        for id in VendorId::all() {
1492            let section = id.slug();
1493            assert!(
1494                raw.contains(&format!("[{section}]")),
1495                "config.example.toml has no [{section}] section"
1496            );
1497        }
1498
1499        // The example must not ship anything enabled-by-key-only, and must not
1500        // carry a real secret.
1501        assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
1502        assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
1503        assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
1504        assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
1505        assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
1506        assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
1507    }
1508
1509    #[test]
1510    fn cursor_db_path_is_tilde_expanded() {
1511        let f = write_toml(
1512            r#"
1513            [cursor]
1514            db_path = "~/cursor-state.vscdb"
1515            "#,
1516        );
1517        let c = Config::load_from(f.path()).unwrap();
1518        let home = crate::cache::home_dir().unwrap();
1519        assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
1520    }
1521
1522    #[test]
1523    fn cursor_appears_when_enabled() {
1524        let f = write_toml(
1525            r#"
1526            [cursor]
1527            enabled = true
1528            "#,
1529        );
1530        let c = Config::load_from(f.path()).unwrap();
1531        assert!(c.is_enabled(VendorId::Cursor));
1532        assert!(c.enabled_vendors().contains(&VendorId::Cursor));
1533    }
1534}