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 validate_account_label_for("anthropic", label)
331}
332
333fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
334 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
335 let bad = label.is_empty()
336 || label == "."
337 || label == ".."
338 || label.contains(['/', '\\'])
339 || label.contains(':')
340 || label.chars().any(char::is_control)
341 || RESERVED.contains(&label);
342 if bad {
343 return Err(AppError::Credentials(format!(
344 "invalid {vendor} account label {label:?}: must be a non-empty name \
345 without path separators, drive prefixes, control characters, or reserved cache names"
346 )));
347 }
348 Ok(())
349}
350
351fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
359 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
360 return Vec::new();
361 };
362 let mut found: Vec<AnthropicAccount> = entries
363 .flatten()
364 .filter_map(|entry| {
365 let path = entry.path();
366 if !path.is_dir() {
367 return None;
368 }
369 let label = path.file_name()?.to_str()?.to_string();
370 validate_account_label(&label).ok()?;
371 Some(AnthropicAccount {
372 label,
373 credentials_path: path.join(".credentials.json"),
374 })
375 })
376 .collect();
377 found.sort_by(|a, b| a.label.cmp(&b.label));
378 found
379}
380
381pub fn tildify(path: &Path, home: &Path) -> String {
385 path.strip_prefix(home)
386 .map(|rest| {
387 let rendered = rest.display().to_string();
388 #[cfg(windows)]
391 let rendered = rendered.replace('\\', "/");
392 format!("~/{rendered}")
393 })
394 .unwrap_or_else(|_| path.display().to_string())
395}
396
397pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
402 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
403 base.join("accounts").join(label).join(".credentials.json")
404}
405
406pub fn add_anthropic_account_to_doc(
412 doc: &mut toml_edit::DocumentMut,
413 label: &str,
414 credentials_path: &str,
415) -> Result<()> {
416 use toml_edit::{Item, Table, value};
417
418 validate_account_label(label)?;
419
420 let anthropic = doc
421 .entry("anthropic")
422 .or_insert_with(|| Item::Table(Table::new()));
423 let anthropic = anthropic
424 .as_table_mut()
425 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
426
427 let accounts = anthropic
428 .entry("accounts")
429 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
430 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
431 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
432 })?;
433
434 let exists = accounts
435 .iter()
436 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
437 if exists {
438 return Err(AppError::Credentials(format!(
439 "anthropic account {label:?} already exists in config.toml"
440 )));
441 }
442
443 let mut table = Table::new();
444 table["label"] = value(label);
445 table["credentials_path"] = value(credentials_path);
446 accounts.push(table);
447 Ok(())
448}
449
450#[derive(Debug, Clone, Deserialize, Serialize)]
451#[serde(default)]
452pub struct OpenAiConfig {
453 pub enabled: bool,
454 pub codex_auth_path: Option<PathBuf>,
456 #[serde(default)]
461 pub accounts: Vec<OpenAiAccount>,
462 pub admin_key_env: String,
470}
471
472#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
483pub struct OpenAiAccount {
484 pub label: String,
487 pub codex_auth_path: PathBuf,
490}
491
492impl OpenAiConfig {
493 pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
497 let Some(label) = label else {
498 return match &self.codex_auth_path {
499 Some(path) => Ok(path.clone()),
500 None => crate::openai::creds::default_path(),
501 };
502 };
503 self.accounts
504 .iter()
505 .find(|account| account.label == label)
506 .map(|account| account.codex_auth_path.clone())
507 .ok_or_else(|| {
508 AppError::Credentials(format!(
509 "no OpenAI account named {label:?}. Add it under \
510 [[openai.accounts]], or drop --account to use the default login."
511 ))
512 })
513 }
514}
515
516impl Default for OpenAiConfig {
517 fn default() -> Self {
518 Self {
519 enabled: true,
520 codex_auth_path: None,
521 accounts: Vec::new(),
522 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
523 }
524 }
525}
526
527#[derive(Debug, Clone, Default, Deserialize, Serialize)]
528#[serde(default)]
529pub struct NousConfig {
530 pub enabled: bool,
531}
532
533#[derive(Debug, Clone, Deserialize, Serialize)]
534#[serde(default)]
535pub struct OpenCodeGoConfig {
536 pub enabled: bool,
537 pub api_key_env: String,
538 pub api_key: Option<String>,
539}
540
541impl Default for OpenCodeGoConfig {
542 fn default() -> Self {
543 Self {
544 enabled: false,
545 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
546 api_key: None,
547 }
548 }
549}
550
551#[derive(Debug, Clone, Deserialize, Serialize)]
552#[serde(default)]
553pub struct ZaiConfig {
554 pub enabled: bool,
555 pub api_key_env: String,
557 pub api_key: Option<String>,
560 pub plan_tier: Option<String>,
562}
563
564impl Default for ZaiConfig {
565 fn default() -> Self {
566 Self {
567 enabled: true,
568 api_key_env: "ZAI_API_KEY".to_string(),
569 api_key: None,
570 plan_tier: None,
571 }
572 }
573}
574
575#[derive(Debug, Clone, Deserialize, Serialize)]
576#[serde(default)]
577pub struct OpenRouterConfig {
578 pub enabled: bool,
579 pub accounts: Vec<OpenRouterAccount>,
582 pub show_default_account: bool,
586 pub api_key_env: String,
587 pub api_key: Option<String>,
588}
589
590impl Default for OpenRouterConfig {
591 fn default() -> Self {
592 Self {
593 enabled: true,
594 accounts: Vec::new(),
595 show_default_account: true,
596 api_key_env: "OPENROUTER_API_KEY".to_string(),
597 api_key: None,
598 }
599 }
600}
601
602#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
605pub struct OpenRouterAccount {
606 pub label: String,
608 #[serde(default)]
610 pub api_key_env: Option<String>,
611 #[serde(default)]
613 pub api_key: Option<String>,
614}
615
616impl OpenRouterConfig {
617 pub fn account(&self, label: &str) -> Result<&OpenRouterAccount> {
620 validate_account_label_for("openrouter", label)?;
621 self.accounts
622 .iter()
623 .find(|account| account.label == label)
624 .ok_or_else(|| {
625 let known: Vec<&str> = self
626 .accounts
627 .iter()
628 .map(|account| account.label.as_str())
629 .collect();
630 AppError::Credentials(format!(
631 "openrouter account {label:?} not found in [[openrouter.accounts]]; \
632 known labels: {known:?}"
633 ))
634 })
635 }
636
637 pub fn resolve_api_key(&self, label: Option<&str>) -> Result<String> {
640 match label {
641 None => resolve_api_key("OpenRouter", &self.api_key_env, self.api_key.as_deref()),
642 Some(label) => {
643 let account = self.account(label)?;
644 resolve_api_key_in_section(
645 &format!("OpenRouter account {label:?}"),
646 "[[openrouter.accounts]]",
647 account.api_key_env.as_deref().unwrap_or(""),
648 account.api_key.as_deref(),
649 )
650 }
651 }
652 }
653}
654
655#[derive(Debug, Clone, Deserialize, Serialize)]
656#[serde(default)]
657pub struct DeepseekConfig {
658 pub enabled: bool,
659 pub api_key_env: String,
660 pub api_key: Option<String>,
661}
662
663impl Default for DeepseekConfig {
664 fn default() -> Self {
665 Self {
666 enabled: false,
667 api_key_env: "DEEPSEEK_API_KEY".to_string(),
668 api_key: None,
669 }
670 }
671}
672
673#[derive(Debug, Clone, Deserialize, Serialize)]
674#[serde(default)]
675pub struct KimiConfig {
676 pub enabled: bool,
677 pub api_key_env: String,
678 pub api_key: Option<String>,
681 pub credentials_path: Option<PathBuf>,
685 pub region: String,
690}
691
692impl Default for KimiConfig {
693 fn default() -> Self {
694 Self {
695 enabled: false,
696 api_key_env: "KIMI_API_KEY".to_string(),
697 api_key: None,
698 credentials_path: None,
699 region: "auto".to_string(),
700 }
701 }
702}
703
704#[derive(Debug, Clone, Deserialize, Serialize)]
705#[serde(default)]
706pub struct KiloConfig {
707 pub enabled: bool,
708 pub api_key_env: String,
709 pub api_key: Option<String>,
710 pub organization_id: Option<String>,
713}
714
715impl Default for KiloConfig {
716 fn default() -> Self {
717 Self {
720 enabled: false,
721 api_key_env: "KILO_API_KEY".to_string(),
722 api_key: None,
723 organization_id: None,
724 }
725 }
726}
727
728#[derive(Debug, Clone, Deserialize, Serialize)]
729#[serde(default)]
730pub struct NovitaConfig {
731 pub enabled: bool,
732 pub api_key_env: String,
733 pub api_key: Option<String>,
734}
735
736impl Default for NovitaConfig {
737 fn default() -> Self {
738 Self {
740 enabled: false,
741 api_key_env: "NOVITA_API_KEY".to_string(),
742 api_key: None,
743 }
744 }
745}
746
747#[derive(Debug, Clone, Deserialize, Serialize)]
748#[serde(default)]
749pub struct MinimaxConfig {
750 pub enabled: bool,
751 pub api_key_env: String,
752 pub api_key: Option<String>,
753 pub region: String,
759}
760
761impl Default for MinimaxConfig {
762 fn default() -> Self {
763 Self {
765 enabled: false,
766 api_key_env: "MINIMAX_API_KEY".to_string(),
767 api_key: None,
768 region: "global".to_string(),
769 }
770 }
771}
772
773#[derive(Debug, Clone, Deserialize, Serialize)]
774#[serde(default)]
775pub struct MoonshotConfig {
776 pub enabled: bool,
777 pub api_key_env: String,
778 pub api_key: Option<String>,
779 pub region: String,
781}
782
783impl Default for MoonshotConfig {
784 fn default() -> Self {
785 Self {
787 enabled: false,
788 api_key_env: "MOONSHOT_API_KEY".to_string(),
789 api_key: None,
790 region: "global".to_string(),
791 }
792 }
793}
794
795#[derive(Debug, Clone, Deserialize, Serialize)]
796#[serde(default)]
797pub struct GrokConfig {
798 pub enabled: bool,
799 pub api_key_env: String,
801 pub api_key: Option<String>,
802 pub team_id: Option<String>,
805}
806
807impl Default for GrokConfig {
808 fn default() -> Self {
809 Self {
811 enabled: false,
812 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
813 api_key: None,
814 team_id: None,
815 }
816 }
817}
818
819#[derive(Debug, Clone, Deserialize, Serialize)]
827#[serde(default)]
828pub struct SuperGrokConfig {
829 pub enabled: bool,
830 pub grok_binary: PathBuf,
834 pub auth_path: Option<PathBuf>,
837 pub config_path: Option<PathBuf>,
838}
839
840impl Default for SuperGrokConfig {
841 fn default() -> Self {
842 Self {
843 enabled: false,
844 grok_binary: default_grok_binary(),
845 auth_path: None,
846 config_path: None,
847 }
848 }
849}
850
851fn default_grok_binary() -> PathBuf {
852 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
853 let grok_home = std::env::var_os("GROK_HOME")
854 .filter(|value| !value.is_empty())
855 .map(PathBuf::from)
856 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
857 grok_home
858 .map(|home| home.join("bin").join(executable))
859 .unwrap_or_else(|| PathBuf::from(executable))
860}
861
862#[derive(Debug, Clone, Default, Deserialize, Serialize)]
865#[serde(default)]
866pub struct AntigravityConfig {
867 pub enabled: bool,
868}
869
870#[derive(Debug, Clone, Default, Deserialize, Serialize)]
880#[serde(default)]
881pub struct CursorConfig {
882 pub enabled: bool,
883 pub db_path: Option<PathBuf>,
887 pub agent_auth_path: Option<PathBuf>,
892}
893
894#[derive(Debug, Clone, Default, Deserialize, Serialize)]
903#[serde(default)]
904pub struct KiroConfig {
905 pub enabled: bool,
906 pub db_path: Option<PathBuf>,
910}
911
912#[derive(Debug, Clone, Deserialize, Serialize)]
913#[serde(default)]
914pub struct AnthropicApiConfig {
915 pub enabled: bool,
916 pub api_key_env: String,
919 pub api_key: Option<String>,
920 pub monthly_limit: Option<f64>,
923}
924
925impl Default for AnthropicApiConfig {
926 fn default() -> Self {
927 Self {
929 enabled: false,
930 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
931 api_key: None,
932 monthly_limit: None,
933 }
934 }
935}
936
937pub fn resolve_api_key(
940 vendor_label: &str,
941 env_var_name: &str,
942 inline: Option<&str>,
943) -> crate::error::Result<String> {
944 let section = match vendor_label {
945 "OpenCode Go" => "[opencode-go]".to_string(),
946 _ => format!("[{}]", vendor_label.to_lowercase()),
947 };
948 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
949}
950
951pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
955 if is_valid_env_var_name(env_var_name)
956 && let Ok(v) = std::env::var(env_var_name)
957 && !v.is_empty()
958 {
959 return Some(v);
960 }
961 inline.filter(|v| !v.is_empty()).map(str::to_string)
962}
963
964fn resolve_api_key_in_section(
965 vendor_label: &str,
966 section: &str,
967 env_var_name: &str,
968 inline: Option<&str>,
969) -> crate::error::Result<String> {
970 if let Some(key) = optional_api_key(env_var_name, inline) {
971 return Ok(key);
972 }
973 let valid_env_name = is_valid_env_var_name(env_var_name);
974 let advice = if valid_env_name {
975 "set an API key in a valid environment variable or set `api_key`"
976 } else {
977 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
978 };
979 Err(crate::error::AppError::Credentials(format!(
980 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
981 config_path_hint()
982 )))
983}
984
985fn is_valid_env_var_name(name: &str) -> bool {
986 let mut chars = name.chars();
987 let Some(first) = chars.next() else {
988 return false;
989 };
990 (first.is_ascii_alphabetic() || first == '_')
991 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
992}
993
994impl Config {
995 pub fn load() -> Result<Self> {
998 let Some(path) = resolved_path() else {
999 return Ok(Self::default());
1000 };
1001 Self::load_from(&path)
1002 }
1003
1004 pub fn load_from(path: &std::path::Path) -> Result<Self> {
1005 match std::fs::read_to_string(path) {
1006 Ok(s) => {
1007 let mut config: Self = toml::from_str(&s)?;
1008 config.expand_paths();
1012 config.validate()?;
1013 #[cfg(unix)]
1014 config.protect_inline_api_keys(path)?;
1015 Ok(config)
1016 }
1017 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1018 Err(e) => Err(AppError::io_at(path, e)),
1019 }
1020 }
1021
1022 fn expand_paths(&mut self) {
1023 expand_tilde_opt(&mut self.context.projects_path);
1024 expand_tilde_opt(&mut self.anthropic.credentials_path);
1025 expand_tilde_opt(&mut self.anthropic.accounts_dir);
1026 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
1027 expand_tilde_opt(&mut self.openai.codex_auth_path);
1028 expand_tilde_opt(&mut self.cursor.db_path);
1029 expand_tilde_opt(&mut self.cursor.agent_auth_path);
1030 expand_tilde_opt(&mut self.kiro.db_path);
1031 expand_tilde_opt(&mut self.kimi.credentials_path);
1032 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
1033 expand_tilde_opt(&mut self.supergrok.auth_path);
1034 expand_tilde_opt(&mut self.supergrok.config_path);
1035 for account in &mut self.anthropic.accounts {
1036 account.credentials_path = expand_tilde(&account.credentials_path);
1037 }
1038 }
1039
1040 #[cfg(unix)]
1043 fn has_inline_api_keys(&self) -> bool {
1044 [
1045 self.zai.api_key.as_deref(),
1046 self.openrouter.api_key.as_deref(),
1047 self.deepseek.api_key.as_deref(),
1048 self.kimi.api_key.as_deref(),
1049 self.kilo.api_key.as_deref(),
1050 self.novita.api_key.as_deref(),
1051 self.minimax.api_key.as_deref(),
1052 self.moonshot.api_key.as_deref(),
1053 self.grok.api_key.as_deref(),
1054 self.anthropic_api.api_key.as_deref(),
1055 self.opencode_go.api_key.as_deref(),
1056 ]
1057 .into_iter()
1058 .chain(
1059 self.openrouter
1060 .accounts
1061 .iter()
1062 .map(|account| account.api_key.as_deref()),
1063 )
1064 .any(|key| key.is_some_and(|key| !key.is_empty()))
1065 }
1066
1067 #[cfg(unix)]
1068 fn protect_inline_api_keys(&self, path: &Path) -> Result<()> {
1069 if !self.has_inline_api_keys() {
1070 return Ok(());
1071 }
1072
1073 let metadata = std::fs::metadata(path).map_err(|_| {
1074 AppError::Credentials(format!(
1075 "config at {} contains inline api_key values but its permissions could not be checked; fix permissions or move keys to environment variables",
1076 path.display()
1077 ))
1078 })?;
1079 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
1080 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
1081 AppError::Credentials(format!(
1082 "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",
1083 path.display()
1084 ))
1085 })?;
1086 }
1087 Ok(())
1088 }
1089
1090 pub fn is_enabled(&self, id: VendorId) -> bool {
1091 match id {
1092 VendorId::Anthropic => self.anthropic.enabled,
1093 VendorId::AnthropicApi => self.anthropic_api.enabled,
1094 VendorId::Openai => self.openai.enabled,
1095 VendorId::Zai => self.zai.enabled,
1096 VendorId::Openrouter => self.openrouter.enabled,
1097 VendorId::Deepseek => self.deepseek.enabled,
1098 VendorId::Kimi => self.kimi.enabled,
1099 VendorId::Kilo => self.kilo.enabled,
1100 VendorId::Novita => self.novita.enabled,
1101 VendorId::Moonshot => self.moonshot.enabled,
1102 VendorId::Grok => self.grok.enabled,
1103 VendorId::Supergrok => self.supergrok.enabled,
1104 VendorId::Antigravity => self.antigravity.enabled,
1105 VendorId::Cursor => self.cursor.enabled,
1106 VendorId::Minimax => self.minimax.enabled,
1107 VendorId::Kiro => self.kiro.enabled,
1108 VendorId::NousResearch => self.nous.enabled,
1109 VendorId::OpenCodeGo => self.opencode_go.enabled,
1110 }
1111 }
1112
1113 pub fn enabled_vendors(&self) -> Vec<VendorId> {
1114 VendorId::all()
1115 .iter()
1116 .copied()
1117 .filter(|id| self.is_enabled(*id))
1118 .collect()
1119 }
1120
1121 pub fn validate(&self) -> Result<()> {
1125 if self.context.context_window_tokens == Some(0) {
1126 return Err(AppError::Other(
1127 "[context] context_window_tokens must be greater than zero".into(),
1128 ));
1129 }
1130 for (model, tokens) in &self.context.model_context_window_tokens {
1131 if model.trim().is_empty() {
1132 return Err(AppError::Other(
1133 "[context] model_context_window_tokens keys must not be empty".into(),
1134 ));
1135 }
1136 if *tokens == 0 {
1137 return Err(AppError::Other(format!(
1138 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
1139 )));
1140 }
1141 }
1142 if let Some(limit) = self.anthropic_api.monthly_limit
1143 && (!limit.is_finite() || limit <= 0.0)
1144 {
1145 return Err(AppError::Other(
1146 "[anthropic_api] monthly_limit must be finite and greater than zero; \
1147 remove it to show spend without a limit"
1148 .into(),
1149 ));
1150 }
1151 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
1152 && !self.kimi.region.eq_ignore_ascii_case("auto")
1153 {
1154 return Err(AppError::Other(format!(
1155 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
1156 self.kimi.region
1157 )));
1158 }
1159 if !self.minimax.region.eq_ignore_ascii_case("global")
1160 && !self.minimax.region.eq_ignore_ascii_case("cn")
1161 {
1162 return Err(AppError::Other(format!(
1163 "[minimax] region must be \"global\" or \"cn\", got {:?}",
1164 self.minimax.region
1165 )));
1166 }
1167 if self.supergrok.grok_binary.as_os_str().is_empty() {
1168 return Err(AppError::Other(
1169 "[supergrok] grok_binary must not be empty".into(),
1170 ));
1171 }
1172 let mut labels = HashSet::new();
1173 for account in &self.anthropic.accounts {
1174 validate_account_label(&account.label)?;
1175 if !labels.insert(&account.label) {
1176 return Err(AppError::Credentials(format!(
1177 "duplicate anthropic account label {:?}",
1178 account.label
1179 )));
1180 }
1181 }
1182 let mut openrouter_labels = HashSet::new();
1183 for account in &self.openrouter.accounts {
1184 validate_account_label_for("openrouter", &account.label)?;
1185 if !openrouter_labels.insert(&account.label) {
1186 return Err(AppError::Credentials(format!(
1187 "duplicate openrouter account label {:?}",
1188 account.label
1189 )));
1190 }
1191 let has_env = account
1192 .api_key_env
1193 .as_deref()
1194 .is_some_and(|name| !name.is_empty());
1195 let has_inline = account
1196 .api_key
1197 .as_deref()
1198 .is_some_and(|key| !key.is_empty());
1199 if !has_env && !has_inline {
1200 return Err(AppError::Credentials(format!(
1201 "openrouter account {:?} must set api_key_env or api_key",
1202 account.label
1203 )));
1204 }
1205 }
1206 Ok(())
1207 }
1208}
1209
1210#[cfg(unix)]
1211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1212enum InlineKeyPermissionDecision {
1213 Ok,
1214 Tighten,
1215}
1216
1217#[cfg(unix)]
1218fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
1219 if mode & 0o077 == 0 {
1220 InlineKeyPermissionDecision::Ok
1221 } else {
1222 InlineKeyPermissionDecision::Tighten
1223 }
1224}
1225
1226pub fn default_path() -> Option<PathBuf> {
1227 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
1228 Some(proj.config_dir().join("config.toml"))
1229}
1230
1231fn legacy_xdg_path() -> Option<PathBuf> {
1236 let home = crate::cache::home_dir().ok()?;
1237 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
1238}
1239
1240pub fn resolved_path() -> Option<PathBuf> {
1249 let canonical = default_path();
1250 if let Some(p) = &canonical
1251 && p.exists()
1252 {
1253 return canonical;
1254 }
1255 if let Some(legacy) = legacy_xdg_path()
1256 && legacy.exists()
1257 {
1258 return Some(legacy);
1259 }
1260 canonical
1261}
1262
1263fn expand_tilde(p: &std::path::Path) -> PathBuf {
1266 let Some(s) = p.to_str() else {
1267 return p.to_path_buf();
1268 };
1269 let rest = if s == "~" {
1270 ""
1271 } else if let Some(r) = s.strip_prefix("~/") {
1272 r
1273 } else {
1274 return p.to_path_buf();
1275 };
1276 match crate::cache::home_dir() {
1277 Ok(home) if rest.is_empty() => home,
1278 Ok(home) => home.join(rest),
1279 Err(_) => p.to_path_buf(),
1280 }
1281}
1282
1283fn expand_tilde_opt(p: &mut Option<PathBuf>) {
1284 if let Some(inner) = p.as_ref() {
1285 *p = Some(expand_tilde(inner));
1286 }
1287}
1288
1289pub fn config_path_hint() -> String {
1294 resolved_path()
1295 .map(|p| p.display().to_string())
1296 .unwrap_or_else(|| "config.toml".to_string())
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301 use super::*;
1302 use std::io::Write;
1303 use tempfile::NamedTempFile;
1304
1305 #[cfg(unix)]
1306 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1307
1308 fn write_toml(s: &str) -> NamedTempFile {
1309 let mut f = NamedTempFile::new().unwrap();
1310 f.write_all(s.as_bytes()).unwrap();
1311 f.flush().unwrap();
1312 f
1313 }
1314
1315 #[test]
1319 fn openai_without_accounts_resolves_the_singular_path() {
1320 let explicit = OpenAiConfig {
1321 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
1322 ..OpenAiConfig::default()
1323 };
1324 assert_eq!(
1325 explicit.resolve_auth_path(None).unwrap(),
1326 PathBuf::from("/tmp/codex/auth.json")
1327 );
1328
1329 let bare = OpenAiConfig::default();
1330 assert_eq!(
1331 bare.resolve_auth_path(None).unwrap(),
1332 crate::openai::creds::default_path().unwrap(),
1333 "no codex_auth_path must still mean ~/.codex/auth.json"
1334 );
1335 }
1336
1337 #[test]
1340 fn openai_named_accounts_resolve_their_own_auth_file() {
1341 let config: Config = toml::from_str(
1342 r#"
1343 [openai]
1344 codex_auth_path = "/tmp/personal/auth.json"
1345 [[openai.accounts]]
1346 label = "work"
1347 codex_auth_path = "/tmp/work/auth.json"
1348 "#,
1349 )
1350 .unwrap();
1351
1352 assert_eq!(
1353 config.openai.resolve_auth_path(Some("work")).unwrap(),
1354 PathBuf::from("/tmp/work/auth.json")
1355 );
1356 assert_eq!(
1357 config.openai.resolve_auth_path(None).unwrap(),
1358 PathBuf::from("/tmp/personal/auth.json")
1359 );
1360 }
1361
1362 #[test]
1365 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
1366 let config = OpenAiConfig {
1367 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
1368 accounts: vec![OpenAiAccount {
1369 label: "work".into(),
1370 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
1371 }],
1372 ..OpenAiConfig::default()
1373 };
1374 let err = config
1375 .resolve_auth_path(Some("nope"))
1376 .unwrap_err()
1377 .to_string();
1378 assert!(err.contains("nope"), "{err}");
1379 assert!(err.contains("[[openai.accounts]]"), "{err}");
1380 }
1381
1382 #[test]
1383 fn defaults_enable_only_the_four_core_vendors() {
1384 let c = Config::default();
1385 assert!(c.is_enabled(VendorId::Anthropic));
1386 assert!(c.is_enabled(VendorId::Openai));
1387 assert!(c.is_enabled(VendorId::Zai));
1388 assert!(c.is_enabled(VendorId::Openrouter));
1389 for opt_in in [
1390 VendorId::AnthropicApi,
1391 VendorId::Deepseek,
1392 VendorId::Kimi,
1393 VendorId::Kilo,
1394 VendorId::Novita,
1395 VendorId::Moonshot,
1396 VendorId::Grok,
1397 VendorId::Supergrok,
1398 VendorId::Cursor,
1399 VendorId::Minimax,
1400 VendorId::Kiro,
1401 ] {
1402 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
1403 }
1404 assert_eq!(c.enabled_vendors().len(), 4);
1405 }
1406
1407 #[test]
1408 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
1409 let config = Config::default();
1410 assert!(!config.is_enabled(VendorId::NousResearch));
1411 assert!(!config.is_enabled(VendorId::OpenCodeGo));
1412 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
1413 assert!(config.opencode_go.api_key.is_none());
1414 }
1415
1416 #[cfg(unix)]
1417 #[test]
1418 fn opencode_go_inline_key_is_protected_like_other_api_keys() {
1419 let mut config = Config::default();
1420 config.opencode_go.api_key = Some("<redacted>".to_string());
1421 assert!(config.has_inline_api_keys());
1422 }
1423
1424 #[cfg(unix)]
1425 #[test]
1426 fn openrouter_named_inline_keys_receive_config_file_protection() {
1427 let mut config = Config::default();
1428 config.openrouter.accounts.push(OpenRouterAccount {
1429 label: "work".into(),
1430 api_key_env: None,
1431 api_key: Some("<redacted>".into()),
1432 });
1433 assert!(config.has_inline_api_keys());
1434 }
1435
1436 #[test]
1437 fn missing_file_uses_defaults() {
1438 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
1439 let c = Config::load_from(path).unwrap();
1440 assert!(c.is_enabled(VendorId::Anthropic));
1441 }
1442
1443 #[test]
1444 fn parses_full_config() {
1445 let f = write_toml(
1446 r#"
1447 [anthropic]
1448 enabled = true
1449
1450 [openai]
1451 enabled = false
1452 admin_key_env = "MY_ADMIN_KEY"
1453
1454 [zai]
1455 enabled = true
1456 api_key_env = "MY_ZAI"
1457 plan_tier = "pro"
1458
1459 [openrouter]
1460 enabled = false
1461 "#,
1462 );
1463 let c = Config::load_from(f.path()).unwrap();
1464 assert!(c.is_enabled(VendorId::Anthropic));
1465 assert!(!c.is_enabled(VendorId::Openai));
1466 assert!(c.is_enabled(VendorId::Zai));
1467 assert!(!c.is_enabled(VendorId::Openrouter));
1468 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
1469 assert_eq!(c.zai.api_key_env, "MY_ZAI");
1470 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
1471 assert!(c.openrouter.accounts.is_empty());
1472 assert!(c.openrouter.show_default_account);
1473 }
1474
1475 #[test]
1476 fn partial_config_falls_back_to_defaults() {
1477 let f = write_toml(
1478 r#"[openai]
1479enabled = false
1480"#,
1481 );
1482 let c = Config::load_from(f.path()).unwrap();
1483 assert!(!c.is_enabled(VendorId::Openai));
1484 assert!(c.is_enabled(VendorId::Anthropic));
1486 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
1487 }
1488
1489 #[test]
1490 fn malformed_toml_returns_error() {
1491 let f = write_toml("this is not = = valid");
1492 assert!(Config::load_from(f.path()).is_err());
1493 }
1494
1495 #[cfg(unix)]
1496 #[test]
1497 fn load_from_tightens_world_readable_config_with_inline_api_key() {
1498 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
1499 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1500
1501 Config::load_from(file.path()).unwrap();
1502
1503 assert_eq!(
1504 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1505 0o600
1506 );
1507 }
1508
1509 #[cfg(unix)]
1510 #[test]
1511 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
1512 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
1513 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1514
1515 Config::load_from(file.path()).unwrap();
1516
1517 assert_eq!(
1518 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1519 0o644
1520 );
1521 }
1522
1523 #[cfg(unix)]
1524 #[test]
1525 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
1526 assert_eq!(
1527 inline_key_permission_decision(0o600),
1528 InlineKeyPermissionDecision::Ok
1529 );
1530 assert_eq!(
1531 inline_key_permission_decision(0o640),
1532 InlineKeyPermissionDecision::Tighten
1533 );
1534 assert_eq!(
1535 inline_key_permission_decision(0o604),
1536 InlineKeyPermissionDecision::Tighten
1537 );
1538 }
1539
1540 #[test]
1541 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
1542 for value in ["0", "-1", "inf", "nan"] {
1543 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
1544 let error = Config::load_from(file.path()).unwrap_err().to_string();
1545 assert!(error.contains("monthly_limit"), "value {value}: {error}");
1546 }
1547
1548 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
1549 assert_eq!(
1550 Config::load_from(file.path())
1551 .unwrap()
1552 .anthropic_api
1553 .monthly_limit,
1554 Some(1000.0)
1555 );
1556 }
1557
1558 #[test]
1559 fn minimax_region_accepts_only_known_instances() {
1560 for region in ["global", "GLOBAL", "cn", "CN"] {
1561 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1562 assert_eq!(
1563 Config::load_from(file.path()).unwrap().minimax.region,
1564 region
1565 );
1566 }
1567
1568 for region in ["", "china", "us"] {
1569 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1570 let error = Config::load_from(file.path()).unwrap_err().to_string();
1571 assert!(error.contains("[minimax] region"), "{error}");
1572 }
1573 }
1574
1575 #[test]
1576 fn kimi_region_accepts_auto_and_both_deployments() {
1577 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
1578 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
1579 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
1580 }
1581
1582 for region in ["", "us", "oversea"] {
1583 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
1584 let error = Config::load_from(file.path()).unwrap_err().to_string();
1585 assert!(error.contains("[kimi] region"), "{error}");
1586 }
1587 }
1588
1589 #[test]
1590 fn kimi_defaults_to_auto_region_and_no_credential_override() {
1591 let defaults = KimiConfig::default();
1592 assert_eq!(defaults.region, "auto");
1593 assert_eq!(defaults.credentials_path, None);
1594 assert!(!defaults.enabled);
1595 }
1596
1597 #[test]
1598 fn kimi_credentials_path_expands_a_tilde() {
1599 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
1600 let path = Config::load_from(file.path())
1601 .unwrap()
1602 .kimi
1603 .credentials_path
1604 .unwrap();
1605 assert!(!path.starts_with("~"), "{}", path.display());
1606 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
1607 }
1608
1609 #[test]
1610 fn optional_api_key_reports_absence_instead_of_failing() {
1611 assert_eq!(
1612 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", Some("inline")),
1613 Some("inline".to_string())
1614 );
1615 assert_eq!(
1616 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", None),
1617 None
1618 );
1619 assert_eq!(optional_api_key("KIMI_API_KEY_UNSET", Some("")), None);
1620 assert_eq!(
1623 optional_api_key("9INVALID", Some("inline")),
1624 Some("inline".to_string())
1625 );
1626 }
1627
1628 #[test]
1629 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
1630 let defaults = Config::default();
1631 assert!(!defaults.context.enabled);
1632 assert_eq!(
1633 defaults.context.window_tokens_for(Some("claude-test")),
1634 None
1635 );
1636
1637 let file = write_toml(
1638 r#"
1639 [context]
1640 enabled = true
1641 context_window_tokens = 200000
1642
1643 [context.model_context_window_tokens]
1644 claude-opus-1m = 1000000
1645 "claude exact id" = 300000
1646 "#,
1647 );
1648 let config = Config::load_from(file.path()).unwrap();
1649 assert!(config.context.enabled);
1650 assert_eq!(
1651 config.context.window_tokens_for(Some("claude-opus-1m")),
1652 Some(1_000_000)
1653 );
1654 assert_eq!(
1655 config.context.window_tokens_for(Some("claude exact id")),
1656 Some(300_000)
1657 );
1658 assert_eq!(
1659 config.context.window_tokens_for(Some("another-model")),
1660 Some(200_000)
1661 );
1662 }
1663
1664 #[test]
1665 fn context_layout_defaults_to_full_and_parses_each_variant() {
1666 assert_eq!(Config::default().context.layout, ContextLayout::Full);
1667 for (text, want) in [
1668 ("full", ContextLayout::Full),
1669 ("split", ContextLayout::Split),
1670 ("bottom", ContextLayout::Bottom),
1671 ] {
1672 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
1673 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
1674 }
1675 let file = write_toml("[context]\nlayout = \"floating\"\n");
1676 assert!(
1677 Config::load_from(file.path()).is_err(),
1678 "an unknown layout must be rejected, not silently defaulted"
1679 );
1680 }
1681
1682 #[test]
1683 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
1684 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
1685 for (text, want) in [
1686 ("sidebar", VendorBoxStyle::Sidebar),
1687 ("navbar", VendorBoxStyle::Navbar),
1688 ("none", VendorBoxStyle::None),
1689 ] {
1690 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
1691 assert_eq!(
1692 Config::load_from(file.path()).unwrap().ui.vendor_box(),
1693 want
1694 );
1695 }
1696 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
1697 assert!(
1698 Config::load_from(file.path()).is_err(),
1699 "an unknown vendor_box style must be rejected, not silently defaulted"
1700 );
1701 }
1702
1703 #[test]
1704 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
1705 for source in [
1706 "[context]\ncontext_window_tokens = 0\n",
1707 "[context.model_context_window_tokens]\nclaude = 0\n",
1708 "[context.model_context_window_tokens]\n\" \" = 200000\n",
1709 ] {
1710 let file = write_toml(source);
1711 let error = Config::load_from(file.path()).unwrap_err().to_string();
1712 assert!(error.contains("context"), "{error}");
1713 }
1714 }
1715
1716 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
1718 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
1719 M.lock().unwrap_or_else(|p| p.into_inner())
1720 }
1721
1722 #[test]
1723 fn resolve_api_key_prefers_env_over_inline() {
1724 let _g = env_guard();
1725 let var = "AI_USAGEBAR_TEST_ENV_WINS";
1727 unsafe { std::env::set_var(var, "from-env") };
1729 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
1730 unsafe { std::env::remove_var(var) };
1731 assert_eq!(got, "from-env");
1732 }
1733
1734 #[test]
1735 fn resolve_api_key_falls_back_to_inline() {
1736 let _g = env_guard();
1737 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
1738 unsafe { std::env::remove_var(var) };
1739 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
1740 assert_eq!(got, "inline-key");
1741 }
1742
1743 #[test]
1744 fn resolve_api_key_errors_when_both_missing() {
1745 let _g = env_guard();
1746 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
1747 unsafe { std::env::remove_var(var) };
1748 let err = resolve_api_key("Zai", var, None).unwrap_err();
1749 match err {
1750 crate::error::AppError::Credentials(msg) => {
1751 assert!(
1752 msg.contains("api_key"),
1753 "error should suggest config field: {msg}"
1754 );
1755 }
1756 other => panic!("expected Credentials error, got {other:?}"),
1757 }
1758 }
1759
1760 #[test]
1761 fn resolve_api_key_uses_exact_opencode_go_section_name() {
1762 let _g = env_guard();
1763 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
1764 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
1765 let message = err.to_string();
1766 assert!(
1767 message.contains("[opencode-go]"),
1768 "wrong section hint: {message}"
1769 );
1770 assert!(
1771 !message.contains("[opencode go]"),
1772 "wrong section hint: {message}"
1773 );
1774 }
1775
1776 #[test]
1777 fn config_path_hint_ends_with_config_toml() {
1778 assert!(config_path_hint().ends_with("config.toml"));
1781 }
1782
1783 #[test]
1784 fn resolve_api_key_treats_empty_env_as_unset() {
1785 let _g = env_guard();
1786 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
1787 unsafe { std::env::set_var(var, "") };
1788 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
1789 unsafe { std::env::remove_var(var) };
1790 assert_eq!(got, "inline");
1791 }
1792
1793 #[test]
1794 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
1795 let _g = env_guard();
1796 let bad = "sk-kimi-very-real-looking-pasted-secret";
1798 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
1799 let msg = err.to_string();
1800 assert!(
1801 msg.contains("invalid") && msg.contains("api_key_env"),
1802 "error should explain misconfiguration: {msg}"
1803 );
1804 assert!(
1805 !msg.contains(bad),
1806 "error must not echo the misconfigured value: {msg}"
1807 );
1808 assert!(msg.contains("valid environment variable name"));
1809 assert!(
1810 msg.contains("[kimi]"),
1811 "error should point at the lowercase TOML section: {msg}"
1812 );
1813 }
1814
1815 #[test]
1816 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
1817 let _g = env_guard();
1818 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
1819 assert_eq!(got, "inline-key");
1820 }
1821
1822 #[test]
1823 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
1824 let _g = env_guard();
1825 let pasted_secret = "sk_pasted_secret";
1828 unsafe { std::env::remove_var(pasted_secret) };
1829 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
1830 assert!(
1831 !err.to_string().contains(pasted_secret),
1832 "error must not echo configured api_key_env values"
1833 );
1834 }
1835
1836 #[test]
1837 fn is_valid_env_var_name_rules() {
1838 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
1840 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
1841 }
1842 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
1844 assert!(
1845 !is_valid_env_var_name(invalid),
1846 "{invalid} should be invalid"
1847 );
1848 }
1849 }
1850
1851 #[test]
1852 fn config_parses_with_inline_api_key_and_primary() {
1853 let f = write_toml(
1854 r#"
1855 [ui]
1856 primary = "openrouter"
1857
1858 [zai]
1859 enabled = true
1860 api_key_env = "MY_ZAI"
1861 api_key = "sk-zai-inline"
1862
1863 [openrouter]
1864 enabled = true
1865 api_key = "sk-or-inline"
1866 "#,
1867 );
1868 let c = Config::load_from(f.path()).unwrap();
1869 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
1870 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
1871 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
1872 }
1873
1874 #[test]
1875 fn openrouter_named_accounts_preserve_the_default_contract() {
1876 let f = write_toml(
1877 r#"
1878 [openrouter]
1879 enabled = true
1880 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
1881 api_key = "default-inline"
1882 show_default_account = false
1883
1884 [[openrouter.accounts]]
1885 label = "work"
1886 api_key_env = "OPENROUTER_WORK_API_KEY"
1887
1888 [[openrouter.accounts]]
1889 label = "personal"
1890 api_key = "personal-inline"
1891 "#,
1892 );
1893 let _g = env_guard();
1894 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
1895 let config = Config::load_from(f.path()).unwrap();
1896 assert!(!config.openrouter.show_default_account);
1897 assert_eq!(config.openrouter.accounts.len(), 2);
1898 assert_eq!(
1899 config.openrouter.resolve_api_key(None).unwrap(),
1900 "default-inline"
1901 );
1902 assert_eq!(
1903 config.openrouter.resolve_api_key(Some("personal")).unwrap(),
1904 "personal-inline"
1905 );
1906 }
1907
1908 #[test]
1909 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
1910 for source in [
1911 r#"
1912 [[openrouter.accounts]]
1913 label = "work"
1914 api_key = "one"
1915 [[openrouter.accounts]]
1916 label = "work"
1917 api_key = "two"
1918 "#,
1919 r#"
1920 [[openrouter.accounts]]
1921 label = "../work"
1922 api_key = "one"
1923 "#,
1924 r#"
1925 [[openrouter.accounts]]
1926 label = "work"
1927 "#,
1928 ] {
1929 let f = write_toml(source);
1930 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
1931 }
1932 }
1933
1934 #[test]
1935 fn openrouter_unknown_account_never_falls_back_to_default_key() {
1936 let mut config = OpenRouterConfig {
1937 api_key: Some("default-secret".into()),
1938 ..OpenRouterConfig::default()
1939 };
1940 config.accounts.push(OpenRouterAccount {
1941 label: "work".into(),
1942 api_key_env: None,
1943 api_key: Some("work-secret".into()),
1944 });
1945 let message = config
1946 .resolve_api_key(Some("missing"))
1947 .unwrap_err()
1948 .to_string();
1949 assert!(message.contains("missing") && message.contains("work"));
1950 assert!(!message.contains("default-secret"));
1951 assert!(!message.contains("work-secret"));
1952 }
1953
1954 #[test]
1955 fn openrouter_account_key_errors_do_not_echo_configured_values() {
1956 let config = OpenRouterConfig {
1957 accounts: vec![OpenRouterAccount {
1958 label: "work".into(),
1959 api_key_env: Some("sk_pasted_secret".into()),
1960 api_key: None,
1961 }],
1962 ..OpenRouterConfig::default()
1963 };
1964 let _g = env_guard();
1965 unsafe { std::env::remove_var("sk_pasted_secret") };
1966 let message = config
1967 .resolve_api_key(Some("work"))
1968 .unwrap_err()
1969 .to_string();
1970 assert!(message.contains("[[openrouter.accounts]]"));
1971 assert!(!message.contains("sk_pasted_secret"));
1972 }
1973
1974 #[test]
1975 fn enabled_vendors_preserves_canonical_order() {
1976 let c = Config::default();
1979 assert_eq!(
1980 c.enabled_vendors(),
1981 vec![
1982 VendorId::Anthropic,
1983 VendorId::Openai,
1984 VendorId::Zai,
1985 VendorId::Openrouter,
1986 ]
1987 );
1988 }
1989
1990 #[test]
1991 fn deepseek_appears_when_enabled() {
1992 let f = write_toml(
1993 r#"
1994 [deepseek]
1995 enabled = true
1996 api_key = "sk-test"
1997 "#,
1998 );
1999 let c = Config::load_from(f.path()).unwrap();
2000 assert!(c.is_enabled(VendorId::Deepseek));
2001 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
2002 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
2003 }
2004
2005 #[test]
2006 fn tilde_paths_are_expanded_on_load() {
2007 let f = write_toml(
2011 r#"
2012 [context]
2013 projects_path = "~/.claude/projects"
2014
2015 [anthropic]
2016 credentials_path = "~/.claude/.credentials.json"
2017
2018 [[anthropic.accounts]]
2019 label = "work"
2020 credentials_path = "~/work.json"
2021 "#,
2022 );
2023 let c = Config::load_from(f.path()).unwrap();
2024 let home = crate::cache::home_dir().unwrap();
2025
2026 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
2027 let got = c.anthropic.credentials_path.unwrap();
2028 assert_eq!(got, home.join(".claude/.credentials.json"));
2029 assert!(!got.to_string_lossy().contains('~'));
2030 assert_eq!(
2031 c.anthropic.accounts[0].credentials_path,
2032 home.join("work.json")
2033 );
2034 }
2035
2036 #[test]
2037 fn absolute_and_relative_paths_are_left_alone() {
2038 let f = write_toml(
2039 r#"
2040 [anthropic]
2041 credentials_path = "/etc/creds.json"
2042 "#,
2043 );
2044 let c = Config::load_from(f.path()).unwrap();
2045 assert_eq!(
2046 c.anthropic.credentials_path.unwrap(),
2047 std::path::Path::new("/etc/creds.json")
2048 );
2049
2050 let f2 = write_toml(
2052 r#"
2053 [anthropic]
2054 credentials_path = "~someone/creds.json"
2055 "#,
2056 );
2057 let c2 = Config::load_from(f2.path()).unwrap();
2058 assert_eq!(
2059 c2.anthropic.credentials_path.unwrap(),
2060 std::path::Path::new("~someone/creds.json")
2061 );
2062 }
2063
2064 #[test]
2065 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
2066 let p = resolved_path().expect("a config path must resolve");
2069 assert!(p.ends_with("config.toml"));
2070 let canonical = default_path().unwrap();
2071 let legacy = legacy_xdg_path().unwrap();
2072 assert!(
2073 p == canonical || p == legacy,
2074 "resolved to an unexpected location: {}",
2075 p.display()
2076 );
2077 }
2078
2079 #[test]
2080 fn misspelled_section_is_rejected_not_ignored() {
2081 let f = write_toml(
2084 r#"
2085 [openrouer]
2086 enabled = true
2087 api_key = "sk-or-v1-typo"
2088 "#,
2089 );
2090 let err = Config::load_from(f.path()).unwrap_err().to_string();
2091 assert!(
2092 err.contains("openrouer"),
2093 "error should name the typo: {err}"
2094 );
2095 }
2096
2097 #[test]
2098 fn invalid_toml_is_an_error_not_silent_defaults() {
2099 let f = write_toml("[zai\nenabled = true\n");
2100 assert!(Config::load_from(f.path()).is_err());
2101 }
2102
2103 #[test]
2104 fn a_missing_file_is_still_just_defaults() {
2105 let dir = tempfile::tempdir().unwrap();
2108 let missing = dir.path().join("nope").join("config.toml");
2109 let c = Config::load_from(&missing).unwrap();
2110 assert!(c.is_enabled(VendorId::Anthropic));
2111 }
2112
2113 #[test]
2114 fn kimi_appears_when_enabled() {
2115 let f = write_toml(
2116 r#"
2117 [kimi]
2118 enabled = true
2119 api_key = "sk-test"
2120 "#,
2121 );
2122 let c = Config::load_from(f.path()).unwrap();
2123 assert!(c.is_enabled(VendorId::Kimi));
2124 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
2125 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
2126 }
2127
2128 #[test]
2129 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
2130 let f = write_toml(
2131 r#"
2132 [deepseek]
2133 enabled = true
2134 api_key = "sk-ds"
2135
2136 [kimi]
2137 enabled = true
2138 api_key = "sk-kimi"
2139 "#,
2140 );
2141 let c = Config::load_from(f.path()).unwrap();
2142 assert_eq!(
2143 c.enabled_vendors(),
2144 vec![
2145 VendorId::Anthropic,
2146 VendorId::Openai,
2147 VendorId::Zai,
2148 VendorId::Openrouter,
2149 VendorId::Deepseek,
2150 VendorId::Kimi,
2151 ]
2152 );
2153 }
2154
2155 #[test]
2156 fn parses_anthropic_accounts_and_looks_them_up() {
2157 let f = write_toml(
2158 r#"
2159 [anthropic]
2160 enabled = true
2161
2162 [[anthropic.accounts]]
2163 label = "personal"
2164 credentials_path = "/creds/personal.json"
2165
2166 [[anthropic.accounts]]
2167 label = "work"
2168 credentials_path = "/creds/work.json"
2169 "#,
2170 );
2171 let c = Config::load_from(f.path()).unwrap();
2172 assert_eq!(c.anthropic.accounts.len(), 2);
2173 let work = c.anthropic.account("work").unwrap();
2174 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
2175 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
2177 assert!(err.contains("missing") && err.contains("work"), "{err}");
2178 }
2179
2180 #[test]
2181 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
2182 let f = write_toml(
2183 r#"
2184 [[anthropic.accounts]]
2185 label = "work"
2186 credentials_path = "/creds/work-one.json"
2187
2188 [[anthropic.accounts]]
2189 label = "work"
2190 credentials_path = "/creds/work-two.json"
2191 "#,
2192 );
2193 let err = Config::load_from(f.path()).unwrap_err().to_string();
2194 assert!(
2195 err.contains("duplicate anthropic account label \"work\""),
2196 "{err}"
2197 );
2198 }
2199
2200 #[test]
2201 fn account_label_rejects_path_like_names() {
2202 let cfg = AnthropicConfig::default();
2203 for bad in [
2204 "",
2205 ".",
2206 "..",
2207 "a/b",
2208 r"a\b",
2209 "C:work",
2210 "line\nbreak",
2211 "tab\tname",
2212 "usage.json",
2213 ".stale",
2214 ".last_error",
2215 ".fetch.lock",
2216 ] {
2217 let err = cfg.account(bad).unwrap_err();
2218 assert!(
2219 format!("{err:?}").contains("invalid anthropic account label"),
2220 "{bad:?} should be rejected as a label"
2221 );
2222 }
2223 }
2224
2225 #[test]
2226 fn anthropic_accounts_default_to_empty() {
2227 assert!(Config::default().anthropic.accounts.is_empty());
2230 assert!(Config::default().anthropic.accounts_dir.is_none());
2231 }
2232
2233 fn seed_account_dir(root: &std::path::Path, label: &str) {
2239 let dir = root.join(label);
2240 std::fs::create_dir_all(&dir).unwrap();
2241 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
2242 }
2243
2244 #[test]
2245 fn discovers_account_dirs_in_claude_config_dir_layout() {
2246 let td = tempfile::tempdir().unwrap();
2247 seed_account_dir(td.path(), "work");
2248 seed_account_dir(td.path(), "personal");
2249 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
2252 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
2254
2255 let cfg = AnthropicConfig {
2256 accounts_dir: Some(td.path().to_path_buf()),
2257 ..Default::default()
2258 };
2259 let all = cfg.all_accounts();
2260 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
2261 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
2262 assert_eq!(
2263 all[2].credentials_path,
2264 td.path().join("work").join(".credentials.json")
2265 );
2266 }
2267
2268 #[test]
2269 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
2270 let td = tempfile::tempdir().unwrap();
2271 seed_account_dir(td.path(), "work");
2272 let cfg = AnthropicConfig {
2273 accounts: vec![AnthropicAccount {
2274 label: "work".into(),
2275 credentials_path: "/explicit/work.json".into(),
2276 }],
2277 accounts_dir: Some(td.path().to_path_buf()),
2278 ..Default::default()
2279 };
2280 let all = cfg.all_accounts();
2281 assert_eq!(all.len(), 1, "no duplicate label");
2282 assert_eq!(
2283 all[0].credentials_path,
2284 std::path::Path::new("/explicit/work.json"),
2285 "explicit entry wins"
2286 );
2287 seed_account_dir(td.path(), "other");
2289 assert_eq!(cfg.account("other").unwrap().label, "other");
2290 }
2291
2292 #[test]
2293 fn missing_accounts_dir_is_silently_empty_not_an_error() {
2294 let cfg = AnthropicConfig {
2295 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
2296 ..Default::default()
2297 };
2298 assert!(cfg.all_accounts().is_empty());
2299 }
2300
2301 #[test]
2302 fn accounts_dir_is_tilde_expanded_on_load() {
2303 let f = write_toml(
2304 r#"
2305 [anthropic]
2306 accounts_dir = "~/.config/ai-usagebar/accounts"
2307 "#,
2308 );
2309 let c = Config::load_from(f.path()).unwrap();
2310 let home = crate::cache::home_dir().unwrap();
2311 assert_eq!(
2312 c.anthropic.accounts_dir,
2313 Some(home.join(".config/ai-usagebar/accounts"))
2314 );
2315 }
2316
2317 #[test]
2318 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
2319 let f = write_toml(
2320 r#"
2321 [anthropic]
2322 desktop_profiles_dir = "~/.claude-acc/profiles"
2323 "#,
2324 );
2325 let c = Config::load_from(f.path()).unwrap();
2326 let home = crate::cache::home_dir().unwrap();
2327 assert_eq!(
2328 c.anthropic.desktop_profiles_dir,
2329 Some(home.join(".claude-acc/profiles"))
2330 );
2331 }
2332
2333 #[test]
2334 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
2335 let cfg = AnthropicConfig {
2336 accounts: vec![
2337 AnthropicAccount {
2338 label: "work".into(),
2339 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
2340 },
2341 AnthropicAccount {
2342 label: "personal".into(),
2343 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
2344 },
2345 ],
2346 ..Default::default()
2347 };
2348
2349 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
2350 assert!(
2351 matches!(&idle, CredsTarget::Named { config_dir, .. }
2352 if config_dir == std::path::Path::new("/tmp/accounts/work")),
2353 "{idle:?}"
2354 );
2355
2356 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
2358 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
2359
2360 assert_eq!(idle_cache.dir(), live_cache.dir());
2363 }
2364
2365 #[test]
2366 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
2367 let cfg = AnthropicConfig {
2368 accounts: vec![AnthropicAccount {
2369 label: "work".into(),
2370 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
2371 }],
2372 ..Default::default()
2373 };
2374 let (target, _) = cfg.account_target_with("work", None).unwrap();
2375 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
2376 }
2377
2378 fn config_example() -> PathBuf {
2382 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
2383 }
2384
2385 #[test]
2386 fn shipped_example_parses_as_a_real_config() {
2387 let c = Config::load_from(&config_example()).unwrap();
2392 assert!(!c.context.enabled);
2393 assert!(c.is_enabled(VendorId::Anthropic));
2394 assert!(c.is_enabled(VendorId::Openai));
2395 assert!(!c.is_enabled(VendorId::AnthropicApi));
2396 assert!(!c.is_enabled(VendorId::Deepseek));
2397 assert!(!c.is_enabled(VendorId::Kimi));
2398 assert!(!c.is_enabled(VendorId::Kilo));
2399 assert!(!c.is_enabled(VendorId::Novita));
2400 assert!(!c.is_enabled(VendorId::Moonshot));
2401 assert!(!c.is_enabled(VendorId::Grok));
2402 assert!(!c.is_enabled(VendorId::Cursor));
2403 assert!(!c.is_enabled(VendorId::Minimax));
2404 }
2405
2406 #[test]
2407 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
2408 let text = std::fs::read_to_string(config_example()).unwrap();
2413 let live: Vec<&str> = text
2414 .lines()
2415 .map(str::trim)
2416 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
2417 .collect();
2418 assert!(
2419 live.is_empty(),
2420 "admin_key_env must stay commented out while it is inert: {live:?}"
2421 );
2422 assert!(
2425 text.contains("admin_key_env") && text.contains("RESERVED"),
2426 "the example should keep describing admin_key_env as reserved"
2427 );
2428 }
2429
2430 #[test]
2431 fn admin_key_env_is_accepted_but_changes_nothing() {
2432 let f = write_toml(
2436 r#"
2437 [openai]
2438 admin_key_env = "SOME_ADMIN_KEY"
2439 "#,
2440 );
2441 let c = Config::load_from(f.path()).unwrap();
2442 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
2443 let default = OpenAiConfig::default();
2445 assert_eq!(c.openai.enabled, default.enabled);
2446 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
2447 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
2448 }
2449
2450 #[test]
2451 fn config_example_documents_every_vendor_without_secrets() {
2452 let raw = std::fs::read_to_string(config_example()).unwrap();
2453 let cfg = Config::load_from(&config_example()).unwrap();
2454 for id in VendorId::all() {
2457 let section = id.slug();
2458 assert!(
2459 raw.contains(&format!("[{section}]")),
2460 "config.example.toml has no [{section}] section"
2461 );
2462 }
2463
2464 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
2467 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
2468 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
2469 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
2470 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
2471 assert!(!cfg.supergrok.enabled);
2472 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
2473 assert_eq!(
2474 cfg.supergrok
2475 .grok_binary
2476 .file_name()
2477 .and_then(|p| p.to_str()),
2478 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
2479 );
2480 assert!(cfg.supergrok.auth_path.is_none());
2481 assert!(cfg.supergrok.config_path.is_none());
2482 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
2483 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
2484 }
2485
2486 #[test]
2487 fn supergrok_binary_must_not_be_empty() {
2488 let file = write_toml(
2489 r#"
2490 [supergrok]
2491 enabled = true
2492 grok_binary = ""
2493 "#,
2494 );
2495 let error = Config::load_from(file.path()).unwrap_err().to_string();
2496 assert!(error.contains("grok_binary must not be empty"));
2497 }
2498
2499 #[test]
2500 fn supergrok_paths_are_tilde_expanded() {
2501 let file = write_toml(
2502 r#"
2503 [supergrok]
2504 grok_binary = "~/bin/grok"
2505 auth_path = "~/.grok/auth.json"
2506 config_path = "~/.grok/config.toml"
2507 "#,
2508 );
2509 let config = Config::load_from(file.path()).unwrap();
2510 let home = crate::cache::home_dir().unwrap();
2511 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
2512 assert_eq!(
2513 config.supergrok.auth_path,
2514 Some(home.join(".grok/auth.json"))
2515 );
2516 assert_eq!(
2517 config.supergrok.config_path,
2518 Some(home.join(".grok/config.toml"))
2519 );
2520 }
2521
2522 #[test]
2523 fn kiro_db_path_is_tilde_expanded() {
2524 let f = write_toml(
2525 r#"
2526 [kiro]
2527 db_path = "~/kiro-data.sqlite3"
2528 "#,
2529 );
2530 let c = Config::load_from(f.path()).unwrap();
2531 let home = crate::cache::home_dir().unwrap();
2532 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
2533 }
2534
2535 #[test]
2536 fn kiro_appears_when_enabled() {
2537 let f = write_toml(
2538 r#"
2539 [kiro]
2540 enabled = true
2541 "#,
2542 );
2543 let c = Config::load_from(f.path()).unwrap();
2544 assert!(c.is_enabled(VendorId::Kiro));
2545 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
2546 }
2547
2548 #[test]
2549 fn cursor_db_path_is_tilde_expanded() {
2550 let f = write_toml(
2551 r#"
2552 [cursor]
2553 db_path = "~/cursor-state.vscdb"
2554 "#,
2555 );
2556 let c = Config::load_from(f.path()).unwrap();
2557 let home = crate::cache::home_dir().unwrap();
2558 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
2559 }
2560
2561 #[test]
2562 fn cursor_agent_auth_path_is_tilde_expanded() {
2563 let f = write_toml(
2564 r#"
2565 [cursor]
2566 agent_auth_path = "~/cursor-agent-auth.json"
2567 "#,
2568 );
2569 let c = Config::load_from(f.path()).unwrap();
2570 let home = crate::cache::home_dir().unwrap();
2571 assert_eq!(
2572 c.cursor.agent_auth_path,
2573 Some(home.join("cursor-agent-auth.json"))
2574 );
2575 }
2576
2577 #[test]
2578 fn cursor_appears_when_enabled() {
2579 let f = write_toml(
2580 r#"
2581 [cursor]
2582 enabled = true
2583 "#,
2584 );
2585 let c = Config::load_from(f.path()).unwrap();
2586 assert!(c.is_enabled(VendorId::Cursor));
2587 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
2588 }
2589
2590 #[test]
2591 fn add_account_appends_and_preserves_existing() {
2592 let mut doc: toml_edit::DocumentMut = r#"
2593# keep me
2594[anthropic]
2595enabled = true
2596
2597[[anthropic.accounts]]
2598label = "personal"
2599credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
2600"#
2601 .parse()
2602 .unwrap();
2603 add_anthropic_account_to_doc(
2604 &mut doc,
2605 "work",
2606 "~/.config/ai-usagebar/accounts/work/.credentials.json",
2607 )
2608 .unwrap();
2609 let rendered = doc.to_string();
2610 assert!(rendered.contains("# keep me"), "comment must survive");
2611 let f = write_toml(&rendered);
2613 let c = Config::load_from(f.path()).unwrap();
2614 let labels: Vec<&str> = c
2615 .anthropic
2616 .accounts
2617 .iter()
2618 .map(|a| a.label.as_str())
2619 .collect();
2620 assert_eq!(labels, vec!["personal", "work"]);
2621 }
2622
2623 #[test]
2624 fn add_account_to_empty_doc_is_loadable() {
2625 let mut doc = toml_edit::DocumentMut::new();
2626 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
2627 let f = write_toml(&doc.to_string());
2628 let c = Config::load_from(f.path()).unwrap();
2629 assert_eq!(c.anthropic.accounts.len(), 1);
2630 assert_eq!(c.anthropic.accounts[0].label, "solo");
2631 }
2632
2633 #[test]
2634 fn add_account_rejects_duplicate_label() {
2635 let mut doc: toml_edit::DocumentMut = r#"
2636[[anthropic.accounts]]
2637label = "work"
2638credentials_path = "~/w/.credentials.json"
2639"#
2640 .parse()
2641 .unwrap();
2642 assert!(
2643 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
2644 "a duplicate label must be rejected, not appended"
2645 );
2646 }
2647
2648 #[test]
2649 fn add_account_rejects_bad_label() {
2650 let mut doc = toml_edit::DocumentMut::new();
2651 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
2652 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
2653 }
2654
2655 #[test]
2656 fn tildify_collapses_home_only() {
2657 let home = Path::new("/Users/me");
2658 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
2659 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
2660 }
2661
2662 #[test]
2663 fn default_account_credentials_path_nests_under_config_dir() {
2664 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
2665 assert_eq!(
2666 default_account_credentials_path(cfg, "work"),
2667 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
2668 );
2669 }
2670}