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}
51
52/// UI / dispatch preferences. Currently just `primary` — which vendor the
53/// widget shows when `--vendor` is omitted, and which TUI tab is selected
54/// at startup.
55#[derive(Debug, Clone, Default, Deserialize, Serialize)]
56#[serde(default)]
57pub struct UiConfig {
58    /// `None` → fall back to anthropic for backward compatibility.
59    pub primary: Option<VendorId>,
60}
61
62/// Where the context view docks in the dashboard body. `v` cycles it while the
63/// overlay is open; the config value is what it opens with.
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
65#[serde(rename_all = "lowercase")]
66pub enum ContextLayout {
67    /// Takes the whole body, the way a vendor panel does.
68    #[default]
69    Full,
70    /// Beside the dashboard.
71    Split,
72    /// Below the dashboard.
73    Bottom,
74}
75
76impl ContextLayout {
77    pub fn next(self) -> Self {
78        match self {
79            ContextLayout::Full => ContextLayout::Split,
80            ContextLayout::Split => ContextLayout::Bottom,
81            ContextLayout::Bottom => ContextLayout::Full,
82        }
83    }
84
85    pub fn label(self) -> &'static str {
86        match self {
87            ContextLayout::Full => "full",
88            ContextLayout::Split => "split",
89            ContextLayout::Bottom => "bottom",
90        }
91    }
92}
93
94/// Optional local Claude Code context-window monitor. This is deliberately
95/// separate from vendors: sessions are discovered from local transcripts and
96/// change while the TUI is running, whereas vendor tabs are config-declared
97/// account identities.
98#[derive(Debug, Clone, Default, Deserialize, Serialize)]
99#[serde(default)]
100pub struct ContextConfig {
101    /// Keep the filesystem scanner completely dormant unless explicitly
102    /// enabled. The `c` key and its footer hint are hidden while disabled.
103    pub enabled: bool,
104    /// Override Claude Code's normal `~/.claude/projects` transcript root.
105    pub projects_path: Option<PathBuf>,
106    /// Optional fallback denominator. When absent, sessions without an exact
107    /// model override show their input-token count without inventing a %.
108    pub context_window_tokens: Option<u64>,
109    /// Exact Claude model id -> context-window size. This takes precedence
110    /// over `context_window_tokens`, which keeps mixed 200K/1M histories safe.
111    pub model_context_window_tokens: BTreeMap<String, u64>,
112    /// Where the view opens: full | split | bottom.
113    pub layout: ContextLayout,
114}
115
116impl ContextConfig {
117    pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
118        model
119            .and_then(|model| self.model_context_window_tokens.get(model).copied())
120            .filter(|tokens| *tokens > 0)
121            .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
122    }
123}
124
125#[derive(Debug, Clone, Deserialize, Serialize)]
126#[serde(default)]
127pub struct AnthropicConfig {
128    pub enabled: bool,
129    /// Override the credentials file path (defaults to `~/.claude/.credentials.json`).
130    /// This is the *default* account; extra subscriptions go in `accounts`.
131    pub credentials_path: Option<PathBuf>,
132    /// Extra Anthropic accounts beyond the default, each selected on the CLI
133    /// with `--account <label>` (issue #14). Empty by default, so existing
134    /// single-account configs are byte-for-byte unchanged.
135    pub accounts: Vec<AnthropicAccount>,
136}
137
138impl Default for AnthropicConfig {
139    fn default() -> Self {
140        Self {
141            enabled: true,
142            credentials_path: None,
143            accounts: Vec::new(),
144        }
145    }
146}
147
148/// One extra Anthropic account beyond the default (issue #14). The default
149/// account stays the singular `[anthropic] credentials_path`; each entry here
150/// is an additional subscription selected on the CLI with `--account <label>`.
151///
152/// ```toml
153/// [[anthropic.accounts]]
154/// label = "work"
155/// credentials_path = "~/.config/ai-usagebar/accounts/work.json"
156/// ```
157#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
158pub struct AnthropicAccount {
159    /// Stable name used on the CLI (`--account <label>`) and as the cache
160    /// subdir (`~/.cache/ai-usagebar/anthropic/<label>`).
161    pub label: String,
162    /// OAuth credentials file for this account (same JSON shape Claude Code
163    /// writes). Token refreshes are written back here, so each account keeps
164    /// itself alive independently.
165    pub credentials_path: PathBuf,
166}
167
168impl AnthropicConfig {
169    /// Find a configured extra account by label, or error listing the known
170    /// labels so a typo fails loudly instead of silently hitting the default.
171    pub fn account(&self, label: &str) -> Result<&AnthropicAccount> {
172        validate_account_label(label)?;
173        self.accounts
174            .iter()
175            .find(|a| a.label == label)
176            .ok_or_else(|| {
177                let known: Vec<&str> = self.accounts.iter().map(|a| a.label.as_str()).collect();
178                AppError::Credentials(format!(
179                    "anthropic account {label:?} not found in [[anthropic.accounts]]; \
180                     known labels: {known:?}"
181                ))
182            })
183    }
184
185    /// Resolve a named account to the credentials target + isolated cache it
186    /// fetches through: a strict [`CredsTarget::Explicit`] on the account's file
187    /// (never the Keychain — issue #15) and an `anthropic/<label>` cache subdir.
188    /// Shared by the widget (`--account`) and the TUI's per-account tab (#14,
189    /// #17) so both resolve accounts identically; the widget layers its
190    /// `--cache-dir` override on top of the cache returned here.
191    pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
192        let account = self.account(label)?;
193        Ok((
194            CredsTarget::Explicit(account.credentials_path.clone()),
195            Cache::for_vendor_account("anthropic", label)?,
196        ))
197    }
198}
199
200/// The label doubles as a cache subdirectory name
201/// (`~/.cache/ai-usagebar/anthropic/<label>/`), which nests inside the default
202/// account's cache dir — so path separators or dot-dirs would escape or
203/// collide with the cache layout (`usage.json`, `.stale`, …). Reject anything
204/// that isn't a plain single-segment name.
205fn validate_account_label(label: &str) -> Result<()> {
206    let bad = label.is_empty()
207        || label == "."
208        || label == ".."
209        || label.contains(['/', '\\'])
210        || label == "usage.json";
211    if bad {
212        return Err(AppError::Credentials(format!(
213            "invalid anthropic account label {label:?}: must be a non-empty name \
214             without path separators (it becomes a cache subdirectory)"
215        )));
216    }
217    Ok(())
218}
219
220#[derive(Debug, Clone, Deserialize, Serialize)]
221#[serde(default)]
222pub struct OpenAiConfig {
223    pub enabled: bool,
224    /// Override the Codex auth file path (defaults to `~/.codex/auth.json`).
225    pub codex_auth_path: Option<PathBuf>,
226    /// Reserved, and inert: names the env var an API-key-only path *would*
227    /// read (admin key → `/v1/organization/costs`). Nothing consumes it —
228    /// OpenAI usage comes solely from Codex OAuth. Kept because that path is
229    /// still intended, not for back-compat: `[openai]` doesn't deny unknown
230    /// fields, so an existing `admin_key_env` would load either way. See
231    /// `config.example.toml`, which ships it commented out so nobody sets it
232    /// expecting an effect.
233    pub admin_key_env: String,
234}
235
236impl Default for OpenAiConfig {
237    fn default() -> Self {
238        Self {
239            enabled: true,
240            codex_auth_path: None,
241            admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
242        }
243    }
244}
245
246#[derive(Debug, Clone, Deserialize, Serialize)]
247#[serde(default)]
248pub struct ZaiConfig {
249    pub enabled: bool,
250    /// Env var name to read the key from (env wins over `api_key`).
251    pub api_key_env: String,
252    /// Inline key (fallback when the env var is unset). Chmod 600 your
253    /// config file if you put a real key here.
254    pub api_key: Option<String>,
255    /// Optional plan tier label (lite/pro/max) — display-only.
256    pub plan_tier: Option<String>,
257}
258
259impl Default for ZaiConfig {
260    fn default() -> Self {
261        Self {
262            enabled: true,
263            api_key_env: "ZAI_API_KEY".to_string(),
264            api_key: None,
265            plan_tier: None,
266        }
267    }
268}
269
270#[derive(Debug, Clone, Deserialize, Serialize)]
271#[serde(default)]
272pub struct OpenRouterConfig {
273    pub enabled: bool,
274    pub api_key_env: String,
275    pub api_key: Option<String>,
276}
277
278impl Default for OpenRouterConfig {
279    fn default() -> Self {
280        Self {
281            enabled: true,
282            api_key_env: "OPENROUTER_API_KEY".to_string(),
283            api_key: None,
284        }
285    }
286}
287
288#[derive(Debug, Clone, Deserialize, Serialize)]
289#[serde(default)]
290pub struct DeepseekConfig {
291    pub enabled: bool,
292    pub api_key_env: String,
293    pub api_key: Option<String>,
294}
295
296impl Default for DeepseekConfig {
297    fn default() -> Self {
298        Self {
299            enabled: false,
300            api_key_env: "DEEPSEEK_API_KEY".to_string(),
301            api_key: None,
302        }
303    }
304}
305
306#[derive(Debug, Clone, Deserialize, Serialize)]
307#[serde(default)]
308pub struct KimiConfig {
309    pub enabled: bool,
310    pub api_key_env: String,
311    pub api_key: Option<String>,
312}
313
314impl Default for KimiConfig {
315    fn default() -> Self {
316        Self {
317            enabled: false,
318            api_key_env: "KIMI_API_KEY".to_string(),
319            api_key: None,
320        }
321    }
322}
323
324#[derive(Debug, Clone, Deserialize, Serialize)]
325#[serde(default)]
326pub struct KiloConfig {
327    pub enabled: bool,
328    pub api_key_env: String,
329    pub api_key: Option<String>,
330    /// Optional Kilo organization id — scopes the balance to a team via the
331    /// `x-kilocode-organizationid` header. Omit for the personal balance.
332    pub organization_id: Option<String>,
333}
334
335impl Default for KiloConfig {
336    fn default() -> Self {
337        // Opt-in like DeepSeek: requires an explicit API key, so it defaults to
338        // disabled and never affects existing installs.
339        Self {
340            enabled: false,
341            api_key_env: "KILO_API_KEY".to_string(),
342            api_key: None,
343            organization_id: None,
344        }
345    }
346}
347
348#[derive(Debug, Clone, Deserialize, Serialize)]
349#[serde(default)]
350pub struct NovitaConfig {
351    pub enabled: bool,
352    pub api_key_env: String,
353    pub api_key: Option<String>,
354}
355
356impl Default for NovitaConfig {
357    fn default() -> Self {
358        // Opt-in like DeepSeek/Kilo: needs an explicit API key.
359        Self {
360            enabled: false,
361            api_key_env: "NOVITA_API_KEY".to_string(),
362            api_key: None,
363        }
364    }
365}
366
367#[derive(Debug, Clone, Deserialize, Serialize)]
368#[serde(default)]
369pub struct MoonshotConfig {
370    pub enabled: bool,
371    pub api_key_env: String,
372    pub api_key: Option<String>,
373    /// `"global"` → api.moonshot.ai (USD); `"cn"` → api.moonshot.cn (CNY).
374    pub region: String,
375}
376
377impl Default for MoonshotConfig {
378    fn default() -> Self {
379        // Opt-in like DeepSeek/Kilo/Novita: needs an explicit API key.
380        Self {
381            enabled: false,
382            api_key_env: "MOONSHOT_API_KEY".to_string(),
383            api_key: None,
384            region: "global".to_string(),
385        }
386    }
387}
388
389#[derive(Debug, Clone, Deserialize, Serialize)]
390#[serde(default)]
391pub struct GrokConfig {
392    pub enabled: bool,
393    /// Env var for the xAI **Management** key (distinct from the inference key).
394    pub api_key_env: String,
395    pub api_key: Option<String>,
396    /// Optional team id. When absent, it's auto-resolved from the management
397    /// key via `/auth/management-keys/validation`.
398    pub team_id: Option<String>,
399}
400
401impl Default for GrokConfig {
402    fn default() -> Self {
403        // Opt-in: needs a management key (and, for prepaid, a team).
404        Self {
405            enabled: false,
406            api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
407            api_key: None,
408            team_id: None,
409        }
410    }
411}
412
413/// Antigravity reads its quota from whichever local Antigravity product is
414/// running, so it needs no credentials — only an on/off switch.
415#[derive(Debug, Clone, Default, Deserialize, Serialize)]
416#[serde(default)]
417pub struct AntigravityConfig {
418    pub enabled: bool,
419}
420
421#[derive(Debug, Clone, Deserialize, Serialize)]
422#[serde(default)]
423pub struct AnthropicApiConfig {
424    pub enabled: bool,
425    /// Env var for the Console **Admin key** (`sk-ant-admin01-…`), distinct from
426    /// an inference key and from the Claude Code OAuth login.
427    pub api_key_env: String,
428    pub api_key: Option<String>,
429    /// Monthly USD spend limit, used only for the spend-vs-limit % display. The
430    /// API exposes neither this limit nor the remaining prepaid balance.
431    pub monthly_limit: Option<f64>,
432}
433
434impl Default for AnthropicApiConfig {
435    fn default() -> Self {
436        // Opt-in: needs an explicit Admin key.
437        Self {
438            enabled: false,
439            api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
440            api_key: None,
441            monthly_limit: None,
442        }
443    }
444}
445
446/// Resolve an API key for a vendor: a valid env-var name wins, then inline
447/// config, then a clear error naming both fields. Used by every API-key vendor.
448pub fn resolve_api_key(
449    vendor_label: &str,
450    env_var_name: &str,
451    inline: Option<&str>,
452) -> crate::error::Result<String> {
453    let valid_env_name = is_valid_env_var_name(env_var_name);
454    if valid_env_name
455        && let Ok(v) = std::env::var(env_var_name)
456        && !v.is_empty()
457    {
458        return Ok(v);
459    }
460    if let Some(v) = inline
461        && !v.is_empty()
462    {
463        return Ok(v.to_string());
464    }
465    let advice = if valid_env_name {
466        "set an API key in a valid environment variable or set `api_key`"
467    } else {
468        "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
469    };
470    Err(crate::error::AppError::Credentials(format!(
471        "{vendor_label}: no API key. Either {advice} under [{}] in {}.",
472        vendor_label.to_lowercase(),
473        config_path_hint()
474    )))
475}
476
477fn is_valid_env_var_name(name: &str) -> bool {
478    let mut chars = name.chars();
479    let Some(first) = chars.next() else {
480        return false;
481    };
482    (first.is_ascii_alphabetic() || first == '_')
483        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
484}
485
486impl Config {
487    /// Load from `~/.config/ai-usagebar/config.toml`. Returns defaults if the
488    /// file doesn't exist; errors only on actual parse failures.
489    pub fn load() -> Result<Self> {
490        let Some(path) = resolved_path() else {
491            return Ok(Self::default());
492        };
493        Self::load_from(&path)
494    }
495
496    pub fn load_from(path: &std::path::Path) -> Result<Self> {
497        match std::fs::read_to_string(path) {
498            Ok(s) => {
499                let mut config: Self = toml::from_str(&s)?;
500                // `~` is shell syntax, not path syntax: `PathBuf` keeps it
501                // literally, so a documented `credentials_path = "~/..."`
502                // silently pointed at a directory named `~`.
503                config.expand_paths();
504                config.validate()?;
505                Ok(config)
506            }
507            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
508            Err(e) => Err(AppError::io_at(path, e)),
509        }
510    }
511
512    fn expand_paths(&mut self) {
513        expand_tilde_opt(&mut self.context.projects_path);
514        expand_tilde_opt(&mut self.anthropic.credentials_path);
515        expand_tilde_opt(&mut self.openai.codex_auth_path);
516        for account in &mut self.anthropic.accounts {
517            account.credentials_path = expand_tilde(&account.credentials_path);
518        }
519    }
520
521    pub fn is_enabled(&self, id: VendorId) -> bool {
522        match id {
523            VendorId::Anthropic => self.anthropic.enabled,
524            VendorId::AnthropicApi => self.anthropic_api.enabled,
525            VendorId::Openai => self.openai.enabled,
526            VendorId::Zai => self.zai.enabled,
527            VendorId::Openrouter => self.openrouter.enabled,
528            VendorId::Deepseek => self.deepseek.enabled,
529            VendorId::Kimi => self.kimi.enabled,
530            VendorId::Kilo => self.kilo.enabled,
531            VendorId::Novita => self.novita.enabled,
532            VendorId::Moonshot => self.moonshot.enabled,
533            VendorId::Grok => self.grok.enabled,
534            VendorId::Antigravity => self.antigravity.enabled,
535        }
536    }
537
538    pub fn enabled_vendors(&self) -> Vec<VendorId> {
539        VendorId::all()
540            .iter()
541            .copied()
542            .filter(|id| self.is_enabled(*id))
543            .collect()
544    }
545
546    /// Validate cross-entry constraints that serde cannot express. Account
547    /// labels are both CLI selectors and TUI tab identities, so duplicates
548    /// would make either destination ambiguous.
549    pub fn validate(&self) -> Result<()> {
550        if self.context.context_window_tokens == Some(0) {
551            return Err(AppError::Other(
552                "[context] context_window_tokens must be greater than zero".into(),
553            ));
554        }
555        for (model, tokens) in &self.context.model_context_window_tokens {
556            if model.trim().is_empty() {
557                return Err(AppError::Other(
558                    "[context] model_context_window_tokens keys must not be empty".into(),
559                ));
560            }
561            if *tokens == 0 {
562                return Err(AppError::Other(format!(
563                    "[context] model_context_window_tokens entry {model:?} must be greater than zero"
564                )));
565            }
566        }
567        if let Some(limit) = self.anthropic_api.monthly_limit
568            && (!limit.is_finite() || limit <= 0.0)
569        {
570            return Err(AppError::Other(
571                "[anthropic_api] monthly_limit must be finite and greater than zero; \
572                 remove it to show spend without a limit"
573                    .into(),
574            ));
575        }
576        let mut labels = HashSet::new();
577        for account in &self.anthropic.accounts {
578            validate_account_label(&account.label)?;
579            if !labels.insert(&account.label) {
580                return Err(AppError::Credentials(format!(
581                    "duplicate anthropic account label {:?}",
582                    account.label
583                )));
584            }
585        }
586        Ok(())
587    }
588}
589
590pub fn default_path() -> Option<PathBuf> {
591    let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
592    Some(proj.config_dir().join("config.toml"))
593}
594
595/// The Unix-conventional location, which is what every doc, the config
596/// example, and both desktop integrations have always pointed at. On Linux it
597/// *is* [`default_path`]; on macOS `ProjectDirs` resolves to
598/// `~/Library/Application Support/…` instead, so the two diverge.
599fn legacy_xdg_path() -> Option<PathBuf> {
600    let home = crate::cache::home_dir().ok()?;
601    Some(home.join(".config").join("ai-usagebar").join("config.toml"))
602}
603
604/// The config file actually in effect.
605///
606/// [`default_path`] stays canonical, but on macOS a file at the documented
607/// `~/.config/ai-usagebar/config.toml` is honored when the canonical one does
608/// not exist — otherwise everyone who followed the README (and both desktop
609/// integrations, which read that path) silently got defaults. The legacy file
610/// is never moved or rewritten: it may hold API keys, and relocating a secret
611/// behind the user's back is not this tool's business.
612pub fn resolved_path() -> Option<PathBuf> {
613    let canonical = default_path();
614    if let Some(p) = &canonical
615        && p.exists()
616    {
617        return canonical;
618    }
619    if let Some(legacy) = legacy_xdg_path()
620        && legacy.exists()
621    {
622        return Some(legacy);
623    }
624    canonical
625}
626
627/// Expand a leading `~` (or `~/`) against the user's home directory. Anything
628/// else — including `~user` — is left untouched.
629fn expand_tilde(p: &std::path::Path) -> PathBuf {
630    let Some(s) = p.to_str() else {
631        return p.to_path_buf();
632    };
633    let rest = if s == "~" {
634        ""
635    } else if let Some(r) = s.strip_prefix("~/") {
636        r
637    } else {
638        return p.to_path_buf();
639    };
640    match crate::cache::home_dir() {
641        Ok(home) if rest.is_empty() => home,
642        Ok(home) => home.join(rest),
643        Err(_) => p.to_path_buf(),
644    }
645}
646
647fn expand_tilde_opt(p: &mut Option<PathBuf>) {
648    if let Some(inner) = p.as_ref() {
649        *p = Some(expand_tilde(inner));
650    }
651}
652
653/// Resolved `config.toml` path as a string for user-facing messages. Uses the
654/// platform's config dir (`directories::ProjectDirs`), so it reads correctly on
655/// Linux, macOS, and Windows instead of hard-coding the Unix `~/.config` path.
656/// Falls back to the bare filename if the path can't be resolved.
657pub fn config_path_hint() -> String {
658    resolved_path()
659        .map(|p| p.display().to_string())
660        .unwrap_or_else(|| "config.toml".to_string())
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use std::io::Write;
667    use tempfile::NamedTempFile;
668
669    fn write_toml(s: &str) -> NamedTempFile {
670        let mut f = NamedTempFile::new().unwrap();
671        f.write_all(s.as_bytes()).unwrap();
672        f.flush().unwrap();
673        f
674    }
675
676    #[test]
677    fn defaults_enable_only_the_four_core_vendors() {
678        let c = Config::default();
679        assert!(c.is_enabled(VendorId::Anthropic));
680        assert!(c.is_enabled(VendorId::Openai));
681        assert!(c.is_enabled(VendorId::Zai));
682        assert!(c.is_enabled(VendorId::Openrouter));
683        for opt_in in [
684            VendorId::AnthropicApi,
685            VendorId::Deepseek,
686            VendorId::Kimi,
687            VendorId::Kilo,
688            VendorId::Novita,
689            VendorId::Moonshot,
690            VendorId::Grok,
691        ] {
692            assert!(!c.is_enabled(opt_in), "{opt_in:?}");
693        }
694        assert_eq!(c.enabled_vendors().len(), 4);
695    }
696
697    #[test]
698    fn missing_file_uses_defaults() {
699        let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
700        let c = Config::load_from(path).unwrap();
701        assert!(c.is_enabled(VendorId::Anthropic));
702    }
703
704    #[test]
705    fn parses_full_config() {
706        let f = write_toml(
707            r#"
708            [anthropic]
709            enabled = true
710
711            [openai]
712            enabled = false
713            admin_key_env = "MY_ADMIN_KEY"
714
715            [zai]
716            enabled = true
717            api_key_env = "MY_ZAI"
718            plan_tier = "pro"
719
720            [openrouter]
721            enabled = false
722            "#,
723        );
724        let c = Config::load_from(f.path()).unwrap();
725        assert!(c.is_enabled(VendorId::Anthropic));
726        assert!(!c.is_enabled(VendorId::Openai));
727        assert!(c.is_enabled(VendorId::Zai));
728        assert!(!c.is_enabled(VendorId::Openrouter));
729        assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
730        assert_eq!(c.zai.api_key_env, "MY_ZAI");
731        assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
732    }
733
734    #[test]
735    fn partial_config_falls_back_to_defaults() {
736        let f = write_toml(
737            r#"[openai]
738enabled = false
739"#,
740        );
741        let c = Config::load_from(f.path()).unwrap();
742        assert!(!c.is_enabled(VendorId::Openai));
743        // Other vendors keep their defaults.
744        assert!(c.is_enabled(VendorId::Anthropic));
745        assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
746    }
747
748    #[test]
749    fn malformed_toml_returns_error() {
750        let f = write_toml("this is not = = valid");
751        assert!(Config::load_from(f.path()).is_err());
752    }
753
754    #[test]
755    fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
756        for value in ["0", "-1", "inf", "nan"] {
757            let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
758            let error = Config::load_from(file.path()).unwrap_err().to_string();
759            assert!(error.contains("monthly_limit"), "value {value}: {error}");
760        }
761
762        let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
763        assert_eq!(
764            Config::load_from(file.path())
765                .unwrap()
766                .anthropic_api
767                .monthly_limit,
768            Some(1000.0)
769        );
770    }
771
772    #[test]
773    fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
774        let defaults = Config::default();
775        assert!(!defaults.context.enabled);
776        assert_eq!(
777            defaults.context.window_tokens_for(Some("claude-test")),
778            None
779        );
780
781        let file = write_toml(
782            r#"
783            [context]
784            enabled = true
785            context_window_tokens = 200000
786
787            [context.model_context_window_tokens]
788            claude-opus-1m = 1000000
789            "claude exact id" = 300000
790            "#,
791        );
792        let config = Config::load_from(file.path()).unwrap();
793        assert!(config.context.enabled);
794        assert_eq!(
795            config.context.window_tokens_for(Some("claude-opus-1m")),
796            Some(1_000_000)
797        );
798        assert_eq!(
799            config.context.window_tokens_for(Some("claude exact id")),
800            Some(300_000)
801        );
802        assert_eq!(
803            config.context.window_tokens_for(Some("another-model")),
804            Some(200_000)
805        );
806    }
807
808    #[test]
809    fn context_layout_defaults_to_full_and_parses_each_variant() {
810        assert_eq!(Config::default().context.layout, ContextLayout::Full);
811        for (text, want) in [
812            ("full", ContextLayout::Full),
813            ("split", ContextLayout::Split),
814            ("bottom", ContextLayout::Bottom),
815        ] {
816            let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
817            assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
818        }
819        let file = write_toml("[context]\nlayout = \"floating\"\n");
820        assert!(
821            Config::load_from(file.path()).is_err(),
822            "an unknown layout must be rejected, not silently defaulted"
823        );
824    }
825
826    #[test]
827    fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
828        for source in [
829            "[context]\ncontext_window_tokens = 0\n",
830            "[context.model_context_window_tokens]\nclaude = 0\n",
831            "[context.model_context_window_tokens]\n\" \" = 200000\n",
832        ] {
833            let file = write_toml(source);
834            let error = Config::load_from(file.path()).unwrap_err().to_string();
835            assert!(error.contains("context"), "{error}");
836        }
837    }
838
839    // serial guard for env-var manipulation tests so they don't race
840    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
841        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
842        M.lock().unwrap_or_else(|p| p.into_inner())
843    }
844
845    #[test]
846    fn resolve_api_key_prefers_env_over_inline() {
847        let _g = env_guard();
848        // Use a unique env var name so we don't clobber test parallelism.
849        let var = "AI_USAGEBAR_TEST_ENV_WINS";
850        // SAFETY: tests are single-threaded under env_guard.
851        unsafe { std::env::set_var(var, "from-env") };
852        let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
853        unsafe { std::env::remove_var(var) };
854        assert_eq!(got, "from-env");
855    }
856
857    #[test]
858    fn resolve_api_key_falls_back_to_inline() {
859        let _g = env_guard();
860        let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
861        unsafe { std::env::remove_var(var) };
862        let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
863        assert_eq!(got, "inline-key");
864    }
865
866    #[test]
867    fn resolve_api_key_errors_when_both_missing() {
868        let _g = env_guard();
869        let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
870        unsafe { std::env::remove_var(var) };
871        let err = resolve_api_key("Zai", var, None).unwrap_err();
872        match err {
873            crate::error::AppError::Credentials(msg) => {
874                assert!(
875                    msg.contains("api_key"),
876                    "error should suggest config field: {msg}"
877                );
878            }
879            other => panic!("expected Credentials error, got {other:?}"),
880        }
881    }
882
883    #[test]
884    fn config_path_hint_ends_with_config_toml() {
885        // Platform-resolved (Linux/macOS/Windows), but always ends in the
886        // config filename — the trailing segment is what messages rely on.
887        assert!(config_path_hint().ends_with("config.toml"));
888    }
889
890    #[test]
891    fn resolve_api_key_treats_empty_env_as_unset() {
892        let _g = env_guard();
893        let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
894        unsafe { std::env::set_var(var, "") };
895        let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
896        unsafe { std::env::remove_var(var) };
897        assert_eq!(got, "inline");
898    }
899
900    #[test]
901    fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
902        let _g = env_guard();
903        // Simulates a user accidentally pasting the key into api_key_env.
904        let bad = "sk-kimi-very-real-looking-pasted-secret";
905        let err = resolve_api_key("Kimi", bad, None).unwrap_err();
906        let msg = err.to_string();
907        assert!(
908            msg.contains("invalid") && msg.contains("api_key_env"),
909            "error should explain misconfiguration: {msg}"
910        );
911        assert!(
912            !msg.contains(bad),
913            "error must not echo the misconfigured value: {msg}"
914        );
915        assert!(msg.contains("valid environment variable name"));
916        assert!(
917            msg.contains("[kimi]"),
918            "error should point at the lowercase TOML section: {msg}"
919        );
920    }
921
922    #[test]
923    fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
924        let _g = env_guard();
925        let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
926        assert_eq!(got, "inline-key");
927    }
928
929    #[test]
930    fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
931        let _g = env_guard();
932        // This is syntactically a valid environment variable name, but could
933        // be a pasted secret and must not be reflected in the error.
934        let pasted_secret = "sk_pasted_secret";
935        unsafe { std::env::remove_var(pasted_secret) };
936        let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
937        assert!(
938            !err.to_string().contains(pasted_secret),
939            "error must not echo configured api_key_env values"
940        );
941    }
942
943    #[test]
944    fn is_valid_env_var_name_rules() {
945        // Valid: alphabetic or underscore first, then alnum/underscore.
946        for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
947            assert!(is_valid_env_var_name(valid), "{valid} should be valid");
948        }
949        // Invalid: empty, digit-first, or shell-illegal characters.
950        for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
951            assert!(
952                !is_valid_env_var_name(invalid),
953                "{invalid} should be invalid"
954            );
955        }
956    }
957
958    #[test]
959    fn config_parses_with_inline_api_key_and_primary() {
960        let f = write_toml(
961            r#"
962            [ui]
963            primary = "openrouter"
964
965            [zai]
966            enabled = true
967            api_key_env = "MY_ZAI"
968            api_key = "sk-zai-inline"
969
970            [openrouter]
971            enabled = true
972            api_key = "sk-or-inline"
973            "#,
974        );
975        let c = Config::load_from(f.path()).unwrap();
976        assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
977        assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
978        assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
979    }
980
981    #[test]
982    fn enabled_vendors_preserves_canonical_order() {
983        // DeepSeek and Kimi are disabled by default (require explicit API key
984        // config), so they are absent from the enabled list unless enabled.
985        let c = Config::default();
986        assert_eq!(
987            c.enabled_vendors(),
988            vec![
989                VendorId::Anthropic,
990                VendorId::Openai,
991                VendorId::Zai,
992                VendorId::Openrouter,
993            ]
994        );
995    }
996
997    #[test]
998    fn deepseek_appears_when_enabled() {
999        let f = write_toml(
1000            r#"
1001            [deepseek]
1002            enabled = true
1003            api_key = "sk-test"
1004            "#,
1005        );
1006        let c = Config::load_from(f.path()).unwrap();
1007        assert!(c.is_enabled(VendorId::Deepseek));
1008        assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
1009        assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
1010    }
1011
1012    #[test]
1013    fn tilde_paths_are_expanded_on_load() {
1014        // `PathBuf` keeps `~` literally, so the documented
1015        // `credentials_path = "~/..."` used to resolve to a directory named
1016        // `~` relative to the process's cwd.
1017        let f = write_toml(
1018            r#"
1019            [context]
1020            projects_path = "~/.claude/projects"
1021
1022            [anthropic]
1023            credentials_path = "~/.claude/.credentials.json"
1024
1025            [[anthropic.accounts]]
1026            label = "work"
1027            credentials_path = "~/work.json"
1028            "#,
1029        );
1030        let c = Config::load_from(f.path()).unwrap();
1031        let home = crate::cache::home_dir().unwrap();
1032
1033        assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
1034        let got = c.anthropic.credentials_path.unwrap();
1035        assert_eq!(got, home.join(".claude/.credentials.json"));
1036        assert!(!got.to_string_lossy().contains('~'));
1037        assert_eq!(
1038            c.anthropic.accounts[0].credentials_path,
1039            home.join("work.json")
1040        );
1041    }
1042
1043    #[test]
1044    fn absolute_and_relative_paths_are_left_alone() {
1045        let f = write_toml(
1046            r#"
1047            [anthropic]
1048            credentials_path = "/etc/creds.json"
1049            "#,
1050        );
1051        let c = Config::load_from(f.path()).unwrap();
1052        assert_eq!(
1053            c.anthropic.credentials_path.unwrap(),
1054            std::path::Path::new("/etc/creds.json")
1055        );
1056
1057        // `~user` is not ours to interpret.
1058        let f2 = write_toml(
1059            r#"
1060            [anthropic]
1061            credentials_path = "~someone/creds.json"
1062            "#,
1063        );
1064        let c2 = Config::load_from(f2.path()).unwrap();
1065        assert_eq!(
1066            c2.anthropic.credentials_path.unwrap(),
1067            std::path::Path::new("~someone/creds.json")
1068        );
1069    }
1070
1071    #[test]
1072    fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
1073        // Hermetic: only asserts the shape, never which file happens to exist
1074        // on the machine running the tests.
1075        let p = resolved_path().expect("a config path must resolve");
1076        assert!(p.ends_with("config.toml"));
1077        let canonical = default_path().unwrap();
1078        let legacy = legacy_xdg_path().unwrap();
1079        assert!(
1080            p == canonical || p == legacy,
1081            "resolved to an unexpected location: {}",
1082            p.display()
1083        );
1084    }
1085
1086    #[test]
1087    fn misspelled_section_is_rejected_not_ignored() {
1088        // The regression this guards: `[openrouer]` used to parse fine, leave
1089        // OpenRouter on its defaults, and give the user no hint at all.
1090        let f = write_toml(
1091            r#"
1092            [openrouer]
1093            enabled = true
1094            api_key = "sk-or-v1-typo"
1095            "#,
1096        );
1097        let err = Config::load_from(f.path()).unwrap_err().to_string();
1098        assert!(
1099            err.contains("openrouer"),
1100            "error should name the typo: {err}"
1101        );
1102    }
1103
1104    #[test]
1105    fn invalid_toml_is_an_error_not_silent_defaults() {
1106        let f = write_toml("[zai\nenabled = true\n");
1107        assert!(Config::load_from(f.path()).is_err());
1108    }
1109
1110    #[test]
1111    fn a_missing_file_is_still_just_defaults() {
1112        // Absence stays the legitimate "use defaults" case — only real parse
1113        // and I/O failures are errors.
1114        let dir = tempfile::tempdir().unwrap();
1115        let missing = dir.path().join("nope").join("config.toml");
1116        let c = Config::load_from(&missing).unwrap();
1117        assert!(c.is_enabled(VendorId::Anthropic));
1118    }
1119
1120    #[test]
1121    fn kimi_appears_when_enabled() {
1122        let f = write_toml(
1123            r#"
1124            [kimi]
1125            enabled = true
1126            api_key = "sk-test"
1127            "#,
1128        );
1129        let c = Config::load_from(f.path()).unwrap();
1130        assert!(c.is_enabled(VendorId::Kimi));
1131        assert!(c.enabled_vendors().contains(&VendorId::Kimi));
1132        assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
1133    }
1134
1135    #[test]
1136    fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
1137        let f = write_toml(
1138            r#"
1139            [deepseek]
1140            enabled = true
1141            api_key = "sk-ds"
1142
1143            [kimi]
1144            enabled = true
1145            api_key = "sk-kimi"
1146            "#,
1147        );
1148        let c = Config::load_from(f.path()).unwrap();
1149        assert_eq!(
1150            c.enabled_vendors(),
1151            vec![
1152                VendorId::Anthropic,
1153                VendorId::Openai,
1154                VendorId::Zai,
1155                VendorId::Openrouter,
1156                VendorId::Deepseek,
1157                VendorId::Kimi,
1158            ]
1159        );
1160    }
1161
1162    #[test]
1163    fn parses_anthropic_accounts_and_looks_them_up() {
1164        let f = write_toml(
1165            r#"
1166            [anthropic]
1167            enabled = true
1168
1169            [[anthropic.accounts]]
1170            label = "personal"
1171            credentials_path = "/creds/personal.json"
1172
1173            [[anthropic.accounts]]
1174            label = "work"
1175            credentials_path = "/creds/work.json"
1176            "#,
1177        );
1178        let c = Config::load_from(f.path()).unwrap();
1179        assert_eq!(c.anthropic.accounts.len(), 2);
1180        let work = c.anthropic.account("work").unwrap();
1181        assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
1182        // A typo names the offending label and lists the known ones.
1183        let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
1184        assert!(err.contains("missing") && err.contains("work"), "{err}");
1185    }
1186
1187    #[test]
1188    fn duplicate_anthropic_account_labels_are_rejected_on_load() {
1189        let f = write_toml(
1190            r#"
1191            [[anthropic.accounts]]
1192            label = "work"
1193            credentials_path = "/creds/work-one.json"
1194
1195            [[anthropic.accounts]]
1196            label = "work"
1197            credentials_path = "/creds/work-two.json"
1198            "#,
1199        );
1200        let err = Config::load_from(f.path()).unwrap_err().to_string();
1201        assert!(
1202            err.contains("duplicate anthropic account label \"work\""),
1203            "{err}"
1204        );
1205    }
1206
1207    #[test]
1208    fn account_label_rejects_path_like_names() {
1209        let cfg = AnthropicConfig::default();
1210        for bad in ["", ".", "..", "a/b", r"a\b", "usage.json"] {
1211            let err = cfg.account(bad).unwrap_err();
1212            assert!(
1213                format!("{err:?}").contains("invalid anthropic account label"),
1214                "{bad:?} should be rejected as a label"
1215            );
1216        }
1217    }
1218
1219    #[test]
1220    fn anthropic_accounts_default_to_empty() {
1221        // No [[anthropic.accounts]] → the single default account, empty list,
1222        // nothing to migrate (issue #14, back-compat rule 1).
1223        assert!(Config::default().anthropic.accounts.is_empty());
1224    }
1225
1226    /// The shipped example, which `make install` puts in
1227    /// `share/ai-usagebar/config.example.toml`. Repo-relative, so this stays
1228    /// hermetic — it never touches the user's real config.
1229    fn config_example() -> PathBuf {
1230        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
1231    }
1232
1233    #[test]
1234    fn shipped_example_parses_as_a_real_config() {
1235        // The example is documentation users copy verbatim, but nothing used
1236        // to parse it — so a renamed section or field could rot there
1237        // unnoticed, and `deny_unknown_fields` would reject the copy on the
1238        // user's machine instead of in CI.
1239        let c = Config::load_from(&config_example()).unwrap();
1240        assert!(!c.context.enabled);
1241        assert!(c.is_enabled(VendorId::Anthropic));
1242        assert!(c.is_enabled(VendorId::Openai));
1243        assert!(!c.is_enabled(VendorId::AnthropicApi));
1244        assert!(!c.is_enabled(VendorId::Deepseek));
1245        assert!(!c.is_enabled(VendorId::Kimi));
1246        assert!(!c.is_enabled(VendorId::Kilo));
1247        assert!(!c.is_enabled(VendorId::Novita));
1248        assert!(!c.is_enabled(VendorId::Moonshot));
1249        assert!(!c.is_enabled(VendorId::Grok));
1250    }
1251
1252    #[test]
1253    fn shipped_example_does_not_advertise_admin_key_env_as_working() {
1254        // The regression: the example shipped an *uncommented*
1255        // `admin_key_env = "OPENAI_ADMIN_KEY"`, indistinguishable from a live
1256        // setting. Nothing reads it, so a user could set it, skip
1257        // `codex login`, and wait for usage that never arrives.
1258        let text = std::fs::read_to_string(config_example()).unwrap();
1259        let live: Vec<&str> = text
1260            .lines()
1261            .map(str::trim)
1262            .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
1263            .collect();
1264        assert!(
1265            live.is_empty(),
1266            "admin_key_env must stay commented out while it is inert: {live:?}"
1267        );
1268        // Still documented, though — silently dropping it would leave users
1269        // who already set it with no explanation of why it does nothing.
1270        assert!(
1271            text.contains("admin_key_env") && text.contains("RESERVED"),
1272            "the example should keep describing admin_key_env as reserved"
1273        );
1274    }
1275
1276    #[test]
1277    fn admin_key_env_is_accepted_but_changes_nothing() {
1278        // The field survives because the API-key-only path is still intended.
1279        // What has to hold today is narrower: setting it loads without error
1280        // and moves nothing the code actually acts on.
1281        let f = write_toml(
1282            r#"
1283            [openai]
1284            admin_key_env = "SOME_ADMIN_KEY"
1285            "#,
1286        );
1287        let c = Config::load_from(f.path()).unwrap();
1288        assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
1289        // Nothing else moved: OpenAI still resolves through Codex OAuth only.
1290        let default = OpenAiConfig::default();
1291        assert_eq!(c.openai.enabled, default.enabled);
1292        assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
1293        assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
1294    }
1295
1296    #[test]
1297    fn config_example_documents_every_vendor_without_secrets() {
1298        let raw = std::fs::read_to_string(config_example()).unwrap();
1299        let cfg = Config::load_from(&config_example()).unwrap();
1300        // Every vendor the binary can dispatch needs a documented section, or
1301        // users have no way to discover how to turn it on.
1302        for id in VendorId::all() {
1303            let section = id.slug();
1304            assert!(
1305                raw.contains(&format!("[{section}]")),
1306                "config.example.toml has no [{section}] section"
1307            );
1308        }
1309
1310        // The example must not ship anything enabled-by-key-only, and must not
1311        // carry a real secret.
1312        assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
1313        assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
1314        assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
1315        assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
1316        assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
1317    }
1318}