1use std::collections::{BTreeMap, HashSet};
18use std::path::{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 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
257 let bad = label.is_empty()
258 || label == "."
259 || label == ".."
260 || label.contains(['/', '\\'])
261 || label.chars().any(char::is_control)
262 || RESERVED.contains(&label);
263 if bad {
264 return Err(AppError::Credentials(format!(
265 "invalid anthropic account label {label:?}: must be a non-empty name \
266 without path separators, control characters, or reserved cache names"
267 )));
268 }
269 Ok(())
270}
271
272fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
280 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
281 return Vec::new();
282 };
283 let mut found: Vec<AnthropicAccount> = entries
284 .flatten()
285 .filter_map(|entry| {
286 let path = entry.path();
287 if !path.is_dir() {
288 return None;
289 }
290 let label = path.file_name()?.to_str()?.to_string();
291 validate_account_label(&label).ok()?;
292 Some(AnthropicAccount {
293 label,
294 credentials_path: path.join(".credentials.json"),
295 })
296 })
297 .collect();
298 found.sort_by(|a, b| a.label.cmp(&b.label));
299 found
300}
301
302pub fn tildify(path: &Path, home: &Path) -> String {
306 path.strip_prefix(home)
307 .map(|rest| {
308 let rendered = rest.display().to_string();
309 #[cfg(windows)]
312 let rendered = rendered.replace('\\', "/");
313 format!("~/{rendered}")
314 })
315 .unwrap_or_else(|_| path.display().to_string())
316}
317
318pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
323 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
324 base.join("accounts").join(label).join(".credentials.json")
325}
326
327pub fn add_anthropic_account_to_doc(
333 doc: &mut toml_edit::DocumentMut,
334 label: &str,
335 credentials_path: &str,
336) -> Result<()> {
337 use toml_edit::{Item, Table, value};
338
339 validate_account_label(label)?;
340
341 let anthropic = doc
342 .entry("anthropic")
343 .or_insert_with(|| Item::Table(Table::new()));
344 let anthropic = anthropic
345 .as_table_mut()
346 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
347
348 let accounts = anthropic
349 .entry("accounts")
350 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
351 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
352 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
353 })?;
354
355 let exists = accounts
356 .iter()
357 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
358 if exists {
359 return Err(AppError::Credentials(format!(
360 "anthropic account {label:?} already exists in config.toml"
361 )));
362 }
363
364 let mut table = Table::new();
365 table["label"] = value(label);
366 table["credentials_path"] = value(credentials_path);
367 accounts.push(table);
368 Ok(())
369}
370
371#[derive(Debug, Clone, Deserialize, Serialize)]
372#[serde(default)]
373pub struct OpenAiConfig {
374 pub enabled: bool,
375 pub codex_auth_path: Option<PathBuf>,
377 pub admin_key_env: String,
385}
386
387impl Default for OpenAiConfig {
388 fn default() -> Self {
389 Self {
390 enabled: true,
391 codex_auth_path: None,
392 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
393 }
394 }
395}
396
397#[derive(Debug, Clone, Deserialize, Serialize)]
398#[serde(default)]
399pub struct ZaiConfig {
400 pub enabled: bool,
401 pub api_key_env: String,
403 pub api_key: Option<String>,
406 pub plan_tier: Option<String>,
408}
409
410impl Default for ZaiConfig {
411 fn default() -> Self {
412 Self {
413 enabled: true,
414 api_key_env: "ZAI_API_KEY".to_string(),
415 api_key: None,
416 plan_tier: None,
417 }
418 }
419}
420
421#[derive(Debug, Clone, Deserialize, Serialize)]
422#[serde(default)]
423pub struct OpenRouterConfig {
424 pub enabled: bool,
425 pub api_key_env: String,
426 pub api_key: Option<String>,
427}
428
429impl Default for OpenRouterConfig {
430 fn default() -> Self {
431 Self {
432 enabled: true,
433 api_key_env: "OPENROUTER_API_KEY".to_string(),
434 api_key: None,
435 }
436 }
437}
438
439#[derive(Debug, Clone, Deserialize, Serialize)]
440#[serde(default)]
441pub struct DeepseekConfig {
442 pub enabled: bool,
443 pub api_key_env: String,
444 pub api_key: Option<String>,
445}
446
447impl Default for DeepseekConfig {
448 fn default() -> Self {
449 Self {
450 enabled: false,
451 api_key_env: "DEEPSEEK_API_KEY".to_string(),
452 api_key: None,
453 }
454 }
455}
456
457#[derive(Debug, Clone, Deserialize, Serialize)]
458#[serde(default)]
459pub struct KimiConfig {
460 pub enabled: bool,
461 pub api_key_env: String,
462 pub api_key: Option<String>,
463}
464
465impl Default for KimiConfig {
466 fn default() -> Self {
467 Self {
468 enabled: false,
469 api_key_env: "KIMI_API_KEY".to_string(),
470 api_key: None,
471 }
472 }
473}
474
475#[derive(Debug, Clone, Deserialize, Serialize)]
476#[serde(default)]
477pub struct KiloConfig {
478 pub enabled: bool,
479 pub api_key_env: String,
480 pub api_key: Option<String>,
481 pub organization_id: Option<String>,
484}
485
486impl Default for KiloConfig {
487 fn default() -> Self {
488 Self {
491 enabled: false,
492 api_key_env: "KILO_API_KEY".to_string(),
493 api_key: None,
494 organization_id: None,
495 }
496 }
497}
498
499#[derive(Debug, Clone, Deserialize, Serialize)]
500#[serde(default)]
501pub struct NovitaConfig {
502 pub enabled: bool,
503 pub api_key_env: String,
504 pub api_key: Option<String>,
505}
506
507impl Default for NovitaConfig {
508 fn default() -> Self {
509 Self {
511 enabled: false,
512 api_key_env: "NOVITA_API_KEY".to_string(),
513 api_key: None,
514 }
515 }
516}
517
518#[derive(Debug, Clone, Deserialize, Serialize)]
519#[serde(default)]
520pub struct MoonshotConfig {
521 pub enabled: bool,
522 pub api_key_env: String,
523 pub api_key: Option<String>,
524 pub region: String,
526}
527
528impl Default for MoonshotConfig {
529 fn default() -> Self {
530 Self {
532 enabled: false,
533 api_key_env: "MOONSHOT_API_KEY".to_string(),
534 api_key: None,
535 region: "global".to_string(),
536 }
537 }
538}
539
540#[derive(Debug, Clone, Deserialize, Serialize)]
541#[serde(default)]
542pub struct GrokConfig {
543 pub enabled: bool,
544 pub api_key_env: String,
546 pub api_key: Option<String>,
547 pub team_id: Option<String>,
550}
551
552impl Default for GrokConfig {
553 fn default() -> Self {
554 Self {
556 enabled: false,
557 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
558 api_key: None,
559 team_id: None,
560 }
561 }
562}
563
564#[derive(Debug, Clone, Default, Deserialize, Serialize)]
567#[serde(default)]
568pub struct AntigravityConfig {
569 pub enabled: bool,
570}
571
572#[derive(Debug, Clone, Default, Deserialize, Serialize)]
582#[serde(default)]
583pub struct CursorConfig {
584 pub enabled: bool,
585 pub db_path: Option<PathBuf>,
589}
590
591#[derive(Debug, Clone, Deserialize, Serialize)]
592#[serde(default)]
593pub struct AnthropicApiConfig {
594 pub enabled: bool,
595 pub api_key_env: String,
598 pub api_key: Option<String>,
599 pub monthly_limit: Option<f64>,
602}
603
604impl Default for AnthropicApiConfig {
605 fn default() -> Self {
606 Self {
608 enabled: false,
609 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
610 api_key: None,
611 monthly_limit: None,
612 }
613 }
614}
615
616pub fn resolve_api_key(
619 vendor_label: &str,
620 env_var_name: &str,
621 inline: Option<&str>,
622) -> crate::error::Result<String> {
623 let valid_env_name = is_valid_env_var_name(env_var_name);
624 if valid_env_name
625 && let Ok(v) = std::env::var(env_var_name)
626 && !v.is_empty()
627 {
628 return Ok(v);
629 }
630 if let Some(v) = inline
631 && !v.is_empty()
632 {
633 return Ok(v.to_string());
634 }
635 let advice = if valid_env_name {
636 "set an API key in a valid environment variable or set `api_key`"
637 } else {
638 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
639 };
640 Err(crate::error::AppError::Credentials(format!(
641 "{vendor_label}: no API key. Either {advice} under [{}] in {}.",
642 vendor_label.to_lowercase(),
643 config_path_hint()
644 )))
645}
646
647fn is_valid_env_var_name(name: &str) -> bool {
648 let mut chars = name.chars();
649 let Some(first) = chars.next() else {
650 return false;
651 };
652 (first.is_ascii_alphabetic() || first == '_')
653 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
654}
655
656impl Config {
657 pub fn load() -> Result<Self> {
660 let Some(path) = resolved_path() else {
661 return Ok(Self::default());
662 };
663 Self::load_from(&path)
664 }
665
666 pub fn load_from(path: &std::path::Path) -> Result<Self> {
667 match std::fs::read_to_string(path) {
668 Ok(s) => {
669 let mut config: Self = toml::from_str(&s)?;
670 config.expand_paths();
674 config.validate()?;
675 Ok(config)
676 }
677 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
678 Err(e) => Err(AppError::io_at(path, e)),
679 }
680 }
681
682 fn expand_paths(&mut self) {
683 expand_tilde_opt(&mut self.context.projects_path);
684 expand_tilde_opt(&mut self.anthropic.credentials_path);
685 expand_tilde_opt(&mut self.anthropic.accounts_dir);
686 expand_tilde_opt(&mut self.openai.codex_auth_path);
687 expand_tilde_opt(&mut self.cursor.db_path);
688 for account in &mut self.anthropic.accounts {
689 account.credentials_path = expand_tilde(&account.credentials_path);
690 }
691 }
692
693 pub fn is_enabled(&self, id: VendorId) -> bool {
694 match id {
695 VendorId::Anthropic => self.anthropic.enabled,
696 VendorId::AnthropicApi => self.anthropic_api.enabled,
697 VendorId::Openai => self.openai.enabled,
698 VendorId::Zai => self.zai.enabled,
699 VendorId::Openrouter => self.openrouter.enabled,
700 VendorId::Deepseek => self.deepseek.enabled,
701 VendorId::Kimi => self.kimi.enabled,
702 VendorId::Kilo => self.kilo.enabled,
703 VendorId::Novita => self.novita.enabled,
704 VendorId::Moonshot => self.moonshot.enabled,
705 VendorId::Grok => self.grok.enabled,
706 VendorId::Antigravity => self.antigravity.enabled,
707 VendorId::Cursor => self.cursor.enabled,
708 }
709 }
710
711 pub fn enabled_vendors(&self) -> Vec<VendorId> {
712 VendorId::all()
713 .iter()
714 .copied()
715 .filter(|id| self.is_enabled(*id))
716 .collect()
717 }
718
719 pub fn validate(&self) -> Result<()> {
723 if self.context.context_window_tokens == Some(0) {
724 return Err(AppError::Other(
725 "[context] context_window_tokens must be greater than zero".into(),
726 ));
727 }
728 for (model, tokens) in &self.context.model_context_window_tokens {
729 if model.trim().is_empty() {
730 return Err(AppError::Other(
731 "[context] model_context_window_tokens keys must not be empty".into(),
732 ));
733 }
734 if *tokens == 0 {
735 return Err(AppError::Other(format!(
736 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
737 )));
738 }
739 }
740 if let Some(limit) = self.anthropic_api.monthly_limit
741 && (!limit.is_finite() || limit <= 0.0)
742 {
743 return Err(AppError::Other(
744 "[anthropic_api] monthly_limit must be finite and greater than zero; \
745 remove it to show spend without a limit"
746 .into(),
747 ));
748 }
749 let mut labels = HashSet::new();
750 for account in &self.anthropic.accounts {
751 validate_account_label(&account.label)?;
752 if !labels.insert(&account.label) {
753 return Err(AppError::Credentials(format!(
754 "duplicate anthropic account label {:?}",
755 account.label
756 )));
757 }
758 }
759 Ok(())
760 }
761}
762
763pub fn default_path() -> Option<PathBuf> {
764 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
765 Some(proj.config_dir().join("config.toml"))
766}
767
768fn legacy_xdg_path() -> Option<PathBuf> {
773 let home = crate::cache::home_dir().ok()?;
774 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
775}
776
777pub fn resolved_path() -> Option<PathBuf> {
786 let canonical = default_path();
787 if let Some(p) = &canonical
788 && p.exists()
789 {
790 return canonical;
791 }
792 if let Some(legacy) = legacy_xdg_path()
793 && legacy.exists()
794 {
795 return Some(legacy);
796 }
797 canonical
798}
799
800fn expand_tilde(p: &std::path::Path) -> PathBuf {
803 let Some(s) = p.to_str() else {
804 return p.to_path_buf();
805 };
806 let rest = if s == "~" {
807 ""
808 } else if let Some(r) = s.strip_prefix("~/") {
809 r
810 } else {
811 return p.to_path_buf();
812 };
813 match crate::cache::home_dir() {
814 Ok(home) if rest.is_empty() => home,
815 Ok(home) => home.join(rest),
816 Err(_) => p.to_path_buf(),
817 }
818}
819
820fn expand_tilde_opt(p: &mut Option<PathBuf>) {
821 if let Some(inner) = p.as_ref() {
822 *p = Some(expand_tilde(inner));
823 }
824}
825
826pub fn config_path_hint() -> String {
831 resolved_path()
832 .map(|p| p.display().to_string())
833 .unwrap_or_else(|| "config.toml".to_string())
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839 use std::io::Write;
840 use tempfile::NamedTempFile;
841
842 fn write_toml(s: &str) -> NamedTempFile {
843 let mut f = NamedTempFile::new().unwrap();
844 f.write_all(s.as_bytes()).unwrap();
845 f.flush().unwrap();
846 f
847 }
848
849 #[test]
850 fn defaults_enable_only_the_four_core_vendors() {
851 let c = Config::default();
852 assert!(c.is_enabled(VendorId::Anthropic));
853 assert!(c.is_enabled(VendorId::Openai));
854 assert!(c.is_enabled(VendorId::Zai));
855 assert!(c.is_enabled(VendorId::Openrouter));
856 for opt_in in [
857 VendorId::AnthropicApi,
858 VendorId::Deepseek,
859 VendorId::Kimi,
860 VendorId::Kilo,
861 VendorId::Novita,
862 VendorId::Moonshot,
863 VendorId::Grok,
864 VendorId::Cursor,
865 ] {
866 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
867 }
868 assert_eq!(c.enabled_vendors().len(), 4);
869 }
870
871 #[test]
872 fn missing_file_uses_defaults() {
873 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
874 let c = Config::load_from(path).unwrap();
875 assert!(c.is_enabled(VendorId::Anthropic));
876 }
877
878 #[test]
879 fn parses_full_config() {
880 let f = write_toml(
881 r#"
882 [anthropic]
883 enabled = true
884
885 [openai]
886 enabled = false
887 admin_key_env = "MY_ADMIN_KEY"
888
889 [zai]
890 enabled = true
891 api_key_env = "MY_ZAI"
892 plan_tier = "pro"
893
894 [openrouter]
895 enabled = false
896 "#,
897 );
898 let c = Config::load_from(f.path()).unwrap();
899 assert!(c.is_enabled(VendorId::Anthropic));
900 assert!(!c.is_enabled(VendorId::Openai));
901 assert!(c.is_enabled(VendorId::Zai));
902 assert!(!c.is_enabled(VendorId::Openrouter));
903 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
904 assert_eq!(c.zai.api_key_env, "MY_ZAI");
905 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
906 }
907
908 #[test]
909 fn partial_config_falls_back_to_defaults() {
910 let f = write_toml(
911 r#"[openai]
912enabled = false
913"#,
914 );
915 let c = Config::load_from(f.path()).unwrap();
916 assert!(!c.is_enabled(VendorId::Openai));
917 assert!(c.is_enabled(VendorId::Anthropic));
919 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
920 }
921
922 #[test]
923 fn malformed_toml_returns_error() {
924 let f = write_toml("this is not = = valid");
925 assert!(Config::load_from(f.path()).is_err());
926 }
927
928 #[test]
929 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
930 for value in ["0", "-1", "inf", "nan"] {
931 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
932 let error = Config::load_from(file.path()).unwrap_err().to_string();
933 assert!(error.contains("monthly_limit"), "value {value}: {error}");
934 }
935
936 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
937 assert_eq!(
938 Config::load_from(file.path())
939 .unwrap()
940 .anthropic_api
941 .monthly_limit,
942 Some(1000.0)
943 );
944 }
945
946 #[test]
947 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
948 let defaults = Config::default();
949 assert!(!defaults.context.enabled);
950 assert_eq!(
951 defaults.context.window_tokens_for(Some("claude-test")),
952 None
953 );
954
955 let file = write_toml(
956 r#"
957 [context]
958 enabled = true
959 context_window_tokens = 200000
960
961 [context.model_context_window_tokens]
962 claude-opus-1m = 1000000
963 "claude exact id" = 300000
964 "#,
965 );
966 let config = Config::load_from(file.path()).unwrap();
967 assert!(config.context.enabled);
968 assert_eq!(
969 config.context.window_tokens_for(Some("claude-opus-1m")),
970 Some(1_000_000)
971 );
972 assert_eq!(
973 config.context.window_tokens_for(Some("claude exact id")),
974 Some(300_000)
975 );
976 assert_eq!(
977 config.context.window_tokens_for(Some("another-model")),
978 Some(200_000)
979 );
980 }
981
982 #[test]
983 fn context_layout_defaults_to_full_and_parses_each_variant() {
984 assert_eq!(Config::default().context.layout, ContextLayout::Full);
985 for (text, want) in [
986 ("full", ContextLayout::Full),
987 ("split", ContextLayout::Split),
988 ("bottom", ContextLayout::Bottom),
989 ] {
990 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
991 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
992 }
993 let file = write_toml("[context]\nlayout = \"floating\"\n");
994 assert!(
995 Config::load_from(file.path()).is_err(),
996 "an unknown layout must be rejected, not silently defaulted"
997 );
998 }
999
1000 #[test]
1001 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
1002 for source in [
1003 "[context]\ncontext_window_tokens = 0\n",
1004 "[context.model_context_window_tokens]\nclaude = 0\n",
1005 "[context.model_context_window_tokens]\n\" \" = 200000\n",
1006 ] {
1007 let file = write_toml(source);
1008 let error = Config::load_from(file.path()).unwrap_err().to_string();
1009 assert!(error.contains("context"), "{error}");
1010 }
1011 }
1012
1013 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
1015 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
1016 M.lock().unwrap_or_else(|p| p.into_inner())
1017 }
1018
1019 #[test]
1020 fn resolve_api_key_prefers_env_over_inline() {
1021 let _g = env_guard();
1022 let var = "AI_USAGEBAR_TEST_ENV_WINS";
1024 unsafe { std::env::set_var(var, "from-env") };
1026 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
1027 unsafe { std::env::remove_var(var) };
1028 assert_eq!(got, "from-env");
1029 }
1030
1031 #[test]
1032 fn resolve_api_key_falls_back_to_inline() {
1033 let _g = env_guard();
1034 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
1035 unsafe { std::env::remove_var(var) };
1036 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
1037 assert_eq!(got, "inline-key");
1038 }
1039
1040 #[test]
1041 fn resolve_api_key_errors_when_both_missing() {
1042 let _g = env_guard();
1043 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
1044 unsafe { std::env::remove_var(var) };
1045 let err = resolve_api_key("Zai", var, None).unwrap_err();
1046 match err {
1047 crate::error::AppError::Credentials(msg) => {
1048 assert!(
1049 msg.contains("api_key"),
1050 "error should suggest config field: {msg}"
1051 );
1052 }
1053 other => panic!("expected Credentials error, got {other:?}"),
1054 }
1055 }
1056
1057 #[test]
1058 fn config_path_hint_ends_with_config_toml() {
1059 assert!(config_path_hint().ends_with("config.toml"));
1062 }
1063
1064 #[test]
1065 fn resolve_api_key_treats_empty_env_as_unset() {
1066 let _g = env_guard();
1067 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
1068 unsafe { std::env::set_var(var, "") };
1069 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
1070 unsafe { std::env::remove_var(var) };
1071 assert_eq!(got, "inline");
1072 }
1073
1074 #[test]
1075 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
1076 let _g = env_guard();
1077 let bad = "sk-kimi-very-real-looking-pasted-secret";
1079 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
1080 let msg = err.to_string();
1081 assert!(
1082 msg.contains("invalid") && msg.contains("api_key_env"),
1083 "error should explain misconfiguration: {msg}"
1084 );
1085 assert!(
1086 !msg.contains(bad),
1087 "error must not echo the misconfigured value: {msg}"
1088 );
1089 assert!(msg.contains("valid environment variable name"));
1090 assert!(
1091 msg.contains("[kimi]"),
1092 "error should point at the lowercase TOML section: {msg}"
1093 );
1094 }
1095
1096 #[test]
1097 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
1098 let _g = env_guard();
1099 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
1100 assert_eq!(got, "inline-key");
1101 }
1102
1103 #[test]
1104 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
1105 let _g = env_guard();
1106 let pasted_secret = "sk_pasted_secret";
1109 unsafe { std::env::remove_var(pasted_secret) };
1110 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
1111 assert!(
1112 !err.to_string().contains(pasted_secret),
1113 "error must not echo configured api_key_env values"
1114 );
1115 }
1116
1117 #[test]
1118 fn is_valid_env_var_name_rules() {
1119 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
1121 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
1122 }
1123 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
1125 assert!(
1126 !is_valid_env_var_name(invalid),
1127 "{invalid} should be invalid"
1128 );
1129 }
1130 }
1131
1132 #[test]
1133 fn config_parses_with_inline_api_key_and_primary() {
1134 let f = write_toml(
1135 r#"
1136 [ui]
1137 primary = "openrouter"
1138
1139 [zai]
1140 enabled = true
1141 api_key_env = "MY_ZAI"
1142 api_key = "sk-zai-inline"
1143
1144 [openrouter]
1145 enabled = true
1146 api_key = "sk-or-inline"
1147 "#,
1148 );
1149 let c = Config::load_from(f.path()).unwrap();
1150 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
1151 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
1152 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
1153 }
1154
1155 #[test]
1156 fn enabled_vendors_preserves_canonical_order() {
1157 let c = Config::default();
1160 assert_eq!(
1161 c.enabled_vendors(),
1162 vec![
1163 VendorId::Anthropic,
1164 VendorId::Openai,
1165 VendorId::Zai,
1166 VendorId::Openrouter,
1167 ]
1168 );
1169 }
1170
1171 #[test]
1172 fn deepseek_appears_when_enabled() {
1173 let f = write_toml(
1174 r#"
1175 [deepseek]
1176 enabled = true
1177 api_key = "sk-test"
1178 "#,
1179 );
1180 let c = Config::load_from(f.path()).unwrap();
1181 assert!(c.is_enabled(VendorId::Deepseek));
1182 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
1183 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
1184 }
1185
1186 #[test]
1187 fn tilde_paths_are_expanded_on_load() {
1188 let f = write_toml(
1192 r#"
1193 [context]
1194 projects_path = "~/.claude/projects"
1195
1196 [anthropic]
1197 credentials_path = "~/.claude/.credentials.json"
1198
1199 [[anthropic.accounts]]
1200 label = "work"
1201 credentials_path = "~/work.json"
1202 "#,
1203 );
1204 let c = Config::load_from(f.path()).unwrap();
1205 let home = crate::cache::home_dir().unwrap();
1206
1207 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
1208 let got = c.anthropic.credentials_path.unwrap();
1209 assert_eq!(got, home.join(".claude/.credentials.json"));
1210 assert!(!got.to_string_lossy().contains('~'));
1211 assert_eq!(
1212 c.anthropic.accounts[0].credentials_path,
1213 home.join("work.json")
1214 );
1215 }
1216
1217 #[test]
1218 fn absolute_and_relative_paths_are_left_alone() {
1219 let f = write_toml(
1220 r#"
1221 [anthropic]
1222 credentials_path = "/etc/creds.json"
1223 "#,
1224 );
1225 let c = Config::load_from(f.path()).unwrap();
1226 assert_eq!(
1227 c.anthropic.credentials_path.unwrap(),
1228 std::path::Path::new("/etc/creds.json")
1229 );
1230
1231 let f2 = write_toml(
1233 r#"
1234 [anthropic]
1235 credentials_path = "~someone/creds.json"
1236 "#,
1237 );
1238 let c2 = Config::load_from(f2.path()).unwrap();
1239 assert_eq!(
1240 c2.anthropic.credentials_path.unwrap(),
1241 std::path::Path::new("~someone/creds.json")
1242 );
1243 }
1244
1245 #[test]
1246 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
1247 let p = resolved_path().expect("a config path must resolve");
1250 assert!(p.ends_with("config.toml"));
1251 let canonical = default_path().unwrap();
1252 let legacy = legacy_xdg_path().unwrap();
1253 assert!(
1254 p == canonical || p == legacy,
1255 "resolved to an unexpected location: {}",
1256 p.display()
1257 );
1258 }
1259
1260 #[test]
1261 fn misspelled_section_is_rejected_not_ignored() {
1262 let f = write_toml(
1265 r#"
1266 [openrouer]
1267 enabled = true
1268 api_key = "sk-or-v1-typo"
1269 "#,
1270 );
1271 let err = Config::load_from(f.path()).unwrap_err().to_string();
1272 assert!(
1273 err.contains("openrouer"),
1274 "error should name the typo: {err}"
1275 );
1276 }
1277
1278 #[test]
1279 fn invalid_toml_is_an_error_not_silent_defaults() {
1280 let f = write_toml("[zai\nenabled = true\n");
1281 assert!(Config::load_from(f.path()).is_err());
1282 }
1283
1284 #[test]
1285 fn a_missing_file_is_still_just_defaults() {
1286 let dir = tempfile::tempdir().unwrap();
1289 let missing = dir.path().join("nope").join("config.toml");
1290 let c = Config::load_from(&missing).unwrap();
1291 assert!(c.is_enabled(VendorId::Anthropic));
1292 }
1293
1294 #[test]
1295 fn kimi_appears_when_enabled() {
1296 let f = write_toml(
1297 r#"
1298 [kimi]
1299 enabled = true
1300 api_key = "sk-test"
1301 "#,
1302 );
1303 let c = Config::load_from(f.path()).unwrap();
1304 assert!(c.is_enabled(VendorId::Kimi));
1305 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
1306 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
1307 }
1308
1309 #[test]
1310 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
1311 let f = write_toml(
1312 r#"
1313 [deepseek]
1314 enabled = true
1315 api_key = "sk-ds"
1316
1317 [kimi]
1318 enabled = true
1319 api_key = "sk-kimi"
1320 "#,
1321 );
1322 let c = Config::load_from(f.path()).unwrap();
1323 assert_eq!(
1324 c.enabled_vendors(),
1325 vec![
1326 VendorId::Anthropic,
1327 VendorId::Openai,
1328 VendorId::Zai,
1329 VendorId::Openrouter,
1330 VendorId::Deepseek,
1331 VendorId::Kimi,
1332 ]
1333 );
1334 }
1335
1336 #[test]
1337 fn parses_anthropic_accounts_and_looks_them_up() {
1338 let f = write_toml(
1339 r#"
1340 [anthropic]
1341 enabled = true
1342
1343 [[anthropic.accounts]]
1344 label = "personal"
1345 credentials_path = "/creds/personal.json"
1346
1347 [[anthropic.accounts]]
1348 label = "work"
1349 credentials_path = "/creds/work.json"
1350 "#,
1351 );
1352 let c = Config::load_from(f.path()).unwrap();
1353 assert_eq!(c.anthropic.accounts.len(), 2);
1354 let work = c.anthropic.account("work").unwrap();
1355 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
1356 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
1358 assert!(err.contains("missing") && err.contains("work"), "{err}");
1359 }
1360
1361 #[test]
1362 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
1363 let f = write_toml(
1364 r#"
1365 [[anthropic.accounts]]
1366 label = "work"
1367 credentials_path = "/creds/work-one.json"
1368
1369 [[anthropic.accounts]]
1370 label = "work"
1371 credentials_path = "/creds/work-two.json"
1372 "#,
1373 );
1374 let err = Config::load_from(f.path()).unwrap_err().to_string();
1375 assert!(
1376 err.contains("duplicate anthropic account label \"work\""),
1377 "{err}"
1378 );
1379 }
1380
1381 #[test]
1382 fn account_label_rejects_path_like_names() {
1383 let cfg = AnthropicConfig::default();
1384 for bad in [
1385 "",
1386 ".",
1387 "..",
1388 "a/b",
1389 r"a\b",
1390 "line\nbreak",
1391 "tab\tname",
1392 "usage.json",
1393 ".stale",
1394 ".last_error",
1395 ".fetch.lock",
1396 ] {
1397 let err = cfg.account(bad).unwrap_err();
1398 assert!(
1399 format!("{err:?}").contains("invalid anthropic account label"),
1400 "{bad:?} should be rejected as a label"
1401 );
1402 }
1403 }
1404
1405 #[test]
1406 fn anthropic_accounts_default_to_empty() {
1407 assert!(Config::default().anthropic.accounts.is_empty());
1410 assert!(Config::default().anthropic.accounts_dir.is_none());
1411 }
1412
1413 fn seed_account_dir(root: &std::path::Path, label: &str) {
1419 let dir = root.join(label);
1420 std::fs::create_dir_all(&dir).unwrap();
1421 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1422 }
1423
1424 #[test]
1425 fn discovers_account_dirs_in_claude_config_dir_layout() {
1426 let td = tempfile::tempdir().unwrap();
1427 seed_account_dir(td.path(), "work");
1428 seed_account_dir(td.path(), "personal");
1429 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
1432 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
1434
1435 let cfg = AnthropicConfig {
1436 accounts_dir: Some(td.path().to_path_buf()),
1437 ..Default::default()
1438 };
1439 let all = cfg.all_accounts();
1440 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
1441 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
1442 assert_eq!(
1443 all[2].credentials_path,
1444 td.path().join("work").join(".credentials.json")
1445 );
1446 }
1447
1448 #[test]
1449 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
1450 let td = tempfile::tempdir().unwrap();
1451 seed_account_dir(td.path(), "work");
1452 let cfg = AnthropicConfig {
1453 accounts: vec![AnthropicAccount {
1454 label: "work".into(),
1455 credentials_path: "/explicit/work.json".into(),
1456 }],
1457 accounts_dir: Some(td.path().to_path_buf()),
1458 ..Default::default()
1459 };
1460 let all = cfg.all_accounts();
1461 assert_eq!(all.len(), 1, "no duplicate label");
1462 assert_eq!(
1463 all[0].credentials_path,
1464 std::path::Path::new("/explicit/work.json"),
1465 "explicit entry wins"
1466 );
1467 seed_account_dir(td.path(), "other");
1469 assert_eq!(cfg.account("other").unwrap().label, "other");
1470 }
1471
1472 #[test]
1473 fn missing_accounts_dir_is_silently_empty_not_an_error() {
1474 let cfg = AnthropicConfig {
1475 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
1476 ..Default::default()
1477 };
1478 assert!(cfg.all_accounts().is_empty());
1479 }
1480
1481 #[test]
1482 fn accounts_dir_is_tilde_expanded_on_load() {
1483 let f = write_toml(
1484 r#"
1485 [anthropic]
1486 accounts_dir = "~/.config/ai-usagebar/accounts"
1487 "#,
1488 );
1489 let c = Config::load_from(f.path()).unwrap();
1490 let home = crate::cache::home_dir().unwrap();
1491 assert_eq!(
1492 c.anthropic.accounts_dir,
1493 Some(home.join(".config/ai-usagebar/accounts"))
1494 );
1495 }
1496
1497 fn config_example() -> PathBuf {
1501 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
1502 }
1503
1504 #[test]
1505 fn shipped_example_parses_as_a_real_config() {
1506 let c = Config::load_from(&config_example()).unwrap();
1511 assert!(!c.context.enabled);
1512 assert!(c.is_enabled(VendorId::Anthropic));
1513 assert!(c.is_enabled(VendorId::Openai));
1514 assert!(!c.is_enabled(VendorId::AnthropicApi));
1515 assert!(!c.is_enabled(VendorId::Deepseek));
1516 assert!(!c.is_enabled(VendorId::Kimi));
1517 assert!(!c.is_enabled(VendorId::Kilo));
1518 assert!(!c.is_enabled(VendorId::Novita));
1519 assert!(!c.is_enabled(VendorId::Moonshot));
1520 assert!(!c.is_enabled(VendorId::Grok));
1521 assert!(!c.is_enabled(VendorId::Cursor));
1522 }
1523
1524 #[test]
1525 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
1526 let text = std::fs::read_to_string(config_example()).unwrap();
1531 let live: Vec<&str> = text
1532 .lines()
1533 .map(str::trim)
1534 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
1535 .collect();
1536 assert!(
1537 live.is_empty(),
1538 "admin_key_env must stay commented out while it is inert: {live:?}"
1539 );
1540 assert!(
1543 text.contains("admin_key_env") && text.contains("RESERVED"),
1544 "the example should keep describing admin_key_env as reserved"
1545 );
1546 }
1547
1548 #[test]
1549 fn admin_key_env_is_accepted_but_changes_nothing() {
1550 let f = write_toml(
1554 r#"
1555 [openai]
1556 admin_key_env = "SOME_ADMIN_KEY"
1557 "#,
1558 );
1559 let c = Config::load_from(f.path()).unwrap();
1560 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
1561 let default = OpenAiConfig::default();
1563 assert_eq!(c.openai.enabled, default.enabled);
1564 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
1565 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
1566 }
1567
1568 #[test]
1569 fn config_example_documents_every_vendor_without_secrets() {
1570 let raw = std::fs::read_to_string(config_example()).unwrap();
1571 let cfg = Config::load_from(&config_example()).unwrap();
1572 for id in VendorId::all() {
1575 let section = id.slug();
1576 assert!(
1577 raw.contains(&format!("[{section}]")),
1578 "config.example.toml has no [{section}] section"
1579 );
1580 }
1581
1582 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
1585 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
1586 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
1587 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
1588 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
1589 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
1590 }
1591
1592 #[test]
1593 fn cursor_db_path_is_tilde_expanded() {
1594 let f = write_toml(
1595 r#"
1596 [cursor]
1597 db_path = "~/cursor-state.vscdb"
1598 "#,
1599 );
1600 let c = Config::load_from(f.path()).unwrap();
1601 let home = crate::cache::home_dir().unwrap();
1602 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
1603 }
1604
1605 #[test]
1606 fn cursor_appears_when_enabled() {
1607 let f = write_toml(
1608 r#"
1609 [cursor]
1610 enabled = true
1611 "#,
1612 );
1613 let c = Config::load_from(f.path()).unwrap();
1614 assert!(c.is_enabled(VendorId::Cursor));
1615 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
1616 }
1617
1618 #[test]
1619 fn add_account_appends_and_preserves_existing() {
1620 let mut doc: toml_edit::DocumentMut = r#"
1621# keep me
1622[anthropic]
1623enabled = true
1624
1625[[anthropic.accounts]]
1626label = "personal"
1627credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
1628"#
1629 .parse()
1630 .unwrap();
1631 add_anthropic_account_to_doc(
1632 &mut doc,
1633 "work",
1634 "~/.config/ai-usagebar/accounts/work/.credentials.json",
1635 )
1636 .unwrap();
1637 let rendered = doc.to_string();
1638 assert!(rendered.contains("# keep me"), "comment must survive");
1639 let f = write_toml(&rendered);
1641 let c = Config::load_from(f.path()).unwrap();
1642 let labels: Vec<&str> = c
1643 .anthropic
1644 .accounts
1645 .iter()
1646 .map(|a| a.label.as_str())
1647 .collect();
1648 assert_eq!(labels, vec!["personal", "work"]);
1649 }
1650
1651 #[test]
1652 fn add_account_to_empty_doc_is_loadable() {
1653 let mut doc = toml_edit::DocumentMut::new();
1654 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
1655 let f = write_toml(&doc.to_string());
1656 let c = Config::load_from(f.path()).unwrap();
1657 assert_eq!(c.anthropic.accounts.len(), 1);
1658 assert_eq!(c.anthropic.accounts[0].label, "solo");
1659 }
1660
1661 #[test]
1662 fn add_account_rejects_duplicate_label() {
1663 let mut doc: toml_edit::DocumentMut = r#"
1664[[anthropic.accounts]]
1665label = "work"
1666credentials_path = "~/w/.credentials.json"
1667"#
1668 .parse()
1669 .unwrap();
1670 assert!(
1671 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
1672 "a duplicate label must be rejected, not appended"
1673 );
1674 }
1675
1676 #[test]
1677 fn add_account_rejects_bad_label() {
1678 let mut doc = toml_edit::DocumentMut::new();
1679 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
1680 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
1681 }
1682
1683 #[test]
1684 fn tildify_collapses_home_only() {
1685 let home = Path::new("/Users/me");
1686 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
1687 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
1688 }
1689
1690 #[test]
1691 fn default_account_credentials_path_nests_under_config_dir() {
1692 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
1693 assert_eq!(
1694 default_account_credentials_path(cfg, "work"),
1695 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
1696 );
1697 }
1698}