1use std::collections::{BTreeMap, HashSet};
19use std::path::{Path, PathBuf};
20
21#[cfg(unix)]
22use std::os::unix::fs::{MetadataExt, PermissionsExt};
23
24use serde::{Deserialize, Serialize};
25
26use crate::anthropic::creds::CredsTarget;
27use crate::cache::Cache;
28use crate::error::{AppError, Result};
29use crate::vendor::VendorId;
30
31#[derive(Debug, Clone, Default, Deserialize, Serialize)]
38#[serde(default, deny_unknown_fields)]
39pub struct Config {
40 pub ui: UiConfig,
41 pub context: ContextConfig,
42 pub anthropic: AnthropicConfig,
43 pub anthropic_api: AnthropicApiConfig,
44 pub openai: OpenAiConfig,
45 pub copilot: CopilotConfig,
46 pub zai: ZaiConfig,
47 pub openrouter: OpenRouterConfig,
48 pub deepseek: DeepseekConfig,
49 pub kimi: KimiConfig,
50 pub kilo: KiloConfig,
51 pub novita: NovitaConfig,
52 pub moonshot: MoonshotConfig,
53 pub grok: GrokConfig,
54 pub supergrok: SuperGrokConfig,
55 pub antigravity: AntigravityConfig,
56 pub cursor: CursorConfig,
57 pub minimax: MinimaxConfig,
58 pub kiro: KiroConfig,
59 pub nous: NousConfig,
60 #[serde(rename = "opencode-go")]
61 pub opencode_go: OpenCodeGoConfig,
62 pub commandcode: CommandCodeConfig,
63}
64
65#[derive(Debug, Clone, Default, Deserialize, Serialize)]
69#[serde(default)]
70pub struct UiConfig {
71 pub primary: Option<VendorId>,
73 pub overview_vendors: Option<Vec<VendorId>>,
77 pub vendor_box: Option<VendorBoxStyle>,
79}
80
81impl UiConfig {
82 pub fn vendor_box(&self) -> VendorBoxStyle {
83 self.vendor_box.unwrap_or_default()
84 }
85}
86
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
89#[serde(rename_all = "lowercase")]
90pub enum VendorBoxStyle {
91 #[default]
93 Sidebar,
94 Navbar,
96 None,
98}
99
100#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
103#[serde(rename_all = "lowercase")]
104pub enum ContextLayout {
105 #[default]
107 Full,
108 Split,
110 Bottom,
112}
113
114impl ContextLayout {
115 pub fn next(self) -> Self {
116 match self {
117 ContextLayout::Full => ContextLayout::Split,
118 ContextLayout::Split => ContextLayout::Bottom,
119 ContextLayout::Bottom => ContextLayout::Full,
120 }
121 }
122
123 pub fn label(self) -> &'static str {
124 match self {
125 ContextLayout::Full => "full",
126 ContextLayout::Split => "split",
127 ContextLayout::Bottom => "bottom",
128 }
129 }
130}
131
132#[derive(Debug, Clone, Default, Deserialize, Serialize)]
137#[serde(default)]
138pub struct ContextConfig {
139 pub enabled: bool,
142 pub projects_path: Option<PathBuf>,
144 pub context_window_tokens: Option<u64>,
147 pub model_context_window_tokens: BTreeMap<String, u64>,
150 pub layout: ContextLayout,
152}
153
154impl ContextConfig {
155 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
156 model
157 .and_then(|model| self.model_context_window_tokens.get(model).copied())
158 .filter(|tokens| *tokens > 0)
159 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
160 }
161}
162
163#[derive(Debug, Clone, Deserialize, Serialize)]
164#[serde(default)]
165pub struct AnthropicConfig {
166 pub enabled: bool,
167 pub credentials_path: Option<PathBuf>,
170 pub accounts: Vec<AnthropicAccount>,
174 pub accounts_dir: Option<PathBuf>,
182 pub show_default_account: bool,
188 pub desktop_profiles_dir: Option<PathBuf>,
194}
195
196impl Default for AnthropicConfig {
197 fn default() -> Self {
198 Self {
199 enabled: true,
200 credentials_path: None,
201 accounts: Vec::new(),
202 accounts_dir: None,
203 show_default_account: true,
204 desktop_profiles_dir: None,
205 }
206 }
207}
208
209#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
219pub struct AnthropicAccount {
220 pub label: String,
223 pub credentials_path: PathBuf,
227}
228
229impl AnthropicAccount {
230 pub fn config_dir(&self) -> PathBuf {
235 self.credentials_path
236 .parent()
237 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
238 }
239}
240
241impl AnthropicConfig {
242 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
248 let mut out = self.accounts.clone();
249 if let Some(dir) = &self.accounts_dir {
250 for acct in discover_accounts(dir) {
251 if !out.iter().any(|a| a.label == acct.label) {
252 out.push(acct);
253 }
254 }
255 }
256 out
257 }
258
259 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
264 validate_account_label(label)?;
265 let all = self.all_accounts();
266 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
267 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
268 AppError::Credentials(format!(
269 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
270 known labels: {known:?}"
271 ))
272 })
273 }
274
275 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
286 let active = crate::anthropic::cli_account::home_claude_json()
287 .ok()
288 .and_then(|path| {
289 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
290 });
291 self.account_target_with(label, active.as_deref())
292 }
293
294 pub fn account_target_with(
305 &self,
306 label: &str,
307 cli_active: Option<&str>,
308 ) -> Result<(CredsTarget, Cache)> {
309 let account = self.account(label)?;
310 let cache = Cache::for_vendor_account("anthropic", label)?;
311 if cli_active == Some(label) {
312 return Ok((
313 CredsTarget::Default(crate::anthropic::creds::default_path()?),
314 cache,
315 ));
316 }
317 Ok((
318 CredsTarget::Named {
319 config_dir: account.config_dir(),
320 path: account.credentials_path,
321 },
322 cache,
323 ))
324 }
325}
326
327pub fn validate_account_label(label: &str) -> Result<()> {
333 validate_account_label_for("anthropic", label)
334}
335
336fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
337 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
338 let bad = label.is_empty()
339 || label == "."
340 || label == ".."
341 || label.contains(['/', '\\'])
342 || label.contains(':')
343 || label.chars().any(char::is_control)
344 || RESERVED.contains(&label);
345 if bad {
346 return Err(AppError::Credentials(format!(
347 "invalid {vendor} account label {label:?}: must be a non-empty name \
348 without path separators, drive prefixes, control characters, or reserved cache names"
349 )));
350 }
351 Ok(())
352}
353
354fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
362 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
363 return Vec::new();
364 };
365 let mut found: Vec<AnthropicAccount> = entries
366 .flatten()
367 .filter_map(|entry| {
368 let path = entry.path();
369 if !path.is_dir() {
370 return None;
371 }
372 let label = path.file_name()?.to_str()?.to_string();
373 validate_account_label(&label).ok()?;
374 Some(AnthropicAccount {
375 label,
376 credentials_path: path.join(".credentials.json"),
377 })
378 })
379 .collect();
380 found.sort_by(|a, b| a.label.cmp(&b.label));
381 found
382}
383
384pub fn tildify(path: &Path, home: &Path) -> String {
388 path.strip_prefix(home)
389 .map(|rest| {
390 let rendered = rest.display().to_string();
391 #[cfg(windows)]
394 let rendered = rendered.replace('\\', "/");
395 format!("~/{rendered}")
396 })
397 .unwrap_or_else(|_| path.display().to_string())
398}
399
400pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
405 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
406 base.join("accounts").join(label).join(".credentials.json")
407}
408
409pub fn add_anthropic_account_to_doc(
415 doc: &mut toml_edit::DocumentMut,
416 label: &str,
417 credentials_path: &str,
418) -> Result<()> {
419 use toml_edit::{Item, Table, value};
420
421 validate_account_label(label)?;
422
423 let anthropic = doc
424 .entry("anthropic")
425 .or_insert_with(|| Item::Table(Table::new()));
426 let anthropic = anthropic
427 .as_table_mut()
428 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
429
430 let accounts = anthropic
431 .entry("accounts")
432 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
433 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
434 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
435 })?;
436
437 let exists = accounts
438 .iter()
439 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
440 if exists {
441 return Err(AppError::Credentials(format!(
442 "anthropic account {label:?} already exists in config.toml"
443 )));
444 }
445
446 let mut table = Table::new();
447 table["label"] = value(label);
448 table["credentials_path"] = value(credentials_path);
449 accounts.push(table);
450 Ok(())
451}
452
453#[derive(Debug, Clone, Deserialize, Serialize)]
454#[serde(default)]
455pub struct OpenAiConfig {
456 pub enabled: bool,
457 pub codex_auth_path: Option<PathBuf>,
459 #[serde(default)]
464 pub accounts: Vec<OpenAiAccount>,
465 pub admin_key_env: String,
473}
474
475#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
486pub struct OpenAiAccount {
487 pub label: String,
490 pub codex_auth_path: PathBuf,
493}
494
495impl OpenAiConfig {
496 pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
500 let Some(label) = label else {
501 return match &self.codex_auth_path {
502 Some(path) => Ok(path.clone()),
503 None => crate::openai::creds::default_path(),
504 };
505 };
506 self.accounts
507 .iter()
508 .find(|account| account.label == label)
509 .map(|account| account.codex_auth_path.clone())
510 .ok_or_else(|| {
511 AppError::Credentials(format!(
512 "no OpenAI account named {label:?}. Add it under \
513 [[openai.accounts]], or drop --account to use the default login."
514 ))
515 })
516 }
517}
518
519impl Default for OpenAiConfig {
520 fn default() -> Self {
521 Self {
522 enabled: true,
523 codex_auth_path: None,
524 accounts: Vec::new(),
525 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
526 }
527 }
528}
529
530#[derive(Debug, Clone, Default, Deserialize, Serialize)]
534#[serde(default)]
535pub struct CopilotConfig {
536 pub enabled: bool,
537 pub gh_binary: Option<PathBuf>,
540}
541
542impl CopilotConfig {
543 pub fn resolve_token(&self) -> Result<String> {
544 self.resolve_token_with(
545 |name| std::env::var_os(name),
546 &crate::copilot::credentials::SystemGhAuthTokenRunner,
547 )
548 }
549
550 fn resolve_token_with(
551 &self,
552 environment: impl Fn(&str) -> Option<std::ffi::OsString>,
553 runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
554 ) -> Result<String> {
555 if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
556 let token = value.into_string().map_err(|_| {
557 AppError::Credentials(
558 "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
559 )
560 })?;
561 if !token.is_empty() {
562 return Ok(token);
563 }
564 }
565 crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref())
566 }
567}
568
569#[derive(Debug, Clone, Default, Deserialize, Serialize)]
570#[serde(default)]
571pub struct NousConfig {
572 pub enabled: bool,
573}
574
575#[derive(Debug, Clone, Deserialize, Serialize)]
576#[serde(default)]
577pub struct OpenCodeGoConfig {
578 pub enabled: bool,
579 pub api_key_env: String,
580 pub api_key: Option<String>,
581}
582
583#[derive(Debug, Clone, Default, Deserialize, Serialize)]
587#[serde(default)]
588pub struct CommandCodeConfig {
589 pub enabled: bool,
590 pub auth_paths: Option<Vec<PathBuf>>,
591}
592
593impl Default for OpenCodeGoConfig {
594 fn default() -> Self {
595 Self {
596 enabled: false,
597 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
598 api_key: None,
599 }
600 }
601}
602
603#[derive(Debug, Clone, Deserialize, Serialize)]
604#[serde(default)]
605pub struct ZaiConfig {
606 pub enabled: bool,
607 pub api_key_env: String,
609 pub api_key: Option<String>,
612 pub plan_tier: Option<String>,
614}
615
616impl Default for ZaiConfig {
617 fn default() -> Self {
618 Self {
619 enabled: true,
620 api_key_env: "ZAI_API_KEY".to_string(),
621 api_key: None,
622 plan_tier: None,
623 }
624 }
625}
626
627#[derive(Debug, Clone, Deserialize, Serialize)]
628#[serde(default)]
629pub struct OpenRouterConfig {
630 pub enabled: bool,
631 pub accounts: Vec<OpenRouterAccount>,
634 pub show_default_account: bool,
638 pub api_key_env: String,
639 pub api_key: Option<String>,
640}
641
642impl Default for OpenRouterConfig {
643 fn default() -> Self {
644 Self {
645 enabled: true,
646 accounts: Vec::new(),
647 show_default_account: true,
648 api_key_env: "OPENROUTER_API_KEY".to_string(),
649 api_key: None,
650 }
651 }
652}
653
654#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
657pub struct OpenRouterAccount {
658 pub label: String,
660 #[serde(default)]
662 pub api_key_env: Option<String>,
663 #[serde(default)]
665 pub api_key: Option<String>,
666}
667
668impl OpenRouterConfig {
669 pub fn account(&self, label: &str) -> Result<&OpenRouterAccount> {
672 validate_account_label_for("openrouter", label)?;
673 self.accounts
674 .iter()
675 .find(|account| account.label == label)
676 .ok_or_else(|| {
677 let known: Vec<&str> = self
678 .accounts
679 .iter()
680 .map(|account| account.label.as_str())
681 .collect();
682 AppError::Credentials(format!(
683 "openrouter account {label:?} not found in [[openrouter.accounts]]; \
684 known labels: {known:?}"
685 ))
686 })
687 }
688
689 pub fn resolve_api_key(&self, label: Option<&str>) -> Result<String> {
692 match label {
693 None => resolve_api_key("OpenRouter", &self.api_key_env, self.api_key.as_deref()),
694 Some(label) => {
695 let account = self.account(label)?;
696 resolve_api_key_in_section(
697 &format!("OpenRouter account {label:?}"),
698 "[[openrouter.accounts]]",
699 account.api_key_env.as_deref().unwrap_or(""),
700 account.api_key.as_deref(),
701 )
702 }
703 }
704 }
705}
706
707#[derive(Debug, Clone, Deserialize, Serialize)]
708#[serde(default)]
709pub struct DeepseekConfig {
710 pub enabled: bool,
711 pub api_key_env: String,
712 pub api_key: Option<String>,
713}
714
715impl Default for DeepseekConfig {
716 fn default() -> Self {
717 Self {
718 enabled: false,
719 api_key_env: "DEEPSEEK_API_KEY".to_string(),
720 api_key: None,
721 }
722 }
723}
724
725#[derive(Debug, Clone, Deserialize, Serialize)]
726#[serde(default)]
727pub struct KimiConfig {
728 pub enabled: bool,
729 pub api_key_env: String,
730 pub api_key: Option<String>,
733 pub credentials_path: Option<PathBuf>,
737 pub region: String,
742}
743
744impl Default for KimiConfig {
745 fn default() -> Self {
746 Self {
747 enabled: false,
748 api_key_env: "KIMI_API_KEY".to_string(),
749 api_key: None,
750 credentials_path: None,
751 region: "auto".to_string(),
752 }
753 }
754}
755
756#[derive(Debug, Clone, Deserialize, Serialize)]
757#[serde(default)]
758pub struct KiloConfig {
759 pub enabled: bool,
760 pub api_key_env: String,
761 pub api_key: Option<String>,
762 pub organization_id: Option<String>,
765}
766
767impl Default for KiloConfig {
768 fn default() -> Self {
769 Self {
772 enabled: false,
773 api_key_env: "KILO_API_KEY".to_string(),
774 api_key: None,
775 organization_id: None,
776 }
777 }
778}
779
780#[derive(Debug, Clone, Deserialize, Serialize)]
781#[serde(default)]
782pub struct NovitaConfig {
783 pub enabled: bool,
784 pub api_key_env: String,
785 pub api_key: Option<String>,
786}
787
788impl Default for NovitaConfig {
789 fn default() -> Self {
790 Self {
792 enabled: false,
793 api_key_env: "NOVITA_API_KEY".to_string(),
794 api_key: None,
795 }
796 }
797}
798
799#[derive(Debug, Clone, Deserialize, Serialize)]
800#[serde(default)]
801pub struct MinimaxConfig {
802 pub enabled: bool,
803 pub api_key_env: String,
804 pub api_key: Option<String>,
805 pub region: String,
811}
812
813impl Default for MinimaxConfig {
814 fn default() -> Self {
815 Self {
817 enabled: false,
818 api_key_env: "MINIMAX_API_KEY".to_string(),
819 api_key: None,
820 region: "global".to_string(),
821 }
822 }
823}
824
825#[derive(Debug, Clone, Deserialize, Serialize)]
826#[serde(default)]
827pub struct MoonshotConfig {
828 pub enabled: bool,
829 pub api_key_env: String,
830 pub api_key: Option<String>,
831 pub region: String,
833}
834
835impl Default for MoonshotConfig {
836 fn default() -> Self {
837 Self {
839 enabled: false,
840 api_key_env: "MOONSHOT_API_KEY".to_string(),
841 api_key: None,
842 region: "global".to_string(),
843 }
844 }
845}
846
847#[derive(Debug, Clone, Deserialize, Serialize)]
848#[serde(default)]
849pub struct GrokConfig {
850 pub enabled: bool,
851 pub api_key_env: String,
853 pub api_key: Option<String>,
854 pub team_id: Option<String>,
857}
858
859impl Default for GrokConfig {
860 fn default() -> Self {
861 Self {
863 enabled: false,
864 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
865 api_key: None,
866 team_id: None,
867 }
868 }
869}
870
871#[derive(Debug, Clone, Deserialize, Serialize)]
879#[serde(default)]
880pub struct SuperGrokConfig {
881 pub enabled: bool,
882 pub grok_binary: PathBuf,
886 pub auth_path: Option<PathBuf>,
889 pub config_path: Option<PathBuf>,
890}
891
892impl Default for SuperGrokConfig {
893 fn default() -> Self {
894 Self {
895 enabled: false,
896 grok_binary: default_grok_binary(),
897 auth_path: None,
898 config_path: None,
899 }
900 }
901}
902
903fn default_grok_binary() -> PathBuf {
904 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
905 let grok_home = std::env::var_os("GROK_HOME")
906 .filter(|value| !value.is_empty())
907 .map(PathBuf::from)
908 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
909 grok_home
910 .map(|home| home.join("bin").join(executable))
911 .unwrap_or_else(|| PathBuf::from(executable))
912}
913
914#[derive(Debug, Clone, Default, Deserialize, Serialize)]
917#[serde(default)]
918pub struct AntigravityConfig {
919 pub enabled: bool,
920}
921
922#[derive(Debug, Clone, Default, Deserialize, Serialize)]
932#[serde(default)]
933pub struct CursorConfig {
934 pub enabled: bool,
935 pub db_path: Option<PathBuf>,
939 pub agent_auth_path: Option<PathBuf>,
944}
945
946#[derive(Debug, Clone, Default, Deserialize, Serialize)]
955#[serde(default)]
956pub struct KiroConfig {
957 pub enabled: bool,
958 pub db_path: Option<PathBuf>,
962}
963
964#[derive(Debug, Clone, Deserialize, Serialize)]
965#[serde(default)]
966pub struct AnthropicApiConfig {
967 pub enabled: bool,
968 pub api_key_env: String,
971 pub api_key: Option<String>,
972 pub monthly_limit: Option<f64>,
975}
976
977impl Default for AnthropicApiConfig {
978 fn default() -> Self {
979 Self {
981 enabled: false,
982 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
983 api_key: None,
984 monthly_limit: None,
985 }
986 }
987}
988
989pub fn resolve_api_key(
992 vendor_label: &str,
993 env_var_name: &str,
994 inline: Option<&str>,
995) -> crate::error::Result<String> {
996 let section = match vendor_label {
997 "OpenCode Go" => "[opencode-go]".to_string(),
998 _ => format!("[{}]", vendor_label.to_lowercase()),
999 };
1000 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
1001}
1002
1003pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
1007 if is_valid_env_var_name(env_var_name)
1008 && let Ok(v) = std::env::var(env_var_name)
1009 && !v.is_empty()
1010 {
1011 return Some(v);
1012 }
1013 inline.filter(|v| !v.is_empty()).map(str::to_string)
1014}
1015
1016fn resolve_api_key_in_section(
1017 vendor_label: &str,
1018 section: &str,
1019 env_var_name: &str,
1020 inline: Option<&str>,
1021) -> crate::error::Result<String> {
1022 if let Some(key) = optional_api_key(env_var_name, inline) {
1023 return Ok(key);
1024 }
1025 let valid_env_name = is_valid_env_var_name(env_var_name);
1026 let advice = if valid_env_name {
1027 "set an API key in a valid environment variable or set `api_key`"
1028 } else {
1029 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
1030 };
1031 Err(crate::error::AppError::Credentials(format!(
1032 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
1033 config_path_hint()
1034 )))
1035}
1036
1037fn is_valid_env_var_name(name: &str) -> bool {
1038 let mut chars = name.chars();
1039 let Some(first) = chars.next() else {
1040 return false;
1041 };
1042 (first.is_ascii_alphabetic() || first == '_')
1043 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1044}
1045
1046impl Config {
1047 pub fn load() -> Result<Self> {
1050 let Some(path) = resolved_path() else {
1051 return Ok(Self::default());
1052 };
1053 Self::load_from(&path)
1054 }
1055
1056 pub fn load_from(path: &std::path::Path) -> Result<Self> {
1057 match std::fs::read_to_string(path) {
1058 Ok(s) => {
1059 let mut config: Self = toml::from_str(&s)?;
1060 config.expand_paths();
1064 config.validate()?;
1065 #[cfg(unix)]
1066 config.protect_inline_secrets(path)?;
1067 Ok(config)
1068 }
1069 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1070 Err(e) => Err(AppError::io_at(path, e)),
1071 }
1072 }
1073
1074 fn expand_paths(&mut self) {
1075 expand_tilde_opt(&mut self.context.projects_path);
1076 expand_tilde_opt(&mut self.anthropic.credentials_path);
1077 expand_tilde_opt(&mut self.anthropic.accounts_dir);
1078 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
1079 expand_tilde_opt(&mut self.openai.codex_auth_path);
1080 expand_tilde_opt(&mut self.cursor.db_path);
1081 expand_tilde_opt(&mut self.cursor.agent_auth_path);
1082 expand_tilde_opt(&mut self.kiro.db_path);
1083 expand_tilde_opt(&mut self.kimi.credentials_path);
1084 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
1085 expand_tilde_opt(&mut self.supergrok.auth_path);
1086 expand_tilde_opt(&mut self.supergrok.config_path);
1087 for account in &mut self.anthropic.accounts {
1088 account.credentials_path = expand_tilde(&account.credentials_path);
1089 }
1090 for account in &mut self.openai.accounts {
1091 account.codex_auth_path = expand_tilde(&account.codex_auth_path);
1092 }
1093 }
1094
1095 #[cfg(unix)]
1099 fn has_inline_secrets(&self) -> bool {
1100 [
1101 self.zai.api_key.as_deref(),
1102 self.openrouter.api_key.as_deref(),
1103 self.deepseek.api_key.as_deref(),
1104 self.kimi.api_key.as_deref(),
1105 self.kilo.api_key.as_deref(),
1106 self.novita.api_key.as_deref(),
1107 self.minimax.api_key.as_deref(),
1108 self.moonshot.api_key.as_deref(),
1109 self.grok.api_key.as_deref(),
1110 self.anthropic_api.api_key.as_deref(),
1111 self.opencode_go.api_key.as_deref(),
1112 ]
1113 .into_iter()
1114 .chain(
1115 self.openrouter
1116 .accounts
1117 .iter()
1118 .map(|account| account.api_key.as_deref()),
1119 )
1120 .any(|key| key.is_some_and(|key| !key.is_empty()))
1121 }
1122
1123 #[cfg(unix)]
1124 fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
1125 if !self.has_inline_secrets() {
1126 return Ok(());
1127 }
1128
1129 let metadata = std::fs::metadata(path).map_err(|_| {
1130 AppError::Credentials(format!(
1131 "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
1132 path.display()
1133 ))
1134 })?;
1135 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
1136 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
1137 AppError::Credentials(format!(
1138 "config at {} contains inline credentials but is group/other-readable and could not be tightened to 0600; fix permissions or move credentials to environment variables",
1139 path.display()
1140 ))
1141 })?;
1142 }
1143 Ok(())
1144 }
1145
1146 pub fn is_enabled(&self, id: VendorId) -> bool {
1147 match id {
1148 VendorId::Anthropic => self.anthropic.enabled,
1149 VendorId::AnthropicApi => self.anthropic_api.enabled,
1150 VendorId::Openai => self.openai.enabled,
1151 VendorId::Copilot => self.copilot.enabled,
1152 VendorId::Zai => self.zai.enabled,
1153 VendorId::Openrouter => self.openrouter.enabled,
1154 VendorId::Deepseek => self.deepseek.enabled,
1155 VendorId::Kimi => self.kimi.enabled,
1156 VendorId::Kilo => self.kilo.enabled,
1157 VendorId::Novita => self.novita.enabled,
1158 VendorId::Moonshot => self.moonshot.enabled,
1159 VendorId::Grok => self.grok.enabled,
1160 VendorId::Supergrok => self.supergrok.enabled,
1161 VendorId::Antigravity => self.antigravity.enabled,
1162 VendorId::Cursor => self.cursor.enabled,
1163 VendorId::Minimax => self.minimax.enabled,
1164 VendorId::Kiro => self.kiro.enabled,
1165 VendorId::NousResearch => self.nous.enabled,
1166 VendorId::OpenCodeGo => self.opencode_go.enabled,
1167 VendorId::CommandCode => self.commandcode.enabled,
1168 }
1169 }
1170
1171 pub fn api_key_env_for(&self, id: VendorId) -> &str {
1178 match id {
1179 VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
1180 VendorId::Zai => &self.zai.api_key_env,
1181 VendorId::Openrouter => &self.openrouter.api_key_env,
1182 VendorId::Deepseek => &self.deepseek.api_key_env,
1183 VendorId::Kimi => &self.kimi.api_key_env,
1184 VendorId::Kilo => &self.kilo.api_key_env,
1185 VendorId::Novita => &self.novita.api_key_env,
1186 VendorId::Moonshot => &self.moonshot.api_key_env,
1187 VendorId::Grok => &self.grok.api_key_env,
1188 VendorId::Minimax => &self.minimax.api_key_env,
1189 VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
1190 VendorId::Anthropic
1193 | VendorId::Openai
1194 | VendorId::Copilot
1195 | VendorId::Supergrok
1196 | VendorId::Antigravity
1197 | VendorId::Cursor
1198 | VendorId::Kiro
1199 | VendorId::NousResearch
1200 | VendorId::CommandCode => id.api_key_env(),
1201 }
1202 }
1203
1204 pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
1208 let raw = match id {
1209 VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
1210 VendorId::Zai => self.zai.api_key.as_deref(),
1211 VendorId::Openrouter => self.openrouter.api_key.as_deref(),
1212 VendorId::Deepseek => self.deepseek.api_key.as_deref(),
1213 VendorId::Kimi => self.kimi.api_key.as_deref(),
1214 VendorId::Kilo => self.kilo.api_key.as_deref(),
1215 VendorId::Novita => self.novita.api_key.as_deref(),
1216 VendorId::Moonshot => self.moonshot.api_key.as_deref(),
1217 VendorId::Grok => self.grok.api_key.as_deref(),
1218 VendorId::Minimax => self.minimax.api_key.as_deref(),
1219 VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
1220 VendorId::Anthropic
1221 | VendorId::Openai
1222 | VendorId::Copilot
1223 | VendorId::Supergrok
1224 | VendorId::Antigravity
1225 | VendorId::Cursor
1226 | VendorId::Kiro
1227 | VendorId::NousResearch
1228 | VendorId::CommandCode => None,
1229 };
1230 raw.filter(|key| !key.is_empty())
1231 }
1232
1233 pub fn enabled_vendors(&self) -> Vec<VendorId> {
1234 VendorId::all()
1235 .iter()
1236 .copied()
1237 .filter(|id| self.is_enabled(*id))
1238 .collect()
1239 }
1240
1241 pub fn validate(&self) -> Result<()> {
1245 if self.context.context_window_tokens == Some(0) {
1246 return Err(AppError::Other(
1247 "[context] context_window_tokens must be greater than zero".into(),
1248 ));
1249 }
1250 for (model, tokens) in &self.context.model_context_window_tokens {
1251 if model.trim().is_empty() {
1252 return Err(AppError::Other(
1253 "[context] model_context_window_tokens keys must not be empty".into(),
1254 ));
1255 }
1256 if *tokens == 0 {
1257 return Err(AppError::Other(format!(
1258 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
1259 )));
1260 }
1261 }
1262 if let Some(limit) = self.anthropic_api.monthly_limit
1263 && (!limit.is_finite() || limit <= 0.0)
1264 {
1265 return Err(AppError::Other(
1266 "[anthropic_api] monthly_limit must be finite and greater than zero; \
1267 remove it to show spend without a limit"
1268 .into(),
1269 ));
1270 }
1271 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
1272 && !self.kimi.region.eq_ignore_ascii_case("auto")
1273 {
1274 return Err(AppError::Other(format!(
1275 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
1276 self.kimi.region
1277 )));
1278 }
1279 if !self.minimax.region.eq_ignore_ascii_case("global")
1280 && !self.minimax.region.eq_ignore_ascii_case("cn")
1281 {
1282 return Err(AppError::Other(format!(
1283 "[minimax] region must be \"global\" or \"cn\", got {:?}",
1284 self.minimax.region
1285 )));
1286 }
1287 if self.supergrok.grok_binary.as_os_str().is_empty() {
1288 return Err(AppError::Other(
1289 "[supergrok] grok_binary must not be empty".into(),
1290 ));
1291 }
1292 let mut labels = HashSet::new();
1293 for account in &self.anthropic.accounts {
1294 validate_account_label(&account.label)?;
1295 if !labels.insert(&account.label) {
1296 return Err(AppError::Credentials(format!(
1297 "duplicate anthropic account label {:?}",
1298 account.label
1299 )));
1300 }
1301 }
1302 let mut openai_labels = HashSet::new();
1303 for account in &self.openai.accounts {
1304 validate_account_label_for("openai", &account.label)?;
1305 if !openai_labels.insert(&account.label) {
1306 return Err(AppError::Credentials(format!(
1307 "duplicate openai account label {:?}",
1308 account.label
1309 )));
1310 }
1311 }
1312 let mut openrouter_labels = HashSet::new();
1313 for account in &self.openrouter.accounts {
1314 validate_account_label_for("openrouter", &account.label)?;
1315 if !openrouter_labels.insert(&account.label) {
1316 return Err(AppError::Credentials(format!(
1317 "duplicate openrouter account label {:?}",
1318 account.label
1319 )));
1320 }
1321 let has_env = account
1322 .api_key_env
1323 .as_deref()
1324 .is_some_and(|name| !name.is_empty());
1325 let has_inline = account
1326 .api_key
1327 .as_deref()
1328 .is_some_and(|key| !key.is_empty());
1329 if !has_env && !has_inline {
1330 return Err(AppError::Credentials(format!(
1331 "openrouter account {:?} must set api_key_env or api_key",
1332 account.label
1333 )));
1334 }
1335 }
1336 Ok(())
1337 }
1338}
1339
1340#[cfg(unix)]
1341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1342enum InlineKeyPermissionDecision {
1343 Ok,
1344 Tighten,
1345}
1346
1347#[cfg(unix)]
1348fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
1349 if mode & 0o077 == 0 {
1350 InlineKeyPermissionDecision::Ok
1351 } else {
1352 InlineKeyPermissionDecision::Tighten
1353 }
1354}
1355
1356pub fn default_path() -> Option<PathBuf> {
1357 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
1358 Some(proj.config_dir().join("config.toml"))
1359}
1360
1361fn legacy_xdg_path() -> Option<PathBuf> {
1366 let home = crate::cache::home_dir().ok()?;
1367 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
1368}
1369
1370pub fn resolved_path() -> Option<PathBuf> {
1381 if let Some(path) = override_path() {
1382 return Some(path);
1383 }
1384 let canonical = default_path();
1385 if let Some(p) = &canonical
1386 && p.exists()
1387 {
1388 return canonical;
1389 }
1390 if let Some(legacy) = legacy_xdg_path()
1391 && legacy.exists()
1392 {
1393 return Some(legacy);
1394 }
1395 canonical
1396}
1397
1398static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
1399
1400pub fn set_override_path(path: &std::path::Path) {
1406 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
1407 *slot = Some(path.to_path_buf());
1408 }
1409}
1410
1411#[doc(hidden)]
1414pub fn clear_override_path() {
1415 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
1416 *slot = None;
1417 }
1418}
1419
1420fn override_path() -> Option<PathBuf> {
1421 PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
1422}
1423
1424#[doc(hidden)]
1430pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
1431 #[cfg(unix)]
1432 {
1433 use std::os::unix::ffi::{OsStrExt, OsStringExt};
1434 let rest = arg.as_bytes().strip_prefix(b"--config=")?;
1435 Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
1436 }
1437 #[cfg(windows)]
1438 {
1439 use std::os::windows::ffi::{OsStrExt, OsStringExt};
1440 const PREFIX: &[u16] = &[
1441 b'-' as u16,
1442 b'-' as u16,
1443 b'c' as u16,
1444 b'o' as u16,
1445 b'n' as u16,
1446 b'f' as u16,
1447 b'i' as u16,
1448 b'g' as u16,
1449 b'=' as u16,
1450 ];
1451 let wide: Vec<u16> = arg.encode_wide().collect();
1452 let rest = wide.strip_prefix(PREFIX)?;
1453 Some(std::ffi::OsString::from_wide(rest).into())
1454 }
1455 #[cfg(not(any(unix, windows)))]
1456 {
1457 Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
1458 }
1459}
1460
1461fn expand_tilde(p: &std::path::Path) -> PathBuf {
1464 let Some(s) = p.to_str() else {
1465 return p.to_path_buf();
1466 };
1467 let rest = if s == "~" {
1468 ""
1469 } else if let Some(r) = s.strip_prefix("~/") {
1470 r
1471 } else {
1472 return p.to_path_buf();
1473 };
1474 match crate::cache::home_dir() {
1475 Ok(home) if rest.is_empty() => home,
1476 Ok(home) => home.join(rest),
1477 Err(_) => p.to_path_buf(),
1478 }
1479}
1480
1481fn expand_tilde_opt(p: &mut Option<PathBuf>) {
1482 if let Some(inner) = p.as_ref() {
1483 *p = Some(expand_tilde(inner));
1484 }
1485}
1486
1487pub fn config_path_hint() -> String {
1492 resolved_path()
1493 .map(|p| p.display().to_string())
1494 .unwrap_or_else(|| "config.toml".to_string())
1495}
1496
1497#[cfg(test)]
1498mod tests {
1499 use super::*;
1500 use std::io::Write;
1501 use tempfile::NamedTempFile;
1502
1503 #[cfg(unix)]
1504 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1505
1506 fn write_toml(s: &str) -> NamedTempFile {
1507 let mut f = NamedTempFile::new().unwrap();
1508 f.write_all(s.as_bytes()).unwrap();
1509 f.flush().unwrap();
1510 f
1511 }
1512
1513 #[test]
1517 fn openai_without_accounts_resolves_the_singular_path() {
1518 let explicit = OpenAiConfig {
1519 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
1520 ..OpenAiConfig::default()
1521 };
1522 assert_eq!(
1523 explicit.resolve_auth_path(None).unwrap(),
1524 PathBuf::from("/tmp/codex/auth.json")
1525 );
1526
1527 let bare = OpenAiConfig::default();
1528 assert_eq!(
1529 bare.resolve_auth_path(None).unwrap(),
1530 crate::openai::creds::default_path().unwrap(),
1531 "no codex_auth_path must still mean ~/.codex/auth.json"
1532 );
1533 }
1534
1535 #[test]
1538 fn openai_named_accounts_resolve_their_own_auth_file() {
1539 let config: Config = toml::from_str(
1540 r#"
1541 [openai]
1542 codex_auth_path = "/tmp/personal/auth.json"
1543 [[openai.accounts]]
1544 label = "work"
1545 codex_auth_path = "/tmp/work/auth.json"
1546 "#,
1547 )
1548 .unwrap();
1549
1550 assert_eq!(
1551 config.openai.resolve_auth_path(Some("work")).unwrap(),
1552 PathBuf::from("/tmp/work/auth.json")
1553 );
1554 assert_eq!(
1555 config.openai.resolve_auth_path(None).unwrap(),
1556 PathBuf::from("/tmp/personal/auth.json")
1557 );
1558 }
1559
1560 #[test]
1563 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
1564 let config = OpenAiConfig {
1565 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
1566 accounts: vec![OpenAiAccount {
1567 label: "work".into(),
1568 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
1569 }],
1570 ..OpenAiConfig::default()
1571 };
1572 let err = config
1573 .resolve_auth_path(Some("nope"))
1574 .unwrap_err()
1575 .to_string();
1576 assert!(err.contains("nope"), "{err}");
1577 assert!(err.contains("[[openai.accounts]]"), "{err}");
1578 }
1579
1580 #[test]
1581 fn defaults_enable_only_the_four_core_vendors() {
1582 let c = Config::default();
1583 assert!(c.is_enabled(VendorId::Anthropic));
1584 assert!(c.is_enabled(VendorId::Openai));
1585 assert!(c.is_enabled(VendorId::Zai));
1586 assert!(c.is_enabled(VendorId::Openrouter));
1587 for opt_in in [
1588 VendorId::AnthropicApi,
1589 VendorId::Copilot,
1590 VendorId::Deepseek,
1591 VendorId::Kimi,
1592 VendorId::Kilo,
1593 VendorId::Novita,
1594 VendorId::Moonshot,
1595 VendorId::Grok,
1596 VendorId::Supergrok,
1597 VendorId::Cursor,
1598 VendorId::Minimax,
1599 VendorId::Kiro,
1600 ] {
1601 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
1602 }
1603 assert_eq!(c.enabled_vendors().len(), 4);
1604 }
1605
1606 #[test]
1607 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
1608 let config = Config::default();
1609 assert!(!config.is_enabled(VendorId::NousResearch));
1610 assert!(!config.is_enabled(VendorId::OpenCodeGo));
1611 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
1612 assert!(config.opencode_go.api_key.is_none());
1613 assert!(!config.is_enabled(VendorId::Copilot));
1614 }
1615
1616 #[cfg(unix)]
1617 #[test]
1618 fn inline_credentials_are_protected() {
1619 let mut config = Config::default();
1620 config.opencode_go.api_key = Some("<redacted>".to_string());
1621 assert!(config.has_inline_secrets());
1622 }
1623
1624 #[cfg(unix)]
1625 #[test]
1626 fn openrouter_named_inline_keys_receive_config_file_protection() {
1627 let mut config = Config::default();
1628 config.openrouter.accounts.push(OpenRouterAccount {
1629 label: "work".into(),
1630 api_key_env: None,
1631 api_key: Some("<redacted>".into()),
1632 });
1633 assert!(config.has_inline_secrets());
1634 }
1635
1636 #[test]
1637 fn missing_file_uses_defaults() {
1638 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
1639 let c = Config::load_from(path).unwrap();
1640 assert!(c.is_enabled(VendorId::Anthropic));
1641 }
1642
1643 #[test]
1644 fn parses_full_config() {
1645 let f = write_toml(
1646 r#"
1647 [anthropic]
1648 enabled = true
1649
1650 [openai]
1651 enabled = false
1652 admin_key_env = "MY_ADMIN_KEY"
1653
1654 [zai]
1655 enabled = true
1656 api_key_env = "MY_ZAI"
1657 plan_tier = "pro"
1658
1659 [openrouter]
1660 enabled = false
1661 "#,
1662 );
1663 let c = Config::load_from(f.path()).unwrap();
1664 assert!(c.is_enabled(VendorId::Anthropic));
1665 assert!(!c.is_enabled(VendorId::Openai));
1666 assert!(c.is_enabled(VendorId::Zai));
1667 assert!(!c.is_enabled(VendorId::Openrouter));
1668 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
1669 assert_eq!(c.zai.api_key_env, "MY_ZAI");
1670 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
1671 assert!(c.openrouter.accounts.is_empty());
1672 assert!(c.openrouter.show_default_account);
1673 }
1674
1675 #[test]
1676 fn partial_config_falls_back_to_defaults() {
1677 let f = write_toml(
1678 r#"[openai]
1679enabled = false
1680"#,
1681 );
1682 let c = Config::load_from(f.path()).unwrap();
1683 assert!(!c.is_enabled(VendorId::Openai));
1684 assert!(c.is_enabled(VendorId::Anthropic));
1686 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
1687 }
1688
1689 #[test]
1690 fn malformed_toml_returns_error() {
1691 let f = write_toml("this is not = = valid");
1692 assert!(Config::load_from(f.path()).is_err());
1693 }
1694
1695 #[cfg(unix)]
1696 #[test]
1697 fn load_from_tightens_world_readable_config_with_inline_api_key() {
1698 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
1699 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1700
1701 Config::load_from(file.path()).unwrap();
1702
1703 assert_eq!(
1704 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1705 0o600
1706 );
1707 }
1708
1709 #[cfg(unix)]
1710 #[test]
1711 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
1712 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
1713 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1714
1715 Config::load_from(file.path()).unwrap();
1716
1717 assert_eq!(
1718 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1719 0o644
1720 );
1721 }
1722
1723 #[cfg(unix)]
1724 #[test]
1725 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
1726 assert_eq!(
1727 inline_key_permission_decision(0o600),
1728 InlineKeyPermissionDecision::Ok
1729 );
1730 assert_eq!(
1731 inline_key_permission_decision(0o640),
1732 InlineKeyPermissionDecision::Tighten
1733 );
1734 assert_eq!(
1735 inline_key_permission_decision(0o604),
1736 InlineKeyPermissionDecision::Tighten
1737 );
1738 }
1739
1740 #[test]
1741 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
1742 for value in ["0", "-1", "inf", "nan"] {
1743 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
1744 let error = Config::load_from(file.path()).unwrap_err().to_string();
1745 assert!(error.contains("monthly_limit"), "value {value}: {error}");
1746 }
1747
1748 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
1749 assert_eq!(
1750 Config::load_from(file.path())
1751 .unwrap()
1752 .anthropic_api
1753 .monthly_limit,
1754 Some(1000.0)
1755 );
1756 }
1757
1758 #[test]
1759 fn minimax_region_accepts_only_known_instances() {
1760 for region in ["global", "GLOBAL", "cn", "CN"] {
1761 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1762 assert_eq!(
1763 Config::load_from(file.path()).unwrap().minimax.region,
1764 region
1765 );
1766 }
1767
1768 for region in ["", "china", "us"] {
1769 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1770 let error = Config::load_from(file.path()).unwrap_err().to_string();
1771 assert!(error.contains("[minimax] region"), "{error}");
1772 }
1773 }
1774
1775 #[test]
1776 fn kimi_region_accepts_auto_and_both_deployments() {
1777 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
1778 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
1779 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
1780 }
1781
1782 for region in ["", "us", "oversea"] {
1783 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
1784 let error = Config::load_from(file.path()).unwrap_err().to_string();
1785 assert!(error.contains("[kimi] region"), "{error}");
1786 }
1787 }
1788
1789 #[test]
1790 fn kimi_defaults_to_auto_region_and_no_credential_override() {
1791 let defaults = KimiConfig::default();
1792 assert_eq!(defaults.region, "auto");
1793 assert_eq!(defaults.credentials_path, None);
1794 assert!(!defaults.enabled);
1795 }
1796
1797 #[test]
1798 fn kimi_credentials_path_expands_a_tilde() {
1799 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
1800 let path = Config::load_from(file.path())
1801 .unwrap()
1802 .kimi
1803 .credentials_path
1804 .unwrap();
1805 assert!(!path.starts_with("~"), "{}", path.display());
1806 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
1807 }
1808
1809 #[test]
1810 fn optional_api_key_reports_absence_instead_of_failing() {
1811 assert_eq!(
1812 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", Some("inline")),
1813 Some("inline".to_string())
1814 );
1815 assert_eq!(
1816 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", None),
1817 None
1818 );
1819 assert_eq!(optional_api_key("KIMI_API_KEY_UNSET", Some("")), None);
1820 assert_eq!(
1823 optional_api_key("9INVALID", Some("inline")),
1824 Some("inline".to_string())
1825 );
1826 }
1827
1828 #[test]
1829 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
1830 let defaults = Config::default();
1831 assert!(!defaults.context.enabled);
1832 assert_eq!(
1833 defaults.context.window_tokens_for(Some("claude-test")),
1834 None
1835 );
1836
1837 let file = write_toml(
1838 r#"
1839 [context]
1840 enabled = true
1841 context_window_tokens = 200000
1842
1843 [context.model_context_window_tokens]
1844 claude-opus-1m = 1000000
1845 "claude exact id" = 300000
1846 "#,
1847 );
1848 let config = Config::load_from(file.path()).unwrap();
1849 assert!(config.context.enabled);
1850 assert_eq!(
1851 config.context.window_tokens_for(Some("claude-opus-1m")),
1852 Some(1_000_000)
1853 );
1854 assert_eq!(
1855 config.context.window_tokens_for(Some("claude exact id")),
1856 Some(300_000)
1857 );
1858 assert_eq!(
1859 config.context.window_tokens_for(Some("another-model")),
1860 Some(200_000)
1861 );
1862 }
1863
1864 #[test]
1865 fn context_layout_defaults_to_full_and_parses_each_variant() {
1866 assert_eq!(Config::default().context.layout, ContextLayout::Full);
1867 for (text, want) in [
1868 ("full", ContextLayout::Full),
1869 ("split", ContextLayout::Split),
1870 ("bottom", ContextLayout::Bottom),
1871 ] {
1872 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
1873 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
1874 }
1875 let file = write_toml("[context]\nlayout = \"floating\"\n");
1876 assert!(
1877 Config::load_from(file.path()).is_err(),
1878 "an unknown layout must be rejected, not silently defaulted"
1879 );
1880 }
1881
1882 #[test]
1883 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
1884 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
1885 for (text, want) in [
1886 ("sidebar", VendorBoxStyle::Sidebar),
1887 ("navbar", VendorBoxStyle::Navbar),
1888 ("none", VendorBoxStyle::None),
1889 ] {
1890 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
1891 assert_eq!(
1892 Config::load_from(file.path()).unwrap().ui.vendor_box(),
1893 want
1894 );
1895 }
1896 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
1897 assert!(
1898 Config::load_from(file.path()).is_err(),
1899 "an unknown vendor_box style must be rejected, not silently defaulted"
1900 );
1901 }
1902
1903 #[test]
1904 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
1905 for source in [
1906 "[context]\ncontext_window_tokens = 0\n",
1907 "[context.model_context_window_tokens]\nclaude = 0\n",
1908 "[context.model_context_window_tokens]\n\" \" = 200000\n",
1909 ] {
1910 let file = write_toml(source);
1911 let error = Config::load_from(file.path()).unwrap_err().to_string();
1912 assert!(error.contains("context"), "{error}");
1913 }
1914 }
1915
1916 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
1918 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
1919 M.lock().unwrap_or_else(|p| p.into_inner())
1920 }
1921
1922 #[test]
1923 fn resolve_api_key_prefers_env_over_inline() {
1924 let _g = env_guard();
1925 let var = "AI_USAGEBAR_TEST_ENV_WINS";
1927 unsafe { std::env::set_var(var, "from-env") };
1929 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
1930 unsafe { std::env::remove_var(var) };
1931 assert_eq!(got, "from-env");
1932 }
1933
1934 #[test]
1935 fn resolve_api_key_falls_back_to_inline() {
1936 let _g = env_guard();
1937 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
1938 unsafe { std::env::remove_var(var) };
1939 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
1940 assert_eq!(got, "inline-key");
1941 }
1942
1943 #[test]
1944 fn copilot_token_prefers_explicit_environment_over_gh_cli() {
1945 struct NeverRun;
1946 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
1947 fn run(
1948 &self,
1949 _: &crate::copilot::credentials::GhAuthTokenCommand,
1950 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
1951 panic!("environment override must not invoke gh")
1952 }
1953 }
1954
1955 let token = CopilotConfig::default()
1956 .resolve_token_with(
1957 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
1958 &NeverRun,
1959 )
1960 .unwrap();
1961 assert_eq!(token, "from-environment");
1962 }
1963
1964 #[test]
1965 fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
1966 struct FailedGh;
1967 impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
1968 fn run(
1969 &self,
1970 _: &crate::copilot::credentials::GhAuthTokenCommand,
1971 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
1972 Ok(crate::copilot::credentials::GhAuthTokenOutput {
1973 success: false,
1974 stdout: b"never-echo-gh-output".to_vec(),
1975 })
1976 }
1977 }
1978 let error = CopilotConfig::default()
1979 .resolve_token_with(|_| None, &FailedGh)
1980 .unwrap_err()
1981 .to_string();
1982 assert!(error.contains("gh auth login --web"));
1983 assert!(!error.contains("never-echo-gh-output"));
1984 }
1985
1986 #[test]
1987 fn resolve_api_key_errors_when_both_missing() {
1988 let _g = env_guard();
1989 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
1990 unsafe { std::env::remove_var(var) };
1991 let err = resolve_api_key("Zai", var, None).unwrap_err();
1992 match err {
1993 crate::error::AppError::Credentials(msg) => {
1994 assert!(
1995 msg.contains("api_key"),
1996 "error should suggest config field: {msg}"
1997 );
1998 }
1999 other => panic!("expected Credentials error, got {other:?}"),
2000 }
2001 }
2002
2003 #[test]
2004 fn resolve_api_key_uses_exact_opencode_go_section_name() {
2005 let _g = env_guard();
2006 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
2007 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
2008 let message = err.to_string();
2009 assert!(
2010 message.contains("[opencode-go]"),
2011 "wrong section hint: {message}"
2012 );
2013 assert!(
2014 !message.contains("[opencode go]"),
2015 "wrong section hint: {message}"
2016 );
2017 }
2018
2019 fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
2020 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
2021 M.lock().unwrap_or_else(|p| p.into_inner())
2022 }
2023
2024 struct ScopedPathOverride {
2030 _serial: std::sync::MutexGuard<'static, ()>,
2031 }
2032
2033 impl Drop for ScopedPathOverride {
2034 fn drop(&mut self) {
2035 clear_override_path();
2036 }
2037 }
2038
2039 fn scoped_path_override() -> ScopedPathOverride {
2040 ScopedPathOverride {
2041 _serial: path_override_guard(),
2042 }
2043 }
2044
2045 #[test]
2046 fn override_path_wins_over_canonical_and_legacy() {
2047 let _scoped = scoped_path_override();
2048 let file = NamedTempFile::new().unwrap();
2049 set_override_path(file.path());
2050 assert_eq!(resolved_path().as_deref(), Some(file.path()));
2051 assert_eq!(config_path_hint(), file.path().display().to_string());
2052 clear_override_path();
2053 let p = resolved_path().expect("a config path must resolve");
2055 assert!(p.ends_with("config.toml"));
2056 }
2057
2058 #[test]
2059 fn scoped_override_guard_clears_the_override_on_panic() {
2060 let hook = std::panic::take_hook();
2063 std::panic::set_hook(Box::new(|_| {}));
2064 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2065 let _scoped = scoped_path_override();
2066 set_override_path(std::path::Path::new("panicked-override.toml"));
2067 panic!("simulated mid-test failure");
2068 }))
2069 .is_err();
2070 std::panic::set_hook(hook);
2071 assert!(panicked, "the simulated failure must run");
2072 let _serial = path_override_guard();
2073 assert!(
2074 override_path().is_none(),
2075 "a panicking test must not leak the override into siblings"
2076 );
2077 }
2078
2079 #[test]
2080 fn config_path_hint_ends_with_config_toml() {
2081 let _g = path_override_guard();
2082 assert!(config_path_hint().ends_with("config.toml"));
2085 }
2086
2087 #[test]
2088 fn config_flag_value_splits_the_equals_form() {
2089 use std::ffi::OsStr;
2090 assert_eq!(
2091 config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
2092 Some(std::path::Path::new("work.toml"))
2093 );
2094 assert_eq!(
2095 config_flag_value(OsStr::new("--config=")).as_deref(),
2096 Some(std::path::Path::new(""))
2097 );
2098 assert_eq!(config_flag_value(OsStr::new("--config")), None);
2099 assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
2100 assert_eq!(config_flag_value(OsStr::new("account")), None);
2101 }
2102
2103 #[cfg(unix)]
2107 #[test]
2108 fn config_flag_value_keeps_undecodable_bytes_intact() {
2109 use std::ffi::OsString;
2110 use std::os::unix::ffi::{OsStrExt, OsStringExt};
2111 let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
2112 let value = config_flag_value(&raw).expect("prefix matches");
2113 assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
2114 }
2115
2116 #[cfg(windows)]
2117 #[test]
2118 fn config_flag_value_keeps_lone_surrogates_intact() {
2119 use std::ffi::OsString;
2120 use std::os::windows::ffi::{OsStrExt, OsStringExt};
2121 let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
2122 wide.push(0xDC00); wide.extend("x.toml".encode_utf16());
2124 let raw = OsString::from_wide(&wide);
2125 let value = config_flag_value(&raw).expect("prefix matches");
2126 let mut expected = vec![0xDC00u16];
2127 expected.extend("x.toml".encode_utf16());
2128 assert_eq!(
2129 value.as_os_str().encode_wide().collect::<Vec<_>>(),
2130 expected
2131 );
2132 }
2133
2134 #[test]
2135 fn resolve_api_key_treats_empty_env_as_unset() {
2136 let _g = env_guard();
2137 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
2138 unsafe { std::env::set_var(var, "") };
2139 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
2140 unsafe { std::env::remove_var(var) };
2141 assert_eq!(got, "inline");
2142 }
2143
2144 #[test]
2145 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
2146 let _g = env_guard();
2147 let bad = "sk-kimi-very-real-looking-pasted-secret";
2149 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
2150 let msg = err.to_string();
2151 assert!(
2152 msg.contains("invalid") && msg.contains("api_key_env"),
2153 "error should explain misconfiguration: {msg}"
2154 );
2155 assert!(
2156 !msg.contains(bad),
2157 "error must not echo the misconfigured value: {msg}"
2158 );
2159 assert!(msg.contains("valid environment variable name"));
2160 assert!(
2161 msg.contains("[kimi]"),
2162 "error should point at the lowercase TOML section: {msg}"
2163 );
2164 }
2165
2166 #[test]
2167 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
2168 let _g = env_guard();
2169 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
2170 assert_eq!(got, "inline-key");
2171 }
2172
2173 #[test]
2174 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
2175 let _g = env_guard();
2176 let pasted_secret = "sk_pasted_secret";
2179 unsafe { std::env::remove_var(pasted_secret) };
2180 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
2181 assert!(
2182 !err.to_string().contains(pasted_secret),
2183 "error must not echo configured api_key_env values"
2184 );
2185 }
2186
2187 #[test]
2188 fn is_valid_env_var_name_rules() {
2189 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
2191 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
2192 }
2193 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
2195 assert!(
2196 !is_valid_env_var_name(invalid),
2197 "{invalid} should be invalid"
2198 );
2199 }
2200 }
2201
2202 #[test]
2203 fn config_parses_with_inline_api_key_and_primary() {
2204 let f = write_toml(
2205 r#"
2206 [ui]
2207 primary = "openrouter"
2208
2209 [zai]
2210 enabled = true
2211 api_key_env = "MY_ZAI"
2212 api_key = "sk-zai-inline"
2213
2214 [openrouter]
2215 enabled = true
2216 api_key = "sk-or-inline"
2217 "#,
2218 );
2219 let c = Config::load_from(f.path()).unwrap();
2220 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
2221 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
2222 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
2223 }
2224
2225 #[test]
2226 fn openrouter_named_accounts_preserve_the_default_contract() {
2227 let f = write_toml(
2228 r#"
2229 [openrouter]
2230 enabled = true
2231 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
2232 api_key = "default-inline"
2233 show_default_account = false
2234
2235 [[openrouter.accounts]]
2236 label = "work"
2237 api_key_env = "OPENROUTER_WORK_API_KEY"
2238
2239 [[openrouter.accounts]]
2240 label = "personal"
2241 api_key = "personal-inline"
2242 "#,
2243 );
2244 let _g = env_guard();
2245 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
2246 let config = Config::load_from(f.path()).unwrap();
2247 assert!(!config.openrouter.show_default_account);
2248 assert_eq!(config.openrouter.accounts.len(), 2);
2249 assert_eq!(
2250 config.openrouter.resolve_api_key(None).unwrap(),
2251 "default-inline"
2252 );
2253 assert_eq!(
2254 config.openrouter.resolve_api_key(Some("personal")).unwrap(),
2255 "personal-inline"
2256 );
2257 }
2258
2259 #[test]
2260 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
2261 for source in [
2262 r#"
2263 [[openrouter.accounts]]
2264 label = "work"
2265 api_key = "one"
2266 [[openrouter.accounts]]
2267 label = "work"
2268 api_key = "two"
2269 "#,
2270 r#"
2271 [[openrouter.accounts]]
2272 label = "../work"
2273 api_key = "one"
2274 "#,
2275 r#"
2276 [[openrouter.accounts]]
2277 label = "work"
2278 "#,
2279 ] {
2280 let f = write_toml(source);
2281 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
2282 }
2283 }
2284
2285 #[test]
2286 fn openrouter_unknown_account_never_falls_back_to_default_key() {
2287 let mut config = OpenRouterConfig {
2288 api_key: Some("default-secret".into()),
2289 ..OpenRouterConfig::default()
2290 };
2291 config.accounts.push(OpenRouterAccount {
2292 label: "work".into(),
2293 api_key_env: None,
2294 api_key: Some("work-secret".into()),
2295 });
2296 let message = config
2297 .resolve_api_key(Some("missing"))
2298 .unwrap_err()
2299 .to_string();
2300 assert!(message.contains("missing") && message.contains("work"));
2301 assert!(!message.contains("default-secret"));
2302 assert!(!message.contains("work-secret"));
2303 }
2304
2305 #[test]
2306 fn openrouter_account_key_errors_do_not_echo_configured_values() {
2307 let config = OpenRouterConfig {
2308 accounts: vec![OpenRouterAccount {
2309 label: "work".into(),
2310 api_key_env: Some("sk_pasted_secret".into()),
2311 api_key: None,
2312 }],
2313 ..OpenRouterConfig::default()
2314 };
2315 let _g = env_guard();
2316 unsafe { std::env::remove_var("sk_pasted_secret") };
2317 let message = config
2318 .resolve_api_key(Some("work"))
2319 .unwrap_err()
2320 .to_string();
2321 assert!(message.contains("[[openrouter.accounts]]"));
2322 assert!(!message.contains("sk_pasted_secret"));
2323 }
2324
2325 #[test]
2326 fn enabled_vendors_preserves_canonical_order() {
2327 let c = Config::default();
2330 assert_eq!(
2331 c.enabled_vendors(),
2332 vec![
2333 VendorId::Anthropic,
2334 VendorId::Openai,
2335 VendorId::Zai,
2336 VendorId::Openrouter,
2337 ]
2338 );
2339 }
2340
2341 #[test]
2342 fn deepseek_appears_when_enabled() {
2343 let f = write_toml(
2344 r#"
2345 [deepseek]
2346 enabled = true
2347 api_key = "sk-test"
2348 "#,
2349 );
2350 let c = Config::load_from(f.path()).unwrap();
2351 assert!(c.is_enabled(VendorId::Deepseek));
2352 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
2353 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
2354 }
2355
2356 #[test]
2357 fn tilde_paths_are_expanded_on_load() {
2358 let f = write_toml(
2362 r#"
2363 [context]
2364 projects_path = "~/.claude/projects"
2365
2366 [anthropic]
2367 credentials_path = "~/.claude/.credentials.json"
2368
2369 [[anthropic.accounts]]
2370 label = "work"
2371 credentials_path = "~/work.json"
2372 "#,
2373 );
2374 let c = Config::load_from(f.path()).unwrap();
2375 let home = crate::cache::home_dir().unwrap();
2376
2377 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
2378 let got = c.anthropic.credentials_path.unwrap();
2379 assert_eq!(got, home.join(".claude/.credentials.json"));
2380 assert!(!got.to_string_lossy().contains('~'));
2381 assert_eq!(
2382 c.anthropic.accounts[0].credentials_path,
2383 home.join("work.json")
2384 );
2385 }
2386
2387 #[test]
2388 fn absolute_and_relative_paths_are_left_alone() {
2389 let f = write_toml(
2390 r#"
2391 [anthropic]
2392 credentials_path = "/etc/creds.json"
2393 "#,
2394 );
2395 let c = Config::load_from(f.path()).unwrap();
2396 assert_eq!(
2397 c.anthropic.credentials_path.unwrap(),
2398 std::path::Path::new("/etc/creds.json")
2399 );
2400
2401 let f2 = write_toml(
2403 r#"
2404 [anthropic]
2405 credentials_path = "~someone/creds.json"
2406 "#,
2407 );
2408 let c2 = Config::load_from(f2.path()).unwrap();
2409 assert_eq!(
2410 c2.anthropic.credentials_path.unwrap(),
2411 std::path::Path::new("~someone/creds.json")
2412 );
2413 }
2414
2415 #[test]
2416 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
2417 let _g = path_override_guard();
2418 let p = resolved_path().expect("a config path must resolve");
2421 assert!(p.ends_with("config.toml"));
2422 let canonical = default_path().unwrap();
2423 let legacy = legacy_xdg_path().unwrap();
2424 assert!(
2425 p == canonical || p == legacy,
2426 "resolved to an unexpected location: {}",
2427 p.display()
2428 );
2429 }
2430
2431 #[test]
2432 fn misspelled_section_is_rejected_not_ignored() {
2433 let f = write_toml(
2436 r#"
2437 [openrouer]
2438 enabled = true
2439 api_key = "sk-or-v1-typo"
2440 "#,
2441 );
2442 let err = Config::load_from(f.path()).unwrap_err().to_string();
2443 assert!(
2444 err.contains("openrouer"),
2445 "error should name the typo: {err}"
2446 );
2447 }
2448
2449 #[test]
2450 fn invalid_toml_is_an_error_not_silent_defaults() {
2451 let f = write_toml("[zai\nenabled = true\n");
2452 assert!(Config::load_from(f.path()).is_err());
2453 }
2454
2455 #[test]
2456 fn a_missing_file_is_still_just_defaults() {
2457 let dir = tempfile::tempdir().unwrap();
2460 let missing = dir.path().join("nope").join("config.toml");
2461 let c = Config::load_from(&missing).unwrap();
2462 assert!(c.is_enabled(VendorId::Anthropic));
2463 }
2464
2465 #[test]
2466 fn kimi_appears_when_enabled() {
2467 let f = write_toml(
2468 r#"
2469 [kimi]
2470 enabled = true
2471 api_key = "sk-test"
2472 "#,
2473 );
2474 let c = Config::load_from(f.path()).unwrap();
2475 assert!(c.is_enabled(VendorId::Kimi));
2476 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
2477 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
2478 }
2479
2480 #[test]
2481 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
2482 let f = write_toml(
2483 r#"
2484 [deepseek]
2485 enabled = true
2486 api_key = "sk-ds"
2487
2488 [kimi]
2489 enabled = true
2490 api_key = "sk-kimi"
2491 "#,
2492 );
2493 let c = Config::load_from(f.path()).unwrap();
2494 assert_eq!(
2495 c.enabled_vendors(),
2496 vec![
2497 VendorId::Anthropic,
2498 VendorId::Openai,
2499 VendorId::Zai,
2500 VendorId::Openrouter,
2501 VendorId::Deepseek,
2502 VendorId::Kimi,
2503 ]
2504 );
2505 }
2506
2507 #[test]
2508 fn parses_anthropic_accounts_and_looks_them_up() {
2509 let f = write_toml(
2510 r#"
2511 [anthropic]
2512 enabled = true
2513
2514 [[anthropic.accounts]]
2515 label = "personal"
2516 credentials_path = "/creds/personal.json"
2517
2518 [[anthropic.accounts]]
2519 label = "work"
2520 credentials_path = "/creds/work.json"
2521 "#,
2522 );
2523 let c = Config::load_from(f.path()).unwrap();
2524 assert_eq!(c.anthropic.accounts.len(), 2);
2525 let work = c.anthropic.account("work").unwrap();
2526 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
2527 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
2529 assert!(err.contains("missing") && err.contains("work"), "{err}");
2530 }
2531
2532 #[test]
2533 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
2534 let f = write_toml(
2535 r#"
2536 [[anthropic.accounts]]
2537 label = "work"
2538 credentials_path = "/creds/work-one.json"
2539
2540 [[anthropic.accounts]]
2541 label = "work"
2542 credentials_path = "/creds/work-two.json"
2543 "#,
2544 );
2545 let err = Config::load_from(f.path()).unwrap_err().to_string();
2546 assert!(
2547 err.contains("duplicate anthropic account label \"work\""),
2548 "{err}"
2549 );
2550 }
2551
2552 #[test]
2553 fn account_label_rejects_path_like_names() {
2554 let cfg = AnthropicConfig::default();
2555 for bad in [
2556 "",
2557 ".",
2558 "..",
2559 "a/b",
2560 r"a\b",
2561 "C:work",
2562 "line\nbreak",
2563 "tab\tname",
2564 "usage.json",
2565 ".stale",
2566 ".last_error",
2567 ".fetch.lock",
2568 ] {
2569 let err = cfg.account(bad).unwrap_err();
2570 assert!(
2571 format!("{err:?}").contains("invalid anthropic account label"),
2572 "{bad:?} should be rejected as a label"
2573 );
2574 }
2575 }
2576
2577 #[test]
2578 fn anthropic_accounts_default_to_empty() {
2579 assert!(Config::default().anthropic.accounts.is_empty());
2582 assert!(Config::default().anthropic.accounts_dir.is_none());
2583 }
2584
2585 fn seed_account_dir(root: &std::path::Path, label: &str) {
2591 let dir = root.join(label);
2592 std::fs::create_dir_all(&dir).unwrap();
2593 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
2594 }
2595
2596 #[test]
2597 fn discovers_account_dirs_in_claude_config_dir_layout() {
2598 let td = tempfile::tempdir().unwrap();
2599 seed_account_dir(td.path(), "work");
2600 seed_account_dir(td.path(), "personal");
2601 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
2604 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
2606
2607 let cfg = AnthropicConfig {
2608 accounts_dir: Some(td.path().to_path_buf()),
2609 ..Default::default()
2610 };
2611 let all = cfg.all_accounts();
2612 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
2613 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
2614 assert_eq!(
2615 all[2].credentials_path,
2616 td.path().join("work").join(".credentials.json")
2617 );
2618 }
2619
2620 #[test]
2621 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
2622 let td = tempfile::tempdir().unwrap();
2623 seed_account_dir(td.path(), "work");
2624 let cfg = AnthropicConfig {
2625 accounts: vec![AnthropicAccount {
2626 label: "work".into(),
2627 credentials_path: "/explicit/work.json".into(),
2628 }],
2629 accounts_dir: Some(td.path().to_path_buf()),
2630 ..Default::default()
2631 };
2632 let all = cfg.all_accounts();
2633 assert_eq!(all.len(), 1, "no duplicate label");
2634 assert_eq!(
2635 all[0].credentials_path,
2636 std::path::Path::new("/explicit/work.json"),
2637 "explicit entry wins"
2638 );
2639 seed_account_dir(td.path(), "other");
2641 assert_eq!(cfg.account("other").unwrap().label, "other");
2642 }
2643
2644 #[test]
2645 fn missing_accounts_dir_is_silently_empty_not_an_error() {
2646 let cfg = AnthropicConfig {
2647 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
2648 ..Default::default()
2649 };
2650 assert!(cfg.all_accounts().is_empty());
2651 }
2652
2653 #[test]
2654 fn openai_account_auth_paths_are_tilde_expanded_on_load() {
2655 let f = write_toml(
2656 r#"
2657 [[openai.accounts]]
2658 label = "work"
2659 codex_auth_path = "~/.codex-work/auth.json"
2660 "#,
2661 );
2662 let c = Config::load_from(f.path()).unwrap();
2663 let home = crate::cache::home_dir().unwrap();
2664 assert_eq!(
2665 c.openai.accounts[0].codex_auth_path,
2666 home.join(".codex-work/auth.json")
2667 );
2668 }
2669
2670 #[test]
2671 fn accounts_dir_is_tilde_expanded_on_load() {
2672 let f = write_toml(
2673 r#"
2674 [anthropic]
2675 accounts_dir = "~/.config/ai-usagebar/accounts"
2676 "#,
2677 );
2678 let c = Config::load_from(f.path()).unwrap();
2679 let home = crate::cache::home_dir().unwrap();
2680 assert_eq!(
2681 c.anthropic.accounts_dir,
2682 Some(home.join(".config/ai-usagebar/accounts"))
2683 );
2684 }
2685
2686 #[test]
2687 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
2688 let f = write_toml(
2689 r#"
2690 [anthropic]
2691 desktop_profiles_dir = "~/.claude-acc/profiles"
2692 "#,
2693 );
2694 let c = Config::load_from(f.path()).unwrap();
2695 let home = crate::cache::home_dir().unwrap();
2696 assert_eq!(
2697 c.anthropic.desktop_profiles_dir,
2698 Some(home.join(".claude-acc/profiles"))
2699 );
2700 }
2701
2702 #[test]
2703 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
2704 let cfg = AnthropicConfig {
2705 accounts: vec![
2706 AnthropicAccount {
2707 label: "work".into(),
2708 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
2709 },
2710 AnthropicAccount {
2711 label: "personal".into(),
2712 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
2713 },
2714 ],
2715 ..Default::default()
2716 };
2717
2718 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
2719 assert!(
2720 matches!(&idle, CredsTarget::Named { config_dir, .. }
2721 if config_dir == std::path::Path::new("/tmp/accounts/work")),
2722 "{idle:?}"
2723 );
2724
2725 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
2727 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
2728
2729 assert_eq!(idle_cache.dir(), live_cache.dir());
2732 }
2733
2734 #[test]
2735 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
2736 let cfg = AnthropicConfig {
2737 accounts: vec![AnthropicAccount {
2738 label: "work".into(),
2739 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
2740 }],
2741 ..Default::default()
2742 };
2743 let (target, _) = cfg.account_target_with("work", None).unwrap();
2744 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
2745 }
2746
2747 fn config_example() -> PathBuf {
2751 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
2752 }
2753
2754 #[test]
2755 fn shipped_example_parses_as_a_real_config() {
2756 let c = Config::load_from(&config_example()).unwrap();
2761 assert!(!c.context.enabled);
2762 assert!(c.is_enabled(VendorId::Anthropic));
2763 assert!(c.is_enabled(VendorId::Openai));
2764 assert!(!c.is_enabled(VendorId::AnthropicApi));
2765 assert!(!c.is_enabled(VendorId::Deepseek));
2766 assert!(!c.is_enabled(VendorId::Kimi));
2767 assert!(!c.is_enabled(VendorId::Kilo));
2768 assert!(!c.is_enabled(VendorId::Novita));
2769 assert!(!c.is_enabled(VendorId::Moonshot));
2770 assert!(!c.is_enabled(VendorId::Grok));
2771 assert!(!c.is_enabled(VendorId::Cursor));
2772 assert!(!c.is_enabled(VendorId::Minimax));
2773 }
2774
2775 #[test]
2776 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
2777 let text = std::fs::read_to_string(config_example()).unwrap();
2782 let live: Vec<&str> = text
2783 .lines()
2784 .map(str::trim)
2785 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
2786 .collect();
2787 assert!(
2788 live.is_empty(),
2789 "admin_key_env must stay commented out while it is inert: {live:?}"
2790 );
2791 assert!(
2794 text.contains("admin_key_env") && text.contains("RESERVED"),
2795 "the example should keep describing admin_key_env as reserved"
2796 );
2797 }
2798
2799 #[test]
2800 fn admin_key_env_is_accepted_but_changes_nothing() {
2801 let f = write_toml(
2805 r#"
2806 [openai]
2807 admin_key_env = "SOME_ADMIN_KEY"
2808 "#,
2809 );
2810 let c = Config::load_from(f.path()).unwrap();
2811 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
2812 let default = OpenAiConfig::default();
2814 assert_eq!(c.openai.enabled, default.enabled);
2815 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
2816 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
2817 }
2818
2819 #[test]
2820 fn config_example_documents_every_vendor_without_secrets() {
2821 let raw = std::fs::read_to_string(config_example()).unwrap();
2822 let cfg = Config::load_from(&config_example()).unwrap();
2823 for id in VendorId::all() {
2826 let section = id.slug();
2827 assert!(
2828 raw.contains(&format!("[{section}]")),
2829 "config.example.toml has no [{section}] section"
2830 );
2831 }
2832
2833 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
2836 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
2837 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
2838 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
2839 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
2840 assert!(!cfg.supergrok.enabled);
2841 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
2842 assert_eq!(
2843 cfg.supergrok
2844 .grok_binary
2845 .file_name()
2846 .and_then(|p| p.to_str()),
2847 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
2848 );
2849 assert!(cfg.supergrok.auth_path.is_none());
2850 assert!(cfg.supergrok.config_path.is_none());
2851 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
2852 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
2853 }
2854
2855 #[test]
2856 fn supergrok_binary_must_not_be_empty() {
2857 let file = write_toml(
2858 r#"
2859 [supergrok]
2860 enabled = true
2861 grok_binary = ""
2862 "#,
2863 );
2864 let error = Config::load_from(file.path()).unwrap_err().to_string();
2865 assert!(error.contains("grok_binary must not be empty"));
2866 }
2867
2868 #[test]
2869 fn supergrok_paths_are_tilde_expanded() {
2870 let file = write_toml(
2871 r#"
2872 [supergrok]
2873 grok_binary = "~/bin/grok"
2874 auth_path = "~/.grok/auth.json"
2875 config_path = "~/.grok/config.toml"
2876 "#,
2877 );
2878 let config = Config::load_from(file.path()).unwrap();
2879 let home = crate::cache::home_dir().unwrap();
2880 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
2881 assert_eq!(
2882 config.supergrok.auth_path,
2883 Some(home.join(".grok/auth.json"))
2884 );
2885 assert_eq!(
2886 config.supergrok.config_path,
2887 Some(home.join(".grok/config.toml"))
2888 );
2889 }
2890
2891 #[test]
2892 fn kiro_db_path_is_tilde_expanded() {
2893 let f = write_toml(
2894 r#"
2895 [kiro]
2896 db_path = "~/kiro-data.sqlite3"
2897 "#,
2898 );
2899 let c = Config::load_from(f.path()).unwrap();
2900 let home = crate::cache::home_dir().unwrap();
2901 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
2902 }
2903
2904 #[test]
2905 fn kiro_appears_when_enabled() {
2906 let f = write_toml(
2907 r#"
2908 [kiro]
2909 enabled = true
2910 "#,
2911 );
2912 let c = Config::load_from(f.path()).unwrap();
2913 assert!(c.is_enabled(VendorId::Kiro));
2914 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
2915 }
2916
2917 #[test]
2918 fn cursor_db_path_is_tilde_expanded() {
2919 let f = write_toml(
2920 r#"
2921 [cursor]
2922 db_path = "~/cursor-state.vscdb"
2923 "#,
2924 );
2925 let c = Config::load_from(f.path()).unwrap();
2926 let home = crate::cache::home_dir().unwrap();
2927 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
2928 }
2929
2930 #[test]
2931 fn cursor_agent_auth_path_is_tilde_expanded() {
2932 let f = write_toml(
2933 r#"
2934 [cursor]
2935 agent_auth_path = "~/cursor-agent-auth.json"
2936 "#,
2937 );
2938 let c = Config::load_from(f.path()).unwrap();
2939 let home = crate::cache::home_dir().unwrap();
2940 assert_eq!(
2941 c.cursor.agent_auth_path,
2942 Some(home.join("cursor-agent-auth.json"))
2943 );
2944 }
2945
2946 #[test]
2947 fn cursor_appears_when_enabled() {
2948 let f = write_toml(
2949 r#"
2950 [cursor]
2951 enabled = true
2952 "#,
2953 );
2954 let c = Config::load_from(f.path()).unwrap();
2955 assert!(c.is_enabled(VendorId::Cursor));
2956 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
2957 }
2958
2959 #[test]
2960 fn add_account_appends_and_preserves_existing() {
2961 let mut doc: toml_edit::DocumentMut = r#"
2962# keep me
2963[anthropic]
2964enabled = true
2965
2966[[anthropic.accounts]]
2967label = "personal"
2968credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
2969"#
2970 .parse()
2971 .unwrap();
2972 add_anthropic_account_to_doc(
2973 &mut doc,
2974 "work",
2975 "~/.config/ai-usagebar/accounts/work/.credentials.json",
2976 )
2977 .unwrap();
2978 let rendered = doc.to_string();
2979 assert!(rendered.contains("# keep me"), "comment must survive");
2980 let f = write_toml(&rendered);
2982 let c = Config::load_from(f.path()).unwrap();
2983 let labels: Vec<&str> = c
2984 .anthropic
2985 .accounts
2986 .iter()
2987 .map(|a| a.label.as_str())
2988 .collect();
2989 assert_eq!(labels, vec!["personal", "work"]);
2990 }
2991
2992 #[test]
2993 fn add_account_to_empty_doc_is_loadable() {
2994 let mut doc = toml_edit::DocumentMut::new();
2995 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
2996 let f = write_toml(&doc.to_string());
2997 let c = Config::load_from(f.path()).unwrap();
2998 assert_eq!(c.anthropic.accounts.len(), 1);
2999 assert_eq!(c.anthropic.accounts[0].label, "solo");
3000 }
3001
3002 #[test]
3003 fn add_account_rejects_duplicate_label() {
3004 let mut doc: toml_edit::DocumentMut = r#"
3005[[anthropic.accounts]]
3006label = "work"
3007credentials_path = "~/w/.credentials.json"
3008"#
3009 .parse()
3010 .unwrap();
3011 assert!(
3012 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
3013 "a duplicate label must be rejected, not appended"
3014 );
3015 }
3016
3017 #[test]
3018 fn add_account_rejects_bad_label() {
3019 let mut doc = toml_edit::DocumentMut::new();
3020 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
3021 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
3022 }
3023
3024 #[test]
3025 fn tildify_collapses_home_only() {
3026 let home = Path::new("/Users/me");
3027 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
3028 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
3029 }
3030
3031 #[test]
3032 fn default_account_credentials_path_nests_under_config_dir() {
3033 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
3034 assert_eq!(
3035 default_account_credentials_path(cfg, "work"),
3036 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
3037 );
3038 }
3039}