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 supergrok: SuperGrokConfig,
50 pub antigravity: AntigravityConfig,
51 pub cursor: CursorConfig,
52 pub minimax: MinimaxConfig,
53 pub kiro: KiroConfig,
54}
55
56#[derive(Debug, Clone, Default, Deserialize, Serialize)]
60#[serde(default)]
61pub struct UiConfig {
62 pub primary: Option<VendorId>,
64 pub overview_vendors: Option<Vec<VendorId>>,
68 pub vendor_box: Option<VendorBoxStyle>,
70}
71
72impl UiConfig {
73 pub fn vendor_box(&self) -> VendorBoxStyle {
74 self.vendor_box.unwrap_or_default()
75 }
76}
77
78#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
80#[serde(rename_all = "lowercase")]
81pub enum VendorBoxStyle {
82 #[default]
84 Sidebar,
85 Navbar,
87 None,
89}
90
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
94#[serde(rename_all = "lowercase")]
95pub enum ContextLayout {
96 #[default]
98 Full,
99 Split,
101 Bottom,
103}
104
105impl ContextLayout {
106 pub fn next(self) -> Self {
107 match self {
108 ContextLayout::Full => ContextLayout::Split,
109 ContextLayout::Split => ContextLayout::Bottom,
110 ContextLayout::Bottom => ContextLayout::Full,
111 }
112 }
113
114 pub fn label(self) -> &'static str {
115 match self {
116 ContextLayout::Full => "full",
117 ContextLayout::Split => "split",
118 ContextLayout::Bottom => "bottom",
119 }
120 }
121}
122
123#[derive(Debug, Clone, Default, Deserialize, Serialize)]
128#[serde(default)]
129pub struct ContextConfig {
130 pub enabled: bool,
133 pub projects_path: Option<PathBuf>,
135 pub context_window_tokens: Option<u64>,
138 pub model_context_window_tokens: BTreeMap<String, u64>,
141 pub layout: ContextLayout,
143}
144
145impl ContextConfig {
146 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
147 model
148 .and_then(|model| self.model_context_window_tokens.get(model).copied())
149 .filter(|tokens| *tokens > 0)
150 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
151 }
152}
153
154#[derive(Debug, Clone, Deserialize, Serialize)]
155#[serde(default)]
156pub struct AnthropicConfig {
157 pub enabled: bool,
158 pub credentials_path: Option<PathBuf>,
161 pub accounts: Vec<AnthropicAccount>,
165 pub accounts_dir: Option<PathBuf>,
173 pub show_default_account: bool,
179 pub desktop_profiles_dir: Option<PathBuf>,
185}
186
187impl Default for AnthropicConfig {
188 fn default() -> Self {
189 Self {
190 enabled: true,
191 credentials_path: None,
192 accounts: Vec::new(),
193 accounts_dir: None,
194 show_default_account: true,
195 desktop_profiles_dir: None,
196 }
197 }
198}
199
200#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
210pub struct AnthropicAccount {
211 pub label: String,
214 pub credentials_path: PathBuf,
218}
219
220impl AnthropicAccount {
221 pub fn config_dir(&self) -> PathBuf {
226 self.credentials_path
227 .parent()
228 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
229 }
230}
231
232impl AnthropicConfig {
233 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
239 let mut out = self.accounts.clone();
240 if let Some(dir) = &self.accounts_dir {
241 for acct in discover_accounts(dir) {
242 if !out.iter().any(|a| a.label == acct.label) {
243 out.push(acct);
244 }
245 }
246 }
247 out
248 }
249
250 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
255 validate_account_label(label)?;
256 let all = self.all_accounts();
257 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
258 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
259 AppError::Credentials(format!(
260 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
261 known labels: {known:?}"
262 ))
263 })
264 }
265
266 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
277 let active = crate::anthropic::cli_account::home_claude_json()
278 .ok()
279 .and_then(|path| {
280 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
281 });
282 self.account_target_with(label, active.as_deref())
283 }
284
285 pub fn account_target_with(
296 &self,
297 label: &str,
298 cli_active: Option<&str>,
299 ) -> Result<(CredsTarget, Cache)> {
300 let account = self.account(label)?;
301 let cache = Cache::for_vendor_account("anthropic", label)?;
302 if cli_active == Some(label) {
303 return Ok((
304 CredsTarget::Default(crate::anthropic::creds::default_path()?),
305 cache,
306 ));
307 }
308 Ok((
309 CredsTarget::Named {
310 config_dir: account.config_dir(),
311 path: account.credentials_path,
312 },
313 cache,
314 ))
315 }
316}
317
318pub fn validate_account_label(label: &str) -> Result<()> {
324 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
325 let bad = label.is_empty()
326 || label == "."
327 || label == ".."
328 || label.contains(['/', '\\'])
329 || label.chars().any(char::is_control)
330 || RESERVED.contains(&label);
331 if bad {
332 return Err(AppError::Credentials(format!(
333 "invalid anthropic account label {label:?}: must be a non-empty name \
334 without path separators, control characters, or reserved cache names"
335 )));
336 }
337 Ok(())
338}
339
340fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
348 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
349 return Vec::new();
350 };
351 let mut found: Vec<AnthropicAccount> = entries
352 .flatten()
353 .filter_map(|entry| {
354 let path = entry.path();
355 if !path.is_dir() {
356 return None;
357 }
358 let label = path.file_name()?.to_str()?.to_string();
359 validate_account_label(&label).ok()?;
360 Some(AnthropicAccount {
361 label,
362 credentials_path: path.join(".credentials.json"),
363 })
364 })
365 .collect();
366 found.sort_by(|a, b| a.label.cmp(&b.label));
367 found
368}
369
370pub fn tildify(path: &Path, home: &Path) -> String {
374 path.strip_prefix(home)
375 .map(|rest| {
376 let rendered = rest.display().to_string();
377 #[cfg(windows)]
380 let rendered = rendered.replace('\\', "/");
381 format!("~/{rendered}")
382 })
383 .unwrap_or_else(|_| path.display().to_string())
384}
385
386pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
391 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
392 base.join("accounts").join(label).join(".credentials.json")
393}
394
395pub fn add_anthropic_account_to_doc(
401 doc: &mut toml_edit::DocumentMut,
402 label: &str,
403 credentials_path: &str,
404) -> Result<()> {
405 use toml_edit::{Item, Table, value};
406
407 validate_account_label(label)?;
408
409 let anthropic = doc
410 .entry("anthropic")
411 .or_insert_with(|| Item::Table(Table::new()));
412 let anthropic = anthropic
413 .as_table_mut()
414 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
415
416 let accounts = anthropic
417 .entry("accounts")
418 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
419 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
420 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
421 })?;
422
423 let exists = accounts
424 .iter()
425 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
426 if exists {
427 return Err(AppError::Credentials(format!(
428 "anthropic account {label:?} already exists in config.toml"
429 )));
430 }
431
432 let mut table = Table::new();
433 table["label"] = value(label);
434 table["credentials_path"] = value(credentials_path);
435 accounts.push(table);
436 Ok(())
437}
438
439#[derive(Debug, Clone, Deserialize, Serialize)]
440#[serde(default)]
441pub struct OpenAiConfig {
442 pub enabled: bool,
443 pub codex_auth_path: Option<PathBuf>,
445 pub admin_key_env: String,
453}
454
455impl Default for OpenAiConfig {
456 fn default() -> Self {
457 Self {
458 enabled: true,
459 codex_auth_path: None,
460 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
461 }
462 }
463}
464
465#[derive(Debug, Clone, Deserialize, Serialize)]
466#[serde(default)]
467pub struct ZaiConfig {
468 pub enabled: bool,
469 pub api_key_env: String,
471 pub api_key: Option<String>,
474 pub plan_tier: Option<String>,
476}
477
478impl Default for ZaiConfig {
479 fn default() -> Self {
480 Self {
481 enabled: true,
482 api_key_env: "ZAI_API_KEY".to_string(),
483 api_key: None,
484 plan_tier: None,
485 }
486 }
487}
488
489#[derive(Debug, Clone, Deserialize, Serialize)]
490#[serde(default)]
491pub struct OpenRouterConfig {
492 pub enabled: bool,
493 pub api_key_env: String,
494 pub api_key: Option<String>,
495}
496
497impl Default for OpenRouterConfig {
498 fn default() -> Self {
499 Self {
500 enabled: true,
501 api_key_env: "OPENROUTER_API_KEY".to_string(),
502 api_key: None,
503 }
504 }
505}
506
507#[derive(Debug, Clone, Deserialize, Serialize)]
508#[serde(default)]
509pub struct DeepseekConfig {
510 pub enabled: bool,
511 pub api_key_env: String,
512 pub api_key: Option<String>,
513}
514
515impl Default for DeepseekConfig {
516 fn default() -> Self {
517 Self {
518 enabled: false,
519 api_key_env: "DEEPSEEK_API_KEY".to_string(),
520 api_key: None,
521 }
522 }
523}
524
525#[derive(Debug, Clone, Deserialize, Serialize)]
526#[serde(default)]
527pub struct KimiConfig {
528 pub enabled: bool,
529 pub api_key_env: String,
530 pub api_key: Option<String>,
531}
532
533impl Default for KimiConfig {
534 fn default() -> Self {
535 Self {
536 enabled: false,
537 api_key_env: "KIMI_API_KEY".to_string(),
538 api_key: None,
539 }
540 }
541}
542
543#[derive(Debug, Clone, Deserialize, Serialize)]
544#[serde(default)]
545pub struct KiloConfig {
546 pub enabled: bool,
547 pub api_key_env: String,
548 pub api_key: Option<String>,
549 pub organization_id: Option<String>,
552}
553
554impl Default for KiloConfig {
555 fn default() -> Self {
556 Self {
559 enabled: false,
560 api_key_env: "KILO_API_KEY".to_string(),
561 api_key: None,
562 organization_id: None,
563 }
564 }
565}
566
567#[derive(Debug, Clone, Deserialize, Serialize)]
568#[serde(default)]
569pub struct NovitaConfig {
570 pub enabled: bool,
571 pub api_key_env: String,
572 pub api_key: Option<String>,
573}
574
575impl Default for NovitaConfig {
576 fn default() -> Self {
577 Self {
579 enabled: false,
580 api_key_env: "NOVITA_API_KEY".to_string(),
581 api_key: None,
582 }
583 }
584}
585
586#[derive(Debug, Clone, Deserialize, Serialize)]
587#[serde(default)]
588pub struct MinimaxConfig {
589 pub enabled: bool,
590 pub api_key_env: String,
591 pub api_key: Option<String>,
592 pub region: String,
598}
599
600impl Default for MinimaxConfig {
601 fn default() -> Self {
602 Self {
604 enabled: false,
605 api_key_env: "MINIMAX_API_KEY".to_string(),
606 api_key: None,
607 region: "global".to_string(),
608 }
609 }
610}
611
612#[derive(Debug, Clone, Deserialize, Serialize)]
613#[serde(default)]
614pub struct MoonshotConfig {
615 pub enabled: bool,
616 pub api_key_env: String,
617 pub api_key: Option<String>,
618 pub region: String,
620}
621
622impl Default for MoonshotConfig {
623 fn default() -> Self {
624 Self {
626 enabled: false,
627 api_key_env: "MOONSHOT_API_KEY".to_string(),
628 api_key: None,
629 region: "global".to_string(),
630 }
631 }
632}
633
634#[derive(Debug, Clone, Deserialize, Serialize)]
635#[serde(default)]
636pub struct GrokConfig {
637 pub enabled: bool,
638 pub api_key_env: String,
640 pub api_key: Option<String>,
641 pub team_id: Option<String>,
644}
645
646impl Default for GrokConfig {
647 fn default() -> Self {
648 Self {
650 enabled: false,
651 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
652 api_key: None,
653 team_id: None,
654 }
655 }
656}
657
658#[derive(Debug, Clone, Deserialize, Serialize)]
666#[serde(default)]
667pub struct SuperGrokConfig {
668 pub enabled: bool,
669 pub grok_binary: PathBuf,
673 pub auth_path: Option<PathBuf>,
676 pub config_path: Option<PathBuf>,
677}
678
679impl Default for SuperGrokConfig {
680 fn default() -> Self {
681 Self {
682 enabled: false,
683 grok_binary: default_grok_binary(),
684 auth_path: None,
685 config_path: None,
686 }
687 }
688}
689
690fn default_grok_binary() -> PathBuf {
691 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
692 let grok_home = std::env::var_os("GROK_HOME")
693 .filter(|value| !value.is_empty())
694 .map(PathBuf::from)
695 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
696 grok_home
697 .map(|home| home.join("bin").join(executable))
698 .unwrap_or_else(|| PathBuf::from(executable))
699}
700
701#[derive(Debug, Clone, Default, Deserialize, Serialize)]
704#[serde(default)]
705pub struct AntigravityConfig {
706 pub enabled: bool,
707}
708
709#[derive(Debug, Clone, Default, Deserialize, Serialize)]
719#[serde(default)]
720pub struct CursorConfig {
721 pub enabled: bool,
722 pub db_path: Option<PathBuf>,
726 pub agent_auth_path: Option<PathBuf>,
731}
732
733#[derive(Debug, Clone, Default, Deserialize, Serialize)]
742#[serde(default)]
743pub struct KiroConfig {
744 pub enabled: bool,
745 pub db_path: Option<PathBuf>,
749}
750
751#[derive(Debug, Clone, Deserialize, Serialize)]
752#[serde(default)]
753pub struct AnthropicApiConfig {
754 pub enabled: bool,
755 pub api_key_env: String,
758 pub api_key: Option<String>,
759 pub monthly_limit: Option<f64>,
762}
763
764impl Default for AnthropicApiConfig {
765 fn default() -> Self {
766 Self {
768 enabled: false,
769 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
770 api_key: None,
771 monthly_limit: None,
772 }
773 }
774}
775
776pub fn resolve_api_key(
779 vendor_label: &str,
780 env_var_name: &str,
781 inline: Option<&str>,
782) -> crate::error::Result<String> {
783 let valid_env_name = is_valid_env_var_name(env_var_name);
784 if valid_env_name
785 && let Ok(v) = std::env::var(env_var_name)
786 && !v.is_empty()
787 {
788 return Ok(v);
789 }
790 if let Some(v) = inline
791 && !v.is_empty()
792 {
793 return Ok(v.to_string());
794 }
795 let advice = if valid_env_name {
796 "set an API key in a valid environment variable or set `api_key`"
797 } else {
798 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
799 };
800 Err(crate::error::AppError::Credentials(format!(
801 "{vendor_label}: no API key. Either {advice} under [{}] in {}.",
802 vendor_label.to_lowercase(),
803 config_path_hint()
804 )))
805}
806
807fn is_valid_env_var_name(name: &str) -> bool {
808 let mut chars = name.chars();
809 let Some(first) = chars.next() else {
810 return false;
811 };
812 (first.is_ascii_alphabetic() || first == '_')
813 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
814}
815
816impl Config {
817 pub fn load() -> Result<Self> {
820 let Some(path) = resolved_path() else {
821 return Ok(Self::default());
822 };
823 Self::load_from(&path)
824 }
825
826 pub fn load_from(path: &std::path::Path) -> Result<Self> {
827 match std::fs::read_to_string(path) {
828 Ok(s) => {
829 let mut config: Self = toml::from_str(&s)?;
830 config.expand_paths();
834 config.validate()?;
835 Ok(config)
836 }
837 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
838 Err(e) => Err(AppError::io_at(path, e)),
839 }
840 }
841
842 fn expand_paths(&mut self) {
843 expand_tilde_opt(&mut self.context.projects_path);
844 expand_tilde_opt(&mut self.anthropic.credentials_path);
845 expand_tilde_opt(&mut self.anthropic.accounts_dir);
846 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
847 expand_tilde_opt(&mut self.openai.codex_auth_path);
848 expand_tilde_opt(&mut self.cursor.db_path);
849 expand_tilde_opt(&mut self.cursor.agent_auth_path);
850 expand_tilde_opt(&mut self.kiro.db_path);
851 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
852 expand_tilde_opt(&mut self.supergrok.auth_path);
853 expand_tilde_opt(&mut self.supergrok.config_path);
854 for account in &mut self.anthropic.accounts {
855 account.credentials_path = expand_tilde(&account.credentials_path);
856 }
857 }
858
859 pub fn is_enabled(&self, id: VendorId) -> bool {
860 match id {
861 VendorId::Anthropic => self.anthropic.enabled,
862 VendorId::AnthropicApi => self.anthropic_api.enabled,
863 VendorId::Openai => self.openai.enabled,
864 VendorId::Zai => self.zai.enabled,
865 VendorId::Openrouter => self.openrouter.enabled,
866 VendorId::Deepseek => self.deepseek.enabled,
867 VendorId::Kimi => self.kimi.enabled,
868 VendorId::Kilo => self.kilo.enabled,
869 VendorId::Novita => self.novita.enabled,
870 VendorId::Moonshot => self.moonshot.enabled,
871 VendorId::Grok => self.grok.enabled,
872 VendorId::Supergrok => self.supergrok.enabled,
873 VendorId::Antigravity => self.antigravity.enabled,
874 VendorId::Cursor => self.cursor.enabled,
875 VendorId::Minimax => self.minimax.enabled,
876 VendorId::Kiro => self.kiro.enabled,
877 }
878 }
879
880 pub fn enabled_vendors(&self) -> Vec<VendorId> {
881 VendorId::all()
882 .iter()
883 .copied()
884 .filter(|id| self.is_enabled(*id))
885 .collect()
886 }
887
888 pub fn validate(&self) -> Result<()> {
892 if self.context.context_window_tokens == Some(0) {
893 return Err(AppError::Other(
894 "[context] context_window_tokens must be greater than zero".into(),
895 ));
896 }
897 for (model, tokens) in &self.context.model_context_window_tokens {
898 if model.trim().is_empty() {
899 return Err(AppError::Other(
900 "[context] model_context_window_tokens keys must not be empty".into(),
901 ));
902 }
903 if *tokens == 0 {
904 return Err(AppError::Other(format!(
905 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
906 )));
907 }
908 }
909 if let Some(limit) = self.anthropic_api.monthly_limit
910 && (!limit.is_finite() || limit <= 0.0)
911 {
912 return Err(AppError::Other(
913 "[anthropic_api] monthly_limit must be finite and greater than zero; \
914 remove it to show spend without a limit"
915 .into(),
916 ));
917 }
918 if !self.minimax.region.eq_ignore_ascii_case("global")
919 && !self.minimax.region.eq_ignore_ascii_case("cn")
920 {
921 return Err(AppError::Other(format!(
922 "[minimax] region must be \"global\" or \"cn\", got {:?}",
923 self.minimax.region
924 )));
925 }
926 if self.supergrok.grok_binary.as_os_str().is_empty() {
927 return Err(AppError::Other(
928 "[supergrok] grok_binary must not be empty".into(),
929 ));
930 }
931 let mut labels = HashSet::new();
932 for account in &self.anthropic.accounts {
933 validate_account_label(&account.label)?;
934 if !labels.insert(&account.label) {
935 return Err(AppError::Credentials(format!(
936 "duplicate anthropic account label {:?}",
937 account.label
938 )));
939 }
940 }
941 Ok(())
942 }
943}
944
945pub fn default_path() -> Option<PathBuf> {
946 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
947 Some(proj.config_dir().join("config.toml"))
948}
949
950fn legacy_xdg_path() -> Option<PathBuf> {
955 let home = crate::cache::home_dir().ok()?;
956 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
957}
958
959pub fn resolved_path() -> Option<PathBuf> {
968 let canonical = default_path();
969 if let Some(p) = &canonical
970 && p.exists()
971 {
972 return canonical;
973 }
974 if let Some(legacy) = legacy_xdg_path()
975 && legacy.exists()
976 {
977 return Some(legacy);
978 }
979 canonical
980}
981
982fn expand_tilde(p: &std::path::Path) -> PathBuf {
985 let Some(s) = p.to_str() else {
986 return p.to_path_buf();
987 };
988 let rest = if s == "~" {
989 ""
990 } else if let Some(r) = s.strip_prefix("~/") {
991 r
992 } else {
993 return p.to_path_buf();
994 };
995 match crate::cache::home_dir() {
996 Ok(home) if rest.is_empty() => home,
997 Ok(home) => home.join(rest),
998 Err(_) => p.to_path_buf(),
999 }
1000}
1001
1002fn expand_tilde_opt(p: &mut Option<PathBuf>) {
1003 if let Some(inner) = p.as_ref() {
1004 *p = Some(expand_tilde(inner));
1005 }
1006}
1007
1008pub fn config_path_hint() -> String {
1013 resolved_path()
1014 .map(|p| p.display().to_string())
1015 .unwrap_or_else(|| "config.toml".to_string())
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020 use super::*;
1021 use std::io::Write;
1022 use tempfile::NamedTempFile;
1023
1024 fn write_toml(s: &str) -> NamedTempFile {
1025 let mut f = NamedTempFile::new().unwrap();
1026 f.write_all(s.as_bytes()).unwrap();
1027 f.flush().unwrap();
1028 f
1029 }
1030
1031 #[test]
1032 fn defaults_enable_only_the_four_core_vendors() {
1033 let c = Config::default();
1034 assert!(c.is_enabled(VendorId::Anthropic));
1035 assert!(c.is_enabled(VendorId::Openai));
1036 assert!(c.is_enabled(VendorId::Zai));
1037 assert!(c.is_enabled(VendorId::Openrouter));
1038 for opt_in in [
1039 VendorId::AnthropicApi,
1040 VendorId::Deepseek,
1041 VendorId::Kimi,
1042 VendorId::Kilo,
1043 VendorId::Novita,
1044 VendorId::Moonshot,
1045 VendorId::Grok,
1046 VendorId::Supergrok,
1047 VendorId::Cursor,
1048 VendorId::Minimax,
1049 VendorId::Kiro,
1050 ] {
1051 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
1052 }
1053 assert_eq!(c.enabled_vendors().len(), 4);
1054 }
1055
1056 #[test]
1057 fn missing_file_uses_defaults() {
1058 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
1059 let c = Config::load_from(path).unwrap();
1060 assert!(c.is_enabled(VendorId::Anthropic));
1061 }
1062
1063 #[test]
1064 fn parses_full_config() {
1065 let f = write_toml(
1066 r#"
1067 [anthropic]
1068 enabled = true
1069
1070 [openai]
1071 enabled = false
1072 admin_key_env = "MY_ADMIN_KEY"
1073
1074 [zai]
1075 enabled = true
1076 api_key_env = "MY_ZAI"
1077 plan_tier = "pro"
1078
1079 [openrouter]
1080 enabled = false
1081 "#,
1082 );
1083 let c = Config::load_from(f.path()).unwrap();
1084 assert!(c.is_enabled(VendorId::Anthropic));
1085 assert!(!c.is_enabled(VendorId::Openai));
1086 assert!(c.is_enabled(VendorId::Zai));
1087 assert!(!c.is_enabled(VendorId::Openrouter));
1088 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
1089 assert_eq!(c.zai.api_key_env, "MY_ZAI");
1090 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
1091 }
1092
1093 #[test]
1094 fn partial_config_falls_back_to_defaults() {
1095 let f = write_toml(
1096 r#"[openai]
1097enabled = false
1098"#,
1099 );
1100 let c = Config::load_from(f.path()).unwrap();
1101 assert!(!c.is_enabled(VendorId::Openai));
1102 assert!(c.is_enabled(VendorId::Anthropic));
1104 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
1105 }
1106
1107 #[test]
1108 fn malformed_toml_returns_error() {
1109 let f = write_toml("this is not = = valid");
1110 assert!(Config::load_from(f.path()).is_err());
1111 }
1112
1113 #[test]
1114 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
1115 for value in ["0", "-1", "inf", "nan"] {
1116 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
1117 let error = Config::load_from(file.path()).unwrap_err().to_string();
1118 assert!(error.contains("monthly_limit"), "value {value}: {error}");
1119 }
1120
1121 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
1122 assert_eq!(
1123 Config::load_from(file.path())
1124 .unwrap()
1125 .anthropic_api
1126 .monthly_limit,
1127 Some(1000.0)
1128 );
1129 }
1130
1131 #[test]
1132 fn minimax_region_accepts_only_known_instances() {
1133 for region in ["global", "GLOBAL", "cn", "CN"] {
1134 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1135 assert_eq!(
1136 Config::load_from(file.path()).unwrap().minimax.region,
1137 region
1138 );
1139 }
1140
1141 for region in ["", "china", "us"] {
1142 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1143 let error = Config::load_from(file.path()).unwrap_err().to_string();
1144 assert!(error.contains("[minimax] region"), "{error}");
1145 }
1146 }
1147
1148 #[test]
1149 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
1150 let defaults = Config::default();
1151 assert!(!defaults.context.enabled);
1152 assert_eq!(
1153 defaults.context.window_tokens_for(Some("claude-test")),
1154 None
1155 );
1156
1157 let file = write_toml(
1158 r#"
1159 [context]
1160 enabled = true
1161 context_window_tokens = 200000
1162
1163 [context.model_context_window_tokens]
1164 claude-opus-1m = 1000000
1165 "claude exact id" = 300000
1166 "#,
1167 );
1168 let config = Config::load_from(file.path()).unwrap();
1169 assert!(config.context.enabled);
1170 assert_eq!(
1171 config.context.window_tokens_for(Some("claude-opus-1m")),
1172 Some(1_000_000)
1173 );
1174 assert_eq!(
1175 config.context.window_tokens_for(Some("claude exact id")),
1176 Some(300_000)
1177 );
1178 assert_eq!(
1179 config.context.window_tokens_for(Some("another-model")),
1180 Some(200_000)
1181 );
1182 }
1183
1184 #[test]
1185 fn context_layout_defaults_to_full_and_parses_each_variant() {
1186 assert_eq!(Config::default().context.layout, ContextLayout::Full);
1187 for (text, want) in [
1188 ("full", ContextLayout::Full),
1189 ("split", ContextLayout::Split),
1190 ("bottom", ContextLayout::Bottom),
1191 ] {
1192 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
1193 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
1194 }
1195 let file = write_toml("[context]\nlayout = \"floating\"\n");
1196 assert!(
1197 Config::load_from(file.path()).is_err(),
1198 "an unknown layout must be rejected, not silently defaulted"
1199 );
1200 }
1201
1202 #[test]
1203 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
1204 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
1205 for (text, want) in [
1206 ("sidebar", VendorBoxStyle::Sidebar),
1207 ("navbar", VendorBoxStyle::Navbar),
1208 ("none", VendorBoxStyle::None),
1209 ] {
1210 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
1211 assert_eq!(
1212 Config::load_from(file.path()).unwrap().ui.vendor_box(),
1213 want
1214 );
1215 }
1216 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
1217 assert!(
1218 Config::load_from(file.path()).is_err(),
1219 "an unknown vendor_box style must be rejected, not silently defaulted"
1220 );
1221 }
1222
1223 #[test]
1224 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
1225 for source in [
1226 "[context]\ncontext_window_tokens = 0\n",
1227 "[context.model_context_window_tokens]\nclaude = 0\n",
1228 "[context.model_context_window_tokens]\n\" \" = 200000\n",
1229 ] {
1230 let file = write_toml(source);
1231 let error = Config::load_from(file.path()).unwrap_err().to_string();
1232 assert!(error.contains("context"), "{error}");
1233 }
1234 }
1235
1236 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
1238 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
1239 M.lock().unwrap_or_else(|p| p.into_inner())
1240 }
1241
1242 #[test]
1243 fn resolve_api_key_prefers_env_over_inline() {
1244 let _g = env_guard();
1245 let var = "AI_USAGEBAR_TEST_ENV_WINS";
1247 unsafe { std::env::set_var(var, "from-env") };
1249 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
1250 unsafe { std::env::remove_var(var) };
1251 assert_eq!(got, "from-env");
1252 }
1253
1254 #[test]
1255 fn resolve_api_key_falls_back_to_inline() {
1256 let _g = env_guard();
1257 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
1258 unsafe { std::env::remove_var(var) };
1259 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
1260 assert_eq!(got, "inline-key");
1261 }
1262
1263 #[test]
1264 fn resolve_api_key_errors_when_both_missing() {
1265 let _g = env_guard();
1266 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
1267 unsafe { std::env::remove_var(var) };
1268 let err = resolve_api_key("Zai", var, None).unwrap_err();
1269 match err {
1270 crate::error::AppError::Credentials(msg) => {
1271 assert!(
1272 msg.contains("api_key"),
1273 "error should suggest config field: {msg}"
1274 );
1275 }
1276 other => panic!("expected Credentials error, got {other:?}"),
1277 }
1278 }
1279
1280 #[test]
1281 fn config_path_hint_ends_with_config_toml() {
1282 assert!(config_path_hint().ends_with("config.toml"));
1285 }
1286
1287 #[test]
1288 fn resolve_api_key_treats_empty_env_as_unset() {
1289 let _g = env_guard();
1290 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
1291 unsafe { std::env::set_var(var, "") };
1292 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
1293 unsafe { std::env::remove_var(var) };
1294 assert_eq!(got, "inline");
1295 }
1296
1297 #[test]
1298 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
1299 let _g = env_guard();
1300 let bad = "sk-kimi-very-real-looking-pasted-secret";
1302 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
1303 let msg = err.to_string();
1304 assert!(
1305 msg.contains("invalid") && msg.contains("api_key_env"),
1306 "error should explain misconfiguration: {msg}"
1307 );
1308 assert!(
1309 !msg.contains(bad),
1310 "error must not echo the misconfigured value: {msg}"
1311 );
1312 assert!(msg.contains("valid environment variable name"));
1313 assert!(
1314 msg.contains("[kimi]"),
1315 "error should point at the lowercase TOML section: {msg}"
1316 );
1317 }
1318
1319 #[test]
1320 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
1321 let _g = env_guard();
1322 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
1323 assert_eq!(got, "inline-key");
1324 }
1325
1326 #[test]
1327 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
1328 let _g = env_guard();
1329 let pasted_secret = "sk_pasted_secret";
1332 unsafe { std::env::remove_var(pasted_secret) };
1333 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
1334 assert!(
1335 !err.to_string().contains(pasted_secret),
1336 "error must not echo configured api_key_env values"
1337 );
1338 }
1339
1340 #[test]
1341 fn is_valid_env_var_name_rules() {
1342 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
1344 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
1345 }
1346 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
1348 assert!(
1349 !is_valid_env_var_name(invalid),
1350 "{invalid} should be invalid"
1351 );
1352 }
1353 }
1354
1355 #[test]
1356 fn config_parses_with_inline_api_key_and_primary() {
1357 let f = write_toml(
1358 r#"
1359 [ui]
1360 primary = "openrouter"
1361
1362 [zai]
1363 enabled = true
1364 api_key_env = "MY_ZAI"
1365 api_key = "sk-zai-inline"
1366
1367 [openrouter]
1368 enabled = true
1369 api_key = "sk-or-inline"
1370 "#,
1371 );
1372 let c = Config::load_from(f.path()).unwrap();
1373 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
1374 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
1375 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
1376 }
1377
1378 #[test]
1379 fn enabled_vendors_preserves_canonical_order() {
1380 let c = Config::default();
1383 assert_eq!(
1384 c.enabled_vendors(),
1385 vec![
1386 VendorId::Anthropic,
1387 VendorId::Openai,
1388 VendorId::Zai,
1389 VendorId::Openrouter,
1390 ]
1391 );
1392 }
1393
1394 #[test]
1395 fn deepseek_appears_when_enabled() {
1396 let f = write_toml(
1397 r#"
1398 [deepseek]
1399 enabled = true
1400 api_key = "sk-test"
1401 "#,
1402 );
1403 let c = Config::load_from(f.path()).unwrap();
1404 assert!(c.is_enabled(VendorId::Deepseek));
1405 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
1406 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
1407 }
1408
1409 #[test]
1410 fn tilde_paths_are_expanded_on_load() {
1411 let f = write_toml(
1415 r#"
1416 [context]
1417 projects_path = "~/.claude/projects"
1418
1419 [anthropic]
1420 credentials_path = "~/.claude/.credentials.json"
1421
1422 [[anthropic.accounts]]
1423 label = "work"
1424 credentials_path = "~/work.json"
1425 "#,
1426 );
1427 let c = Config::load_from(f.path()).unwrap();
1428 let home = crate::cache::home_dir().unwrap();
1429
1430 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
1431 let got = c.anthropic.credentials_path.unwrap();
1432 assert_eq!(got, home.join(".claude/.credentials.json"));
1433 assert!(!got.to_string_lossy().contains('~'));
1434 assert_eq!(
1435 c.anthropic.accounts[0].credentials_path,
1436 home.join("work.json")
1437 );
1438 }
1439
1440 #[test]
1441 fn absolute_and_relative_paths_are_left_alone() {
1442 let f = write_toml(
1443 r#"
1444 [anthropic]
1445 credentials_path = "/etc/creds.json"
1446 "#,
1447 );
1448 let c = Config::load_from(f.path()).unwrap();
1449 assert_eq!(
1450 c.anthropic.credentials_path.unwrap(),
1451 std::path::Path::new("/etc/creds.json")
1452 );
1453
1454 let f2 = write_toml(
1456 r#"
1457 [anthropic]
1458 credentials_path = "~someone/creds.json"
1459 "#,
1460 );
1461 let c2 = Config::load_from(f2.path()).unwrap();
1462 assert_eq!(
1463 c2.anthropic.credentials_path.unwrap(),
1464 std::path::Path::new("~someone/creds.json")
1465 );
1466 }
1467
1468 #[test]
1469 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
1470 let p = resolved_path().expect("a config path must resolve");
1473 assert!(p.ends_with("config.toml"));
1474 let canonical = default_path().unwrap();
1475 let legacy = legacy_xdg_path().unwrap();
1476 assert!(
1477 p == canonical || p == legacy,
1478 "resolved to an unexpected location: {}",
1479 p.display()
1480 );
1481 }
1482
1483 #[test]
1484 fn misspelled_section_is_rejected_not_ignored() {
1485 let f = write_toml(
1488 r#"
1489 [openrouer]
1490 enabled = true
1491 api_key = "sk-or-v1-typo"
1492 "#,
1493 );
1494 let err = Config::load_from(f.path()).unwrap_err().to_string();
1495 assert!(
1496 err.contains("openrouer"),
1497 "error should name the typo: {err}"
1498 );
1499 }
1500
1501 #[test]
1502 fn invalid_toml_is_an_error_not_silent_defaults() {
1503 let f = write_toml("[zai\nenabled = true\n");
1504 assert!(Config::load_from(f.path()).is_err());
1505 }
1506
1507 #[test]
1508 fn a_missing_file_is_still_just_defaults() {
1509 let dir = tempfile::tempdir().unwrap();
1512 let missing = dir.path().join("nope").join("config.toml");
1513 let c = Config::load_from(&missing).unwrap();
1514 assert!(c.is_enabled(VendorId::Anthropic));
1515 }
1516
1517 #[test]
1518 fn kimi_appears_when_enabled() {
1519 let f = write_toml(
1520 r#"
1521 [kimi]
1522 enabled = true
1523 api_key = "sk-test"
1524 "#,
1525 );
1526 let c = Config::load_from(f.path()).unwrap();
1527 assert!(c.is_enabled(VendorId::Kimi));
1528 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
1529 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
1530 }
1531
1532 #[test]
1533 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
1534 let f = write_toml(
1535 r#"
1536 [deepseek]
1537 enabled = true
1538 api_key = "sk-ds"
1539
1540 [kimi]
1541 enabled = true
1542 api_key = "sk-kimi"
1543 "#,
1544 );
1545 let c = Config::load_from(f.path()).unwrap();
1546 assert_eq!(
1547 c.enabled_vendors(),
1548 vec![
1549 VendorId::Anthropic,
1550 VendorId::Openai,
1551 VendorId::Zai,
1552 VendorId::Openrouter,
1553 VendorId::Deepseek,
1554 VendorId::Kimi,
1555 ]
1556 );
1557 }
1558
1559 #[test]
1560 fn parses_anthropic_accounts_and_looks_them_up() {
1561 let f = write_toml(
1562 r#"
1563 [anthropic]
1564 enabled = true
1565
1566 [[anthropic.accounts]]
1567 label = "personal"
1568 credentials_path = "/creds/personal.json"
1569
1570 [[anthropic.accounts]]
1571 label = "work"
1572 credentials_path = "/creds/work.json"
1573 "#,
1574 );
1575 let c = Config::load_from(f.path()).unwrap();
1576 assert_eq!(c.anthropic.accounts.len(), 2);
1577 let work = c.anthropic.account("work").unwrap();
1578 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
1579 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
1581 assert!(err.contains("missing") && err.contains("work"), "{err}");
1582 }
1583
1584 #[test]
1585 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
1586 let f = write_toml(
1587 r#"
1588 [[anthropic.accounts]]
1589 label = "work"
1590 credentials_path = "/creds/work-one.json"
1591
1592 [[anthropic.accounts]]
1593 label = "work"
1594 credentials_path = "/creds/work-two.json"
1595 "#,
1596 );
1597 let err = Config::load_from(f.path()).unwrap_err().to_string();
1598 assert!(
1599 err.contains("duplicate anthropic account label \"work\""),
1600 "{err}"
1601 );
1602 }
1603
1604 #[test]
1605 fn account_label_rejects_path_like_names() {
1606 let cfg = AnthropicConfig::default();
1607 for bad in [
1608 "",
1609 ".",
1610 "..",
1611 "a/b",
1612 r"a\b",
1613 "line\nbreak",
1614 "tab\tname",
1615 "usage.json",
1616 ".stale",
1617 ".last_error",
1618 ".fetch.lock",
1619 ] {
1620 let err = cfg.account(bad).unwrap_err();
1621 assert!(
1622 format!("{err:?}").contains("invalid anthropic account label"),
1623 "{bad:?} should be rejected as a label"
1624 );
1625 }
1626 }
1627
1628 #[test]
1629 fn anthropic_accounts_default_to_empty() {
1630 assert!(Config::default().anthropic.accounts.is_empty());
1633 assert!(Config::default().anthropic.accounts_dir.is_none());
1634 }
1635
1636 fn seed_account_dir(root: &std::path::Path, label: &str) {
1642 let dir = root.join(label);
1643 std::fs::create_dir_all(&dir).unwrap();
1644 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1645 }
1646
1647 #[test]
1648 fn discovers_account_dirs_in_claude_config_dir_layout() {
1649 let td = tempfile::tempdir().unwrap();
1650 seed_account_dir(td.path(), "work");
1651 seed_account_dir(td.path(), "personal");
1652 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
1655 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
1657
1658 let cfg = AnthropicConfig {
1659 accounts_dir: Some(td.path().to_path_buf()),
1660 ..Default::default()
1661 };
1662 let all = cfg.all_accounts();
1663 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
1664 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
1665 assert_eq!(
1666 all[2].credentials_path,
1667 td.path().join("work").join(".credentials.json")
1668 );
1669 }
1670
1671 #[test]
1672 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
1673 let td = tempfile::tempdir().unwrap();
1674 seed_account_dir(td.path(), "work");
1675 let cfg = AnthropicConfig {
1676 accounts: vec![AnthropicAccount {
1677 label: "work".into(),
1678 credentials_path: "/explicit/work.json".into(),
1679 }],
1680 accounts_dir: Some(td.path().to_path_buf()),
1681 ..Default::default()
1682 };
1683 let all = cfg.all_accounts();
1684 assert_eq!(all.len(), 1, "no duplicate label");
1685 assert_eq!(
1686 all[0].credentials_path,
1687 std::path::Path::new("/explicit/work.json"),
1688 "explicit entry wins"
1689 );
1690 seed_account_dir(td.path(), "other");
1692 assert_eq!(cfg.account("other").unwrap().label, "other");
1693 }
1694
1695 #[test]
1696 fn missing_accounts_dir_is_silently_empty_not_an_error() {
1697 let cfg = AnthropicConfig {
1698 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
1699 ..Default::default()
1700 };
1701 assert!(cfg.all_accounts().is_empty());
1702 }
1703
1704 #[test]
1705 fn accounts_dir_is_tilde_expanded_on_load() {
1706 let f = write_toml(
1707 r#"
1708 [anthropic]
1709 accounts_dir = "~/.config/ai-usagebar/accounts"
1710 "#,
1711 );
1712 let c = Config::load_from(f.path()).unwrap();
1713 let home = crate::cache::home_dir().unwrap();
1714 assert_eq!(
1715 c.anthropic.accounts_dir,
1716 Some(home.join(".config/ai-usagebar/accounts"))
1717 );
1718 }
1719
1720 #[test]
1721 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
1722 let f = write_toml(
1723 r#"
1724 [anthropic]
1725 desktop_profiles_dir = "~/.claude-acc/profiles"
1726 "#,
1727 );
1728 let c = Config::load_from(f.path()).unwrap();
1729 let home = crate::cache::home_dir().unwrap();
1730 assert_eq!(
1731 c.anthropic.desktop_profiles_dir,
1732 Some(home.join(".claude-acc/profiles"))
1733 );
1734 }
1735
1736 #[test]
1737 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
1738 let cfg = AnthropicConfig {
1739 accounts: vec![
1740 AnthropicAccount {
1741 label: "work".into(),
1742 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
1743 },
1744 AnthropicAccount {
1745 label: "personal".into(),
1746 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
1747 },
1748 ],
1749 ..Default::default()
1750 };
1751
1752 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
1753 assert!(
1754 matches!(&idle, CredsTarget::Named { config_dir, .. }
1755 if config_dir == std::path::Path::new("/tmp/accounts/work")),
1756 "{idle:?}"
1757 );
1758
1759 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
1761 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
1762
1763 assert_eq!(idle_cache.dir(), live_cache.dir());
1766 }
1767
1768 #[test]
1769 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
1770 let cfg = AnthropicConfig {
1771 accounts: vec![AnthropicAccount {
1772 label: "work".into(),
1773 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
1774 }],
1775 ..Default::default()
1776 };
1777 let (target, _) = cfg.account_target_with("work", None).unwrap();
1778 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
1779 }
1780
1781 fn config_example() -> PathBuf {
1785 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
1786 }
1787
1788 #[test]
1789 fn shipped_example_parses_as_a_real_config() {
1790 let c = Config::load_from(&config_example()).unwrap();
1795 assert!(!c.context.enabled);
1796 assert!(c.is_enabled(VendorId::Anthropic));
1797 assert!(c.is_enabled(VendorId::Openai));
1798 assert!(!c.is_enabled(VendorId::AnthropicApi));
1799 assert!(!c.is_enabled(VendorId::Deepseek));
1800 assert!(!c.is_enabled(VendorId::Kimi));
1801 assert!(!c.is_enabled(VendorId::Kilo));
1802 assert!(!c.is_enabled(VendorId::Novita));
1803 assert!(!c.is_enabled(VendorId::Moonshot));
1804 assert!(!c.is_enabled(VendorId::Grok));
1805 assert!(!c.is_enabled(VendorId::Cursor));
1806 assert!(!c.is_enabled(VendorId::Minimax));
1807 }
1808
1809 #[test]
1810 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
1811 let text = std::fs::read_to_string(config_example()).unwrap();
1816 let live: Vec<&str> = text
1817 .lines()
1818 .map(str::trim)
1819 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
1820 .collect();
1821 assert!(
1822 live.is_empty(),
1823 "admin_key_env must stay commented out while it is inert: {live:?}"
1824 );
1825 assert!(
1828 text.contains("admin_key_env") && text.contains("RESERVED"),
1829 "the example should keep describing admin_key_env as reserved"
1830 );
1831 }
1832
1833 #[test]
1834 fn admin_key_env_is_accepted_but_changes_nothing() {
1835 let f = write_toml(
1839 r#"
1840 [openai]
1841 admin_key_env = "SOME_ADMIN_KEY"
1842 "#,
1843 );
1844 let c = Config::load_from(f.path()).unwrap();
1845 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
1846 let default = OpenAiConfig::default();
1848 assert_eq!(c.openai.enabled, default.enabled);
1849 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
1850 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
1851 }
1852
1853 #[test]
1854 fn config_example_documents_every_vendor_without_secrets() {
1855 let raw = std::fs::read_to_string(config_example()).unwrap();
1856 let cfg = Config::load_from(&config_example()).unwrap();
1857 for id in VendorId::all() {
1860 let section = id.slug();
1861 assert!(
1862 raw.contains(&format!("[{section}]")),
1863 "config.example.toml has no [{section}] section"
1864 );
1865 }
1866
1867 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
1870 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
1871 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
1872 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
1873 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
1874 assert!(!cfg.supergrok.enabled);
1875 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
1876 assert_eq!(
1877 cfg.supergrok
1878 .grok_binary
1879 .file_name()
1880 .and_then(|p| p.to_str()),
1881 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
1882 );
1883 assert!(cfg.supergrok.auth_path.is_none());
1884 assert!(cfg.supergrok.config_path.is_none());
1885 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
1886 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
1887 }
1888
1889 #[test]
1890 fn supergrok_binary_must_not_be_empty() {
1891 let file = write_toml(
1892 r#"
1893 [supergrok]
1894 enabled = true
1895 grok_binary = ""
1896 "#,
1897 );
1898 let error = Config::load_from(file.path()).unwrap_err().to_string();
1899 assert!(error.contains("grok_binary must not be empty"));
1900 }
1901
1902 #[test]
1903 fn supergrok_paths_are_tilde_expanded() {
1904 let file = write_toml(
1905 r#"
1906 [supergrok]
1907 grok_binary = "~/bin/grok"
1908 auth_path = "~/.grok/auth.json"
1909 config_path = "~/.grok/config.toml"
1910 "#,
1911 );
1912 let config = Config::load_from(file.path()).unwrap();
1913 let home = crate::cache::home_dir().unwrap();
1914 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
1915 assert_eq!(
1916 config.supergrok.auth_path,
1917 Some(home.join(".grok/auth.json"))
1918 );
1919 assert_eq!(
1920 config.supergrok.config_path,
1921 Some(home.join(".grok/config.toml"))
1922 );
1923 }
1924
1925 #[test]
1926 fn kiro_db_path_is_tilde_expanded() {
1927 let f = write_toml(
1928 r#"
1929 [kiro]
1930 db_path = "~/kiro-data.sqlite3"
1931 "#,
1932 );
1933 let c = Config::load_from(f.path()).unwrap();
1934 let home = crate::cache::home_dir().unwrap();
1935 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
1936 }
1937
1938 #[test]
1939 fn kiro_appears_when_enabled() {
1940 let f = write_toml(
1941 r#"
1942 [kiro]
1943 enabled = true
1944 "#,
1945 );
1946 let c = Config::load_from(f.path()).unwrap();
1947 assert!(c.is_enabled(VendorId::Kiro));
1948 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
1949 }
1950
1951 #[test]
1952 fn cursor_db_path_is_tilde_expanded() {
1953 let f = write_toml(
1954 r#"
1955 [cursor]
1956 db_path = "~/cursor-state.vscdb"
1957 "#,
1958 );
1959 let c = Config::load_from(f.path()).unwrap();
1960 let home = crate::cache::home_dir().unwrap();
1961 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
1962 }
1963
1964 #[test]
1965 fn cursor_agent_auth_path_is_tilde_expanded() {
1966 let f = write_toml(
1967 r#"
1968 [cursor]
1969 agent_auth_path = "~/cursor-agent-auth.json"
1970 "#,
1971 );
1972 let c = Config::load_from(f.path()).unwrap();
1973 let home = crate::cache::home_dir().unwrap();
1974 assert_eq!(
1975 c.cursor.agent_auth_path,
1976 Some(home.join("cursor-agent-auth.json"))
1977 );
1978 }
1979
1980 #[test]
1981 fn cursor_appears_when_enabled() {
1982 let f = write_toml(
1983 r#"
1984 [cursor]
1985 enabled = true
1986 "#,
1987 );
1988 let c = Config::load_from(f.path()).unwrap();
1989 assert!(c.is_enabled(VendorId::Cursor));
1990 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
1991 }
1992
1993 #[test]
1994 fn add_account_appends_and_preserves_existing() {
1995 let mut doc: toml_edit::DocumentMut = r#"
1996# keep me
1997[anthropic]
1998enabled = true
1999
2000[[anthropic.accounts]]
2001label = "personal"
2002credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
2003"#
2004 .parse()
2005 .unwrap();
2006 add_anthropic_account_to_doc(
2007 &mut doc,
2008 "work",
2009 "~/.config/ai-usagebar/accounts/work/.credentials.json",
2010 )
2011 .unwrap();
2012 let rendered = doc.to_string();
2013 assert!(rendered.contains("# keep me"), "comment must survive");
2014 let f = write_toml(&rendered);
2016 let c = Config::load_from(f.path()).unwrap();
2017 let labels: Vec<&str> = c
2018 .anthropic
2019 .accounts
2020 .iter()
2021 .map(|a| a.label.as_str())
2022 .collect();
2023 assert_eq!(labels, vec!["personal", "work"]);
2024 }
2025
2026 #[test]
2027 fn add_account_to_empty_doc_is_loadable() {
2028 let mut doc = toml_edit::DocumentMut::new();
2029 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
2030 let f = write_toml(&doc.to_string());
2031 let c = Config::load_from(f.path()).unwrap();
2032 assert_eq!(c.anthropic.accounts.len(), 1);
2033 assert_eq!(c.anthropic.accounts[0].label, "solo");
2034 }
2035
2036 #[test]
2037 fn add_account_rejects_duplicate_label() {
2038 let mut doc: toml_edit::DocumentMut = r#"
2039[[anthropic.accounts]]
2040label = "work"
2041credentials_path = "~/w/.credentials.json"
2042"#
2043 .parse()
2044 .unwrap();
2045 assert!(
2046 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
2047 "a duplicate label must be rejected, not appended"
2048 );
2049 }
2050
2051 #[test]
2052 fn add_account_rejects_bad_label() {
2053 let mut doc = toml_edit::DocumentMut::new();
2054 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
2055 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
2056 }
2057
2058 #[test]
2059 fn tildify_collapses_home_only() {
2060 let home = Path::new("/Users/me");
2061 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
2062 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
2063 }
2064
2065 #[test]
2066 fn default_account_credentials_path_nests_under_config_dir() {
2067 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
2068 assert_eq!(
2069 default_account_credentials_path(cfg, "work"),
2070 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
2071 );
2072 }
2073}