1use std::collections::{BTreeMap, HashSet};
18use std::path::{Path, PathBuf};
19
20#[cfg(unix)]
21use std::os::unix::fs::{MetadataExt, PermissionsExt};
22
23use serde::{Deserialize, Serialize};
24
25use crate::anthropic::creds::CredsTarget;
26use crate::cache::Cache;
27use crate::error::{AppError, Result};
28use crate::vendor::VendorId;
29
30#[derive(Debug, Clone, Default, Deserialize, Serialize)]
37#[serde(default, deny_unknown_fields)]
38pub struct Config {
39 pub ui: UiConfig,
40 pub context: ContextConfig,
41 pub anthropic: AnthropicConfig,
42 pub anthropic_api: AnthropicApiConfig,
43 pub openai: OpenAiConfig,
44 pub zai: ZaiConfig,
45 pub openrouter: OpenRouterConfig,
46 pub deepseek: DeepseekConfig,
47 pub kimi: KimiConfig,
48 pub kilo: KiloConfig,
49 pub novita: NovitaConfig,
50 pub moonshot: MoonshotConfig,
51 pub grok: GrokConfig,
52 pub supergrok: SuperGrokConfig,
53 pub antigravity: AntigravityConfig,
54 pub cursor: CursorConfig,
55 pub minimax: MinimaxConfig,
56 pub kiro: KiroConfig,
57 pub nous: NousConfig,
58 #[serde(rename = "opencode-go")]
59 pub opencode_go: OpenCodeGoConfig,
60}
61
62#[derive(Debug, Clone, Default, Deserialize, Serialize)]
66#[serde(default)]
67pub struct UiConfig {
68 pub primary: Option<VendorId>,
70 pub overview_vendors: Option<Vec<VendorId>>,
74 pub vendor_box: Option<VendorBoxStyle>,
76}
77
78impl UiConfig {
79 pub fn vendor_box(&self) -> VendorBoxStyle {
80 self.vendor_box.unwrap_or_default()
81 }
82}
83
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
86#[serde(rename_all = "lowercase")]
87pub enum VendorBoxStyle {
88 #[default]
90 Sidebar,
91 Navbar,
93 None,
95}
96
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
100#[serde(rename_all = "lowercase")]
101pub enum ContextLayout {
102 #[default]
104 Full,
105 Split,
107 Bottom,
109}
110
111impl ContextLayout {
112 pub fn next(self) -> Self {
113 match self {
114 ContextLayout::Full => ContextLayout::Split,
115 ContextLayout::Split => ContextLayout::Bottom,
116 ContextLayout::Bottom => ContextLayout::Full,
117 }
118 }
119
120 pub fn label(self) -> &'static str {
121 match self {
122 ContextLayout::Full => "full",
123 ContextLayout::Split => "split",
124 ContextLayout::Bottom => "bottom",
125 }
126 }
127}
128
129#[derive(Debug, Clone, Default, Deserialize, Serialize)]
134#[serde(default)]
135pub struct ContextConfig {
136 pub enabled: bool,
139 pub projects_path: Option<PathBuf>,
141 pub context_window_tokens: Option<u64>,
144 pub model_context_window_tokens: BTreeMap<String, u64>,
147 pub layout: ContextLayout,
149}
150
151impl ContextConfig {
152 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
153 model
154 .and_then(|model| self.model_context_window_tokens.get(model).copied())
155 .filter(|tokens| *tokens > 0)
156 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
157 }
158}
159
160#[derive(Debug, Clone, Deserialize, Serialize)]
161#[serde(default)]
162pub struct AnthropicConfig {
163 pub enabled: bool,
164 pub credentials_path: Option<PathBuf>,
167 pub accounts: Vec<AnthropicAccount>,
171 pub accounts_dir: Option<PathBuf>,
179 pub show_default_account: bool,
185 pub desktop_profiles_dir: Option<PathBuf>,
191}
192
193impl Default for AnthropicConfig {
194 fn default() -> Self {
195 Self {
196 enabled: true,
197 credentials_path: None,
198 accounts: Vec::new(),
199 accounts_dir: None,
200 show_default_account: true,
201 desktop_profiles_dir: None,
202 }
203 }
204}
205
206#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
216pub struct AnthropicAccount {
217 pub label: String,
220 pub credentials_path: PathBuf,
224}
225
226impl AnthropicAccount {
227 pub fn config_dir(&self) -> PathBuf {
232 self.credentials_path
233 .parent()
234 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
235 }
236}
237
238impl AnthropicConfig {
239 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
245 let mut out = self.accounts.clone();
246 if let Some(dir) = &self.accounts_dir {
247 for acct in discover_accounts(dir) {
248 if !out.iter().any(|a| a.label == acct.label) {
249 out.push(acct);
250 }
251 }
252 }
253 out
254 }
255
256 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
261 validate_account_label(label)?;
262 let all = self.all_accounts();
263 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
264 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
265 AppError::Credentials(format!(
266 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
267 known labels: {known:?}"
268 ))
269 })
270 }
271
272 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
283 let active = crate::anthropic::cli_account::home_claude_json()
284 .ok()
285 .and_then(|path| {
286 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
287 });
288 self.account_target_with(label, active.as_deref())
289 }
290
291 pub fn account_target_with(
302 &self,
303 label: &str,
304 cli_active: Option<&str>,
305 ) -> Result<(CredsTarget, Cache)> {
306 let account = self.account(label)?;
307 let cache = Cache::for_vendor_account("anthropic", label)?;
308 if cli_active == Some(label) {
309 return Ok((
310 CredsTarget::Default(crate::anthropic::creds::default_path()?),
311 cache,
312 ));
313 }
314 Ok((
315 CredsTarget::Named {
316 config_dir: account.config_dir(),
317 path: account.credentials_path,
318 },
319 cache,
320 ))
321 }
322}
323
324pub fn validate_account_label(label: &str) -> Result<()> {
330 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
331 let bad = label.is_empty()
332 || label == "."
333 || label == ".."
334 || label.contains(['/', '\\'])
335 || label.chars().any(char::is_control)
336 || RESERVED.contains(&label);
337 if bad {
338 return Err(AppError::Credentials(format!(
339 "invalid anthropic account label {label:?}: must be a non-empty name \
340 without path separators, control characters, or reserved cache names"
341 )));
342 }
343 Ok(())
344}
345
346fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
354 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
355 return Vec::new();
356 };
357 let mut found: Vec<AnthropicAccount> = entries
358 .flatten()
359 .filter_map(|entry| {
360 let path = entry.path();
361 if !path.is_dir() {
362 return None;
363 }
364 let label = path.file_name()?.to_str()?.to_string();
365 validate_account_label(&label).ok()?;
366 Some(AnthropicAccount {
367 label,
368 credentials_path: path.join(".credentials.json"),
369 })
370 })
371 .collect();
372 found.sort_by(|a, b| a.label.cmp(&b.label));
373 found
374}
375
376pub fn tildify(path: &Path, home: &Path) -> String {
380 path.strip_prefix(home)
381 .map(|rest| {
382 let rendered = rest.display().to_string();
383 #[cfg(windows)]
386 let rendered = rendered.replace('\\', "/");
387 format!("~/{rendered}")
388 })
389 .unwrap_or_else(|_| path.display().to_string())
390}
391
392pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
397 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
398 base.join("accounts").join(label).join(".credentials.json")
399}
400
401pub fn add_anthropic_account_to_doc(
407 doc: &mut toml_edit::DocumentMut,
408 label: &str,
409 credentials_path: &str,
410) -> Result<()> {
411 use toml_edit::{Item, Table, value};
412
413 validate_account_label(label)?;
414
415 let anthropic = doc
416 .entry("anthropic")
417 .or_insert_with(|| Item::Table(Table::new()));
418 let anthropic = anthropic
419 .as_table_mut()
420 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
421
422 let accounts = anthropic
423 .entry("accounts")
424 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
425 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
426 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
427 })?;
428
429 let exists = accounts
430 .iter()
431 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
432 if exists {
433 return Err(AppError::Credentials(format!(
434 "anthropic account {label:?} already exists in config.toml"
435 )));
436 }
437
438 let mut table = Table::new();
439 table["label"] = value(label);
440 table["credentials_path"] = value(credentials_path);
441 accounts.push(table);
442 Ok(())
443}
444
445#[derive(Debug, Clone, Deserialize, Serialize)]
446#[serde(default)]
447pub struct OpenAiConfig {
448 pub enabled: bool,
449 pub codex_auth_path: Option<PathBuf>,
451 pub admin_key_env: String,
459}
460
461impl Default for OpenAiConfig {
462 fn default() -> Self {
463 Self {
464 enabled: true,
465 codex_auth_path: None,
466 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
467 }
468 }
469}
470
471#[derive(Debug, Clone, Default, Deserialize, Serialize)]
472#[serde(default)]
473pub struct NousConfig {
474 pub enabled: bool,
475}
476
477#[derive(Debug, Clone, Deserialize, Serialize)]
478#[serde(default)]
479pub struct OpenCodeGoConfig {
480 pub enabled: bool,
481 pub api_key_env: String,
482 pub api_key: Option<String>,
483}
484
485impl Default for OpenCodeGoConfig {
486 fn default() -> Self {
487 Self {
488 enabled: false,
489 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
490 api_key: None,
491 }
492 }
493}
494
495#[derive(Debug, Clone, Deserialize, Serialize)]
496#[serde(default)]
497pub struct ZaiConfig {
498 pub enabled: bool,
499 pub api_key_env: String,
501 pub api_key: Option<String>,
504 pub plan_tier: Option<String>,
506}
507
508impl Default for ZaiConfig {
509 fn default() -> Self {
510 Self {
511 enabled: true,
512 api_key_env: "ZAI_API_KEY".to_string(),
513 api_key: None,
514 plan_tier: None,
515 }
516 }
517}
518
519#[derive(Debug, Clone, Deserialize, Serialize)]
520#[serde(default)]
521pub struct OpenRouterConfig {
522 pub enabled: bool,
523 pub api_key_env: String,
524 pub api_key: Option<String>,
525}
526
527impl Default for OpenRouterConfig {
528 fn default() -> Self {
529 Self {
530 enabled: true,
531 api_key_env: "OPENROUTER_API_KEY".to_string(),
532 api_key: None,
533 }
534 }
535}
536
537#[derive(Debug, Clone, Deserialize, Serialize)]
538#[serde(default)]
539pub struct DeepseekConfig {
540 pub enabled: bool,
541 pub api_key_env: String,
542 pub api_key: Option<String>,
543}
544
545impl Default for DeepseekConfig {
546 fn default() -> Self {
547 Self {
548 enabled: false,
549 api_key_env: "DEEPSEEK_API_KEY".to_string(),
550 api_key: None,
551 }
552 }
553}
554
555#[derive(Debug, Clone, Deserialize, Serialize)]
556#[serde(default)]
557pub struct KimiConfig {
558 pub enabled: bool,
559 pub api_key_env: String,
560 pub api_key: Option<String>,
561}
562
563impl Default for KimiConfig {
564 fn default() -> Self {
565 Self {
566 enabled: false,
567 api_key_env: "KIMI_API_KEY".to_string(),
568 api_key: None,
569 }
570 }
571}
572
573#[derive(Debug, Clone, Deserialize, Serialize)]
574#[serde(default)]
575pub struct KiloConfig {
576 pub enabled: bool,
577 pub api_key_env: String,
578 pub api_key: Option<String>,
579 pub organization_id: Option<String>,
582}
583
584impl Default for KiloConfig {
585 fn default() -> Self {
586 Self {
589 enabled: false,
590 api_key_env: "KILO_API_KEY".to_string(),
591 api_key: None,
592 organization_id: None,
593 }
594 }
595}
596
597#[derive(Debug, Clone, Deserialize, Serialize)]
598#[serde(default)]
599pub struct NovitaConfig {
600 pub enabled: bool,
601 pub api_key_env: String,
602 pub api_key: Option<String>,
603}
604
605impl Default for NovitaConfig {
606 fn default() -> Self {
607 Self {
609 enabled: false,
610 api_key_env: "NOVITA_API_KEY".to_string(),
611 api_key: None,
612 }
613 }
614}
615
616#[derive(Debug, Clone, Deserialize, Serialize)]
617#[serde(default)]
618pub struct MinimaxConfig {
619 pub enabled: bool,
620 pub api_key_env: String,
621 pub api_key: Option<String>,
622 pub region: String,
628}
629
630impl Default for MinimaxConfig {
631 fn default() -> Self {
632 Self {
634 enabled: false,
635 api_key_env: "MINIMAX_API_KEY".to_string(),
636 api_key: None,
637 region: "global".to_string(),
638 }
639 }
640}
641
642#[derive(Debug, Clone, Deserialize, Serialize)]
643#[serde(default)]
644pub struct MoonshotConfig {
645 pub enabled: bool,
646 pub api_key_env: String,
647 pub api_key: Option<String>,
648 pub region: String,
650}
651
652impl Default for MoonshotConfig {
653 fn default() -> Self {
654 Self {
656 enabled: false,
657 api_key_env: "MOONSHOT_API_KEY".to_string(),
658 api_key: None,
659 region: "global".to_string(),
660 }
661 }
662}
663
664#[derive(Debug, Clone, Deserialize, Serialize)]
665#[serde(default)]
666pub struct GrokConfig {
667 pub enabled: bool,
668 pub api_key_env: String,
670 pub api_key: Option<String>,
671 pub team_id: Option<String>,
674}
675
676impl Default for GrokConfig {
677 fn default() -> Self {
678 Self {
680 enabled: false,
681 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
682 api_key: None,
683 team_id: None,
684 }
685 }
686}
687
688#[derive(Debug, Clone, Deserialize, Serialize)]
696#[serde(default)]
697pub struct SuperGrokConfig {
698 pub enabled: bool,
699 pub grok_binary: PathBuf,
703 pub auth_path: Option<PathBuf>,
706 pub config_path: Option<PathBuf>,
707}
708
709impl Default for SuperGrokConfig {
710 fn default() -> Self {
711 Self {
712 enabled: false,
713 grok_binary: default_grok_binary(),
714 auth_path: None,
715 config_path: None,
716 }
717 }
718}
719
720fn default_grok_binary() -> PathBuf {
721 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
722 let grok_home = std::env::var_os("GROK_HOME")
723 .filter(|value| !value.is_empty())
724 .map(PathBuf::from)
725 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
726 grok_home
727 .map(|home| home.join("bin").join(executable))
728 .unwrap_or_else(|| PathBuf::from(executable))
729}
730
731#[derive(Debug, Clone, Default, Deserialize, Serialize)]
734#[serde(default)]
735pub struct AntigravityConfig {
736 pub enabled: bool,
737}
738
739#[derive(Debug, Clone, Default, Deserialize, Serialize)]
749#[serde(default)]
750pub struct CursorConfig {
751 pub enabled: bool,
752 pub db_path: Option<PathBuf>,
756 pub agent_auth_path: Option<PathBuf>,
761}
762
763#[derive(Debug, Clone, Default, Deserialize, Serialize)]
772#[serde(default)]
773pub struct KiroConfig {
774 pub enabled: bool,
775 pub db_path: Option<PathBuf>,
779}
780
781#[derive(Debug, Clone, Deserialize, Serialize)]
782#[serde(default)]
783pub struct AnthropicApiConfig {
784 pub enabled: bool,
785 pub api_key_env: String,
788 pub api_key: Option<String>,
789 pub monthly_limit: Option<f64>,
792}
793
794impl Default for AnthropicApiConfig {
795 fn default() -> Self {
796 Self {
798 enabled: false,
799 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
800 api_key: None,
801 monthly_limit: None,
802 }
803 }
804}
805
806pub fn resolve_api_key(
809 vendor_label: &str,
810 env_var_name: &str,
811 inline: Option<&str>,
812) -> crate::error::Result<String> {
813 let valid_env_name = is_valid_env_var_name(env_var_name);
814 if valid_env_name
815 && let Ok(v) = std::env::var(env_var_name)
816 && !v.is_empty()
817 {
818 return Ok(v);
819 }
820 if let Some(v) = inline
821 && !v.is_empty()
822 {
823 return Ok(v.to_string());
824 }
825 let advice = if valid_env_name {
826 "set an API key in a valid environment variable or set `api_key`"
827 } else {
828 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
829 };
830 let section = match vendor_label {
831 "OpenCode Go" => "opencode-go".to_string(),
832 _ => vendor_label.to_lowercase(),
833 };
834 Err(crate::error::AppError::Credentials(format!(
835 "{vendor_label}: no API key. Either {advice} under [{section}] in {}.",
836 config_path_hint()
837 )))
838}
839
840fn is_valid_env_var_name(name: &str) -> bool {
841 let mut chars = name.chars();
842 let Some(first) = chars.next() else {
843 return false;
844 };
845 (first.is_ascii_alphabetic() || first == '_')
846 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
847}
848
849impl Config {
850 pub fn load() -> Result<Self> {
853 let Some(path) = resolved_path() else {
854 return Ok(Self::default());
855 };
856 Self::load_from(&path)
857 }
858
859 pub fn load_from(path: &std::path::Path) -> Result<Self> {
860 match std::fs::read_to_string(path) {
861 Ok(s) => {
862 let mut config: Self = toml::from_str(&s)?;
863 config.expand_paths();
867 config.validate()?;
868 #[cfg(unix)]
869 config.protect_inline_api_keys(path)?;
870 Ok(config)
871 }
872 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
873 Err(e) => Err(AppError::io_at(path, e)),
874 }
875 }
876
877 fn expand_paths(&mut self) {
878 expand_tilde_opt(&mut self.context.projects_path);
879 expand_tilde_opt(&mut self.anthropic.credentials_path);
880 expand_tilde_opt(&mut self.anthropic.accounts_dir);
881 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
882 expand_tilde_opt(&mut self.openai.codex_auth_path);
883 expand_tilde_opt(&mut self.cursor.db_path);
884 expand_tilde_opt(&mut self.cursor.agent_auth_path);
885 expand_tilde_opt(&mut self.kiro.db_path);
886 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
887 expand_tilde_opt(&mut self.supergrok.auth_path);
888 expand_tilde_opt(&mut self.supergrok.config_path);
889 for account in &mut self.anthropic.accounts {
890 account.credentials_path = expand_tilde(&account.credentials_path);
891 }
892 }
893
894 #[cfg(unix)]
897 fn has_inline_api_keys(&self) -> bool {
898 [
899 self.zai.api_key.as_deref(),
900 self.openrouter.api_key.as_deref(),
901 self.deepseek.api_key.as_deref(),
902 self.kimi.api_key.as_deref(),
903 self.kilo.api_key.as_deref(),
904 self.novita.api_key.as_deref(),
905 self.minimax.api_key.as_deref(),
906 self.moonshot.api_key.as_deref(),
907 self.grok.api_key.as_deref(),
908 self.anthropic_api.api_key.as_deref(),
909 self.opencode_go.api_key.as_deref(),
910 ]
911 .into_iter()
912 .any(|key| key.is_some_and(|key| !key.is_empty()))
913 }
914
915 #[cfg(unix)]
916 fn protect_inline_api_keys(&self, path: &Path) -> Result<()> {
917 if !self.has_inline_api_keys() {
918 return Ok(());
919 }
920
921 let metadata = std::fs::metadata(path).map_err(|_| {
922 AppError::Credentials(format!(
923 "config at {} contains inline api_key values but its permissions could not be checked; fix permissions or move keys to environment variables",
924 path.display()
925 ))
926 })?;
927 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
928 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
929 AppError::Credentials(format!(
930 "config at {} contains inline api_key values but is group/other-readable and could not be tightened to 0600; fix permissions or move keys to environment variables",
931 path.display()
932 ))
933 })?;
934 }
935 Ok(())
936 }
937
938 pub fn is_enabled(&self, id: VendorId) -> bool {
939 match id {
940 VendorId::Anthropic => self.anthropic.enabled,
941 VendorId::AnthropicApi => self.anthropic_api.enabled,
942 VendorId::Openai => self.openai.enabled,
943 VendorId::Zai => self.zai.enabled,
944 VendorId::Openrouter => self.openrouter.enabled,
945 VendorId::Deepseek => self.deepseek.enabled,
946 VendorId::Kimi => self.kimi.enabled,
947 VendorId::Kilo => self.kilo.enabled,
948 VendorId::Novita => self.novita.enabled,
949 VendorId::Moonshot => self.moonshot.enabled,
950 VendorId::Grok => self.grok.enabled,
951 VendorId::Supergrok => self.supergrok.enabled,
952 VendorId::Antigravity => self.antigravity.enabled,
953 VendorId::Cursor => self.cursor.enabled,
954 VendorId::Minimax => self.minimax.enabled,
955 VendorId::Kiro => self.kiro.enabled,
956 VendorId::NousResearch => self.nous.enabled,
957 VendorId::OpenCodeGo => self.opencode_go.enabled,
958 }
959 }
960
961 pub fn enabled_vendors(&self) -> Vec<VendorId> {
962 VendorId::all()
963 .iter()
964 .copied()
965 .filter(|id| self.is_enabled(*id))
966 .collect()
967 }
968
969 pub fn validate(&self) -> Result<()> {
973 if self.context.context_window_tokens == Some(0) {
974 return Err(AppError::Other(
975 "[context] context_window_tokens must be greater than zero".into(),
976 ));
977 }
978 for (model, tokens) in &self.context.model_context_window_tokens {
979 if model.trim().is_empty() {
980 return Err(AppError::Other(
981 "[context] model_context_window_tokens keys must not be empty".into(),
982 ));
983 }
984 if *tokens == 0 {
985 return Err(AppError::Other(format!(
986 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
987 )));
988 }
989 }
990 if let Some(limit) = self.anthropic_api.monthly_limit
991 && (!limit.is_finite() || limit <= 0.0)
992 {
993 return Err(AppError::Other(
994 "[anthropic_api] monthly_limit must be finite and greater than zero; \
995 remove it to show spend without a limit"
996 .into(),
997 ));
998 }
999 if !self.minimax.region.eq_ignore_ascii_case("global")
1000 && !self.minimax.region.eq_ignore_ascii_case("cn")
1001 {
1002 return Err(AppError::Other(format!(
1003 "[minimax] region must be \"global\" or \"cn\", got {:?}",
1004 self.minimax.region
1005 )));
1006 }
1007 if self.supergrok.grok_binary.as_os_str().is_empty() {
1008 return Err(AppError::Other(
1009 "[supergrok] grok_binary must not be empty".into(),
1010 ));
1011 }
1012 let mut labels = HashSet::new();
1013 for account in &self.anthropic.accounts {
1014 validate_account_label(&account.label)?;
1015 if !labels.insert(&account.label) {
1016 return Err(AppError::Credentials(format!(
1017 "duplicate anthropic account label {:?}",
1018 account.label
1019 )));
1020 }
1021 }
1022 Ok(())
1023 }
1024}
1025
1026#[cfg(unix)]
1027#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1028enum InlineKeyPermissionDecision {
1029 Ok,
1030 Tighten,
1031}
1032
1033#[cfg(unix)]
1034fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
1035 if mode & 0o077 == 0 {
1036 InlineKeyPermissionDecision::Ok
1037 } else {
1038 InlineKeyPermissionDecision::Tighten
1039 }
1040}
1041
1042pub fn default_path() -> Option<PathBuf> {
1043 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
1044 Some(proj.config_dir().join("config.toml"))
1045}
1046
1047fn legacy_xdg_path() -> Option<PathBuf> {
1052 let home = crate::cache::home_dir().ok()?;
1053 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
1054}
1055
1056pub fn resolved_path() -> Option<PathBuf> {
1065 let canonical = default_path();
1066 if let Some(p) = &canonical
1067 && p.exists()
1068 {
1069 return canonical;
1070 }
1071 if let Some(legacy) = legacy_xdg_path()
1072 && legacy.exists()
1073 {
1074 return Some(legacy);
1075 }
1076 canonical
1077}
1078
1079fn expand_tilde(p: &std::path::Path) -> PathBuf {
1082 let Some(s) = p.to_str() else {
1083 return p.to_path_buf();
1084 };
1085 let rest = if s == "~" {
1086 ""
1087 } else if let Some(r) = s.strip_prefix("~/") {
1088 r
1089 } else {
1090 return p.to_path_buf();
1091 };
1092 match crate::cache::home_dir() {
1093 Ok(home) if rest.is_empty() => home,
1094 Ok(home) => home.join(rest),
1095 Err(_) => p.to_path_buf(),
1096 }
1097}
1098
1099fn expand_tilde_opt(p: &mut Option<PathBuf>) {
1100 if let Some(inner) = p.as_ref() {
1101 *p = Some(expand_tilde(inner));
1102 }
1103}
1104
1105pub fn config_path_hint() -> String {
1110 resolved_path()
1111 .map(|p| p.display().to_string())
1112 .unwrap_or_else(|| "config.toml".to_string())
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117 use super::*;
1118 use std::io::Write;
1119 use tempfile::NamedTempFile;
1120
1121 #[cfg(unix)]
1122 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1123
1124 fn write_toml(s: &str) -> NamedTempFile {
1125 let mut f = NamedTempFile::new().unwrap();
1126 f.write_all(s.as_bytes()).unwrap();
1127 f.flush().unwrap();
1128 f
1129 }
1130
1131 #[test]
1132 fn defaults_enable_only_the_four_core_vendors() {
1133 let c = Config::default();
1134 assert!(c.is_enabled(VendorId::Anthropic));
1135 assert!(c.is_enabled(VendorId::Openai));
1136 assert!(c.is_enabled(VendorId::Zai));
1137 assert!(c.is_enabled(VendorId::Openrouter));
1138 for opt_in in [
1139 VendorId::AnthropicApi,
1140 VendorId::Deepseek,
1141 VendorId::Kimi,
1142 VendorId::Kilo,
1143 VendorId::Novita,
1144 VendorId::Moonshot,
1145 VendorId::Grok,
1146 VendorId::Supergrok,
1147 VendorId::Cursor,
1148 VendorId::Minimax,
1149 VendorId::Kiro,
1150 ] {
1151 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
1152 }
1153 assert_eq!(c.enabled_vendors().len(), 4);
1154 }
1155
1156 #[test]
1157 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
1158 let config = Config::default();
1159 assert!(!config.is_enabled(VendorId::NousResearch));
1160 assert!(!config.is_enabled(VendorId::OpenCodeGo));
1161 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
1162 assert!(config.opencode_go.api_key.is_none());
1163 }
1164
1165 #[cfg(unix)]
1166 #[test]
1167 fn opencode_go_inline_key_is_protected_like_other_api_keys() {
1168 let mut config = Config::default();
1169 config.opencode_go.api_key = Some("<redacted>".to_string());
1170 assert!(config.has_inline_api_keys());
1171 }
1172
1173 #[test]
1174 fn missing_file_uses_defaults() {
1175 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
1176 let c = Config::load_from(path).unwrap();
1177 assert!(c.is_enabled(VendorId::Anthropic));
1178 }
1179
1180 #[test]
1181 fn parses_full_config() {
1182 let f = write_toml(
1183 r#"
1184 [anthropic]
1185 enabled = true
1186
1187 [openai]
1188 enabled = false
1189 admin_key_env = "MY_ADMIN_KEY"
1190
1191 [zai]
1192 enabled = true
1193 api_key_env = "MY_ZAI"
1194 plan_tier = "pro"
1195
1196 [openrouter]
1197 enabled = false
1198 "#,
1199 );
1200 let c = Config::load_from(f.path()).unwrap();
1201 assert!(c.is_enabled(VendorId::Anthropic));
1202 assert!(!c.is_enabled(VendorId::Openai));
1203 assert!(c.is_enabled(VendorId::Zai));
1204 assert!(!c.is_enabled(VendorId::Openrouter));
1205 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
1206 assert_eq!(c.zai.api_key_env, "MY_ZAI");
1207 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
1208 }
1209
1210 #[test]
1211 fn partial_config_falls_back_to_defaults() {
1212 let f = write_toml(
1213 r#"[openai]
1214enabled = false
1215"#,
1216 );
1217 let c = Config::load_from(f.path()).unwrap();
1218 assert!(!c.is_enabled(VendorId::Openai));
1219 assert!(c.is_enabled(VendorId::Anthropic));
1221 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
1222 }
1223
1224 #[test]
1225 fn malformed_toml_returns_error() {
1226 let f = write_toml("this is not = = valid");
1227 assert!(Config::load_from(f.path()).is_err());
1228 }
1229
1230 #[cfg(unix)]
1231 #[test]
1232 fn load_from_tightens_world_readable_config_with_inline_api_key() {
1233 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
1234 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1235
1236 Config::load_from(file.path()).unwrap();
1237
1238 assert_eq!(
1239 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1240 0o600
1241 );
1242 }
1243
1244 #[cfg(unix)]
1245 #[test]
1246 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
1247 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
1248 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1249
1250 Config::load_from(file.path()).unwrap();
1251
1252 assert_eq!(
1253 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1254 0o644
1255 );
1256 }
1257
1258 #[cfg(unix)]
1259 #[test]
1260 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
1261 assert_eq!(
1262 inline_key_permission_decision(0o600),
1263 InlineKeyPermissionDecision::Ok
1264 );
1265 assert_eq!(
1266 inline_key_permission_decision(0o640),
1267 InlineKeyPermissionDecision::Tighten
1268 );
1269 assert_eq!(
1270 inline_key_permission_decision(0o604),
1271 InlineKeyPermissionDecision::Tighten
1272 );
1273 }
1274
1275 #[test]
1276 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
1277 for value in ["0", "-1", "inf", "nan"] {
1278 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
1279 let error = Config::load_from(file.path()).unwrap_err().to_string();
1280 assert!(error.contains("monthly_limit"), "value {value}: {error}");
1281 }
1282
1283 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
1284 assert_eq!(
1285 Config::load_from(file.path())
1286 .unwrap()
1287 .anthropic_api
1288 .monthly_limit,
1289 Some(1000.0)
1290 );
1291 }
1292
1293 #[test]
1294 fn minimax_region_accepts_only_known_instances() {
1295 for region in ["global", "GLOBAL", "cn", "CN"] {
1296 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1297 assert_eq!(
1298 Config::load_from(file.path()).unwrap().minimax.region,
1299 region
1300 );
1301 }
1302
1303 for region in ["", "china", "us"] {
1304 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1305 let error = Config::load_from(file.path()).unwrap_err().to_string();
1306 assert!(error.contains("[minimax] region"), "{error}");
1307 }
1308 }
1309
1310 #[test]
1311 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
1312 let defaults = Config::default();
1313 assert!(!defaults.context.enabled);
1314 assert_eq!(
1315 defaults.context.window_tokens_for(Some("claude-test")),
1316 None
1317 );
1318
1319 let file = write_toml(
1320 r#"
1321 [context]
1322 enabled = true
1323 context_window_tokens = 200000
1324
1325 [context.model_context_window_tokens]
1326 claude-opus-1m = 1000000
1327 "claude exact id" = 300000
1328 "#,
1329 );
1330 let config = Config::load_from(file.path()).unwrap();
1331 assert!(config.context.enabled);
1332 assert_eq!(
1333 config.context.window_tokens_for(Some("claude-opus-1m")),
1334 Some(1_000_000)
1335 );
1336 assert_eq!(
1337 config.context.window_tokens_for(Some("claude exact id")),
1338 Some(300_000)
1339 );
1340 assert_eq!(
1341 config.context.window_tokens_for(Some("another-model")),
1342 Some(200_000)
1343 );
1344 }
1345
1346 #[test]
1347 fn context_layout_defaults_to_full_and_parses_each_variant() {
1348 assert_eq!(Config::default().context.layout, ContextLayout::Full);
1349 for (text, want) in [
1350 ("full", ContextLayout::Full),
1351 ("split", ContextLayout::Split),
1352 ("bottom", ContextLayout::Bottom),
1353 ] {
1354 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
1355 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
1356 }
1357 let file = write_toml("[context]\nlayout = \"floating\"\n");
1358 assert!(
1359 Config::load_from(file.path()).is_err(),
1360 "an unknown layout must be rejected, not silently defaulted"
1361 );
1362 }
1363
1364 #[test]
1365 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
1366 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
1367 for (text, want) in [
1368 ("sidebar", VendorBoxStyle::Sidebar),
1369 ("navbar", VendorBoxStyle::Navbar),
1370 ("none", VendorBoxStyle::None),
1371 ] {
1372 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
1373 assert_eq!(
1374 Config::load_from(file.path()).unwrap().ui.vendor_box(),
1375 want
1376 );
1377 }
1378 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
1379 assert!(
1380 Config::load_from(file.path()).is_err(),
1381 "an unknown vendor_box style must be rejected, not silently defaulted"
1382 );
1383 }
1384
1385 #[test]
1386 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
1387 for source in [
1388 "[context]\ncontext_window_tokens = 0\n",
1389 "[context.model_context_window_tokens]\nclaude = 0\n",
1390 "[context.model_context_window_tokens]\n\" \" = 200000\n",
1391 ] {
1392 let file = write_toml(source);
1393 let error = Config::load_from(file.path()).unwrap_err().to_string();
1394 assert!(error.contains("context"), "{error}");
1395 }
1396 }
1397
1398 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
1400 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
1401 M.lock().unwrap_or_else(|p| p.into_inner())
1402 }
1403
1404 #[test]
1405 fn resolve_api_key_prefers_env_over_inline() {
1406 let _g = env_guard();
1407 let var = "AI_USAGEBAR_TEST_ENV_WINS";
1409 unsafe { std::env::set_var(var, "from-env") };
1411 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
1412 unsafe { std::env::remove_var(var) };
1413 assert_eq!(got, "from-env");
1414 }
1415
1416 #[test]
1417 fn resolve_api_key_falls_back_to_inline() {
1418 let _g = env_guard();
1419 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
1420 unsafe { std::env::remove_var(var) };
1421 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
1422 assert_eq!(got, "inline-key");
1423 }
1424
1425 #[test]
1426 fn resolve_api_key_errors_when_both_missing() {
1427 let _g = env_guard();
1428 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
1429 unsafe { std::env::remove_var(var) };
1430 let err = resolve_api_key("Zai", var, None).unwrap_err();
1431 match err {
1432 crate::error::AppError::Credentials(msg) => {
1433 assert!(
1434 msg.contains("api_key"),
1435 "error should suggest config field: {msg}"
1436 );
1437 }
1438 other => panic!("expected Credentials error, got {other:?}"),
1439 }
1440 }
1441
1442 #[test]
1443 fn resolve_api_key_uses_exact_opencode_go_section_name() {
1444 let _g = env_guard();
1445 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
1446 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
1447 let message = err.to_string();
1448 assert!(
1449 message.contains("[opencode-go]"),
1450 "wrong section hint: {message}"
1451 );
1452 assert!(
1453 !message.contains("[opencode go]"),
1454 "wrong section hint: {message}"
1455 );
1456 }
1457
1458 #[test]
1459 fn config_path_hint_ends_with_config_toml() {
1460 assert!(config_path_hint().ends_with("config.toml"));
1463 }
1464
1465 #[test]
1466 fn resolve_api_key_treats_empty_env_as_unset() {
1467 let _g = env_guard();
1468 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
1469 unsafe { std::env::set_var(var, "") };
1470 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
1471 unsafe { std::env::remove_var(var) };
1472 assert_eq!(got, "inline");
1473 }
1474
1475 #[test]
1476 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
1477 let _g = env_guard();
1478 let bad = "sk-kimi-very-real-looking-pasted-secret";
1480 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
1481 let msg = err.to_string();
1482 assert!(
1483 msg.contains("invalid") && msg.contains("api_key_env"),
1484 "error should explain misconfiguration: {msg}"
1485 );
1486 assert!(
1487 !msg.contains(bad),
1488 "error must not echo the misconfigured value: {msg}"
1489 );
1490 assert!(msg.contains("valid environment variable name"));
1491 assert!(
1492 msg.contains("[kimi]"),
1493 "error should point at the lowercase TOML section: {msg}"
1494 );
1495 }
1496
1497 #[test]
1498 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
1499 let _g = env_guard();
1500 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
1501 assert_eq!(got, "inline-key");
1502 }
1503
1504 #[test]
1505 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
1506 let _g = env_guard();
1507 let pasted_secret = "sk_pasted_secret";
1510 unsafe { std::env::remove_var(pasted_secret) };
1511 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
1512 assert!(
1513 !err.to_string().contains(pasted_secret),
1514 "error must not echo configured api_key_env values"
1515 );
1516 }
1517
1518 #[test]
1519 fn is_valid_env_var_name_rules() {
1520 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
1522 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
1523 }
1524 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
1526 assert!(
1527 !is_valid_env_var_name(invalid),
1528 "{invalid} should be invalid"
1529 );
1530 }
1531 }
1532
1533 #[test]
1534 fn config_parses_with_inline_api_key_and_primary() {
1535 let f = write_toml(
1536 r#"
1537 [ui]
1538 primary = "openrouter"
1539
1540 [zai]
1541 enabled = true
1542 api_key_env = "MY_ZAI"
1543 api_key = "sk-zai-inline"
1544
1545 [openrouter]
1546 enabled = true
1547 api_key = "sk-or-inline"
1548 "#,
1549 );
1550 let c = Config::load_from(f.path()).unwrap();
1551 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
1552 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
1553 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
1554 }
1555
1556 #[test]
1557 fn enabled_vendors_preserves_canonical_order() {
1558 let c = Config::default();
1561 assert_eq!(
1562 c.enabled_vendors(),
1563 vec![
1564 VendorId::Anthropic,
1565 VendorId::Openai,
1566 VendorId::Zai,
1567 VendorId::Openrouter,
1568 ]
1569 );
1570 }
1571
1572 #[test]
1573 fn deepseek_appears_when_enabled() {
1574 let f = write_toml(
1575 r#"
1576 [deepseek]
1577 enabled = true
1578 api_key = "sk-test"
1579 "#,
1580 );
1581 let c = Config::load_from(f.path()).unwrap();
1582 assert!(c.is_enabled(VendorId::Deepseek));
1583 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
1584 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
1585 }
1586
1587 #[test]
1588 fn tilde_paths_are_expanded_on_load() {
1589 let f = write_toml(
1593 r#"
1594 [context]
1595 projects_path = "~/.claude/projects"
1596
1597 [anthropic]
1598 credentials_path = "~/.claude/.credentials.json"
1599
1600 [[anthropic.accounts]]
1601 label = "work"
1602 credentials_path = "~/work.json"
1603 "#,
1604 );
1605 let c = Config::load_from(f.path()).unwrap();
1606 let home = crate::cache::home_dir().unwrap();
1607
1608 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
1609 let got = c.anthropic.credentials_path.unwrap();
1610 assert_eq!(got, home.join(".claude/.credentials.json"));
1611 assert!(!got.to_string_lossy().contains('~'));
1612 assert_eq!(
1613 c.anthropic.accounts[0].credentials_path,
1614 home.join("work.json")
1615 );
1616 }
1617
1618 #[test]
1619 fn absolute_and_relative_paths_are_left_alone() {
1620 let f = write_toml(
1621 r#"
1622 [anthropic]
1623 credentials_path = "/etc/creds.json"
1624 "#,
1625 );
1626 let c = Config::load_from(f.path()).unwrap();
1627 assert_eq!(
1628 c.anthropic.credentials_path.unwrap(),
1629 std::path::Path::new("/etc/creds.json")
1630 );
1631
1632 let f2 = write_toml(
1634 r#"
1635 [anthropic]
1636 credentials_path = "~someone/creds.json"
1637 "#,
1638 );
1639 let c2 = Config::load_from(f2.path()).unwrap();
1640 assert_eq!(
1641 c2.anthropic.credentials_path.unwrap(),
1642 std::path::Path::new("~someone/creds.json")
1643 );
1644 }
1645
1646 #[test]
1647 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
1648 let p = resolved_path().expect("a config path must resolve");
1651 assert!(p.ends_with("config.toml"));
1652 let canonical = default_path().unwrap();
1653 let legacy = legacy_xdg_path().unwrap();
1654 assert!(
1655 p == canonical || p == legacy,
1656 "resolved to an unexpected location: {}",
1657 p.display()
1658 );
1659 }
1660
1661 #[test]
1662 fn misspelled_section_is_rejected_not_ignored() {
1663 let f = write_toml(
1666 r#"
1667 [openrouer]
1668 enabled = true
1669 api_key = "sk-or-v1-typo"
1670 "#,
1671 );
1672 let err = Config::load_from(f.path()).unwrap_err().to_string();
1673 assert!(
1674 err.contains("openrouer"),
1675 "error should name the typo: {err}"
1676 );
1677 }
1678
1679 #[test]
1680 fn invalid_toml_is_an_error_not_silent_defaults() {
1681 let f = write_toml("[zai\nenabled = true\n");
1682 assert!(Config::load_from(f.path()).is_err());
1683 }
1684
1685 #[test]
1686 fn a_missing_file_is_still_just_defaults() {
1687 let dir = tempfile::tempdir().unwrap();
1690 let missing = dir.path().join("nope").join("config.toml");
1691 let c = Config::load_from(&missing).unwrap();
1692 assert!(c.is_enabled(VendorId::Anthropic));
1693 }
1694
1695 #[test]
1696 fn kimi_appears_when_enabled() {
1697 let f = write_toml(
1698 r#"
1699 [kimi]
1700 enabled = true
1701 api_key = "sk-test"
1702 "#,
1703 );
1704 let c = Config::load_from(f.path()).unwrap();
1705 assert!(c.is_enabled(VendorId::Kimi));
1706 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
1707 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
1708 }
1709
1710 #[test]
1711 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
1712 let f = write_toml(
1713 r#"
1714 [deepseek]
1715 enabled = true
1716 api_key = "sk-ds"
1717
1718 [kimi]
1719 enabled = true
1720 api_key = "sk-kimi"
1721 "#,
1722 );
1723 let c = Config::load_from(f.path()).unwrap();
1724 assert_eq!(
1725 c.enabled_vendors(),
1726 vec![
1727 VendorId::Anthropic,
1728 VendorId::Openai,
1729 VendorId::Zai,
1730 VendorId::Openrouter,
1731 VendorId::Deepseek,
1732 VendorId::Kimi,
1733 ]
1734 );
1735 }
1736
1737 #[test]
1738 fn parses_anthropic_accounts_and_looks_them_up() {
1739 let f = write_toml(
1740 r#"
1741 [anthropic]
1742 enabled = true
1743
1744 [[anthropic.accounts]]
1745 label = "personal"
1746 credentials_path = "/creds/personal.json"
1747
1748 [[anthropic.accounts]]
1749 label = "work"
1750 credentials_path = "/creds/work.json"
1751 "#,
1752 );
1753 let c = Config::load_from(f.path()).unwrap();
1754 assert_eq!(c.anthropic.accounts.len(), 2);
1755 let work = c.anthropic.account("work").unwrap();
1756 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
1757 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
1759 assert!(err.contains("missing") && err.contains("work"), "{err}");
1760 }
1761
1762 #[test]
1763 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
1764 let f = write_toml(
1765 r#"
1766 [[anthropic.accounts]]
1767 label = "work"
1768 credentials_path = "/creds/work-one.json"
1769
1770 [[anthropic.accounts]]
1771 label = "work"
1772 credentials_path = "/creds/work-two.json"
1773 "#,
1774 );
1775 let err = Config::load_from(f.path()).unwrap_err().to_string();
1776 assert!(
1777 err.contains("duplicate anthropic account label \"work\""),
1778 "{err}"
1779 );
1780 }
1781
1782 #[test]
1783 fn account_label_rejects_path_like_names() {
1784 let cfg = AnthropicConfig::default();
1785 for bad in [
1786 "",
1787 ".",
1788 "..",
1789 "a/b",
1790 r"a\b",
1791 "line\nbreak",
1792 "tab\tname",
1793 "usage.json",
1794 ".stale",
1795 ".last_error",
1796 ".fetch.lock",
1797 ] {
1798 let err = cfg.account(bad).unwrap_err();
1799 assert!(
1800 format!("{err:?}").contains("invalid anthropic account label"),
1801 "{bad:?} should be rejected as a label"
1802 );
1803 }
1804 }
1805
1806 #[test]
1807 fn anthropic_accounts_default_to_empty() {
1808 assert!(Config::default().anthropic.accounts.is_empty());
1811 assert!(Config::default().anthropic.accounts_dir.is_none());
1812 }
1813
1814 fn seed_account_dir(root: &std::path::Path, label: &str) {
1820 let dir = root.join(label);
1821 std::fs::create_dir_all(&dir).unwrap();
1822 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1823 }
1824
1825 #[test]
1826 fn discovers_account_dirs_in_claude_config_dir_layout() {
1827 let td = tempfile::tempdir().unwrap();
1828 seed_account_dir(td.path(), "work");
1829 seed_account_dir(td.path(), "personal");
1830 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
1833 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
1835
1836 let cfg = AnthropicConfig {
1837 accounts_dir: Some(td.path().to_path_buf()),
1838 ..Default::default()
1839 };
1840 let all = cfg.all_accounts();
1841 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
1842 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
1843 assert_eq!(
1844 all[2].credentials_path,
1845 td.path().join("work").join(".credentials.json")
1846 );
1847 }
1848
1849 #[test]
1850 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
1851 let td = tempfile::tempdir().unwrap();
1852 seed_account_dir(td.path(), "work");
1853 let cfg = AnthropicConfig {
1854 accounts: vec![AnthropicAccount {
1855 label: "work".into(),
1856 credentials_path: "/explicit/work.json".into(),
1857 }],
1858 accounts_dir: Some(td.path().to_path_buf()),
1859 ..Default::default()
1860 };
1861 let all = cfg.all_accounts();
1862 assert_eq!(all.len(), 1, "no duplicate label");
1863 assert_eq!(
1864 all[0].credentials_path,
1865 std::path::Path::new("/explicit/work.json"),
1866 "explicit entry wins"
1867 );
1868 seed_account_dir(td.path(), "other");
1870 assert_eq!(cfg.account("other").unwrap().label, "other");
1871 }
1872
1873 #[test]
1874 fn missing_accounts_dir_is_silently_empty_not_an_error() {
1875 let cfg = AnthropicConfig {
1876 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
1877 ..Default::default()
1878 };
1879 assert!(cfg.all_accounts().is_empty());
1880 }
1881
1882 #[test]
1883 fn accounts_dir_is_tilde_expanded_on_load() {
1884 let f = write_toml(
1885 r#"
1886 [anthropic]
1887 accounts_dir = "~/.config/ai-usagebar/accounts"
1888 "#,
1889 );
1890 let c = Config::load_from(f.path()).unwrap();
1891 let home = crate::cache::home_dir().unwrap();
1892 assert_eq!(
1893 c.anthropic.accounts_dir,
1894 Some(home.join(".config/ai-usagebar/accounts"))
1895 );
1896 }
1897
1898 #[test]
1899 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
1900 let f = write_toml(
1901 r#"
1902 [anthropic]
1903 desktop_profiles_dir = "~/.claude-acc/profiles"
1904 "#,
1905 );
1906 let c = Config::load_from(f.path()).unwrap();
1907 let home = crate::cache::home_dir().unwrap();
1908 assert_eq!(
1909 c.anthropic.desktop_profiles_dir,
1910 Some(home.join(".claude-acc/profiles"))
1911 );
1912 }
1913
1914 #[test]
1915 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
1916 let cfg = AnthropicConfig {
1917 accounts: vec![
1918 AnthropicAccount {
1919 label: "work".into(),
1920 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
1921 },
1922 AnthropicAccount {
1923 label: "personal".into(),
1924 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
1925 },
1926 ],
1927 ..Default::default()
1928 };
1929
1930 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
1931 assert!(
1932 matches!(&idle, CredsTarget::Named { config_dir, .. }
1933 if config_dir == std::path::Path::new("/tmp/accounts/work")),
1934 "{idle:?}"
1935 );
1936
1937 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
1939 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
1940
1941 assert_eq!(idle_cache.dir(), live_cache.dir());
1944 }
1945
1946 #[test]
1947 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
1948 let cfg = AnthropicConfig {
1949 accounts: vec![AnthropicAccount {
1950 label: "work".into(),
1951 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
1952 }],
1953 ..Default::default()
1954 };
1955 let (target, _) = cfg.account_target_with("work", None).unwrap();
1956 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
1957 }
1958
1959 fn config_example() -> PathBuf {
1963 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
1964 }
1965
1966 #[test]
1967 fn shipped_example_parses_as_a_real_config() {
1968 let c = Config::load_from(&config_example()).unwrap();
1973 assert!(!c.context.enabled);
1974 assert!(c.is_enabled(VendorId::Anthropic));
1975 assert!(c.is_enabled(VendorId::Openai));
1976 assert!(!c.is_enabled(VendorId::AnthropicApi));
1977 assert!(!c.is_enabled(VendorId::Deepseek));
1978 assert!(!c.is_enabled(VendorId::Kimi));
1979 assert!(!c.is_enabled(VendorId::Kilo));
1980 assert!(!c.is_enabled(VendorId::Novita));
1981 assert!(!c.is_enabled(VendorId::Moonshot));
1982 assert!(!c.is_enabled(VendorId::Grok));
1983 assert!(!c.is_enabled(VendorId::Cursor));
1984 assert!(!c.is_enabled(VendorId::Minimax));
1985 }
1986
1987 #[test]
1988 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
1989 let text = std::fs::read_to_string(config_example()).unwrap();
1994 let live: Vec<&str> = text
1995 .lines()
1996 .map(str::trim)
1997 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
1998 .collect();
1999 assert!(
2000 live.is_empty(),
2001 "admin_key_env must stay commented out while it is inert: {live:?}"
2002 );
2003 assert!(
2006 text.contains("admin_key_env") && text.contains("RESERVED"),
2007 "the example should keep describing admin_key_env as reserved"
2008 );
2009 }
2010
2011 #[test]
2012 fn admin_key_env_is_accepted_but_changes_nothing() {
2013 let f = write_toml(
2017 r#"
2018 [openai]
2019 admin_key_env = "SOME_ADMIN_KEY"
2020 "#,
2021 );
2022 let c = Config::load_from(f.path()).unwrap();
2023 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
2024 let default = OpenAiConfig::default();
2026 assert_eq!(c.openai.enabled, default.enabled);
2027 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
2028 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
2029 }
2030
2031 #[test]
2032 fn config_example_documents_every_vendor_without_secrets() {
2033 let raw = std::fs::read_to_string(config_example()).unwrap();
2034 let cfg = Config::load_from(&config_example()).unwrap();
2035 for id in VendorId::all() {
2038 let section = id.slug();
2039 assert!(
2040 raw.contains(&format!("[{section}]")),
2041 "config.example.toml has no [{section}] section"
2042 );
2043 }
2044
2045 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
2048 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
2049 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
2050 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
2051 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
2052 assert!(!cfg.supergrok.enabled);
2053 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
2054 assert_eq!(
2055 cfg.supergrok
2056 .grok_binary
2057 .file_name()
2058 .and_then(|p| p.to_str()),
2059 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
2060 );
2061 assert!(cfg.supergrok.auth_path.is_none());
2062 assert!(cfg.supergrok.config_path.is_none());
2063 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
2064 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
2065 }
2066
2067 #[test]
2068 fn supergrok_binary_must_not_be_empty() {
2069 let file = write_toml(
2070 r#"
2071 [supergrok]
2072 enabled = true
2073 grok_binary = ""
2074 "#,
2075 );
2076 let error = Config::load_from(file.path()).unwrap_err().to_string();
2077 assert!(error.contains("grok_binary must not be empty"));
2078 }
2079
2080 #[test]
2081 fn supergrok_paths_are_tilde_expanded() {
2082 let file = write_toml(
2083 r#"
2084 [supergrok]
2085 grok_binary = "~/bin/grok"
2086 auth_path = "~/.grok/auth.json"
2087 config_path = "~/.grok/config.toml"
2088 "#,
2089 );
2090 let config = Config::load_from(file.path()).unwrap();
2091 let home = crate::cache::home_dir().unwrap();
2092 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
2093 assert_eq!(
2094 config.supergrok.auth_path,
2095 Some(home.join(".grok/auth.json"))
2096 );
2097 assert_eq!(
2098 config.supergrok.config_path,
2099 Some(home.join(".grok/config.toml"))
2100 );
2101 }
2102
2103 #[test]
2104 fn kiro_db_path_is_tilde_expanded() {
2105 let f = write_toml(
2106 r#"
2107 [kiro]
2108 db_path = "~/kiro-data.sqlite3"
2109 "#,
2110 );
2111 let c = Config::load_from(f.path()).unwrap();
2112 let home = crate::cache::home_dir().unwrap();
2113 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
2114 }
2115
2116 #[test]
2117 fn kiro_appears_when_enabled() {
2118 let f = write_toml(
2119 r#"
2120 [kiro]
2121 enabled = true
2122 "#,
2123 );
2124 let c = Config::load_from(f.path()).unwrap();
2125 assert!(c.is_enabled(VendorId::Kiro));
2126 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
2127 }
2128
2129 #[test]
2130 fn cursor_db_path_is_tilde_expanded() {
2131 let f = write_toml(
2132 r#"
2133 [cursor]
2134 db_path = "~/cursor-state.vscdb"
2135 "#,
2136 );
2137 let c = Config::load_from(f.path()).unwrap();
2138 let home = crate::cache::home_dir().unwrap();
2139 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
2140 }
2141
2142 #[test]
2143 fn cursor_agent_auth_path_is_tilde_expanded() {
2144 let f = write_toml(
2145 r#"
2146 [cursor]
2147 agent_auth_path = "~/cursor-agent-auth.json"
2148 "#,
2149 );
2150 let c = Config::load_from(f.path()).unwrap();
2151 let home = crate::cache::home_dir().unwrap();
2152 assert_eq!(
2153 c.cursor.agent_auth_path,
2154 Some(home.join("cursor-agent-auth.json"))
2155 );
2156 }
2157
2158 #[test]
2159 fn cursor_appears_when_enabled() {
2160 let f = write_toml(
2161 r#"
2162 [cursor]
2163 enabled = true
2164 "#,
2165 );
2166 let c = Config::load_from(f.path()).unwrap();
2167 assert!(c.is_enabled(VendorId::Cursor));
2168 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
2169 }
2170
2171 #[test]
2172 fn add_account_appends_and_preserves_existing() {
2173 let mut doc: toml_edit::DocumentMut = r#"
2174# keep me
2175[anthropic]
2176enabled = true
2177
2178[[anthropic.accounts]]
2179label = "personal"
2180credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
2181"#
2182 .parse()
2183 .unwrap();
2184 add_anthropic_account_to_doc(
2185 &mut doc,
2186 "work",
2187 "~/.config/ai-usagebar/accounts/work/.credentials.json",
2188 )
2189 .unwrap();
2190 let rendered = doc.to_string();
2191 assert!(rendered.contains("# keep me"), "comment must survive");
2192 let f = write_toml(&rendered);
2194 let c = Config::load_from(f.path()).unwrap();
2195 let labels: Vec<&str> = c
2196 .anthropic
2197 .accounts
2198 .iter()
2199 .map(|a| a.label.as_str())
2200 .collect();
2201 assert_eq!(labels, vec!["personal", "work"]);
2202 }
2203
2204 #[test]
2205 fn add_account_to_empty_doc_is_loadable() {
2206 let mut doc = toml_edit::DocumentMut::new();
2207 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
2208 let f = write_toml(&doc.to_string());
2209 let c = Config::load_from(f.path()).unwrap();
2210 assert_eq!(c.anthropic.accounts.len(), 1);
2211 assert_eq!(c.anthropic.accounts[0].label, "solo");
2212 }
2213
2214 #[test]
2215 fn add_account_rejects_duplicate_label() {
2216 let mut doc: toml_edit::DocumentMut = r#"
2217[[anthropic.accounts]]
2218label = "work"
2219credentials_path = "~/w/.credentials.json"
2220"#
2221 .parse()
2222 .unwrap();
2223 assert!(
2224 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
2225 "a duplicate label must be rejected, not appended"
2226 );
2227 }
2228
2229 #[test]
2230 fn add_account_rejects_bad_label() {
2231 let mut doc = toml_edit::DocumentMut::new();
2232 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
2233 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
2234 }
2235
2236 #[test]
2237 fn tildify_collapses_home_only() {
2238 let home = Path::new("/Users/me");
2239 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
2240 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
2241 }
2242
2243 #[test]
2244 fn default_account_credentials_path_nests_under_config_dir() {
2245 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
2246 assert_eq!(
2247 default_account_credentials_path(cfg, "work"),
2248 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
2249 );
2250 }
2251}