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