1use std::collections::{BTreeMap, 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)]
34#[serde(default, deny_unknown_fields)]
35pub struct Config {
36 pub ui: UiConfig,
37 pub context: ContextConfig,
38 pub anthropic: AnthropicConfig,
39 pub anthropic_api: AnthropicApiConfig,
40 pub openai: OpenAiConfig,
41 pub zai: ZaiConfig,
42 pub openrouter: OpenRouterConfig,
43 pub deepseek: DeepseekConfig,
44 pub kimi: KimiConfig,
45 pub kilo: KiloConfig,
46 pub novita: NovitaConfig,
47 pub moonshot: MoonshotConfig,
48 pub grok: GrokConfig,
49}
50
51#[derive(Debug, Clone, Default, Deserialize, Serialize)]
55#[serde(default)]
56pub struct UiConfig {
57 pub primary: Option<VendorId>,
59}
60
61#[derive(Debug, Clone, Default, Deserialize, Serialize)]
66#[serde(default)]
67pub struct ContextConfig {
68 pub enabled: bool,
71 pub projects_path: Option<PathBuf>,
73 pub context_window_tokens: Option<u64>,
76 pub model_context_window_tokens: BTreeMap<String, u64>,
79}
80
81impl ContextConfig {
82 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
83 model
84 .and_then(|model| self.model_context_window_tokens.get(model).copied())
85 .filter(|tokens| *tokens > 0)
86 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
87 }
88}
89
90#[derive(Debug, Clone, Deserialize, Serialize)]
91#[serde(default)]
92pub struct AnthropicConfig {
93 pub enabled: bool,
94 pub credentials_path: Option<PathBuf>,
97 pub accounts: Vec<AnthropicAccount>,
101}
102
103impl Default for AnthropicConfig {
104 fn default() -> Self {
105 Self {
106 enabled: true,
107 credentials_path: None,
108 accounts: Vec::new(),
109 }
110 }
111}
112
113#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
123pub struct AnthropicAccount {
124 pub label: String,
127 pub credentials_path: PathBuf,
131}
132
133impl AnthropicConfig {
134 pub fn account(&self, label: &str) -> Result<&AnthropicAccount> {
137 validate_account_label(label)?;
138 self.accounts
139 .iter()
140 .find(|a| a.label == label)
141 .ok_or_else(|| {
142 let known: Vec<&str> = self.accounts.iter().map(|a| a.label.as_str()).collect();
143 AppError::Credentials(format!(
144 "anthropic account {label:?} not found in [[anthropic.accounts]]; \
145 known labels: {known:?}"
146 ))
147 })
148 }
149
150 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
157 let account = self.account(label)?;
158 Ok((
159 CredsTarget::Explicit(account.credentials_path.clone()),
160 Cache::for_vendor_account("anthropic", label)?,
161 ))
162 }
163}
164
165fn validate_account_label(label: &str) -> Result<()> {
171 let bad = label.is_empty()
172 || label == "."
173 || label == ".."
174 || label.contains(['/', '\\'])
175 || label == "usage.json";
176 if bad {
177 return Err(AppError::Credentials(format!(
178 "invalid anthropic account label {label:?}: must be a non-empty name \
179 without path separators (it becomes a cache subdirectory)"
180 )));
181 }
182 Ok(())
183}
184
185#[derive(Debug, Clone, Deserialize, Serialize)]
186#[serde(default)]
187pub struct OpenAiConfig {
188 pub enabled: bool,
189 pub codex_auth_path: Option<PathBuf>,
191 pub admin_key_env: String,
199}
200
201impl Default for OpenAiConfig {
202 fn default() -> Self {
203 Self {
204 enabled: true,
205 codex_auth_path: None,
206 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
207 }
208 }
209}
210
211#[derive(Debug, Clone, Deserialize, Serialize)]
212#[serde(default)]
213pub struct ZaiConfig {
214 pub enabled: bool,
215 pub api_key_env: String,
217 pub api_key: Option<String>,
220 pub plan_tier: Option<String>,
222}
223
224impl Default for ZaiConfig {
225 fn default() -> Self {
226 Self {
227 enabled: true,
228 api_key_env: "ZAI_API_KEY".to_string(),
229 api_key: None,
230 plan_tier: None,
231 }
232 }
233}
234
235#[derive(Debug, Clone, Deserialize, Serialize)]
236#[serde(default)]
237pub struct OpenRouterConfig {
238 pub enabled: bool,
239 pub api_key_env: String,
240 pub api_key: Option<String>,
241}
242
243impl Default for OpenRouterConfig {
244 fn default() -> Self {
245 Self {
246 enabled: true,
247 api_key_env: "OPENROUTER_API_KEY".to_string(),
248 api_key: None,
249 }
250 }
251}
252
253#[derive(Debug, Clone, Deserialize, Serialize)]
254#[serde(default)]
255pub struct DeepseekConfig {
256 pub enabled: bool,
257 pub api_key_env: String,
258 pub api_key: Option<String>,
259}
260
261impl Default for DeepseekConfig {
262 fn default() -> Self {
263 Self {
264 enabled: false,
265 api_key_env: "DEEPSEEK_API_KEY".to_string(),
266 api_key: None,
267 }
268 }
269}
270
271#[derive(Debug, Clone, Deserialize, Serialize)]
272#[serde(default)]
273pub struct KimiConfig {
274 pub enabled: bool,
275 pub api_key_env: String,
276 pub api_key: Option<String>,
277}
278
279impl Default for KimiConfig {
280 fn default() -> Self {
281 Self {
282 enabled: false,
283 api_key_env: "KIMI_API_KEY".to_string(),
284 api_key: None,
285 }
286 }
287}
288
289#[derive(Debug, Clone, Deserialize, Serialize)]
290#[serde(default)]
291pub struct KiloConfig {
292 pub enabled: bool,
293 pub api_key_env: String,
294 pub api_key: Option<String>,
295 pub organization_id: Option<String>,
298}
299
300impl Default for KiloConfig {
301 fn default() -> Self {
302 Self {
305 enabled: false,
306 api_key_env: "KILO_API_KEY".to_string(),
307 api_key: None,
308 organization_id: None,
309 }
310 }
311}
312
313#[derive(Debug, Clone, Deserialize, Serialize)]
314#[serde(default)]
315pub struct NovitaConfig {
316 pub enabled: bool,
317 pub api_key_env: String,
318 pub api_key: Option<String>,
319}
320
321impl Default for NovitaConfig {
322 fn default() -> Self {
323 Self {
325 enabled: false,
326 api_key_env: "NOVITA_API_KEY".to_string(),
327 api_key: None,
328 }
329 }
330}
331
332#[derive(Debug, Clone, Deserialize, Serialize)]
333#[serde(default)]
334pub struct MoonshotConfig {
335 pub enabled: bool,
336 pub api_key_env: String,
337 pub api_key: Option<String>,
338 pub region: String,
340}
341
342impl Default for MoonshotConfig {
343 fn default() -> Self {
344 Self {
346 enabled: false,
347 api_key_env: "MOONSHOT_API_KEY".to_string(),
348 api_key: None,
349 region: "global".to_string(),
350 }
351 }
352}
353
354#[derive(Debug, Clone, Deserialize, Serialize)]
355#[serde(default)]
356pub struct GrokConfig {
357 pub enabled: bool,
358 pub api_key_env: String,
360 pub api_key: Option<String>,
361 pub team_id: Option<String>,
364}
365
366impl Default for GrokConfig {
367 fn default() -> Self {
368 Self {
370 enabled: false,
371 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
372 api_key: None,
373 team_id: None,
374 }
375 }
376}
377
378#[derive(Debug, Clone, Deserialize, Serialize)]
379#[serde(default)]
380pub struct AnthropicApiConfig {
381 pub enabled: bool,
382 pub api_key_env: String,
385 pub api_key: Option<String>,
386 pub monthly_limit: Option<f64>,
389}
390
391impl Default for AnthropicApiConfig {
392 fn default() -> Self {
393 Self {
395 enabled: false,
396 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
397 api_key: None,
398 monthly_limit: None,
399 }
400 }
401}
402
403pub fn resolve_api_key(
406 vendor_label: &str,
407 env_var_name: &str,
408 inline: Option<&str>,
409) -> crate::error::Result<String> {
410 let valid_env_name = is_valid_env_var_name(env_var_name);
411 if valid_env_name
412 && let Ok(v) = std::env::var(env_var_name)
413 && !v.is_empty()
414 {
415 return Ok(v);
416 }
417 if let Some(v) = inline
418 && !v.is_empty()
419 {
420 return Ok(v.to_string());
421 }
422 let advice = if valid_env_name {
423 "set an API key in a valid environment variable or set `api_key`"
424 } else {
425 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
426 };
427 Err(crate::error::AppError::Credentials(format!(
428 "{vendor_label}: no API key. Either {advice} under [{}] in {}.",
429 vendor_label.to_lowercase(),
430 config_path_hint()
431 )))
432}
433
434fn is_valid_env_var_name(name: &str) -> bool {
435 let mut chars = name.chars();
436 let Some(first) = chars.next() else {
437 return false;
438 };
439 (first.is_ascii_alphabetic() || first == '_')
440 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
441}
442
443impl Config {
444 pub fn load() -> Result<Self> {
447 let Some(path) = resolved_path() else {
448 return Ok(Self::default());
449 };
450 Self::load_from(&path)
451 }
452
453 pub fn load_from(path: &std::path::Path) -> Result<Self> {
454 match std::fs::read_to_string(path) {
455 Ok(s) => {
456 let mut config: Self = toml::from_str(&s)?;
457 config.expand_paths();
461 config.validate()?;
462 Ok(config)
463 }
464 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
465 Err(e) => Err(AppError::io_at(path, e)),
466 }
467 }
468
469 fn expand_paths(&mut self) {
470 expand_tilde_opt(&mut self.context.projects_path);
471 expand_tilde_opt(&mut self.anthropic.credentials_path);
472 expand_tilde_opt(&mut self.openai.codex_auth_path);
473 for account in &mut self.anthropic.accounts {
474 account.credentials_path = expand_tilde(&account.credentials_path);
475 }
476 }
477
478 pub fn is_enabled(&self, id: VendorId) -> bool {
479 match id {
480 VendorId::Anthropic => self.anthropic.enabled,
481 VendorId::AnthropicApi => self.anthropic_api.enabled,
482 VendorId::Openai => self.openai.enabled,
483 VendorId::Zai => self.zai.enabled,
484 VendorId::Openrouter => self.openrouter.enabled,
485 VendorId::Deepseek => self.deepseek.enabled,
486 VendorId::Kimi => self.kimi.enabled,
487 VendorId::Kilo => self.kilo.enabled,
488 VendorId::Novita => self.novita.enabled,
489 VendorId::Moonshot => self.moonshot.enabled,
490 VendorId::Grok => self.grok.enabled,
491 }
492 }
493
494 pub fn enabled_vendors(&self) -> Vec<VendorId> {
495 VendorId::all()
496 .iter()
497 .copied()
498 .filter(|id| self.is_enabled(*id))
499 .collect()
500 }
501
502 pub fn validate(&self) -> Result<()> {
506 if self.context.context_window_tokens == Some(0) {
507 return Err(AppError::Other(
508 "[context] context_window_tokens must be greater than zero".into(),
509 ));
510 }
511 for (model, tokens) in &self.context.model_context_window_tokens {
512 if model.trim().is_empty() {
513 return Err(AppError::Other(
514 "[context] model_context_window_tokens keys must not be empty".into(),
515 ));
516 }
517 if *tokens == 0 {
518 return Err(AppError::Other(format!(
519 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
520 )));
521 }
522 }
523 if let Some(limit) = self.anthropic_api.monthly_limit
524 && (!limit.is_finite() || limit <= 0.0)
525 {
526 return Err(AppError::Other(
527 "[anthropic_api] monthly_limit must be finite and greater than zero; \
528 remove it to show spend without a limit"
529 .into(),
530 ));
531 }
532 let mut labels = HashSet::new();
533 for account in &self.anthropic.accounts {
534 validate_account_label(&account.label)?;
535 if !labels.insert(&account.label) {
536 return Err(AppError::Credentials(format!(
537 "duplicate anthropic account label {:?}",
538 account.label
539 )));
540 }
541 }
542 Ok(())
543 }
544}
545
546pub fn default_path() -> Option<PathBuf> {
547 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
548 Some(proj.config_dir().join("config.toml"))
549}
550
551fn legacy_xdg_path() -> Option<PathBuf> {
556 let home = crate::cache::home_dir().ok()?;
557 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
558}
559
560pub fn resolved_path() -> Option<PathBuf> {
569 let canonical = default_path();
570 if let Some(p) = &canonical
571 && p.exists()
572 {
573 return canonical;
574 }
575 if let Some(legacy) = legacy_xdg_path()
576 && legacy.exists()
577 {
578 return Some(legacy);
579 }
580 canonical
581}
582
583fn expand_tilde(p: &std::path::Path) -> PathBuf {
586 let Some(s) = p.to_str() else {
587 return p.to_path_buf();
588 };
589 let rest = if s == "~" {
590 ""
591 } else if let Some(r) = s.strip_prefix("~/") {
592 r
593 } else {
594 return p.to_path_buf();
595 };
596 match crate::cache::home_dir() {
597 Ok(home) if rest.is_empty() => home,
598 Ok(home) => home.join(rest),
599 Err(_) => p.to_path_buf(),
600 }
601}
602
603fn expand_tilde_opt(p: &mut Option<PathBuf>) {
604 if let Some(inner) = p.as_ref() {
605 *p = Some(expand_tilde(inner));
606 }
607}
608
609pub fn config_path_hint() -> String {
614 resolved_path()
615 .map(|p| p.display().to_string())
616 .unwrap_or_else(|| "config.toml".to_string())
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use std::io::Write;
623 use tempfile::NamedTempFile;
624
625 fn write_toml(s: &str) -> NamedTempFile {
626 let mut f = NamedTempFile::new().unwrap();
627 f.write_all(s.as_bytes()).unwrap();
628 f.flush().unwrap();
629 f
630 }
631
632 #[test]
633 fn defaults_enable_only_the_four_core_vendors() {
634 let c = Config::default();
635 assert!(c.is_enabled(VendorId::Anthropic));
636 assert!(c.is_enabled(VendorId::Openai));
637 assert!(c.is_enabled(VendorId::Zai));
638 assert!(c.is_enabled(VendorId::Openrouter));
639 for opt_in in [
640 VendorId::AnthropicApi,
641 VendorId::Deepseek,
642 VendorId::Kimi,
643 VendorId::Kilo,
644 VendorId::Novita,
645 VendorId::Moonshot,
646 VendorId::Grok,
647 ] {
648 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
649 }
650 assert_eq!(c.enabled_vendors().len(), 4);
651 }
652
653 #[test]
654 fn missing_file_uses_defaults() {
655 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
656 let c = Config::load_from(path).unwrap();
657 assert!(c.is_enabled(VendorId::Anthropic));
658 }
659
660 #[test]
661 fn parses_full_config() {
662 let f = write_toml(
663 r#"
664 [anthropic]
665 enabled = true
666
667 [openai]
668 enabled = false
669 admin_key_env = "MY_ADMIN_KEY"
670
671 [zai]
672 enabled = true
673 api_key_env = "MY_ZAI"
674 plan_tier = "pro"
675
676 [openrouter]
677 enabled = false
678 "#,
679 );
680 let c = Config::load_from(f.path()).unwrap();
681 assert!(c.is_enabled(VendorId::Anthropic));
682 assert!(!c.is_enabled(VendorId::Openai));
683 assert!(c.is_enabled(VendorId::Zai));
684 assert!(!c.is_enabled(VendorId::Openrouter));
685 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
686 assert_eq!(c.zai.api_key_env, "MY_ZAI");
687 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
688 }
689
690 #[test]
691 fn partial_config_falls_back_to_defaults() {
692 let f = write_toml(
693 r#"[openai]
694enabled = false
695"#,
696 );
697 let c = Config::load_from(f.path()).unwrap();
698 assert!(!c.is_enabled(VendorId::Openai));
699 assert!(c.is_enabled(VendorId::Anthropic));
701 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
702 }
703
704 #[test]
705 fn malformed_toml_returns_error() {
706 let f = write_toml("this is not = = valid");
707 assert!(Config::load_from(f.path()).is_err());
708 }
709
710 #[test]
711 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
712 for value in ["0", "-1", "inf", "nan"] {
713 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
714 let error = Config::load_from(file.path()).unwrap_err().to_string();
715 assert!(error.contains("monthly_limit"), "value {value}: {error}");
716 }
717
718 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
719 assert_eq!(
720 Config::load_from(file.path())
721 .unwrap()
722 .anthropic_api
723 .monthly_limit,
724 Some(1000.0)
725 );
726 }
727
728 #[test]
729 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
730 let defaults = Config::default();
731 assert!(!defaults.context.enabled);
732 assert_eq!(
733 defaults.context.window_tokens_for(Some("claude-test")),
734 None
735 );
736
737 let file = write_toml(
738 r#"
739 [context]
740 enabled = true
741 context_window_tokens = 200000
742
743 [context.model_context_window_tokens]
744 claude-opus-1m = 1000000
745 "claude exact id" = 300000
746 "#,
747 );
748 let config = Config::load_from(file.path()).unwrap();
749 assert!(config.context.enabled);
750 assert_eq!(
751 config.context.window_tokens_for(Some("claude-opus-1m")),
752 Some(1_000_000)
753 );
754 assert_eq!(
755 config.context.window_tokens_for(Some("claude exact id")),
756 Some(300_000)
757 );
758 assert_eq!(
759 config.context.window_tokens_for(Some("another-model")),
760 Some(200_000)
761 );
762 }
763
764 #[test]
765 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
766 for source in [
767 "[context]\ncontext_window_tokens = 0\n",
768 "[context.model_context_window_tokens]\nclaude = 0\n",
769 "[context.model_context_window_tokens]\n\" \" = 200000\n",
770 ] {
771 let file = write_toml(source);
772 let error = Config::load_from(file.path()).unwrap_err().to_string();
773 assert!(error.contains("context"), "{error}");
774 }
775 }
776
777 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
779 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
780 M.lock().unwrap_or_else(|p| p.into_inner())
781 }
782
783 #[test]
784 fn resolve_api_key_prefers_env_over_inline() {
785 let _g = env_guard();
786 let var = "AI_USAGEBAR_TEST_ENV_WINS";
788 unsafe { std::env::set_var(var, "from-env") };
790 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
791 unsafe { std::env::remove_var(var) };
792 assert_eq!(got, "from-env");
793 }
794
795 #[test]
796 fn resolve_api_key_falls_back_to_inline() {
797 let _g = env_guard();
798 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
799 unsafe { std::env::remove_var(var) };
800 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
801 assert_eq!(got, "inline-key");
802 }
803
804 #[test]
805 fn resolve_api_key_errors_when_both_missing() {
806 let _g = env_guard();
807 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
808 unsafe { std::env::remove_var(var) };
809 let err = resolve_api_key("Zai", var, None).unwrap_err();
810 match err {
811 crate::error::AppError::Credentials(msg) => {
812 assert!(
813 msg.contains("api_key"),
814 "error should suggest config field: {msg}"
815 );
816 }
817 other => panic!("expected Credentials error, got {other:?}"),
818 }
819 }
820
821 #[test]
822 fn config_path_hint_ends_with_config_toml() {
823 assert!(config_path_hint().ends_with("config.toml"));
826 }
827
828 #[test]
829 fn resolve_api_key_treats_empty_env_as_unset() {
830 let _g = env_guard();
831 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
832 unsafe { std::env::set_var(var, "") };
833 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
834 unsafe { std::env::remove_var(var) };
835 assert_eq!(got, "inline");
836 }
837
838 #[test]
839 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
840 let _g = env_guard();
841 let bad = "sk-kimi-very-real-looking-pasted-secret";
843 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
844 let msg = err.to_string();
845 assert!(
846 msg.contains("invalid") && msg.contains("api_key_env"),
847 "error should explain misconfiguration: {msg}"
848 );
849 assert!(
850 !msg.contains(bad),
851 "error must not echo the misconfigured value: {msg}"
852 );
853 assert!(msg.contains("valid environment variable name"));
854 assert!(
855 msg.contains("[kimi]"),
856 "error should point at the lowercase TOML section: {msg}"
857 );
858 }
859
860 #[test]
861 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
862 let _g = env_guard();
863 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
864 assert_eq!(got, "inline-key");
865 }
866
867 #[test]
868 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
869 let _g = env_guard();
870 let pasted_secret = "sk_pasted_secret";
873 unsafe { std::env::remove_var(pasted_secret) };
874 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
875 assert!(
876 !err.to_string().contains(pasted_secret),
877 "error must not echo configured api_key_env values"
878 );
879 }
880
881 #[test]
882 fn is_valid_env_var_name_rules() {
883 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
885 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
886 }
887 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
889 assert!(
890 !is_valid_env_var_name(invalid),
891 "{invalid} should be invalid"
892 );
893 }
894 }
895
896 #[test]
897 fn config_parses_with_inline_api_key_and_primary() {
898 let f = write_toml(
899 r#"
900 [ui]
901 primary = "openrouter"
902
903 [zai]
904 enabled = true
905 api_key_env = "MY_ZAI"
906 api_key = "sk-zai-inline"
907
908 [openrouter]
909 enabled = true
910 api_key = "sk-or-inline"
911 "#,
912 );
913 let c = Config::load_from(f.path()).unwrap();
914 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
915 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
916 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
917 }
918
919 #[test]
920 fn enabled_vendors_preserves_canonical_order() {
921 let c = Config::default();
924 assert_eq!(
925 c.enabled_vendors(),
926 vec![
927 VendorId::Anthropic,
928 VendorId::Openai,
929 VendorId::Zai,
930 VendorId::Openrouter,
931 ]
932 );
933 }
934
935 #[test]
936 fn deepseek_appears_when_enabled() {
937 let f = write_toml(
938 r#"
939 [deepseek]
940 enabled = true
941 api_key = "sk-test"
942 "#,
943 );
944 let c = Config::load_from(f.path()).unwrap();
945 assert!(c.is_enabled(VendorId::Deepseek));
946 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
947 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
948 }
949
950 #[test]
951 fn tilde_paths_are_expanded_on_load() {
952 let f = write_toml(
956 r#"
957 [context]
958 projects_path = "~/.claude/projects"
959
960 [anthropic]
961 credentials_path = "~/.claude/.credentials.json"
962
963 [[anthropic.accounts]]
964 label = "work"
965 credentials_path = "~/work.json"
966 "#,
967 );
968 let c = Config::load_from(f.path()).unwrap();
969 let home = crate::cache::home_dir().unwrap();
970
971 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
972 let got = c.anthropic.credentials_path.unwrap();
973 assert_eq!(got, home.join(".claude/.credentials.json"));
974 assert!(!got.to_string_lossy().contains('~'));
975 assert_eq!(
976 c.anthropic.accounts[0].credentials_path,
977 home.join("work.json")
978 );
979 }
980
981 #[test]
982 fn absolute_and_relative_paths_are_left_alone() {
983 let f = write_toml(
984 r#"
985 [anthropic]
986 credentials_path = "/etc/creds.json"
987 "#,
988 );
989 let c = Config::load_from(f.path()).unwrap();
990 assert_eq!(
991 c.anthropic.credentials_path.unwrap(),
992 std::path::Path::new("/etc/creds.json")
993 );
994
995 let f2 = write_toml(
997 r#"
998 [anthropic]
999 credentials_path = "~someone/creds.json"
1000 "#,
1001 );
1002 let c2 = Config::load_from(f2.path()).unwrap();
1003 assert_eq!(
1004 c2.anthropic.credentials_path.unwrap(),
1005 std::path::Path::new("~someone/creds.json")
1006 );
1007 }
1008
1009 #[test]
1010 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
1011 let p = resolved_path().expect("a config path must resolve");
1014 assert!(p.ends_with("config.toml"));
1015 let canonical = default_path().unwrap();
1016 let legacy = legacy_xdg_path().unwrap();
1017 assert!(
1018 p == canonical || p == legacy,
1019 "resolved to an unexpected location: {}",
1020 p.display()
1021 );
1022 }
1023
1024 #[test]
1025 fn misspelled_section_is_rejected_not_ignored() {
1026 let f = write_toml(
1029 r#"
1030 [openrouer]
1031 enabled = true
1032 api_key = "sk-or-v1-typo"
1033 "#,
1034 );
1035 let err = Config::load_from(f.path()).unwrap_err().to_string();
1036 assert!(
1037 err.contains("openrouer"),
1038 "error should name the typo: {err}"
1039 );
1040 }
1041
1042 #[test]
1043 fn invalid_toml_is_an_error_not_silent_defaults() {
1044 let f = write_toml("[zai\nenabled = true\n");
1045 assert!(Config::load_from(f.path()).is_err());
1046 }
1047
1048 #[test]
1049 fn a_missing_file_is_still_just_defaults() {
1050 let dir = tempfile::tempdir().unwrap();
1053 let missing = dir.path().join("nope").join("config.toml");
1054 let c = Config::load_from(&missing).unwrap();
1055 assert!(c.is_enabled(VendorId::Anthropic));
1056 }
1057
1058 #[test]
1059 fn kimi_appears_when_enabled() {
1060 let f = write_toml(
1061 r#"
1062 [kimi]
1063 enabled = true
1064 api_key = "sk-test"
1065 "#,
1066 );
1067 let c = Config::load_from(f.path()).unwrap();
1068 assert!(c.is_enabled(VendorId::Kimi));
1069 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
1070 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
1071 }
1072
1073 #[test]
1074 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
1075 let f = write_toml(
1076 r#"
1077 [deepseek]
1078 enabled = true
1079 api_key = "sk-ds"
1080
1081 [kimi]
1082 enabled = true
1083 api_key = "sk-kimi"
1084 "#,
1085 );
1086 let c = Config::load_from(f.path()).unwrap();
1087 assert_eq!(
1088 c.enabled_vendors(),
1089 vec![
1090 VendorId::Anthropic,
1091 VendorId::Openai,
1092 VendorId::Zai,
1093 VendorId::Openrouter,
1094 VendorId::Deepseek,
1095 VendorId::Kimi,
1096 ]
1097 );
1098 }
1099
1100 #[test]
1101 fn parses_anthropic_accounts_and_looks_them_up() {
1102 let f = write_toml(
1103 r#"
1104 [anthropic]
1105 enabled = true
1106
1107 [[anthropic.accounts]]
1108 label = "personal"
1109 credentials_path = "/creds/personal.json"
1110
1111 [[anthropic.accounts]]
1112 label = "work"
1113 credentials_path = "/creds/work.json"
1114 "#,
1115 );
1116 let c = Config::load_from(f.path()).unwrap();
1117 assert_eq!(c.anthropic.accounts.len(), 2);
1118 let work = c.anthropic.account("work").unwrap();
1119 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
1120 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
1122 assert!(err.contains("missing") && err.contains("work"), "{err}");
1123 }
1124
1125 #[test]
1126 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
1127 let f = write_toml(
1128 r#"
1129 [[anthropic.accounts]]
1130 label = "work"
1131 credentials_path = "/creds/work-one.json"
1132
1133 [[anthropic.accounts]]
1134 label = "work"
1135 credentials_path = "/creds/work-two.json"
1136 "#,
1137 );
1138 let err = Config::load_from(f.path()).unwrap_err().to_string();
1139 assert!(
1140 err.contains("duplicate anthropic account label \"work\""),
1141 "{err}"
1142 );
1143 }
1144
1145 #[test]
1146 fn account_label_rejects_path_like_names() {
1147 let cfg = AnthropicConfig::default();
1148 for bad in ["", ".", "..", "a/b", r"a\b", "usage.json"] {
1149 let err = cfg.account(bad).unwrap_err();
1150 assert!(
1151 format!("{err:?}").contains("invalid anthropic account label"),
1152 "{bad:?} should be rejected as a label"
1153 );
1154 }
1155 }
1156
1157 #[test]
1158 fn anthropic_accounts_default_to_empty() {
1159 assert!(Config::default().anthropic.accounts.is_empty());
1162 }
1163
1164 fn config_example() -> PathBuf {
1168 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
1169 }
1170
1171 #[test]
1172 fn shipped_example_parses_as_a_real_config() {
1173 let c = Config::load_from(&config_example()).unwrap();
1178 assert!(!c.context.enabled);
1179 assert!(c.is_enabled(VendorId::Anthropic));
1180 assert!(c.is_enabled(VendorId::Openai));
1181 assert!(!c.is_enabled(VendorId::AnthropicApi));
1182 assert!(!c.is_enabled(VendorId::Deepseek));
1183 assert!(!c.is_enabled(VendorId::Kimi));
1184 assert!(!c.is_enabled(VendorId::Kilo));
1185 assert!(!c.is_enabled(VendorId::Novita));
1186 assert!(!c.is_enabled(VendorId::Moonshot));
1187 assert!(!c.is_enabled(VendorId::Grok));
1188 }
1189
1190 #[test]
1191 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
1192 let text = std::fs::read_to_string(config_example()).unwrap();
1197 let live: Vec<&str> = text
1198 .lines()
1199 .map(str::trim)
1200 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
1201 .collect();
1202 assert!(
1203 live.is_empty(),
1204 "admin_key_env must stay commented out while it is inert: {live:?}"
1205 );
1206 assert!(
1209 text.contains("admin_key_env") && text.contains("RESERVED"),
1210 "the example should keep describing admin_key_env as reserved"
1211 );
1212 }
1213
1214 #[test]
1215 fn admin_key_env_is_accepted_but_changes_nothing() {
1216 let f = write_toml(
1220 r#"
1221 [openai]
1222 admin_key_env = "SOME_ADMIN_KEY"
1223 "#,
1224 );
1225 let c = Config::load_from(f.path()).unwrap();
1226 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
1227 let default = OpenAiConfig::default();
1229 assert_eq!(c.openai.enabled, default.enabled);
1230 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
1231 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
1232 }
1233
1234 #[test]
1235 fn config_example_documents_every_vendor_without_secrets() {
1236 let raw = std::fs::read_to_string(config_example()).unwrap();
1237 let cfg = Config::load_from(&config_example()).unwrap();
1238 for id in VendorId::all() {
1241 let section = id.slug();
1242 assert!(
1243 raw.contains(&format!("[{section}]")),
1244 "config.example.toml has no [{section}] section"
1245 );
1246 }
1247
1248 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
1251 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
1252 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
1253 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
1254 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
1255 }
1256}