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