1use std::collections::HashSet;
18use std::path::PathBuf;
19
20use serde::{Deserialize, Serialize};
21
22use crate::anthropic::creds::CredsTarget;
23use crate::cache::Cache;
24use crate::error::{AppError, Result};
25use crate::vendor::VendorId;
26
27#[derive(Debug, Clone, Default, Deserialize, Serialize)]
28#[serde(default)]
29pub struct Config {
30 pub ui: UiConfig,
31 pub anthropic: AnthropicConfig,
32 pub openai: OpenAiConfig,
33 pub zai: ZaiConfig,
34 pub openrouter: OpenRouterConfig,
35 pub deepseek: DeepseekConfig,
36 pub kimi: KimiConfig,
37}
38
39#[derive(Debug, Clone, Default, Deserialize, Serialize)]
43#[serde(default)]
44pub struct UiConfig {
45 pub primary: Option<VendorId>,
47}
48
49#[derive(Debug, Clone, Deserialize, Serialize)]
50#[serde(default)]
51pub struct AnthropicConfig {
52 pub enabled: bool,
53 pub credentials_path: Option<PathBuf>,
56 pub accounts: Vec<AnthropicAccount>,
60}
61
62impl Default for AnthropicConfig {
63 fn default() -> Self {
64 Self {
65 enabled: true,
66 credentials_path: None,
67 accounts: Vec::new(),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
82pub struct AnthropicAccount {
83 pub label: String,
86 pub credentials_path: PathBuf,
90}
91
92impl AnthropicConfig {
93 pub fn account(&self, label: &str) -> Result<&AnthropicAccount> {
96 validate_account_label(label)?;
97 self.accounts
98 .iter()
99 .find(|a| a.label == label)
100 .ok_or_else(|| {
101 let known: Vec<&str> = self.accounts.iter().map(|a| a.label.as_str()).collect();
102 AppError::Credentials(format!(
103 "anthropic account {label:?} not found in [[anthropic.accounts]]; \
104 known labels: {known:?}"
105 ))
106 })
107 }
108
109 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
116 let account = self.account(label)?;
117 Ok((
118 CredsTarget::Explicit(account.credentials_path.clone()),
119 Cache::for_vendor_account("anthropic", label)?,
120 ))
121 }
122}
123
124fn validate_account_label(label: &str) -> Result<()> {
130 let bad = label.is_empty()
131 || label == "."
132 || label == ".."
133 || label.contains(['/', '\\'])
134 || label == "usage.json";
135 if bad {
136 return Err(AppError::Credentials(format!(
137 "invalid anthropic account label {label:?}: must be a non-empty name \
138 without path separators (it becomes a cache subdirectory)"
139 )));
140 }
141 Ok(())
142}
143
144#[derive(Debug, Clone, Deserialize, Serialize)]
145#[serde(default)]
146pub struct OpenAiConfig {
147 pub enabled: bool,
148 pub codex_auth_path: Option<PathBuf>,
150 pub admin_key_env: String,
152}
153
154impl Default for OpenAiConfig {
155 fn default() -> Self {
156 Self {
157 enabled: true,
158 codex_auth_path: None,
159 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
160 }
161 }
162}
163
164#[derive(Debug, Clone, Deserialize, Serialize)]
165#[serde(default)]
166pub struct ZaiConfig {
167 pub enabled: bool,
168 pub api_key_env: String,
170 pub api_key: Option<String>,
173 pub plan_tier: Option<String>,
175}
176
177impl Default for ZaiConfig {
178 fn default() -> Self {
179 Self {
180 enabled: true,
181 api_key_env: "ZAI_API_KEY".to_string(),
182 api_key: None,
183 plan_tier: None,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Deserialize, Serialize)]
189#[serde(default)]
190pub struct OpenRouterConfig {
191 pub enabled: bool,
192 pub api_key_env: String,
193 pub api_key: Option<String>,
194}
195
196impl Default for OpenRouterConfig {
197 fn default() -> Self {
198 Self {
199 enabled: true,
200 api_key_env: "OPENROUTER_API_KEY".to_string(),
201 api_key: None,
202 }
203 }
204}
205
206#[derive(Debug, Clone, Deserialize, Serialize)]
207#[serde(default)]
208pub struct DeepseekConfig {
209 pub enabled: bool,
210 pub api_key_env: String,
211 pub api_key: Option<String>,
212}
213
214impl Default for DeepseekConfig {
215 fn default() -> Self {
216 Self {
217 enabled: false,
218 api_key_env: "DEEPSEEK_API_KEY".to_string(),
219 api_key: None,
220 }
221 }
222}
223
224#[derive(Debug, Clone, Deserialize, Serialize)]
225#[serde(default)]
226pub struct KimiConfig {
227 pub enabled: bool,
228 pub api_key_env: String,
229 pub api_key: Option<String>,
230}
231
232impl Default for KimiConfig {
233 fn default() -> Self {
234 Self {
235 enabled: false,
236 api_key_env: "KIMI_API_KEY".to_string(),
237 api_key: None,
238 }
239 }
240}
241
242pub fn resolve_api_key(
245 vendor_label: &str,
246 env_var_name: &str,
247 inline: Option<&str>,
248) -> crate::error::Result<String> {
249 let valid_env_name = is_valid_env_var_name(env_var_name);
250 if valid_env_name
251 && let Ok(v) = std::env::var(env_var_name)
252 && !v.is_empty()
253 {
254 return Ok(v);
255 }
256 if let Some(v) = inline
257 && !v.is_empty()
258 {
259 return Ok(v.to_string());
260 }
261 let advice = if valid_env_name {
262 "set an API key in a valid environment variable or set `api_key`"
263 } else {
264 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
265 };
266 Err(crate::error::AppError::Credentials(format!(
267 "{vendor_label}: no API key. Either {advice} under [{}] in {}.",
268 vendor_label.to_lowercase(),
269 config_path_hint()
270 )))
271}
272
273fn is_valid_env_var_name(name: &str) -> bool {
274 let mut chars = name.chars();
275 let Some(first) = chars.next() else {
276 return false;
277 };
278 (first.is_ascii_alphabetic() || first == '_')
279 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
280}
281
282impl Config {
283 pub fn load() -> Result<Self> {
286 let Some(path) = default_path() else {
287 return Ok(Self::default());
288 };
289 Self::load_from(&path)
290 }
291
292 pub fn load_from(path: &std::path::Path) -> Result<Self> {
293 match std::fs::read_to_string(path) {
294 Ok(s) => {
295 let config: Self = toml::from_str(&s)?;
296 config.validate()?;
297 Ok(config)
298 }
299 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
300 Err(e) => Err(AppError::io_at(path, e)),
301 }
302 }
303
304 pub fn is_enabled(&self, id: VendorId) -> bool {
305 match id {
306 VendorId::Anthropic => self.anthropic.enabled,
307 VendorId::Openai => self.openai.enabled,
308 VendorId::Zai => self.zai.enabled,
309 VendorId::Openrouter => self.openrouter.enabled,
310 VendorId::Deepseek => self.deepseek.enabled,
311 VendorId::Kimi => self.kimi.enabled,
312 }
313 }
314
315 pub fn enabled_vendors(&self) -> Vec<VendorId> {
316 VendorId::all()
317 .iter()
318 .copied()
319 .filter(|id| self.is_enabled(*id))
320 .collect()
321 }
322
323 pub fn validate(&self) -> Result<()> {
327 let mut labels = HashSet::new();
328 for account in &self.anthropic.accounts {
329 validate_account_label(&account.label)?;
330 if !labels.insert(&account.label) {
331 return Err(AppError::Credentials(format!(
332 "duplicate anthropic account label {:?}",
333 account.label
334 )));
335 }
336 }
337 Ok(())
338 }
339}
340
341pub fn default_path() -> Option<PathBuf> {
342 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
343 Some(proj.config_dir().join("config.toml"))
344}
345
346pub fn config_path_hint() -> String {
351 default_path()
352 .map(|p| p.display().to_string())
353 .unwrap_or_else(|| "config.toml".to_string())
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use std::io::Write;
360 use tempfile::NamedTempFile;
361
362 fn write_toml(s: &str) -> NamedTempFile {
363 let mut f = NamedTempFile::new().unwrap();
364 f.write_all(s.as_bytes()).unwrap();
365 f.flush().unwrap();
366 f
367 }
368
369 #[test]
370 fn defaults_enable_core_vendors_deepseek_and_kimi_default_off() {
371 let c = Config::default();
372 assert!(c.is_enabled(VendorId::Anthropic));
373 assert!(c.is_enabled(VendorId::Openai));
374 assert!(c.is_enabled(VendorId::Zai));
375 assert!(c.is_enabled(VendorId::Openrouter));
376 assert!(!c.is_enabled(VendorId::Deepseek));
378 assert!(!c.is_enabled(VendorId::Kimi));
379 assert_eq!(c.enabled_vendors().len(), 4);
380 }
381
382 #[test]
383 fn missing_file_uses_defaults() {
384 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
385 let c = Config::load_from(path).unwrap();
386 assert!(c.is_enabled(VendorId::Anthropic));
387 }
388
389 #[test]
390 fn parses_full_config() {
391 let f = write_toml(
392 r#"
393 [anthropic]
394 enabled = true
395
396 [openai]
397 enabled = false
398 admin_key_env = "MY_ADMIN_KEY"
399
400 [zai]
401 enabled = true
402 api_key_env = "MY_ZAI"
403 plan_tier = "pro"
404
405 [openrouter]
406 enabled = false
407 "#,
408 );
409 let c = Config::load_from(f.path()).unwrap();
410 assert!(c.is_enabled(VendorId::Anthropic));
411 assert!(!c.is_enabled(VendorId::Openai));
412 assert!(c.is_enabled(VendorId::Zai));
413 assert!(!c.is_enabled(VendorId::Openrouter));
414 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
415 assert_eq!(c.zai.api_key_env, "MY_ZAI");
416 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
417 }
418
419 #[test]
420 fn partial_config_falls_back_to_defaults() {
421 let f = write_toml(
422 r#"[openai]
423enabled = false
424"#,
425 );
426 let c = Config::load_from(f.path()).unwrap();
427 assert!(!c.is_enabled(VendorId::Openai));
428 assert!(c.is_enabled(VendorId::Anthropic));
430 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
431 }
432
433 #[test]
434 fn malformed_toml_returns_error() {
435 let f = write_toml("this is not = = valid");
436 assert!(Config::load_from(f.path()).is_err());
437 }
438
439 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
441 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
442 M.lock().unwrap_or_else(|p| p.into_inner())
443 }
444
445 #[test]
446 fn resolve_api_key_prefers_env_over_inline() {
447 let _g = env_guard();
448 let var = "AI_USAGEBAR_TEST_ENV_WINS";
450 unsafe { std::env::set_var(var, "from-env") };
452 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
453 unsafe { std::env::remove_var(var) };
454 assert_eq!(got, "from-env");
455 }
456
457 #[test]
458 fn resolve_api_key_falls_back_to_inline() {
459 let _g = env_guard();
460 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
461 unsafe { std::env::remove_var(var) };
462 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
463 assert_eq!(got, "inline-key");
464 }
465
466 #[test]
467 fn resolve_api_key_errors_when_both_missing() {
468 let _g = env_guard();
469 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
470 unsafe { std::env::remove_var(var) };
471 let err = resolve_api_key("Zai", var, None).unwrap_err();
472 match err {
473 crate::error::AppError::Credentials(msg) => {
474 assert!(
475 msg.contains("api_key"),
476 "error should suggest config field: {msg}"
477 );
478 }
479 other => panic!("expected Credentials error, got {other:?}"),
480 }
481 }
482
483 #[test]
484 fn config_path_hint_ends_with_config_toml() {
485 assert!(config_path_hint().ends_with("config.toml"));
488 }
489
490 #[test]
491 fn resolve_api_key_treats_empty_env_as_unset() {
492 let _g = env_guard();
493 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
494 unsafe { std::env::set_var(var, "") };
495 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
496 unsafe { std::env::remove_var(var) };
497 assert_eq!(got, "inline");
498 }
499
500 #[test]
501 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
502 let _g = env_guard();
503 let bad = "sk-kimi-very-real-looking-pasted-secret";
505 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
506 let msg = err.to_string();
507 assert!(
508 msg.contains("invalid") && msg.contains("api_key_env"),
509 "error should explain misconfiguration: {msg}"
510 );
511 assert!(
512 !msg.contains(bad),
513 "error must not echo the misconfigured value: {msg}"
514 );
515 assert!(msg.contains("valid environment variable name"));
516 assert!(
517 msg.contains("[kimi]"),
518 "error should point at the lowercase TOML section: {msg}"
519 );
520 }
521
522 #[test]
523 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
524 let _g = env_guard();
525 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
526 assert_eq!(got, "inline-key");
527 }
528
529 #[test]
530 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
531 let _g = env_guard();
532 let pasted_secret = "sk_pasted_secret";
535 unsafe { std::env::remove_var(pasted_secret) };
536 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
537 assert!(
538 !err.to_string().contains(pasted_secret),
539 "error must not echo configured api_key_env values"
540 );
541 }
542
543 #[test]
544 fn is_valid_env_var_name_rules() {
545 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
547 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
548 }
549 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
551 assert!(
552 !is_valid_env_var_name(invalid),
553 "{invalid} should be invalid"
554 );
555 }
556 }
557
558 #[test]
559 fn config_parses_with_inline_api_key_and_primary() {
560 let f = write_toml(
561 r#"
562 [ui]
563 primary = "openrouter"
564
565 [zai]
566 enabled = true
567 api_key_env = "MY_ZAI"
568 api_key = "sk-zai-inline"
569
570 [openrouter]
571 enabled = true
572 api_key = "sk-or-inline"
573 "#,
574 );
575 let c = Config::load_from(f.path()).unwrap();
576 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
577 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
578 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
579 }
580
581 #[test]
582 fn enabled_vendors_preserves_canonical_order() {
583 let c = Config::default();
586 assert_eq!(
587 c.enabled_vendors(),
588 vec![
589 VendorId::Anthropic,
590 VendorId::Openai,
591 VendorId::Zai,
592 VendorId::Openrouter,
593 ]
594 );
595 }
596
597 #[test]
598 fn deepseek_appears_when_enabled() {
599 let f = write_toml(
600 r#"
601 [deepseek]
602 enabled = true
603 api_key = "sk-test"
604 "#,
605 );
606 let c = Config::load_from(f.path()).unwrap();
607 assert!(c.is_enabled(VendorId::Deepseek));
608 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
609 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
610 }
611
612 #[test]
613 fn kimi_appears_when_enabled() {
614 let f = write_toml(
615 r#"
616 [kimi]
617 enabled = true
618 api_key = "sk-test"
619 "#,
620 );
621 let c = Config::load_from(f.path()).unwrap();
622 assert!(c.is_enabled(VendorId::Kimi));
623 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
624 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
625 }
626
627 #[test]
628 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
629 let f = write_toml(
630 r#"
631 [deepseek]
632 enabled = true
633 api_key = "sk-ds"
634
635 [kimi]
636 enabled = true
637 api_key = "sk-kimi"
638 "#,
639 );
640 let c = Config::load_from(f.path()).unwrap();
641 assert_eq!(
642 c.enabled_vendors(),
643 vec![
644 VendorId::Anthropic,
645 VendorId::Openai,
646 VendorId::Zai,
647 VendorId::Openrouter,
648 VendorId::Deepseek,
649 VendorId::Kimi,
650 ]
651 );
652 }
653
654 #[test]
655 fn parses_anthropic_accounts_and_looks_them_up() {
656 let f = write_toml(
657 r#"
658 [anthropic]
659 enabled = true
660
661 [[anthropic.accounts]]
662 label = "personal"
663 credentials_path = "/creds/personal.json"
664
665 [[anthropic.accounts]]
666 label = "work"
667 credentials_path = "/creds/work.json"
668 "#,
669 );
670 let c = Config::load_from(f.path()).unwrap();
671 assert_eq!(c.anthropic.accounts.len(), 2);
672 let work = c.anthropic.account("work").unwrap();
673 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
674 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
676 assert!(err.contains("missing") && err.contains("work"), "{err}");
677 }
678
679 #[test]
680 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
681 let f = write_toml(
682 r#"
683 [[anthropic.accounts]]
684 label = "work"
685 credentials_path = "/creds/work-one.json"
686
687 [[anthropic.accounts]]
688 label = "work"
689 credentials_path = "/creds/work-two.json"
690 "#,
691 );
692 let err = Config::load_from(f.path()).unwrap_err().to_string();
693 assert!(
694 err.contains("duplicate anthropic account label \"work\""),
695 "{err}"
696 );
697 }
698
699 #[test]
700 fn account_label_rejects_path_like_names() {
701 let cfg = AnthropicConfig::default();
702 for bad in ["", ".", "..", "a/b", r"a\b", "usage.json"] {
703 let err = cfg.account(bad).unwrap_err();
704 assert!(
705 format!("{err:?}").contains("invalid anthropic account label"),
706 "{bad:?} should be rejected as a label"
707 );
708 }
709 }
710
711 #[test]
712 fn anthropic_accounts_default_to_empty() {
713 assert!(Config::default().anthropic.accounts.is_empty());
716 }
717}