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