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