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