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