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 pub cursor: CursorConfig,
51}
52
53#[derive(Debug, Clone, Default, Deserialize, Serialize)]
57#[serde(default)]
58pub struct UiConfig {
59 pub primary: Option<VendorId>,
61 pub overview_vendors: Option<Vec<VendorId>>,
65}
66
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
70#[serde(rename_all = "lowercase")]
71pub enum ContextLayout {
72 #[default]
74 Full,
75 Split,
77 Bottom,
79}
80
81impl ContextLayout {
82 pub fn next(self) -> Self {
83 match self {
84 ContextLayout::Full => ContextLayout::Split,
85 ContextLayout::Split => ContextLayout::Bottom,
86 ContextLayout::Bottom => ContextLayout::Full,
87 }
88 }
89
90 pub fn label(self) -> &'static str {
91 match self {
92 ContextLayout::Full => "full",
93 ContextLayout::Split => "split",
94 ContextLayout::Bottom => "bottom",
95 }
96 }
97}
98
99#[derive(Debug, Clone, Default, Deserialize, Serialize)]
104#[serde(default)]
105pub struct ContextConfig {
106 pub enabled: bool,
109 pub projects_path: Option<PathBuf>,
111 pub context_window_tokens: Option<u64>,
114 pub model_context_window_tokens: BTreeMap<String, u64>,
117 pub layout: ContextLayout,
119}
120
121impl ContextConfig {
122 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
123 model
124 .and_then(|model| self.model_context_window_tokens.get(model).copied())
125 .filter(|tokens| *tokens > 0)
126 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
127 }
128}
129
130#[derive(Debug, Clone, Deserialize, Serialize)]
131#[serde(default)]
132pub struct AnthropicConfig {
133 pub enabled: bool,
134 pub credentials_path: Option<PathBuf>,
137 pub accounts: Vec<AnthropicAccount>,
141 pub accounts_dir: Option<PathBuf>,
149 pub show_default_account: bool,
155}
156
157impl Default for AnthropicConfig {
158 fn default() -> Self {
159 Self {
160 enabled: true,
161 credentials_path: None,
162 accounts: Vec::new(),
163 accounts_dir: None,
164 show_default_account: true,
165 }
166 }
167}
168
169#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
179pub struct AnthropicAccount {
180 pub label: String,
183 pub credentials_path: PathBuf,
187}
188
189impl AnthropicConfig {
190 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
196 let mut out = self.accounts.clone();
197 if let Some(dir) = &self.accounts_dir {
198 for acct in discover_accounts(dir) {
199 if !out.iter().any(|a| a.label == acct.label) {
200 out.push(acct);
201 }
202 }
203 }
204 out
205 }
206
207 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
212 validate_account_label(label)?;
213 let all = self.all_accounts();
214 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
215 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
216 AppError::Credentials(format!(
217 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
218 known labels: {known:?}"
219 ))
220 })
221 }
222
223 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
234 let account = self.account(label)?;
235 let config_dir = account
236 .credentials_path
237 .parent()
238 .map(std::path::Path::to_path_buf)
239 .unwrap_or_else(|| account.credentials_path.clone());
240 Ok((
241 CredsTarget::Named {
242 path: account.credentials_path,
243 config_dir,
244 },
245 Cache::for_vendor_account("anthropic", label)?,
246 ))
247 }
248}
249
250fn validate_account_label(label: &str) -> Result<()> {
256 let bad = label.is_empty()
257 || label == "."
258 || label == ".."
259 || label.contains(['/', '\\'])
260 || label == "usage.json";
261 if bad {
262 return Err(AppError::Credentials(format!(
263 "invalid anthropic account label {label:?}: must be a non-empty name \
264 without path separators (it becomes a cache subdirectory)"
265 )));
266 }
267 Ok(())
268}
269
270fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
278 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
279 return Vec::new();
280 };
281 let mut found: Vec<AnthropicAccount> = entries
282 .flatten()
283 .filter_map(|entry| {
284 let path = entry.path();
285 if !path.is_dir() {
286 return None;
287 }
288 let label = path.file_name()?.to_str()?.to_string();
289 validate_account_label(&label).ok()?;
290 Some(AnthropicAccount {
291 label,
292 credentials_path: path.join(".credentials.json"),
293 })
294 })
295 .collect();
296 found.sort_by(|a, b| a.label.cmp(&b.label));
297 found
298}
299
300#[derive(Debug, Clone, Deserialize, Serialize)]
301#[serde(default)]
302pub struct OpenAiConfig {
303 pub enabled: bool,
304 pub codex_auth_path: Option<PathBuf>,
306 pub admin_key_env: String,
314}
315
316impl Default for OpenAiConfig {
317 fn default() -> Self {
318 Self {
319 enabled: true,
320 codex_auth_path: None,
321 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
322 }
323 }
324}
325
326#[derive(Debug, Clone, Deserialize, Serialize)]
327#[serde(default)]
328pub struct ZaiConfig {
329 pub enabled: bool,
330 pub api_key_env: String,
332 pub api_key: Option<String>,
335 pub plan_tier: Option<String>,
337}
338
339impl Default for ZaiConfig {
340 fn default() -> Self {
341 Self {
342 enabled: true,
343 api_key_env: "ZAI_API_KEY".to_string(),
344 api_key: None,
345 plan_tier: None,
346 }
347 }
348}
349
350#[derive(Debug, Clone, Deserialize, Serialize)]
351#[serde(default)]
352pub struct OpenRouterConfig {
353 pub enabled: bool,
354 pub api_key_env: String,
355 pub api_key: Option<String>,
356}
357
358impl Default for OpenRouterConfig {
359 fn default() -> Self {
360 Self {
361 enabled: true,
362 api_key_env: "OPENROUTER_API_KEY".to_string(),
363 api_key: None,
364 }
365 }
366}
367
368#[derive(Debug, Clone, Deserialize, Serialize)]
369#[serde(default)]
370pub struct DeepseekConfig {
371 pub enabled: bool,
372 pub api_key_env: String,
373 pub api_key: Option<String>,
374}
375
376impl Default for DeepseekConfig {
377 fn default() -> Self {
378 Self {
379 enabled: false,
380 api_key_env: "DEEPSEEK_API_KEY".to_string(),
381 api_key: None,
382 }
383 }
384}
385
386#[derive(Debug, Clone, Deserialize, Serialize)]
387#[serde(default)]
388pub struct KimiConfig {
389 pub enabled: bool,
390 pub api_key_env: String,
391 pub api_key: Option<String>,
392}
393
394impl Default for KimiConfig {
395 fn default() -> Self {
396 Self {
397 enabled: false,
398 api_key_env: "KIMI_API_KEY".to_string(),
399 api_key: None,
400 }
401 }
402}
403
404#[derive(Debug, Clone, Deserialize, Serialize)]
405#[serde(default)]
406pub struct KiloConfig {
407 pub enabled: bool,
408 pub api_key_env: String,
409 pub api_key: Option<String>,
410 pub organization_id: Option<String>,
413}
414
415impl Default for KiloConfig {
416 fn default() -> Self {
417 Self {
420 enabled: false,
421 api_key_env: "KILO_API_KEY".to_string(),
422 api_key: None,
423 organization_id: None,
424 }
425 }
426}
427
428#[derive(Debug, Clone, Deserialize, Serialize)]
429#[serde(default)]
430pub struct NovitaConfig {
431 pub enabled: bool,
432 pub api_key_env: String,
433 pub api_key: Option<String>,
434}
435
436impl Default for NovitaConfig {
437 fn default() -> Self {
438 Self {
440 enabled: false,
441 api_key_env: "NOVITA_API_KEY".to_string(),
442 api_key: None,
443 }
444 }
445}
446
447#[derive(Debug, Clone, Deserialize, Serialize)]
448#[serde(default)]
449pub struct MoonshotConfig {
450 pub enabled: bool,
451 pub api_key_env: String,
452 pub api_key: Option<String>,
453 pub region: String,
455}
456
457impl Default for MoonshotConfig {
458 fn default() -> Self {
459 Self {
461 enabled: false,
462 api_key_env: "MOONSHOT_API_KEY".to_string(),
463 api_key: None,
464 region: "global".to_string(),
465 }
466 }
467}
468
469#[derive(Debug, Clone, Deserialize, Serialize)]
470#[serde(default)]
471pub struct GrokConfig {
472 pub enabled: bool,
473 pub api_key_env: String,
475 pub api_key: Option<String>,
476 pub team_id: Option<String>,
479}
480
481impl Default for GrokConfig {
482 fn default() -> Self {
483 Self {
485 enabled: false,
486 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
487 api_key: None,
488 team_id: None,
489 }
490 }
491}
492
493#[derive(Debug, Clone, Default, Deserialize, Serialize)]
496#[serde(default)]
497pub struct AntigravityConfig {
498 pub enabled: bool,
499}
500
501#[derive(Debug, Clone, Default, Deserialize, Serialize)]
511#[serde(default)]
512pub struct CursorConfig {
513 pub enabled: bool,
514 pub db_path: Option<PathBuf>,
518}
519
520#[derive(Debug, Clone, Deserialize, Serialize)]
521#[serde(default)]
522pub struct AnthropicApiConfig {
523 pub enabled: bool,
524 pub api_key_env: String,
527 pub api_key: Option<String>,
528 pub monthly_limit: Option<f64>,
531}
532
533impl Default for AnthropicApiConfig {
534 fn default() -> Self {
535 Self {
537 enabled: false,
538 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
539 api_key: None,
540 monthly_limit: None,
541 }
542 }
543}
544
545pub fn resolve_api_key(
548 vendor_label: &str,
549 env_var_name: &str,
550 inline: Option<&str>,
551) -> crate::error::Result<String> {
552 let valid_env_name = is_valid_env_var_name(env_var_name);
553 if valid_env_name
554 && let Ok(v) = std::env::var(env_var_name)
555 && !v.is_empty()
556 {
557 return Ok(v);
558 }
559 if let Some(v) = inline
560 && !v.is_empty()
561 {
562 return Ok(v.to_string());
563 }
564 let advice = if valid_env_name {
565 "set an API key in a valid environment variable or set `api_key`"
566 } else {
567 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
568 };
569 Err(crate::error::AppError::Credentials(format!(
570 "{vendor_label}: no API key. Either {advice} under [{}] in {}.",
571 vendor_label.to_lowercase(),
572 config_path_hint()
573 )))
574}
575
576fn is_valid_env_var_name(name: &str) -> bool {
577 let mut chars = name.chars();
578 let Some(first) = chars.next() else {
579 return false;
580 };
581 (first.is_ascii_alphabetic() || first == '_')
582 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
583}
584
585impl Config {
586 pub fn load() -> Result<Self> {
589 let Some(path) = resolved_path() else {
590 return Ok(Self::default());
591 };
592 Self::load_from(&path)
593 }
594
595 pub fn load_from(path: &std::path::Path) -> Result<Self> {
596 match std::fs::read_to_string(path) {
597 Ok(s) => {
598 let mut config: Self = toml::from_str(&s)?;
599 config.expand_paths();
603 config.validate()?;
604 Ok(config)
605 }
606 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
607 Err(e) => Err(AppError::io_at(path, e)),
608 }
609 }
610
611 fn expand_paths(&mut self) {
612 expand_tilde_opt(&mut self.context.projects_path);
613 expand_tilde_opt(&mut self.anthropic.credentials_path);
614 expand_tilde_opt(&mut self.anthropic.accounts_dir);
615 expand_tilde_opt(&mut self.openai.codex_auth_path);
616 expand_tilde_opt(&mut self.cursor.db_path);
617 for account in &mut self.anthropic.accounts {
618 account.credentials_path = expand_tilde(&account.credentials_path);
619 }
620 }
621
622 pub fn is_enabled(&self, id: VendorId) -> bool {
623 match id {
624 VendorId::Anthropic => self.anthropic.enabled,
625 VendorId::AnthropicApi => self.anthropic_api.enabled,
626 VendorId::Openai => self.openai.enabled,
627 VendorId::Zai => self.zai.enabled,
628 VendorId::Openrouter => self.openrouter.enabled,
629 VendorId::Deepseek => self.deepseek.enabled,
630 VendorId::Kimi => self.kimi.enabled,
631 VendorId::Kilo => self.kilo.enabled,
632 VendorId::Novita => self.novita.enabled,
633 VendorId::Moonshot => self.moonshot.enabled,
634 VendorId::Grok => self.grok.enabled,
635 VendorId::Antigravity => self.antigravity.enabled,
636 VendorId::Cursor => self.cursor.enabled,
637 }
638 }
639
640 pub fn enabled_vendors(&self) -> Vec<VendorId> {
641 VendorId::all()
642 .iter()
643 .copied()
644 .filter(|id| self.is_enabled(*id))
645 .collect()
646 }
647
648 pub fn validate(&self) -> Result<()> {
652 if self.context.context_window_tokens == Some(0) {
653 return Err(AppError::Other(
654 "[context] context_window_tokens must be greater than zero".into(),
655 ));
656 }
657 for (model, tokens) in &self.context.model_context_window_tokens {
658 if model.trim().is_empty() {
659 return Err(AppError::Other(
660 "[context] model_context_window_tokens keys must not be empty".into(),
661 ));
662 }
663 if *tokens == 0 {
664 return Err(AppError::Other(format!(
665 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
666 )));
667 }
668 }
669 if let Some(limit) = self.anthropic_api.monthly_limit
670 && (!limit.is_finite() || limit <= 0.0)
671 {
672 return Err(AppError::Other(
673 "[anthropic_api] monthly_limit must be finite and greater than zero; \
674 remove it to show spend without a limit"
675 .into(),
676 ));
677 }
678 let mut labels = HashSet::new();
679 for account in &self.anthropic.accounts {
680 validate_account_label(&account.label)?;
681 if !labels.insert(&account.label) {
682 return Err(AppError::Credentials(format!(
683 "duplicate anthropic account label {:?}",
684 account.label
685 )));
686 }
687 }
688 Ok(())
689 }
690}
691
692pub fn default_path() -> Option<PathBuf> {
693 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
694 Some(proj.config_dir().join("config.toml"))
695}
696
697fn legacy_xdg_path() -> Option<PathBuf> {
702 let home = crate::cache::home_dir().ok()?;
703 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
704}
705
706pub fn resolved_path() -> Option<PathBuf> {
715 let canonical = default_path();
716 if let Some(p) = &canonical
717 && p.exists()
718 {
719 return canonical;
720 }
721 if let Some(legacy) = legacy_xdg_path()
722 && legacy.exists()
723 {
724 return Some(legacy);
725 }
726 canonical
727}
728
729fn expand_tilde(p: &std::path::Path) -> PathBuf {
732 let Some(s) = p.to_str() else {
733 return p.to_path_buf();
734 };
735 let rest = if s == "~" {
736 ""
737 } else if let Some(r) = s.strip_prefix("~/") {
738 r
739 } else {
740 return p.to_path_buf();
741 };
742 match crate::cache::home_dir() {
743 Ok(home) if rest.is_empty() => home,
744 Ok(home) => home.join(rest),
745 Err(_) => p.to_path_buf(),
746 }
747}
748
749fn expand_tilde_opt(p: &mut Option<PathBuf>) {
750 if let Some(inner) = p.as_ref() {
751 *p = Some(expand_tilde(inner));
752 }
753}
754
755pub fn config_path_hint() -> String {
760 resolved_path()
761 .map(|p| p.display().to_string())
762 .unwrap_or_else(|| "config.toml".to_string())
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768 use std::io::Write;
769 use tempfile::NamedTempFile;
770
771 fn write_toml(s: &str) -> NamedTempFile {
772 let mut f = NamedTempFile::new().unwrap();
773 f.write_all(s.as_bytes()).unwrap();
774 f.flush().unwrap();
775 f
776 }
777
778 #[test]
779 fn defaults_enable_only_the_four_core_vendors() {
780 let c = Config::default();
781 assert!(c.is_enabled(VendorId::Anthropic));
782 assert!(c.is_enabled(VendorId::Openai));
783 assert!(c.is_enabled(VendorId::Zai));
784 assert!(c.is_enabled(VendorId::Openrouter));
785 for opt_in in [
786 VendorId::AnthropicApi,
787 VendorId::Deepseek,
788 VendorId::Kimi,
789 VendorId::Kilo,
790 VendorId::Novita,
791 VendorId::Moonshot,
792 VendorId::Grok,
793 VendorId::Cursor,
794 ] {
795 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
796 }
797 assert_eq!(c.enabled_vendors().len(), 4);
798 }
799
800 #[test]
801 fn missing_file_uses_defaults() {
802 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
803 let c = Config::load_from(path).unwrap();
804 assert!(c.is_enabled(VendorId::Anthropic));
805 }
806
807 #[test]
808 fn parses_full_config() {
809 let f = write_toml(
810 r#"
811 [anthropic]
812 enabled = true
813
814 [openai]
815 enabled = false
816 admin_key_env = "MY_ADMIN_KEY"
817
818 [zai]
819 enabled = true
820 api_key_env = "MY_ZAI"
821 plan_tier = "pro"
822
823 [openrouter]
824 enabled = false
825 "#,
826 );
827 let c = Config::load_from(f.path()).unwrap();
828 assert!(c.is_enabled(VendorId::Anthropic));
829 assert!(!c.is_enabled(VendorId::Openai));
830 assert!(c.is_enabled(VendorId::Zai));
831 assert!(!c.is_enabled(VendorId::Openrouter));
832 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
833 assert_eq!(c.zai.api_key_env, "MY_ZAI");
834 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
835 }
836
837 #[test]
838 fn partial_config_falls_back_to_defaults() {
839 let f = write_toml(
840 r#"[openai]
841enabled = false
842"#,
843 );
844 let c = Config::load_from(f.path()).unwrap();
845 assert!(!c.is_enabled(VendorId::Openai));
846 assert!(c.is_enabled(VendorId::Anthropic));
848 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
849 }
850
851 #[test]
852 fn malformed_toml_returns_error() {
853 let f = write_toml("this is not = = valid");
854 assert!(Config::load_from(f.path()).is_err());
855 }
856
857 #[test]
858 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
859 for value in ["0", "-1", "inf", "nan"] {
860 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
861 let error = Config::load_from(file.path()).unwrap_err().to_string();
862 assert!(error.contains("monthly_limit"), "value {value}: {error}");
863 }
864
865 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
866 assert_eq!(
867 Config::load_from(file.path())
868 .unwrap()
869 .anthropic_api
870 .monthly_limit,
871 Some(1000.0)
872 );
873 }
874
875 #[test]
876 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
877 let defaults = Config::default();
878 assert!(!defaults.context.enabled);
879 assert_eq!(
880 defaults.context.window_tokens_for(Some("claude-test")),
881 None
882 );
883
884 let file = write_toml(
885 r#"
886 [context]
887 enabled = true
888 context_window_tokens = 200000
889
890 [context.model_context_window_tokens]
891 claude-opus-1m = 1000000
892 "claude exact id" = 300000
893 "#,
894 );
895 let config = Config::load_from(file.path()).unwrap();
896 assert!(config.context.enabled);
897 assert_eq!(
898 config.context.window_tokens_for(Some("claude-opus-1m")),
899 Some(1_000_000)
900 );
901 assert_eq!(
902 config.context.window_tokens_for(Some("claude exact id")),
903 Some(300_000)
904 );
905 assert_eq!(
906 config.context.window_tokens_for(Some("another-model")),
907 Some(200_000)
908 );
909 }
910
911 #[test]
912 fn context_layout_defaults_to_full_and_parses_each_variant() {
913 assert_eq!(Config::default().context.layout, ContextLayout::Full);
914 for (text, want) in [
915 ("full", ContextLayout::Full),
916 ("split", ContextLayout::Split),
917 ("bottom", ContextLayout::Bottom),
918 ] {
919 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
920 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
921 }
922 let file = write_toml("[context]\nlayout = \"floating\"\n");
923 assert!(
924 Config::load_from(file.path()).is_err(),
925 "an unknown layout must be rejected, not silently defaulted"
926 );
927 }
928
929 #[test]
930 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
931 for source in [
932 "[context]\ncontext_window_tokens = 0\n",
933 "[context.model_context_window_tokens]\nclaude = 0\n",
934 "[context.model_context_window_tokens]\n\" \" = 200000\n",
935 ] {
936 let file = write_toml(source);
937 let error = Config::load_from(file.path()).unwrap_err().to_string();
938 assert!(error.contains("context"), "{error}");
939 }
940 }
941
942 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
944 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
945 M.lock().unwrap_or_else(|p| p.into_inner())
946 }
947
948 #[test]
949 fn resolve_api_key_prefers_env_over_inline() {
950 let _g = env_guard();
951 let var = "AI_USAGEBAR_TEST_ENV_WINS";
953 unsafe { std::env::set_var(var, "from-env") };
955 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
956 unsafe { std::env::remove_var(var) };
957 assert_eq!(got, "from-env");
958 }
959
960 #[test]
961 fn resolve_api_key_falls_back_to_inline() {
962 let _g = env_guard();
963 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
964 unsafe { std::env::remove_var(var) };
965 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
966 assert_eq!(got, "inline-key");
967 }
968
969 #[test]
970 fn resolve_api_key_errors_when_both_missing() {
971 let _g = env_guard();
972 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
973 unsafe { std::env::remove_var(var) };
974 let err = resolve_api_key("Zai", var, None).unwrap_err();
975 match err {
976 crate::error::AppError::Credentials(msg) => {
977 assert!(
978 msg.contains("api_key"),
979 "error should suggest config field: {msg}"
980 );
981 }
982 other => panic!("expected Credentials error, got {other:?}"),
983 }
984 }
985
986 #[test]
987 fn config_path_hint_ends_with_config_toml() {
988 assert!(config_path_hint().ends_with("config.toml"));
991 }
992
993 #[test]
994 fn resolve_api_key_treats_empty_env_as_unset() {
995 let _g = env_guard();
996 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
997 unsafe { std::env::set_var(var, "") };
998 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
999 unsafe { std::env::remove_var(var) };
1000 assert_eq!(got, "inline");
1001 }
1002
1003 #[test]
1004 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
1005 let _g = env_guard();
1006 let bad = "sk-kimi-very-real-looking-pasted-secret";
1008 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
1009 let msg = err.to_string();
1010 assert!(
1011 msg.contains("invalid") && msg.contains("api_key_env"),
1012 "error should explain misconfiguration: {msg}"
1013 );
1014 assert!(
1015 !msg.contains(bad),
1016 "error must not echo the misconfigured value: {msg}"
1017 );
1018 assert!(msg.contains("valid environment variable name"));
1019 assert!(
1020 msg.contains("[kimi]"),
1021 "error should point at the lowercase TOML section: {msg}"
1022 );
1023 }
1024
1025 #[test]
1026 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
1027 let _g = env_guard();
1028 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
1029 assert_eq!(got, "inline-key");
1030 }
1031
1032 #[test]
1033 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
1034 let _g = env_guard();
1035 let pasted_secret = "sk_pasted_secret";
1038 unsafe { std::env::remove_var(pasted_secret) };
1039 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
1040 assert!(
1041 !err.to_string().contains(pasted_secret),
1042 "error must not echo configured api_key_env values"
1043 );
1044 }
1045
1046 #[test]
1047 fn is_valid_env_var_name_rules() {
1048 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
1050 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
1051 }
1052 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
1054 assert!(
1055 !is_valid_env_var_name(invalid),
1056 "{invalid} should be invalid"
1057 );
1058 }
1059 }
1060
1061 #[test]
1062 fn config_parses_with_inline_api_key_and_primary() {
1063 let f = write_toml(
1064 r#"
1065 [ui]
1066 primary = "openrouter"
1067
1068 [zai]
1069 enabled = true
1070 api_key_env = "MY_ZAI"
1071 api_key = "sk-zai-inline"
1072
1073 [openrouter]
1074 enabled = true
1075 api_key = "sk-or-inline"
1076 "#,
1077 );
1078 let c = Config::load_from(f.path()).unwrap();
1079 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
1080 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
1081 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
1082 }
1083
1084 #[test]
1085 fn enabled_vendors_preserves_canonical_order() {
1086 let c = Config::default();
1089 assert_eq!(
1090 c.enabled_vendors(),
1091 vec![
1092 VendorId::Anthropic,
1093 VendorId::Openai,
1094 VendorId::Zai,
1095 VendorId::Openrouter,
1096 ]
1097 );
1098 }
1099
1100 #[test]
1101 fn deepseek_appears_when_enabled() {
1102 let f = write_toml(
1103 r#"
1104 [deepseek]
1105 enabled = true
1106 api_key = "sk-test"
1107 "#,
1108 );
1109 let c = Config::load_from(f.path()).unwrap();
1110 assert!(c.is_enabled(VendorId::Deepseek));
1111 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
1112 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
1113 }
1114
1115 #[test]
1116 fn tilde_paths_are_expanded_on_load() {
1117 let f = write_toml(
1121 r#"
1122 [context]
1123 projects_path = "~/.claude/projects"
1124
1125 [anthropic]
1126 credentials_path = "~/.claude/.credentials.json"
1127
1128 [[anthropic.accounts]]
1129 label = "work"
1130 credentials_path = "~/work.json"
1131 "#,
1132 );
1133 let c = Config::load_from(f.path()).unwrap();
1134 let home = crate::cache::home_dir().unwrap();
1135
1136 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
1137 let got = c.anthropic.credentials_path.unwrap();
1138 assert_eq!(got, home.join(".claude/.credentials.json"));
1139 assert!(!got.to_string_lossy().contains('~'));
1140 assert_eq!(
1141 c.anthropic.accounts[0].credentials_path,
1142 home.join("work.json")
1143 );
1144 }
1145
1146 #[test]
1147 fn absolute_and_relative_paths_are_left_alone() {
1148 let f = write_toml(
1149 r#"
1150 [anthropic]
1151 credentials_path = "/etc/creds.json"
1152 "#,
1153 );
1154 let c = Config::load_from(f.path()).unwrap();
1155 assert_eq!(
1156 c.anthropic.credentials_path.unwrap(),
1157 std::path::Path::new("/etc/creds.json")
1158 );
1159
1160 let f2 = write_toml(
1162 r#"
1163 [anthropic]
1164 credentials_path = "~someone/creds.json"
1165 "#,
1166 );
1167 let c2 = Config::load_from(f2.path()).unwrap();
1168 assert_eq!(
1169 c2.anthropic.credentials_path.unwrap(),
1170 std::path::Path::new("~someone/creds.json")
1171 );
1172 }
1173
1174 #[test]
1175 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
1176 let p = resolved_path().expect("a config path must resolve");
1179 assert!(p.ends_with("config.toml"));
1180 let canonical = default_path().unwrap();
1181 let legacy = legacy_xdg_path().unwrap();
1182 assert!(
1183 p == canonical || p == legacy,
1184 "resolved to an unexpected location: {}",
1185 p.display()
1186 );
1187 }
1188
1189 #[test]
1190 fn misspelled_section_is_rejected_not_ignored() {
1191 let f = write_toml(
1194 r#"
1195 [openrouer]
1196 enabled = true
1197 api_key = "sk-or-v1-typo"
1198 "#,
1199 );
1200 let err = Config::load_from(f.path()).unwrap_err().to_string();
1201 assert!(
1202 err.contains("openrouer"),
1203 "error should name the typo: {err}"
1204 );
1205 }
1206
1207 #[test]
1208 fn invalid_toml_is_an_error_not_silent_defaults() {
1209 let f = write_toml("[zai\nenabled = true\n");
1210 assert!(Config::load_from(f.path()).is_err());
1211 }
1212
1213 #[test]
1214 fn a_missing_file_is_still_just_defaults() {
1215 let dir = tempfile::tempdir().unwrap();
1218 let missing = dir.path().join("nope").join("config.toml");
1219 let c = Config::load_from(&missing).unwrap();
1220 assert!(c.is_enabled(VendorId::Anthropic));
1221 }
1222
1223 #[test]
1224 fn kimi_appears_when_enabled() {
1225 let f = write_toml(
1226 r#"
1227 [kimi]
1228 enabled = true
1229 api_key = "sk-test"
1230 "#,
1231 );
1232 let c = Config::load_from(f.path()).unwrap();
1233 assert!(c.is_enabled(VendorId::Kimi));
1234 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
1235 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
1236 }
1237
1238 #[test]
1239 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
1240 let f = write_toml(
1241 r#"
1242 [deepseek]
1243 enabled = true
1244 api_key = "sk-ds"
1245
1246 [kimi]
1247 enabled = true
1248 api_key = "sk-kimi"
1249 "#,
1250 );
1251 let c = Config::load_from(f.path()).unwrap();
1252 assert_eq!(
1253 c.enabled_vendors(),
1254 vec![
1255 VendorId::Anthropic,
1256 VendorId::Openai,
1257 VendorId::Zai,
1258 VendorId::Openrouter,
1259 VendorId::Deepseek,
1260 VendorId::Kimi,
1261 ]
1262 );
1263 }
1264
1265 #[test]
1266 fn parses_anthropic_accounts_and_looks_them_up() {
1267 let f = write_toml(
1268 r#"
1269 [anthropic]
1270 enabled = true
1271
1272 [[anthropic.accounts]]
1273 label = "personal"
1274 credentials_path = "/creds/personal.json"
1275
1276 [[anthropic.accounts]]
1277 label = "work"
1278 credentials_path = "/creds/work.json"
1279 "#,
1280 );
1281 let c = Config::load_from(f.path()).unwrap();
1282 assert_eq!(c.anthropic.accounts.len(), 2);
1283 let work = c.anthropic.account("work").unwrap();
1284 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
1285 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
1287 assert!(err.contains("missing") && err.contains("work"), "{err}");
1288 }
1289
1290 #[test]
1291 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
1292 let f = write_toml(
1293 r#"
1294 [[anthropic.accounts]]
1295 label = "work"
1296 credentials_path = "/creds/work-one.json"
1297
1298 [[anthropic.accounts]]
1299 label = "work"
1300 credentials_path = "/creds/work-two.json"
1301 "#,
1302 );
1303 let err = Config::load_from(f.path()).unwrap_err().to_string();
1304 assert!(
1305 err.contains("duplicate anthropic account label \"work\""),
1306 "{err}"
1307 );
1308 }
1309
1310 #[test]
1311 fn account_label_rejects_path_like_names() {
1312 let cfg = AnthropicConfig::default();
1313 for bad in ["", ".", "..", "a/b", r"a\b", "usage.json"] {
1314 let err = cfg.account(bad).unwrap_err();
1315 assert!(
1316 format!("{err:?}").contains("invalid anthropic account label"),
1317 "{bad:?} should be rejected as a label"
1318 );
1319 }
1320 }
1321
1322 #[test]
1323 fn anthropic_accounts_default_to_empty() {
1324 assert!(Config::default().anthropic.accounts.is_empty());
1327 assert!(Config::default().anthropic.accounts_dir.is_none());
1328 }
1329
1330 fn seed_account_dir(root: &std::path::Path, label: &str) {
1336 let dir = root.join(label);
1337 std::fs::create_dir_all(&dir).unwrap();
1338 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1339 }
1340
1341 #[test]
1342 fn discovers_account_dirs_in_claude_config_dir_layout() {
1343 let td = tempfile::tempdir().unwrap();
1344 seed_account_dir(td.path(), "work");
1345 seed_account_dir(td.path(), "personal");
1346 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
1349 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
1351
1352 let cfg = AnthropicConfig {
1353 accounts_dir: Some(td.path().to_path_buf()),
1354 ..Default::default()
1355 };
1356 let all = cfg.all_accounts();
1357 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
1358 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
1359 assert_eq!(
1360 all[2].credentials_path,
1361 td.path().join("work").join(".credentials.json")
1362 );
1363 }
1364
1365 #[test]
1366 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
1367 let td = tempfile::tempdir().unwrap();
1368 seed_account_dir(td.path(), "work");
1369 let cfg = AnthropicConfig {
1370 accounts: vec![AnthropicAccount {
1371 label: "work".into(),
1372 credentials_path: "/explicit/work.json".into(),
1373 }],
1374 accounts_dir: Some(td.path().to_path_buf()),
1375 ..Default::default()
1376 };
1377 let all = cfg.all_accounts();
1378 assert_eq!(all.len(), 1, "no duplicate label");
1379 assert_eq!(
1380 all[0].credentials_path,
1381 std::path::Path::new("/explicit/work.json"),
1382 "explicit entry wins"
1383 );
1384 seed_account_dir(td.path(), "other");
1386 assert_eq!(cfg.account("other").unwrap().label, "other");
1387 }
1388
1389 #[test]
1390 fn missing_accounts_dir_is_silently_empty_not_an_error() {
1391 let cfg = AnthropicConfig {
1392 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
1393 ..Default::default()
1394 };
1395 assert!(cfg.all_accounts().is_empty());
1396 }
1397
1398 #[test]
1399 fn accounts_dir_is_tilde_expanded_on_load() {
1400 let f = write_toml(
1401 r#"
1402 [anthropic]
1403 accounts_dir = "~/.config/ai-usagebar/accounts"
1404 "#,
1405 );
1406 let c = Config::load_from(f.path()).unwrap();
1407 let home = crate::cache::home_dir().unwrap();
1408 assert_eq!(
1409 c.anthropic.accounts_dir,
1410 Some(home.join(".config/ai-usagebar/accounts"))
1411 );
1412 }
1413
1414 fn config_example() -> PathBuf {
1418 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
1419 }
1420
1421 #[test]
1422 fn shipped_example_parses_as_a_real_config() {
1423 let c = Config::load_from(&config_example()).unwrap();
1428 assert!(!c.context.enabled);
1429 assert!(c.is_enabled(VendorId::Anthropic));
1430 assert!(c.is_enabled(VendorId::Openai));
1431 assert!(!c.is_enabled(VendorId::AnthropicApi));
1432 assert!(!c.is_enabled(VendorId::Deepseek));
1433 assert!(!c.is_enabled(VendorId::Kimi));
1434 assert!(!c.is_enabled(VendorId::Kilo));
1435 assert!(!c.is_enabled(VendorId::Novita));
1436 assert!(!c.is_enabled(VendorId::Moonshot));
1437 assert!(!c.is_enabled(VendorId::Grok));
1438 assert!(!c.is_enabled(VendorId::Cursor));
1439 }
1440
1441 #[test]
1442 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
1443 let text = std::fs::read_to_string(config_example()).unwrap();
1448 let live: Vec<&str> = text
1449 .lines()
1450 .map(str::trim)
1451 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
1452 .collect();
1453 assert!(
1454 live.is_empty(),
1455 "admin_key_env must stay commented out while it is inert: {live:?}"
1456 );
1457 assert!(
1460 text.contains("admin_key_env") && text.contains("RESERVED"),
1461 "the example should keep describing admin_key_env as reserved"
1462 );
1463 }
1464
1465 #[test]
1466 fn admin_key_env_is_accepted_but_changes_nothing() {
1467 let f = write_toml(
1471 r#"
1472 [openai]
1473 admin_key_env = "SOME_ADMIN_KEY"
1474 "#,
1475 );
1476 let c = Config::load_from(f.path()).unwrap();
1477 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
1478 let default = OpenAiConfig::default();
1480 assert_eq!(c.openai.enabled, default.enabled);
1481 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
1482 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
1483 }
1484
1485 #[test]
1486 fn config_example_documents_every_vendor_without_secrets() {
1487 let raw = std::fs::read_to_string(config_example()).unwrap();
1488 let cfg = Config::load_from(&config_example()).unwrap();
1489 for id in VendorId::all() {
1492 let section = id.slug();
1493 assert!(
1494 raw.contains(&format!("[{section}]")),
1495 "config.example.toml has no [{section}] section"
1496 );
1497 }
1498
1499 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
1502 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
1503 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
1504 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
1505 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
1506 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
1507 }
1508
1509 #[test]
1510 fn cursor_db_path_is_tilde_expanded() {
1511 let f = write_toml(
1512 r#"
1513 [cursor]
1514 db_path = "~/cursor-state.vscdb"
1515 "#,
1516 );
1517 let c = Config::load_from(f.path()).unwrap();
1518 let home = crate::cache::home_dir().unwrap();
1519 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
1520 }
1521
1522 #[test]
1523 fn cursor_appears_when_enabled() {
1524 let f = write_toml(
1525 r#"
1526 [cursor]
1527 enabled = true
1528 "#,
1529 );
1530 let c = Config::load_from(f.path()).unwrap();
1531 assert!(c.is_enabled(VendorId::Cursor));
1532 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
1533 }
1534}