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