1use 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#[derive(Debug, Clone, Default, Deserialize, Serialize)]
37#[serde(default)]
38pub struct UiConfig {
39 pub primary: Option<VendorId>,
41}
42
43#[derive(Debug, Clone, Deserialize, Serialize)]
44#[serde(default)]
45pub struct AnthropicConfig {
46 pub enabled: bool,
47 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 pub codex_auth_path: Option<PathBuf>,
66 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 pub api_key_env: String,
86 pub api_key: Option<String>,
89 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
140pub 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 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 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 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 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 let var = "AI_USAGEBAR_TEST_ENV_WINS";
301 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 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}