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//! ```
10//!
11//! Every field is optional with sensible defaults — missing config file is
12//! treated as "use defaults". API keys are read from env vars (the relevant
13//! `*_api_key_env` field lets the user override which env var name).
14
15use std::path::PathBuf;
16
17use serde::{Deserialize, Serialize};
18
19use crate::error::{AppError, Result};
20use crate::vendor::VendorId;
21
22#[derive(Debug, Clone, Default, Deserialize, Serialize)]
23#[serde(default)]
24pub struct Config {
25    pub ui: UiConfig,
26    pub anthropic: AnthropicConfig,
27    pub openai: OpenAiConfig,
28    pub zai: ZaiConfig,
29    pub openrouter: OpenRouterConfig,
30    pub deepseek: DeepseekConfig,
31}
32
33/// UI / dispatch preferences. Currently just `primary` — which vendor the
34/// widget shows when `--vendor` is omitted, and which TUI tab is selected
35/// at startup.
36#[derive(Debug, Clone, Default, Deserialize, Serialize)]
37#[serde(default)]
38pub struct UiConfig {
39    /// `None` → fall back to anthropic for backward compatibility.
40    pub primary: Option<VendorId>,
41}
42
43#[derive(Debug, Clone, Deserialize, Serialize)]
44#[serde(default)]
45pub struct AnthropicConfig {
46    pub enabled: bool,
47    /// Override the credentials file path (defaults to `~/.claude/.credentials.json`).
48    /// This is the *default* account; extra subscriptions go in `accounts`.
49    pub credentials_path: Option<PathBuf>,
50    /// Extra Anthropic accounts beyond the default, each selected on the CLI
51    /// with `--account <label>` (issue #14). Empty by default, so existing
52    /// single-account configs are byte-for-byte unchanged.
53    pub accounts: Vec<AnthropicAccount>,
54}
55
56impl Default for AnthropicConfig {
57    fn default() -> Self {
58        Self {
59            enabled: true,
60            credentials_path: None,
61            accounts: Vec::new(),
62        }
63    }
64}
65
66/// One extra Anthropic account beyond the default (issue #14). The default
67/// account stays the singular `[anthropic] credentials_path`; each entry here
68/// is an additional subscription selected on the CLI with `--account <label>`.
69///
70/// ```toml
71/// [[anthropic.accounts]]
72/// label = "work"
73/// credentials_path = "~/.config/ai-usagebar/accounts/work.json"
74/// ```
75#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
76pub struct AnthropicAccount {
77    /// Stable name used on the CLI (`--account <label>`) and as the cache
78    /// subdir (`~/.cache/ai-usagebar/anthropic/<label>`).
79    pub label: String,
80    /// OAuth credentials file for this account (same JSON shape Claude Code
81    /// writes). Token refreshes are written back here, so each account keeps
82    /// itself alive independently.
83    pub credentials_path: PathBuf,
84}
85
86impl AnthropicConfig {
87    /// Find a configured extra account by label, or error listing the known
88    /// labels so a typo fails loudly instead of silently hitting the default.
89    pub fn account(&self, label: &str) -> Result<&AnthropicAccount> {
90        validate_account_label(label)?;
91        self.accounts
92            .iter()
93            .find(|a| a.label == label)
94            .ok_or_else(|| {
95                let known: Vec<&str> = self.accounts.iter().map(|a| a.label.as_str()).collect();
96                AppError::Credentials(format!(
97                    "anthropic account {label:?} not found in [[anthropic.accounts]]; \
98                     known labels: {known:?}"
99                ))
100            })
101    }
102}
103
104/// The label doubles as a cache subdirectory name
105/// (`~/.cache/ai-usagebar/anthropic/<label>/`), which nests inside the default
106/// account's cache dir — so path separators or dot-dirs would escape or
107/// collide with the cache layout (`usage.json`, `.stale`, …). Reject anything
108/// that isn't a plain single-segment name.
109fn validate_account_label(label: &str) -> Result<()> {
110    let bad = label.is_empty()
111        || label == "."
112        || label == ".."
113        || label.contains(['/', '\\'])
114        || label == "usage.json";
115    if bad {
116        return Err(AppError::Credentials(format!(
117            "invalid anthropic account label {label:?}: must be a non-empty name \
118             without path separators (it becomes a cache subdirectory)"
119        )));
120    }
121    Ok(())
122}
123
124#[derive(Debug, Clone, Deserialize, Serialize)]
125#[serde(default)]
126pub struct OpenAiConfig {
127    pub enabled: bool,
128    /// Override the Codex auth file path (defaults to `~/.codex/auth.json`).
129    pub codex_auth_path: Option<PathBuf>,
130    /// Optional admin key env var name for the API-key-only fallback path.
131    pub admin_key_env: String,
132}
133
134impl Default for OpenAiConfig {
135    fn default() -> Self {
136        Self {
137            enabled: true,
138            codex_auth_path: None,
139            admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
140        }
141    }
142}
143
144#[derive(Debug, Clone, Deserialize, Serialize)]
145#[serde(default)]
146pub struct ZaiConfig {
147    pub enabled: bool,
148    /// Env var name to read the key from (env wins over `api_key`).
149    pub api_key_env: String,
150    /// Inline key (fallback when the env var is unset). Chmod 600 your
151    /// config file if you put a real key here.
152    pub api_key: Option<String>,
153    /// Optional plan tier label (lite/pro/max) — display-only.
154    pub plan_tier: Option<String>,
155}
156
157impl Default for ZaiConfig {
158    fn default() -> Self {
159        Self {
160            enabled: true,
161            api_key_env: "ZAI_API_KEY".to_string(),
162            api_key: None,
163            plan_tier: None,
164        }
165    }
166}
167
168#[derive(Debug, Clone, Deserialize, Serialize)]
169#[serde(default)]
170pub struct OpenRouterConfig {
171    pub enabled: bool,
172    pub api_key_env: String,
173    pub api_key: Option<String>,
174}
175
176impl Default for OpenRouterConfig {
177    fn default() -> Self {
178        Self {
179            enabled: true,
180            api_key_env: "OPENROUTER_API_KEY".to_string(),
181            api_key: None,
182        }
183    }
184}
185
186#[derive(Debug, Clone, Deserialize, Serialize)]
187#[serde(default)]
188pub struct DeepseekConfig {
189    pub enabled: bool,
190    pub api_key_env: String,
191    pub api_key: Option<String>,
192}
193
194impl Default for DeepseekConfig {
195    fn default() -> Self {
196        Self {
197            enabled: false,
198            api_key_env: "DEEPSEEK_API_KEY".to_string(),
199            api_key: None,
200        }
201    }
202}
203
204/// Resolve an API key for a vendor: env var wins, then inline config, then
205/// a clear error naming both fields. Used by Z.AI and OpenRouter vendors.
206pub fn resolve_api_key(
207    vendor_label: &str,
208    env_var_name: &str,
209    inline: Option<&str>,
210) -> crate::error::Result<String> {
211    if !env_var_name.is_empty()
212        && let Ok(v) = std::env::var(env_var_name)
213        && !v.is_empty()
214    {
215        return Ok(v);
216    }
217    if let Some(v) = inline
218        && !v.is_empty()
219    {
220        return Ok(v.to_string());
221    }
222    Err(crate::error::AppError::Credentials(format!(
223        "{vendor_label}: no API key. Either export {env_var_name} or set \
224         `api_key` under [{}] in {}.",
225        vendor_label.to_lowercase(),
226        config_path_hint()
227    )))
228}
229
230impl Config {
231    /// Load from `~/.config/ai-usagebar/config.toml`. Returns defaults if the
232    /// file doesn't exist; errors only on actual parse failures.
233    pub fn load() -> Result<Self> {
234        let Some(path) = default_path() else {
235            return Ok(Self::default());
236        };
237        Self::load_from(&path)
238    }
239
240    pub fn load_from(path: &std::path::Path) -> Result<Self> {
241        match std::fs::read_to_string(path) {
242            Ok(s) => Ok(toml::from_str(&s)?),
243            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
244            Err(e) => Err(AppError::io_at(path, e)),
245        }
246    }
247
248    pub fn is_enabled(&self, id: VendorId) -> bool {
249        match id {
250            VendorId::Anthropic => self.anthropic.enabled,
251            VendorId::Openai => self.openai.enabled,
252            VendorId::Zai => self.zai.enabled,
253            VendorId::Openrouter => self.openrouter.enabled,
254            VendorId::Deepseek => self.deepseek.enabled,
255        }
256    }
257
258    pub fn enabled_vendors(&self) -> Vec<VendorId> {
259        VendorId::all()
260            .iter()
261            .copied()
262            .filter(|id| self.is_enabled(*id))
263            .collect()
264    }
265}
266
267pub fn default_path() -> Option<PathBuf> {
268    let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
269    Some(proj.config_dir().join("config.toml"))
270}
271
272/// Resolved `config.toml` path as a string for user-facing messages. Uses the
273/// platform's config dir (`directories::ProjectDirs`), so it reads correctly on
274/// Linux, macOS, and Windows instead of hard-coding the Unix `~/.config` path.
275/// Falls back to the bare filename if the path can't be resolved.
276pub fn config_path_hint() -> String {
277    default_path()
278        .map(|p| p.display().to_string())
279        .unwrap_or_else(|| "config.toml".to_string())
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::io::Write;
286    use tempfile::NamedTempFile;
287
288    fn write_toml(s: &str) -> NamedTempFile {
289        let mut f = NamedTempFile::new().unwrap();
290        f.write_all(s.as_bytes()).unwrap();
291        f.flush().unwrap();
292        f
293    }
294
295    #[test]
296    fn defaults_enable_all_vendors() {
297        let c = Config::default();
298        assert!(c.is_enabled(VendorId::Anthropic));
299        assert!(c.is_enabled(VendorId::Openai));
300        assert!(c.is_enabled(VendorId::Zai));
301        assert!(c.is_enabled(VendorId::Openrouter));
302        // DeepSeek requires an explicit API key, so it defaults to disabled.
303        assert!(!c.is_enabled(VendorId::Deepseek));
304        assert_eq!(c.enabled_vendors().len(), 4);
305    }
306
307    #[test]
308    fn missing_file_uses_defaults() {
309        let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
310        let c = Config::load_from(path).unwrap();
311        assert!(c.is_enabled(VendorId::Anthropic));
312    }
313
314    #[test]
315    fn parses_full_config() {
316        let f = write_toml(
317            r#"
318            [anthropic]
319            enabled = true
320
321            [openai]
322            enabled = false
323            admin_key_env = "MY_ADMIN_KEY"
324
325            [zai]
326            enabled = true
327            api_key_env = "MY_ZAI"
328            plan_tier = "pro"
329
330            [openrouter]
331            enabled = false
332            "#,
333        );
334        let c = Config::load_from(f.path()).unwrap();
335        assert!(c.is_enabled(VendorId::Anthropic));
336        assert!(!c.is_enabled(VendorId::Openai));
337        assert!(c.is_enabled(VendorId::Zai));
338        assert!(!c.is_enabled(VendorId::Openrouter));
339        assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
340        assert_eq!(c.zai.api_key_env, "MY_ZAI");
341        assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
342    }
343
344    #[test]
345    fn partial_config_falls_back_to_defaults() {
346        let f = write_toml(
347            r#"[openai]
348enabled = false
349"#,
350        );
351        let c = Config::load_from(f.path()).unwrap();
352        assert!(!c.is_enabled(VendorId::Openai));
353        // Other vendors keep their defaults.
354        assert!(c.is_enabled(VendorId::Anthropic));
355        assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
356    }
357
358    #[test]
359    fn malformed_toml_returns_error() {
360        let f = write_toml("this is not = = valid");
361        assert!(Config::load_from(f.path()).is_err());
362    }
363
364    // serial guard for env-var manipulation tests so they don't race
365    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
366        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
367        M.lock().unwrap_or_else(|p| p.into_inner())
368    }
369
370    #[test]
371    fn resolve_api_key_prefers_env_over_inline() {
372        let _g = env_guard();
373        // Use a unique env var name so we don't clobber test parallelism.
374        let var = "AI_USAGEBAR_TEST_ENV_WINS";
375        // SAFETY: tests are single-threaded under env_guard.
376        unsafe { std::env::set_var(var, "from-env") };
377        let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
378        unsafe { std::env::remove_var(var) };
379        assert_eq!(got, "from-env");
380    }
381
382    #[test]
383    fn resolve_api_key_falls_back_to_inline() {
384        let _g = env_guard();
385        let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
386        unsafe { std::env::remove_var(var) };
387        let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
388        assert_eq!(got, "inline-key");
389    }
390
391    #[test]
392    fn resolve_api_key_errors_when_both_missing() {
393        let _g = env_guard();
394        let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
395        unsafe { std::env::remove_var(var) };
396        let err = resolve_api_key("Zai", var, None).unwrap_err();
397        match err {
398            crate::error::AppError::Credentials(msg) => {
399                assert!(msg.contains(var), "error should name env var: {msg}");
400                assert!(
401                    msg.contains("api_key"),
402                    "error should suggest config field: {msg}"
403                );
404            }
405            other => panic!("expected Credentials error, got {other:?}"),
406        }
407    }
408
409    #[test]
410    fn config_path_hint_ends_with_config_toml() {
411        // Platform-resolved (Linux/macOS/Windows), but always ends in the
412        // config filename — the trailing segment is what messages rely on.
413        assert!(config_path_hint().ends_with("config.toml"));
414    }
415
416    #[test]
417    fn resolve_api_key_treats_empty_env_as_unset() {
418        let _g = env_guard();
419        let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
420        unsafe { std::env::set_var(var, "") };
421        let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
422        unsafe { std::env::remove_var(var) };
423        assert_eq!(got, "inline");
424    }
425
426    #[test]
427    fn config_parses_with_inline_api_key_and_primary() {
428        let f = write_toml(
429            r#"
430            [ui]
431            primary = "openrouter"
432
433            [zai]
434            enabled = true
435            api_key_env = "MY_ZAI"
436            api_key = "sk-zai-inline"
437
438            [openrouter]
439            enabled = true
440            api_key = "sk-or-inline"
441            "#,
442        );
443        let c = Config::load_from(f.path()).unwrap();
444        assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
445        assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
446        assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
447    }
448
449    #[test]
450    fn enabled_vendors_preserves_canonical_order() {
451        // DeepSeek is disabled by default (requires explicit API key config),
452        // so it is absent from the enabled list unless the user enables it.
453        let c = Config::default();
454        assert_eq!(
455            c.enabled_vendors(),
456            vec![
457                VendorId::Anthropic,
458                VendorId::Openai,
459                VendorId::Zai,
460                VendorId::Openrouter,
461            ]
462        );
463    }
464
465    #[test]
466    fn deepseek_appears_when_enabled() {
467        let f = write_toml(
468            r#"
469            [deepseek]
470            enabled = true
471            api_key = "sk-test"
472            "#,
473        );
474        let c = Config::load_from(f.path()).unwrap();
475        assert!(c.is_enabled(VendorId::Deepseek));
476        assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
477        assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
478    }
479
480    #[test]
481    fn parses_anthropic_accounts_and_looks_them_up() {
482        let f = write_toml(
483            r#"
484            [anthropic]
485            enabled = true
486
487            [[anthropic.accounts]]
488            label = "personal"
489            credentials_path = "/creds/personal.json"
490
491            [[anthropic.accounts]]
492            label = "work"
493            credentials_path = "/creds/work.json"
494            "#,
495        );
496        let c = Config::load_from(f.path()).unwrap();
497        assert_eq!(c.anthropic.accounts.len(), 2);
498        let work = c.anthropic.account("work").unwrap();
499        assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
500        // A typo names the offending label and lists the known ones.
501        let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
502        assert!(err.contains("missing") && err.contains("work"), "{err}");
503    }
504
505    #[test]
506    fn account_label_rejects_path_like_names() {
507        let cfg = AnthropicConfig::default();
508        for bad in ["", ".", "..", "a/b", r"a\b", "usage.json"] {
509            let err = cfg.account(bad).unwrap_err();
510            assert!(
511                format!("{err:?}").contains("invalid anthropic account label"),
512                "{bad:?} should be rejected as a label"
513            );
514        }
515    }
516
517    #[test]
518    fn anthropic_accounts_default_to_empty() {
519        // No [[anthropic.accounts]] → the single default account, empty list,
520        // nothing to migrate (issue #14, back-compat rule 1).
521        assert!(Config::default().anthropic.accounts.is_empty());
522    }
523}