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 {}.",
162        vendor_label.to_lowercase(),
163        config_path_hint()
164    )))
165}
166
167impl Config {
168    /// Load from `~/.config/ai-usagebar/config.toml`. Returns defaults if the
169    /// file doesn't exist; errors only on actual parse failures.
170    pub fn load() -> Result<Self> {
171        let Some(path) = default_path() else {
172            return Ok(Self::default());
173        };
174        Self::load_from(&path)
175    }
176
177    pub fn load_from(path: &std::path::Path) -> Result<Self> {
178        match std::fs::read_to_string(path) {
179            Ok(s) => Ok(toml::from_str(&s)?),
180            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
181            Err(e) => Err(AppError::io_at(path, e)),
182        }
183    }
184
185    pub fn is_enabled(&self, id: VendorId) -> bool {
186        match id {
187            VendorId::Anthropic => self.anthropic.enabled,
188            VendorId::Openai => self.openai.enabled,
189            VendorId::Zai => self.zai.enabled,
190            VendorId::Openrouter => self.openrouter.enabled,
191            VendorId::Deepseek => self.deepseek.enabled,
192        }
193    }
194
195    pub fn enabled_vendors(&self) -> Vec<VendorId> {
196        VendorId::all()
197            .iter()
198            .copied()
199            .filter(|id| self.is_enabled(*id))
200            .collect()
201    }
202}
203
204pub fn default_path() -> Option<PathBuf> {
205    let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
206    Some(proj.config_dir().join("config.toml"))
207}
208
209/// Resolved `config.toml` path as a string for user-facing messages. Uses the
210/// platform's config dir (`directories::ProjectDirs`), so it reads correctly on
211/// Linux, macOS, and Windows instead of hard-coding the Unix `~/.config` path.
212/// Falls back to the bare filename if the path can't be resolved.
213pub fn config_path_hint() -> String {
214    default_path()
215        .map(|p| p.display().to_string())
216        .unwrap_or_else(|| "config.toml".to_string())
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use std::io::Write;
223    use tempfile::NamedTempFile;
224
225    fn write_toml(s: &str) -> NamedTempFile {
226        let mut f = NamedTempFile::new().unwrap();
227        f.write_all(s.as_bytes()).unwrap();
228        f.flush().unwrap();
229        f
230    }
231
232    #[test]
233    fn defaults_enable_all_vendors() {
234        let c = Config::default();
235        assert!(c.is_enabled(VendorId::Anthropic));
236        assert!(c.is_enabled(VendorId::Openai));
237        assert!(c.is_enabled(VendorId::Zai));
238        assert!(c.is_enabled(VendorId::Openrouter));
239        // DeepSeek requires an explicit API key, so it defaults to disabled.
240        assert!(!c.is_enabled(VendorId::Deepseek));
241        assert_eq!(c.enabled_vendors().len(), 4);
242    }
243
244    #[test]
245    fn missing_file_uses_defaults() {
246        let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
247        let c = Config::load_from(path).unwrap();
248        assert!(c.is_enabled(VendorId::Anthropic));
249    }
250
251    #[test]
252    fn parses_full_config() {
253        let f = write_toml(
254            r#"
255            [anthropic]
256            enabled = true
257
258            [openai]
259            enabled = false
260            admin_key_env = "MY_ADMIN_KEY"
261
262            [zai]
263            enabled = true
264            api_key_env = "MY_ZAI"
265            plan_tier = "pro"
266
267            [openrouter]
268            enabled = false
269            "#,
270        );
271        let c = Config::load_from(f.path()).unwrap();
272        assert!(c.is_enabled(VendorId::Anthropic));
273        assert!(!c.is_enabled(VendorId::Openai));
274        assert!(c.is_enabled(VendorId::Zai));
275        assert!(!c.is_enabled(VendorId::Openrouter));
276        assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
277        assert_eq!(c.zai.api_key_env, "MY_ZAI");
278        assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
279    }
280
281    #[test]
282    fn partial_config_falls_back_to_defaults() {
283        let f = write_toml(
284            r#"[openai]
285enabled = false
286"#,
287        );
288        let c = Config::load_from(f.path()).unwrap();
289        assert!(!c.is_enabled(VendorId::Openai));
290        // Other vendors keep their defaults.
291        assert!(c.is_enabled(VendorId::Anthropic));
292        assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
293    }
294
295    #[test]
296    fn malformed_toml_returns_error() {
297        let f = write_toml("this is not = = valid");
298        assert!(Config::load_from(f.path()).is_err());
299    }
300
301    // serial guard for env-var manipulation tests so they don't race
302    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
303        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
304        M.lock().unwrap_or_else(|p| p.into_inner())
305    }
306
307    #[test]
308    fn resolve_api_key_prefers_env_over_inline() {
309        let _g = env_guard();
310        // Use a unique env var name so we don't clobber test parallelism.
311        let var = "AI_USAGEBAR_TEST_ENV_WINS";
312        // SAFETY: tests are single-threaded under env_guard.
313        unsafe { std::env::set_var(var, "from-env") };
314        let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
315        unsafe { std::env::remove_var(var) };
316        assert_eq!(got, "from-env");
317    }
318
319    #[test]
320    fn resolve_api_key_falls_back_to_inline() {
321        let _g = env_guard();
322        let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
323        unsafe { std::env::remove_var(var) };
324        let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
325        assert_eq!(got, "inline-key");
326    }
327
328    #[test]
329    fn resolve_api_key_errors_when_both_missing() {
330        let _g = env_guard();
331        let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
332        unsafe { std::env::remove_var(var) };
333        let err = resolve_api_key("Zai", var, None).unwrap_err();
334        match err {
335            crate::error::AppError::Credentials(msg) => {
336                assert!(msg.contains(var), "error should name env var: {msg}");
337                assert!(
338                    msg.contains("api_key"),
339                    "error should suggest config field: {msg}"
340                );
341            }
342            other => panic!("expected Credentials error, got {other:?}"),
343        }
344    }
345
346    #[test]
347    fn config_path_hint_ends_with_config_toml() {
348        // Platform-resolved (Linux/macOS/Windows), but always ends in the
349        // config filename — the trailing segment is what messages rely on.
350        assert!(config_path_hint().ends_with("config.toml"));
351    }
352
353    #[test]
354    fn resolve_api_key_treats_empty_env_as_unset() {
355        let _g = env_guard();
356        let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
357        unsafe { std::env::set_var(var, "") };
358        let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
359        unsafe { std::env::remove_var(var) };
360        assert_eq!(got, "inline");
361    }
362
363    #[test]
364    fn config_parses_with_inline_api_key_and_primary() {
365        let f = write_toml(
366            r#"
367            [ui]
368            primary = "openrouter"
369
370            [zai]
371            enabled = true
372            api_key_env = "MY_ZAI"
373            api_key = "sk-zai-inline"
374
375            [openrouter]
376            enabled = true
377            api_key = "sk-or-inline"
378            "#,
379        );
380        let c = Config::load_from(f.path()).unwrap();
381        assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
382        assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
383        assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
384    }
385
386    #[test]
387    fn enabled_vendors_preserves_canonical_order() {
388        // DeepSeek is disabled by default (requires explicit API key config),
389        // so it is absent from the enabled list unless the user enables it.
390        let c = Config::default();
391        assert_eq!(
392            c.enabled_vendors(),
393            vec![
394                VendorId::Anthropic,
395                VendorId::Openai,
396                VendorId::Zai,
397                VendorId::Openrouter,
398            ]
399        );
400    }
401
402    #[test]
403    fn deepseek_appears_when_enabled() {
404        let f = write_toml(
405            r#"
406            [deepseek]
407            enabled = true
408            api_key = "sk-test"
409            "#,
410        );
411        let c = Config::load_from(f.path()).unwrap();
412        assert!(c.is_enabled(VendorId::Deepseek));
413        assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
414        assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
415    }
416}