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>,
50 pub accounts: Vec<AnthropicAccount>,
54}
55
56impl Default for AnthropicConfig {
57 fn default() -> Self {
58 Self {
59 enabled: true,
60 credentials_path: None,
61 accounts: Vec::new(),
62 }
63 }
64}
65
66#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
76pub struct AnthropicAccount {
77 pub label: String,
80 pub credentials_path: PathBuf,
84}
85
86impl AnthropicConfig {
87 pub fn account(&self, label: &str) -> Result<&AnthropicAccount> {
90 validate_account_label(label)?;
91 self.accounts
92 .iter()
93 .find(|a| a.label == label)
94 .ok_or_else(|| {
95 let known: Vec<&str> = self.accounts.iter().map(|a| a.label.as_str()).collect();
96 AppError::Credentials(format!(
97 "anthropic account {label:?} not found in [[anthropic.accounts]]; \
98 known labels: {known:?}"
99 ))
100 })
101 }
102}
103
104fn validate_account_label(label: &str) -> Result<()> {
110 let bad = label.is_empty()
111 || label == "."
112 || label == ".."
113 || label.contains(['/', '\\'])
114 || label == "usage.json";
115 if bad {
116 return Err(AppError::Credentials(format!(
117 "invalid anthropic account label {label:?}: must be a non-empty name \
118 without path separators (it becomes a cache subdirectory)"
119 )));
120 }
121 Ok(())
122}
123
124#[derive(Debug, Clone, Deserialize, Serialize)]
125#[serde(default)]
126pub struct OpenAiConfig {
127 pub enabled: bool,
128 pub codex_auth_path: Option<PathBuf>,
130 pub admin_key_env: String,
132}
133
134impl Default for OpenAiConfig {
135 fn default() -> Self {
136 Self {
137 enabled: true,
138 codex_auth_path: None,
139 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
140 }
141 }
142}
143
144#[derive(Debug, Clone, Deserialize, Serialize)]
145#[serde(default)]
146pub struct ZaiConfig {
147 pub enabled: bool,
148 pub api_key_env: String,
150 pub api_key: Option<String>,
153 pub plan_tier: Option<String>,
155}
156
157impl Default for ZaiConfig {
158 fn default() -> Self {
159 Self {
160 enabled: true,
161 api_key_env: "ZAI_API_KEY".to_string(),
162 api_key: None,
163 plan_tier: None,
164 }
165 }
166}
167
168#[derive(Debug, Clone, Deserialize, Serialize)]
169#[serde(default)]
170pub struct OpenRouterConfig {
171 pub enabled: bool,
172 pub api_key_env: String,
173 pub api_key: Option<String>,
174}
175
176impl Default for OpenRouterConfig {
177 fn default() -> Self {
178 Self {
179 enabled: true,
180 api_key_env: "OPENROUTER_API_KEY".to_string(),
181 api_key: None,
182 }
183 }
184}
185
186#[derive(Debug, Clone, Deserialize, Serialize)]
187#[serde(default)]
188pub struct DeepseekConfig {
189 pub enabled: bool,
190 pub api_key_env: String,
191 pub api_key: Option<String>,
192}
193
194impl Default for DeepseekConfig {
195 fn default() -> Self {
196 Self {
197 enabled: false,
198 api_key_env: "DEEPSEEK_API_KEY".to_string(),
199 api_key: None,
200 }
201 }
202}
203
204pub fn resolve_api_key(
207 vendor_label: &str,
208 env_var_name: &str,
209 inline: Option<&str>,
210) -> crate::error::Result<String> {
211 if !env_var_name.is_empty()
212 && let Ok(v) = std::env::var(env_var_name)
213 && !v.is_empty()
214 {
215 return Ok(v);
216 }
217 if let Some(v) = inline
218 && !v.is_empty()
219 {
220 return Ok(v.to_string());
221 }
222 Err(crate::error::AppError::Credentials(format!(
223 "{vendor_label}: no API key. Either export {env_var_name} or set \
224 `api_key` under [{}] in {}.",
225 vendor_label.to_lowercase(),
226 config_path_hint()
227 )))
228}
229
230impl Config {
231 pub fn load() -> Result<Self> {
234 let Some(path) = default_path() else {
235 return Ok(Self::default());
236 };
237 Self::load_from(&path)
238 }
239
240 pub fn load_from(path: &std::path::Path) -> Result<Self> {
241 match std::fs::read_to_string(path) {
242 Ok(s) => Ok(toml::from_str(&s)?),
243 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
244 Err(e) => Err(AppError::io_at(path, e)),
245 }
246 }
247
248 pub fn is_enabled(&self, id: VendorId) -> bool {
249 match id {
250 VendorId::Anthropic => self.anthropic.enabled,
251 VendorId::Openai => self.openai.enabled,
252 VendorId::Zai => self.zai.enabled,
253 VendorId::Openrouter => self.openrouter.enabled,
254 VendorId::Deepseek => self.deepseek.enabled,
255 }
256 }
257
258 pub fn enabled_vendors(&self) -> Vec<VendorId> {
259 VendorId::all()
260 .iter()
261 .copied()
262 .filter(|id| self.is_enabled(*id))
263 .collect()
264 }
265}
266
267pub fn default_path() -> Option<PathBuf> {
268 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
269 Some(proj.config_dir().join("config.toml"))
270}
271
272pub fn config_path_hint() -> String {
277 default_path()
278 .map(|p| p.display().to_string())
279 .unwrap_or_else(|| "config.toml".to_string())
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use std::io::Write;
286 use tempfile::NamedTempFile;
287
288 fn write_toml(s: &str) -> NamedTempFile {
289 let mut f = NamedTempFile::new().unwrap();
290 f.write_all(s.as_bytes()).unwrap();
291 f.flush().unwrap();
292 f
293 }
294
295 #[test]
296 fn defaults_enable_all_vendors() {
297 let c = Config::default();
298 assert!(c.is_enabled(VendorId::Anthropic));
299 assert!(c.is_enabled(VendorId::Openai));
300 assert!(c.is_enabled(VendorId::Zai));
301 assert!(c.is_enabled(VendorId::Openrouter));
302 assert!(!c.is_enabled(VendorId::Deepseek));
304 assert_eq!(c.enabled_vendors().len(), 4);
305 }
306
307 #[test]
308 fn missing_file_uses_defaults() {
309 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
310 let c = Config::load_from(path).unwrap();
311 assert!(c.is_enabled(VendorId::Anthropic));
312 }
313
314 #[test]
315 fn parses_full_config() {
316 let f = write_toml(
317 r#"
318 [anthropic]
319 enabled = true
320
321 [openai]
322 enabled = false
323 admin_key_env = "MY_ADMIN_KEY"
324
325 [zai]
326 enabled = true
327 api_key_env = "MY_ZAI"
328 plan_tier = "pro"
329
330 [openrouter]
331 enabled = false
332 "#,
333 );
334 let c = Config::load_from(f.path()).unwrap();
335 assert!(c.is_enabled(VendorId::Anthropic));
336 assert!(!c.is_enabled(VendorId::Openai));
337 assert!(c.is_enabled(VendorId::Zai));
338 assert!(!c.is_enabled(VendorId::Openrouter));
339 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
340 assert_eq!(c.zai.api_key_env, "MY_ZAI");
341 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
342 }
343
344 #[test]
345 fn partial_config_falls_back_to_defaults() {
346 let f = write_toml(
347 r#"[openai]
348enabled = false
349"#,
350 );
351 let c = Config::load_from(f.path()).unwrap();
352 assert!(!c.is_enabled(VendorId::Openai));
353 assert!(c.is_enabled(VendorId::Anthropic));
355 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
356 }
357
358 #[test]
359 fn malformed_toml_returns_error() {
360 let f = write_toml("this is not = = valid");
361 assert!(Config::load_from(f.path()).is_err());
362 }
363
364 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
366 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
367 M.lock().unwrap_or_else(|p| p.into_inner())
368 }
369
370 #[test]
371 fn resolve_api_key_prefers_env_over_inline() {
372 let _g = env_guard();
373 let var = "AI_USAGEBAR_TEST_ENV_WINS";
375 unsafe { std::env::set_var(var, "from-env") };
377 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
378 unsafe { std::env::remove_var(var) };
379 assert_eq!(got, "from-env");
380 }
381
382 #[test]
383 fn resolve_api_key_falls_back_to_inline() {
384 let _g = env_guard();
385 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
386 unsafe { std::env::remove_var(var) };
387 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
388 assert_eq!(got, "inline-key");
389 }
390
391 #[test]
392 fn resolve_api_key_errors_when_both_missing() {
393 let _g = env_guard();
394 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
395 unsafe { std::env::remove_var(var) };
396 let err = resolve_api_key("Zai", var, None).unwrap_err();
397 match err {
398 crate::error::AppError::Credentials(msg) => {
399 assert!(msg.contains(var), "error should name env var: {msg}");
400 assert!(
401 msg.contains("api_key"),
402 "error should suggest config field: {msg}"
403 );
404 }
405 other => panic!("expected Credentials error, got {other:?}"),
406 }
407 }
408
409 #[test]
410 fn config_path_hint_ends_with_config_toml() {
411 assert!(config_path_hint().ends_with("config.toml"));
414 }
415
416 #[test]
417 fn resolve_api_key_treats_empty_env_as_unset() {
418 let _g = env_guard();
419 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
420 unsafe { std::env::set_var(var, "") };
421 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
422 unsafe { std::env::remove_var(var) };
423 assert_eq!(got, "inline");
424 }
425
426 #[test]
427 fn config_parses_with_inline_api_key_and_primary() {
428 let f = write_toml(
429 r#"
430 [ui]
431 primary = "openrouter"
432
433 [zai]
434 enabled = true
435 api_key_env = "MY_ZAI"
436 api_key = "sk-zai-inline"
437
438 [openrouter]
439 enabled = true
440 api_key = "sk-or-inline"
441 "#,
442 );
443 let c = Config::load_from(f.path()).unwrap();
444 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
445 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
446 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
447 }
448
449 #[test]
450 fn enabled_vendors_preserves_canonical_order() {
451 let c = Config::default();
454 assert_eq!(
455 c.enabled_vendors(),
456 vec![
457 VendorId::Anthropic,
458 VendorId::Openai,
459 VendorId::Zai,
460 VendorId::Openrouter,
461 ]
462 );
463 }
464
465 #[test]
466 fn deepseek_appears_when_enabled() {
467 let f = write_toml(
468 r#"
469 [deepseek]
470 enabled = true
471 api_key = "sk-test"
472 "#,
473 );
474 let c = Config::load_from(f.path()).unwrap();
475 assert!(c.is_enabled(VendorId::Deepseek));
476 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
477 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
478 }
479
480 #[test]
481 fn parses_anthropic_accounts_and_looks_them_up() {
482 let f = write_toml(
483 r#"
484 [anthropic]
485 enabled = true
486
487 [[anthropic.accounts]]
488 label = "personal"
489 credentials_path = "/creds/personal.json"
490
491 [[anthropic.accounts]]
492 label = "work"
493 credentials_path = "/creds/work.json"
494 "#,
495 );
496 let c = Config::load_from(f.path()).unwrap();
497 assert_eq!(c.anthropic.accounts.len(), 2);
498 let work = c.anthropic.account("work").unwrap();
499 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
500 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
502 assert!(err.contains("missing") && err.contains("work"), "{err}");
503 }
504
505 #[test]
506 fn account_label_rejects_path_like_names() {
507 let cfg = AnthropicConfig::default();
508 for bad in ["", ".", "..", "a/b", r"a\b", "usage.json"] {
509 let err = cfg.account(bad).unwrap_err();
510 assert!(
511 format!("{err:?}").contains("invalid anthropic account label"),
512 "{bad:?} should be rejected as a label"
513 );
514 }
515 }
516
517 #[test]
518 fn anthropic_accounts_default_to_empty() {
519 assert!(Config::default().anthropic.accounts.is_empty());
522 }
523}