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