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    pub credentials_path: Option<PathBuf>,
49}
50
51impl Default for AnthropicConfig {
52    fn default() -> Self {
53        Self {
54            enabled: true,
55            credentials_path: None,
56        }
57    }
58}
59
60#[derive(Debug, Clone, Deserialize, Serialize)]
61#[serde(default)]
62pub struct OpenAiConfig {
63    pub enabled: bool,
64    /// Override the Codex auth file path (defaults to `~/.codex/auth.json`).
65    pub codex_auth_path: Option<PathBuf>,
66    /// Optional admin key env var name for the API-key-only fallback path.
67    pub admin_key_env: String,
68}
69
70impl Default for OpenAiConfig {
71    fn default() -> Self {
72        Self {
73            enabled: true,
74            codex_auth_path: None,
75            admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
76        }
77    }
78}
79
80#[derive(Debug, Clone, Deserialize, Serialize)]
81#[serde(default)]
82pub struct ZaiConfig {
83    pub enabled: bool,
84    /// Env var name to read the key from (env wins over `api_key`).
85    pub api_key_env: String,
86    /// Inline key (fallback when the env var is unset). Chmod 600 your
87    /// config file if you put a real key here.
88    pub api_key: Option<String>,
89    /// Optional plan tier label (lite/pro/max) — display-only.
90    pub plan_tier: Option<String>,
91}
92
93impl Default for ZaiConfig {
94    fn default() -> Self {
95        Self {
96            enabled: true,
97            api_key_env: "ZAI_API_KEY".to_string(),
98            api_key: None,
99            plan_tier: None,
100        }
101    }
102}
103
104#[derive(Debug, Clone, Deserialize, Serialize)]
105#[serde(default)]
106pub struct OpenRouterConfig {
107    pub enabled: bool,
108    pub api_key_env: String,
109    pub api_key: Option<String>,
110}
111
112impl Default for OpenRouterConfig {
113    fn default() -> Self {
114        Self {
115            enabled: true,
116            api_key_env: "OPENROUTER_API_KEY".to_string(),
117            api_key: None,
118        }
119    }
120}
121
122#[derive(Debug, Clone, Deserialize, Serialize)]
123#[serde(default)]
124pub struct DeepseekConfig {
125    pub enabled: bool,
126    pub api_key_env: String,
127    pub api_key: Option<String>,
128}
129
130impl Default for DeepseekConfig {
131    fn default() -> Self {
132        Self {
133            enabled: false,
134            api_key_env: "DEEPSEEK_API_KEY".to_string(),
135            api_key: None,
136        }
137    }
138}
139
140/// Resolve an API key for a vendor: env var wins, then inline config, then
141/// a clear error naming both fields. Used by Z.AI and OpenRouter vendors.
142pub fn resolve_api_key(
143    vendor_label: &str,
144    env_var_name: &str,
145    inline: Option<&str>,
146) -> crate::error::Result<String> {
147    if !env_var_name.is_empty() {
148        if let Ok(v) = std::env::var(env_var_name) {
149            if !v.is_empty() {
150                return Ok(v);
151            }
152        }
153    }
154    if let Some(v) = inline {
155        if !v.is_empty() {
156            return Ok(v.to_string());
157        }
158    }
159    Err(crate::error::AppError::Credentials(format!(
160        "{vendor_label}: no API key. Either export {env_var_name} or set \
161         `api_key` under [{}] in ~/.config/ai-usagebar/config.toml (chmod 600).",
162        vendor_label.to_lowercase()
163    )))
164}
165
166impl Config {
167    /// Load from `~/.config/ai-usagebar/config.toml`. Returns defaults if the
168    /// file doesn't exist; errors only on actual parse failures.
169    pub fn load() -> Result<Self> {
170        let Some(path) = default_path() else {
171            return Ok(Self::default());
172        };
173        Self::load_from(&path)
174    }
175
176    pub fn load_from(path: &std::path::Path) -> Result<Self> {
177        match std::fs::read_to_string(path) {
178            Ok(s) => Ok(toml::from_str(&s)?),
179            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
180            Err(e) => Err(AppError::io_at(path, e)),
181        }
182    }
183
184    pub fn is_enabled(&self, id: VendorId) -> bool {
185        match id {
186            VendorId::Anthropic => self.anthropic.enabled,
187            VendorId::Openai => self.openai.enabled,
188            VendorId::Zai => self.zai.enabled,
189            VendorId::Openrouter => self.openrouter.enabled,
190            VendorId::Deepseek => self.deepseek.enabled,
191        }
192    }
193
194    pub fn enabled_vendors(&self) -> Vec<VendorId> {
195        VendorId::all()
196            .iter()
197            .copied()
198            .filter(|id| self.is_enabled(*id))
199            .collect()
200    }
201}
202
203fn default_path() -> Option<PathBuf> {
204    let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
205    Some(proj.config_dir().join("config.toml"))
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use std::io::Write;
212    use tempfile::NamedTempFile;
213
214    fn write_toml(s: &str) -> NamedTempFile {
215        let mut f = NamedTempFile::new().unwrap();
216        f.write_all(s.as_bytes()).unwrap();
217        f.flush().unwrap();
218        f
219    }
220
221    #[test]
222    fn defaults_enable_all_vendors() {
223        let c = Config::default();
224        assert!(c.is_enabled(VendorId::Anthropic));
225        assert!(c.is_enabled(VendorId::Openai));
226        assert!(c.is_enabled(VendorId::Zai));
227        assert!(c.is_enabled(VendorId::Openrouter));
228        // DeepSeek requires an explicit API key, so it defaults to disabled.
229        assert!(!c.is_enabled(VendorId::Deepseek));
230        assert_eq!(c.enabled_vendors().len(), 4);
231    }
232
233    #[test]
234    fn missing_file_uses_defaults() {
235        let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
236        let c = Config::load_from(path).unwrap();
237        assert!(c.is_enabled(VendorId::Anthropic));
238    }
239
240    #[test]
241    fn parses_full_config() {
242        let f = write_toml(
243            r#"
244            [anthropic]
245            enabled = true
246
247            [openai]
248            enabled = false
249            admin_key_env = "MY_ADMIN_KEY"
250
251            [zai]
252            enabled = true
253            api_key_env = "MY_ZAI"
254            plan_tier = "pro"
255
256            [openrouter]
257            enabled = false
258            "#,
259        );
260        let c = Config::load_from(f.path()).unwrap();
261        assert!(c.is_enabled(VendorId::Anthropic));
262        assert!(!c.is_enabled(VendorId::Openai));
263        assert!(c.is_enabled(VendorId::Zai));
264        assert!(!c.is_enabled(VendorId::Openrouter));
265        assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
266        assert_eq!(c.zai.api_key_env, "MY_ZAI");
267        assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
268    }
269
270    #[test]
271    fn partial_config_falls_back_to_defaults() {
272        let f = write_toml(
273            r#"[openai]
274enabled = false
275"#,
276        );
277        let c = Config::load_from(f.path()).unwrap();
278        assert!(!c.is_enabled(VendorId::Openai));
279        // Other vendors keep their defaults.
280        assert!(c.is_enabled(VendorId::Anthropic));
281        assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
282    }
283
284    #[test]
285    fn malformed_toml_returns_error() {
286        let f = write_toml("this is not = = valid");
287        assert!(Config::load_from(f.path()).is_err());
288    }
289
290    // serial guard for env-var manipulation tests so they don't race
291    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
292        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
293        M.lock().unwrap_or_else(|p| p.into_inner())
294    }
295
296    #[test]
297    fn resolve_api_key_prefers_env_over_inline() {
298        let _g = env_guard();
299        // Use a unique env var name so we don't clobber test parallelism.
300        let var = "AI_USAGEBAR_TEST_ENV_WINS";
301        // SAFETY: tests are single-threaded under env_guard.
302        unsafe { std::env::set_var(var, "from-env") };
303        let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
304        unsafe { std::env::remove_var(var) };
305        assert_eq!(got, "from-env");
306    }
307
308    #[test]
309    fn resolve_api_key_falls_back_to_inline() {
310        let _g = env_guard();
311        let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
312        unsafe { std::env::remove_var(var) };
313        let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
314        assert_eq!(got, "inline-key");
315    }
316
317    #[test]
318    fn resolve_api_key_errors_when_both_missing() {
319        let _g = env_guard();
320        let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
321        unsafe { std::env::remove_var(var) };
322        let err = resolve_api_key("Zai", var, None).unwrap_err();
323        match err {
324            crate::error::AppError::Credentials(msg) => {
325                assert!(msg.contains(var), "error should name env var: {msg}");
326                assert!(
327                    msg.contains("api_key"),
328                    "error should suggest config field: {msg}"
329                );
330            }
331            other => panic!("expected Credentials error, got {other:?}"),
332        }
333    }
334
335    #[test]
336    fn resolve_api_key_treats_empty_env_as_unset() {
337        let _g = env_guard();
338        let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
339        unsafe { std::env::set_var(var, "") };
340        let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
341        unsafe { std::env::remove_var(var) };
342        assert_eq!(got, "inline");
343    }
344
345    #[test]
346    fn config_parses_with_inline_api_key_and_primary() {
347        let f = write_toml(
348            r#"
349            [ui]
350            primary = "openrouter"
351
352            [zai]
353            enabled = true
354            api_key_env = "MY_ZAI"
355            api_key = "sk-zai-inline"
356
357            [openrouter]
358            enabled = true
359            api_key = "sk-or-inline"
360            "#,
361        );
362        let c = Config::load_from(f.path()).unwrap();
363        assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
364        assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
365        assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
366    }
367
368    #[test]
369    fn enabled_vendors_preserves_canonical_order() {
370        // DeepSeek is disabled by default (requires explicit API key config),
371        // so it is absent from the enabled list unless the user enables it.
372        let c = Config::default();
373        assert_eq!(
374            c.enabled_vendors(),
375            vec![
376                VendorId::Anthropic,
377                VendorId::Openai,
378                VendorId::Zai,
379                VendorId::Openrouter,
380            ]
381        );
382    }
383
384    #[test]
385    fn deepseek_appears_when_enabled() {
386        let f = write_toml(
387            r#"
388            [deepseek]
389            enabled = true
390            api_key = "sk-test"
391            "#,
392        );
393        let c = Config::load_from(f.path()).unwrap();
394        assert!(c.is_enabled(VendorId::Deepseek));
395        assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
396        assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
397    }
398}