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 enabled_vendors(&self) -> Vec<VendorId> {
1172 VendorId::all()
1173 .iter()
1174 .copied()
1175 .filter(|id| self.is_enabled(*id))
1176 .collect()
1177 }
1178
1179 pub fn validate(&self) -> Result<()> {
1183 if self.context.context_window_tokens == Some(0) {
1184 return Err(AppError::Other(
1185 "[context] context_window_tokens must be greater than zero".into(),
1186 ));
1187 }
1188 for (model, tokens) in &self.context.model_context_window_tokens {
1189 if model.trim().is_empty() {
1190 return Err(AppError::Other(
1191 "[context] model_context_window_tokens keys must not be empty".into(),
1192 ));
1193 }
1194 if *tokens == 0 {
1195 return Err(AppError::Other(format!(
1196 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
1197 )));
1198 }
1199 }
1200 if let Some(limit) = self.anthropic_api.monthly_limit
1201 && (!limit.is_finite() || limit <= 0.0)
1202 {
1203 return Err(AppError::Other(
1204 "[anthropic_api] monthly_limit must be finite and greater than zero; \
1205 remove it to show spend without a limit"
1206 .into(),
1207 ));
1208 }
1209 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
1210 && !self.kimi.region.eq_ignore_ascii_case("auto")
1211 {
1212 return Err(AppError::Other(format!(
1213 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
1214 self.kimi.region
1215 )));
1216 }
1217 if !self.minimax.region.eq_ignore_ascii_case("global")
1218 && !self.minimax.region.eq_ignore_ascii_case("cn")
1219 {
1220 return Err(AppError::Other(format!(
1221 "[minimax] region must be \"global\" or \"cn\", got {:?}",
1222 self.minimax.region
1223 )));
1224 }
1225 if self.supergrok.grok_binary.as_os_str().is_empty() {
1226 return Err(AppError::Other(
1227 "[supergrok] grok_binary must not be empty".into(),
1228 ));
1229 }
1230 let mut labels = HashSet::new();
1231 for account in &self.anthropic.accounts {
1232 validate_account_label(&account.label)?;
1233 if !labels.insert(&account.label) {
1234 return Err(AppError::Credentials(format!(
1235 "duplicate anthropic account label {:?}",
1236 account.label
1237 )));
1238 }
1239 }
1240 let mut openai_labels = HashSet::new();
1241 for account in &self.openai.accounts {
1242 validate_account_label_for("openai", &account.label)?;
1243 if !openai_labels.insert(&account.label) {
1244 return Err(AppError::Credentials(format!(
1245 "duplicate openai account label {:?}",
1246 account.label
1247 )));
1248 }
1249 }
1250 let mut openrouter_labels = HashSet::new();
1251 for account in &self.openrouter.accounts {
1252 validate_account_label_for("openrouter", &account.label)?;
1253 if !openrouter_labels.insert(&account.label) {
1254 return Err(AppError::Credentials(format!(
1255 "duplicate openrouter account label {:?}",
1256 account.label
1257 )));
1258 }
1259 let has_env = account
1260 .api_key_env
1261 .as_deref()
1262 .is_some_and(|name| !name.is_empty());
1263 let has_inline = account
1264 .api_key
1265 .as_deref()
1266 .is_some_and(|key| !key.is_empty());
1267 if !has_env && !has_inline {
1268 return Err(AppError::Credentials(format!(
1269 "openrouter account {:?} must set api_key_env or api_key",
1270 account.label
1271 )));
1272 }
1273 }
1274 Ok(())
1275 }
1276}
1277
1278#[cfg(unix)]
1279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1280enum InlineKeyPermissionDecision {
1281 Ok,
1282 Tighten,
1283}
1284
1285#[cfg(unix)]
1286fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
1287 if mode & 0o077 == 0 {
1288 InlineKeyPermissionDecision::Ok
1289 } else {
1290 InlineKeyPermissionDecision::Tighten
1291 }
1292}
1293
1294pub fn default_path() -> Option<PathBuf> {
1295 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
1296 Some(proj.config_dir().join("config.toml"))
1297}
1298
1299fn legacy_xdg_path() -> Option<PathBuf> {
1304 let home = crate::cache::home_dir().ok()?;
1305 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
1306}
1307
1308pub fn resolved_path() -> Option<PathBuf> {
1317 let canonical = default_path();
1318 if let Some(p) = &canonical
1319 && p.exists()
1320 {
1321 return canonical;
1322 }
1323 if let Some(legacy) = legacy_xdg_path()
1324 && legacy.exists()
1325 {
1326 return Some(legacy);
1327 }
1328 canonical
1329}
1330
1331fn expand_tilde(p: &std::path::Path) -> PathBuf {
1334 let Some(s) = p.to_str() else {
1335 return p.to_path_buf();
1336 };
1337 let rest = if s == "~" {
1338 ""
1339 } else if let Some(r) = s.strip_prefix("~/") {
1340 r
1341 } else {
1342 return p.to_path_buf();
1343 };
1344 match crate::cache::home_dir() {
1345 Ok(home) if rest.is_empty() => home,
1346 Ok(home) => home.join(rest),
1347 Err(_) => p.to_path_buf(),
1348 }
1349}
1350
1351fn expand_tilde_opt(p: &mut Option<PathBuf>) {
1352 if let Some(inner) = p.as_ref() {
1353 *p = Some(expand_tilde(inner));
1354 }
1355}
1356
1357pub fn config_path_hint() -> String {
1362 resolved_path()
1363 .map(|p| p.display().to_string())
1364 .unwrap_or_else(|| "config.toml".to_string())
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369 use super::*;
1370 use std::io::Write;
1371 use tempfile::NamedTempFile;
1372
1373 #[cfg(unix)]
1374 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1375
1376 fn write_toml(s: &str) -> NamedTempFile {
1377 let mut f = NamedTempFile::new().unwrap();
1378 f.write_all(s.as_bytes()).unwrap();
1379 f.flush().unwrap();
1380 f
1381 }
1382
1383 #[test]
1387 fn openai_without_accounts_resolves_the_singular_path() {
1388 let explicit = OpenAiConfig {
1389 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
1390 ..OpenAiConfig::default()
1391 };
1392 assert_eq!(
1393 explicit.resolve_auth_path(None).unwrap(),
1394 PathBuf::from("/tmp/codex/auth.json")
1395 );
1396
1397 let bare = OpenAiConfig::default();
1398 assert_eq!(
1399 bare.resolve_auth_path(None).unwrap(),
1400 crate::openai::creds::default_path().unwrap(),
1401 "no codex_auth_path must still mean ~/.codex/auth.json"
1402 );
1403 }
1404
1405 #[test]
1408 fn openai_named_accounts_resolve_their_own_auth_file() {
1409 let config: Config = toml::from_str(
1410 r#"
1411 [openai]
1412 codex_auth_path = "/tmp/personal/auth.json"
1413 [[openai.accounts]]
1414 label = "work"
1415 codex_auth_path = "/tmp/work/auth.json"
1416 "#,
1417 )
1418 .unwrap();
1419
1420 assert_eq!(
1421 config.openai.resolve_auth_path(Some("work")).unwrap(),
1422 PathBuf::from("/tmp/work/auth.json")
1423 );
1424 assert_eq!(
1425 config.openai.resolve_auth_path(None).unwrap(),
1426 PathBuf::from("/tmp/personal/auth.json")
1427 );
1428 }
1429
1430 #[test]
1433 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
1434 let config = OpenAiConfig {
1435 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
1436 accounts: vec![OpenAiAccount {
1437 label: "work".into(),
1438 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
1439 }],
1440 ..OpenAiConfig::default()
1441 };
1442 let err = config
1443 .resolve_auth_path(Some("nope"))
1444 .unwrap_err()
1445 .to_string();
1446 assert!(err.contains("nope"), "{err}");
1447 assert!(err.contains("[[openai.accounts]]"), "{err}");
1448 }
1449
1450 #[test]
1451 fn defaults_enable_only_the_four_core_vendors() {
1452 let c = Config::default();
1453 assert!(c.is_enabled(VendorId::Anthropic));
1454 assert!(c.is_enabled(VendorId::Openai));
1455 assert!(c.is_enabled(VendorId::Zai));
1456 assert!(c.is_enabled(VendorId::Openrouter));
1457 for opt_in in [
1458 VendorId::AnthropicApi,
1459 VendorId::Copilot,
1460 VendorId::Deepseek,
1461 VendorId::Kimi,
1462 VendorId::Kilo,
1463 VendorId::Novita,
1464 VendorId::Moonshot,
1465 VendorId::Grok,
1466 VendorId::Supergrok,
1467 VendorId::Cursor,
1468 VendorId::Minimax,
1469 VendorId::Kiro,
1470 ] {
1471 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
1472 }
1473 assert_eq!(c.enabled_vendors().len(), 4);
1474 }
1475
1476 #[test]
1477 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
1478 let config = Config::default();
1479 assert!(!config.is_enabled(VendorId::NousResearch));
1480 assert!(!config.is_enabled(VendorId::OpenCodeGo));
1481 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
1482 assert!(config.opencode_go.api_key.is_none());
1483 assert!(!config.is_enabled(VendorId::Copilot));
1484 }
1485
1486 #[cfg(unix)]
1487 #[test]
1488 fn inline_credentials_are_protected() {
1489 let mut config = Config::default();
1490 config.opencode_go.api_key = Some("<redacted>".to_string());
1491 assert!(config.has_inline_secrets());
1492 }
1493
1494 #[cfg(unix)]
1495 #[test]
1496 fn openrouter_named_inline_keys_receive_config_file_protection() {
1497 let mut config = Config::default();
1498 config.openrouter.accounts.push(OpenRouterAccount {
1499 label: "work".into(),
1500 api_key_env: None,
1501 api_key: Some("<redacted>".into()),
1502 });
1503 assert!(config.has_inline_secrets());
1504 }
1505
1506 #[test]
1507 fn missing_file_uses_defaults() {
1508 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
1509 let c = Config::load_from(path).unwrap();
1510 assert!(c.is_enabled(VendorId::Anthropic));
1511 }
1512
1513 #[test]
1514 fn parses_full_config() {
1515 let f = write_toml(
1516 r#"
1517 [anthropic]
1518 enabled = true
1519
1520 [openai]
1521 enabled = false
1522 admin_key_env = "MY_ADMIN_KEY"
1523
1524 [zai]
1525 enabled = true
1526 api_key_env = "MY_ZAI"
1527 plan_tier = "pro"
1528
1529 [openrouter]
1530 enabled = false
1531 "#,
1532 );
1533 let c = Config::load_from(f.path()).unwrap();
1534 assert!(c.is_enabled(VendorId::Anthropic));
1535 assert!(!c.is_enabled(VendorId::Openai));
1536 assert!(c.is_enabled(VendorId::Zai));
1537 assert!(!c.is_enabled(VendorId::Openrouter));
1538 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
1539 assert_eq!(c.zai.api_key_env, "MY_ZAI");
1540 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
1541 assert!(c.openrouter.accounts.is_empty());
1542 assert!(c.openrouter.show_default_account);
1543 }
1544
1545 #[test]
1546 fn partial_config_falls_back_to_defaults() {
1547 let f = write_toml(
1548 r#"[openai]
1549enabled = false
1550"#,
1551 );
1552 let c = Config::load_from(f.path()).unwrap();
1553 assert!(!c.is_enabled(VendorId::Openai));
1554 assert!(c.is_enabled(VendorId::Anthropic));
1556 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
1557 }
1558
1559 #[test]
1560 fn malformed_toml_returns_error() {
1561 let f = write_toml("this is not = = valid");
1562 assert!(Config::load_from(f.path()).is_err());
1563 }
1564
1565 #[cfg(unix)]
1566 #[test]
1567 fn load_from_tightens_world_readable_config_with_inline_api_key() {
1568 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
1569 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1570
1571 Config::load_from(file.path()).unwrap();
1572
1573 assert_eq!(
1574 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1575 0o600
1576 );
1577 }
1578
1579 #[cfg(unix)]
1580 #[test]
1581 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
1582 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
1583 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1584
1585 Config::load_from(file.path()).unwrap();
1586
1587 assert_eq!(
1588 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1589 0o644
1590 );
1591 }
1592
1593 #[cfg(unix)]
1594 #[test]
1595 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
1596 assert_eq!(
1597 inline_key_permission_decision(0o600),
1598 InlineKeyPermissionDecision::Ok
1599 );
1600 assert_eq!(
1601 inline_key_permission_decision(0o640),
1602 InlineKeyPermissionDecision::Tighten
1603 );
1604 assert_eq!(
1605 inline_key_permission_decision(0o604),
1606 InlineKeyPermissionDecision::Tighten
1607 );
1608 }
1609
1610 #[test]
1611 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
1612 for value in ["0", "-1", "inf", "nan"] {
1613 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
1614 let error = Config::load_from(file.path()).unwrap_err().to_string();
1615 assert!(error.contains("monthly_limit"), "value {value}: {error}");
1616 }
1617
1618 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
1619 assert_eq!(
1620 Config::load_from(file.path())
1621 .unwrap()
1622 .anthropic_api
1623 .monthly_limit,
1624 Some(1000.0)
1625 );
1626 }
1627
1628 #[test]
1629 fn minimax_region_accepts_only_known_instances() {
1630 for region in ["global", "GLOBAL", "cn", "CN"] {
1631 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1632 assert_eq!(
1633 Config::load_from(file.path()).unwrap().minimax.region,
1634 region
1635 );
1636 }
1637
1638 for region in ["", "china", "us"] {
1639 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1640 let error = Config::load_from(file.path()).unwrap_err().to_string();
1641 assert!(error.contains("[minimax] region"), "{error}");
1642 }
1643 }
1644
1645 #[test]
1646 fn kimi_region_accepts_auto_and_both_deployments() {
1647 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
1648 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
1649 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
1650 }
1651
1652 for region in ["", "us", "oversea"] {
1653 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
1654 let error = Config::load_from(file.path()).unwrap_err().to_string();
1655 assert!(error.contains("[kimi] region"), "{error}");
1656 }
1657 }
1658
1659 #[test]
1660 fn kimi_defaults_to_auto_region_and_no_credential_override() {
1661 let defaults = KimiConfig::default();
1662 assert_eq!(defaults.region, "auto");
1663 assert_eq!(defaults.credentials_path, None);
1664 assert!(!defaults.enabled);
1665 }
1666
1667 #[test]
1668 fn kimi_credentials_path_expands_a_tilde() {
1669 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
1670 let path = Config::load_from(file.path())
1671 .unwrap()
1672 .kimi
1673 .credentials_path
1674 .unwrap();
1675 assert!(!path.starts_with("~"), "{}", path.display());
1676 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
1677 }
1678
1679 #[test]
1680 fn optional_api_key_reports_absence_instead_of_failing() {
1681 assert_eq!(
1682 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", Some("inline")),
1683 Some("inline".to_string())
1684 );
1685 assert_eq!(
1686 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", None),
1687 None
1688 );
1689 assert_eq!(optional_api_key("KIMI_API_KEY_UNSET", Some("")), None);
1690 assert_eq!(
1693 optional_api_key("9INVALID", Some("inline")),
1694 Some("inline".to_string())
1695 );
1696 }
1697
1698 #[test]
1699 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
1700 let defaults = Config::default();
1701 assert!(!defaults.context.enabled);
1702 assert_eq!(
1703 defaults.context.window_tokens_for(Some("claude-test")),
1704 None
1705 );
1706
1707 let file = write_toml(
1708 r#"
1709 [context]
1710 enabled = true
1711 context_window_tokens = 200000
1712
1713 [context.model_context_window_tokens]
1714 claude-opus-1m = 1000000
1715 "claude exact id" = 300000
1716 "#,
1717 );
1718 let config = Config::load_from(file.path()).unwrap();
1719 assert!(config.context.enabled);
1720 assert_eq!(
1721 config.context.window_tokens_for(Some("claude-opus-1m")),
1722 Some(1_000_000)
1723 );
1724 assert_eq!(
1725 config.context.window_tokens_for(Some("claude exact id")),
1726 Some(300_000)
1727 );
1728 assert_eq!(
1729 config.context.window_tokens_for(Some("another-model")),
1730 Some(200_000)
1731 );
1732 }
1733
1734 #[test]
1735 fn context_layout_defaults_to_full_and_parses_each_variant() {
1736 assert_eq!(Config::default().context.layout, ContextLayout::Full);
1737 for (text, want) in [
1738 ("full", ContextLayout::Full),
1739 ("split", ContextLayout::Split),
1740 ("bottom", ContextLayout::Bottom),
1741 ] {
1742 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
1743 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
1744 }
1745 let file = write_toml("[context]\nlayout = \"floating\"\n");
1746 assert!(
1747 Config::load_from(file.path()).is_err(),
1748 "an unknown layout must be rejected, not silently defaulted"
1749 );
1750 }
1751
1752 #[test]
1753 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
1754 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
1755 for (text, want) in [
1756 ("sidebar", VendorBoxStyle::Sidebar),
1757 ("navbar", VendorBoxStyle::Navbar),
1758 ("none", VendorBoxStyle::None),
1759 ] {
1760 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
1761 assert_eq!(
1762 Config::load_from(file.path()).unwrap().ui.vendor_box(),
1763 want
1764 );
1765 }
1766 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
1767 assert!(
1768 Config::load_from(file.path()).is_err(),
1769 "an unknown vendor_box style must be rejected, not silently defaulted"
1770 );
1771 }
1772
1773 #[test]
1774 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
1775 for source in [
1776 "[context]\ncontext_window_tokens = 0\n",
1777 "[context.model_context_window_tokens]\nclaude = 0\n",
1778 "[context.model_context_window_tokens]\n\" \" = 200000\n",
1779 ] {
1780 let file = write_toml(source);
1781 let error = Config::load_from(file.path()).unwrap_err().to_string();
1782 assert!(error.contains("context"), "{error}");
1783 }
1784 }
1785
1786 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
1788 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
1789 M.lock().unwrap_or_else(|p| p.into_inner())
1790 }
1791
1792 #[test]
1793 fn resolve_api_key_prefers_env_over_inline() {
1794 let _g = env_guard();
1795 let var = "AI_USAGEBAR_TEST_ENV_WINS";
1797 unsafe { std::env::set_var(var, "from-env") };
1799 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
1800 unsafe { std::env::remove_var(var) };
1801 assert_eq!(got, "from-env");
1802 }
1803
1804 #[test]
1805 fn resolve_api_key_falls_back_to_inline() {
1806 let _g = env_guard();
1807 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
1808 unsafe { std::env::remove_var(var) };
1809 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
1810 assert_eq!(got, "inline-key");
1811 }
1812
1813 #[test]
1814 fn copilot_token_prefers_explicit_environment_over_gh_cli() {
1815 struct NeverRun;
1816 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
1817 fn run(
1818 &self,
1819 _: &crate::copilot::credentials::GhAuthTokenCommand,
1820 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
1821 panic!("environment override must not invoke gh")
1822 }
1823 }
1824
1825 let token = CopilotConfig::default()
1826 .resolve_token_with(
1827 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
1828 &NeverRun,
1829 )
1830 .unwrap();
1831 assert_eq!(token, "from-environment");
1832 }
1833
1834 #[test]
1835 fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
1836 struct FailedGh;
1837 impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
1838 fn run(
1839 &self,
1840 _: &crate::copilot::credentials::GhAuthTokenCommand,
1841 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
1842 Ok(crate::copilot::credentials::GhAuthTokenOutput {
1843 success: false,
1844 stdout: b"never-echo-gh-output".to_vec(),
1845 })
1846 }
1847 }
1848 let error = CopilotConfig::default()
1849 .resolve_token_with(|_| None, &FailedGh)
1850 .unwrap_err()
1851 .to_string();
1852 assert!(error.contains("gh auth login --web"));
1853 assert!(!error.contains("never-echo-gh-output"));
1854 }
1855
1856 #[test]
1857 fn resolve_api_key_errors_when_both_missing() {
1858 let _g = env_guard();
1859 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
1860 unsafe { std::env::remove_var(var) };
1861 let err = resolve_api_key("Zai", var, None).unwrap_err();
1862 match err {
1863 crate::error::AppError::Credentials(msg) => {
1864 assert!(
1865 msg.contains("api_key"),
1866 "error should suggest config field: {msg}"
1867 );
1868 }
1869 other => panic!("expected Credentials error, got {other:?}"),
1870 }
1871 }
1872
1873 #[test]
1874 fn resolve_api_key_uses_exact_opencode_go_section_name() {
1875 let _g = env_guard();
1876 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
1877 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
1878 let message = err.to_string();
1879 assert!(
1880 message.contains("[opencode-go]"),
1881 "wrong section hint: {message}"
1882 );
1883 assert!(
1884 !message.contains("[opencode go]"),
1885 "wrong section hint: {message}"
1886 );
1887 }
1888
1889 #[test]
1890 fn config_path_hint_ends_with_config_toml() {
1891 assert!(config_path_hint().ends_with("config.toml"));
1894 }
1895
1896 #[test]
1897 fn resolve_api_key_treats_empty_env_as_unset() {
1898 let _g = env_guard();
1899 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
1900 unsafe { std::env::set_var(var, "") };
1901 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
1902 unsafe { std::env::remove_var(var) };
1903 assert_eq!(got, "inline");
1904 }
1905
1906 #[test]
1907 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
1908 let _g = env_guard();
1909 let bad = "sk-kimi-very-real-looking-pasted-secret";
1911 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
1912 let msg = err.to_string();
1913 assert!(
1914 msg.contains("invalid") && msg.contains("api_key_env"),
1915 "error should explain misconfiguration: {msg}"
1916 );
1917 assert!(
1918 !msg.contains(bad),
1919 "error must not echo the misconfigured value: {msg}"
1920 );
1921 assert!(msg.contains("valid environment variable name"));
1922 assert!(
1923 msg.contains("[kimi]"),
1924 "error should point at the lowercase TOML section: {msg}"
1925 );
1926 }
1927
1928 #[test]
1929 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
1930 let _g = env_guard();
1931 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
1932 assert_eq!(got, "inline-key");
1933 }
1934
1935 #[test]
1936 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
1937 let _g = env_guard();
1938 let pasted_secret = "sk_pasted_secret";
1941 unsafe { std::env::remove_var(pasted_secret) };
1942 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
1943 assert!(
1944 !err.to_string().contains(pasted_secret),
1945 "error must not echo configured api_key_env values"
1946 );
1947 }
1948
1949 #[test]
1950 fn is_valid_env_var_name_rules() {
1951 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
1953 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
1954 }
1955 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
1957 assert!(
1958 !is_valid_env_var_name(invalid),
1959 "{invalid} should be invalid"
1960 );
1961 }
1962 }
1963
1964 #[test]
1965 fn config_parses_with_inline_api_key_and_primary() {
1966 let f = write_toml(
1967 r#"
1968 [ui]
1969 primary = "openrouter"
1970
1971 [zai]
1972 enabled = true
1973 api_key_env = "MY_ZAI"
1974 api_key = "sk-zai-inline"
1975
1976 [openrouter]
1977 enabled = true
1978 api_key = "sk-or-inline"
1979 "#,
1980 );
1981 let c = Config::load_from(f.path()).unwrap();
1982 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
1983 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
1984 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
1985 }
1986
1987 #[test]
1988 fn openrouter_named_accounts_preserve_the_default_contract() {
1989 let f = write_toml(
1990 r#"
1991 [openrouter]
1992 enabled = true
1993 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
1994 api_key = "default-inline"
1995 show_default_account = false
1996
1997 [[openrouter.accounts]]
1998 label = "work"
1999 api_key_env = "OPENROUTER_WORK_API_KEY"
2000
2001 [[openrouter.accounts]]
2002 label = "personal"
2003 api_key = "personal-inline"
2004 "#,
2005 );
2006 let _g = env_guard();
2007 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
2008 let config = Config::load_from(f.path()).unwrap();
2009 assert!(!config.openrouter.show_default_account);
2010 assert_eq!(config.openrouter.accounts.len(), 2);
2011 assert_eq!(
2012 config.openrouter.resolve_api_key(None).unwrap(),
2013 "default-inline"
2014 );
2015 assert_eq!(
2016 config.openrouter.resolve_api_key(Some("personal")).unwrap(),
2017 "personal-inline"
2018 );
2019 }
2020
2021 #[test]
2022 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
2023 for source in [
2024 r#"
2025 [[openrouter.accounts]]
2026 label = "work"
2027 api_key = "one"
2028 [[openrouter.accounts]]
2029 label = "work"
2030 api_key = "two"
2031 "#,
2032 r#"
2033 [[openrouter.accounts]]
2034 label = "../work"
2035 api_key = "one"
2036 "#,
2037 r#"
2038 [[openrouter.accounts]]
2039 label = "work"
2040 "#,
2041 ] {
2042 let f = write_toml(source);
2043 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
2044 }
2045 }
2046
2047 #[test]
2048 fn openrouter_unknown_account_never_falls_back_to_default_key() {
2049 let mut config = OpenRouterConfig {
2050 api_key: Some("default-secret".into()),
2051 ..OpenRouterConfig::default()
2052 };
2053 config.accounts.push(OpenRouterAccount {
2054 label: "work".into(),
2055 api_key_env: None,
2056 api_key: Some("work-secret".into()),
2057 });
2058 let message = config
2059 .resolve_api_key(Some("missing"))
2060 .unwrap_err()
2061 .to_string();
2062 assert!(message.contains("missing") && message.contains("work"));
2063 assert!(!message.contains("default-secret"));
2064 assert!(!message.contains("work-secret"));
2065 }
2066
2067 #[test]
2068 fn openrouter_account_key_errors_do_not_echo_configured_values() {
2069 let config = OpenRouterConfig {
2070 accounts: vec![OpenRouterAccount {
2071 label: "work".into(),
2072 api_key_env: Some("sk_pasted_secret".into()),
2073 api_key: None,
2074 }],
2075 ..OpenRouterConfig::default()
2076 };
2077 let _g = env_guard();
2078 unsafe { std::env::remove_var("sk_pasted_secret") };
2079 let message = config
2080 .resolve_api_key(Some("work"))
2081 .unwrap_err()
2082 .to_string();
2083 assert!(message.contains("[[openrouter.accounts]]"));
2084 assert!(!message.contains("sk_pasted_secret"));
2085 }
2086
2087 #[test]
2088 fn enabled_vendors_preserves_canonical_order() {
2089 let c = Config::default();
2092 assert_eq!(
2093 c.enabled_vendors(),
2094 vec![
2095 VendorId::Anthropic,
2096 VendorId::Openai,
2097 VendorId::Zai,
2098 VendorId::Openrouter,
2099 ]
2100 );
2101 }
2102
2103 #[test]
2104 fn deepseek_appears_when_enabled() {
2105 let f = write_toml(
2106 r#"
2107 [deepseek]
2108 enabled = true
2109 api_key = "sk-test"
2110 "#,
2111 );
2112 let c = Config::load_from(f.path()).unwrap();
2113 assert!(c.is_enabled(VendorId::Deepseek));
2114 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
2115 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
2116 }
2117
2118 #[test]
2119 fn tilde_paths_are_expanded_on_load() {
2120 let f = write_toml(
2124 r#"
2125 [context]
2126 projects_path = "~/.claude/projects"
2127
2128 [anthropic]
2129 credentials_path = "~/.claude/.credentials.json"
2130
2131 [[anthropic.accounts]]
2132 label = "work"
2133 credentials_path = "~/work.json"
2134 "#,
2135 );
2136 let c = Config::load_from(f.path()).unwrap();
2137 let home = crate::cache::home_dir().unwrap();
2138
2139 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
2140 let got = c.anthropic.credentials_path.unwrap();
2141 assert_eq!(got, home.join(".claude/.credentials.json"));
2142 assert!(!got.to_string_lossy().contains('~'));
2143 assert_eq!(
2144 c.anthropic.accounts[0].credentials_path,
2145 home.join("work.json")
2146 );
2147 }
2148
2149 #[test]
2150 fn absolute_and_relative_paths_are_left_alone() {
2151 let f = write_toml(
2152 r#"
2153 [anthropic]
2154 credentials_path = "/etc/creds.json"
2155 "#,
2156 );
2157 let c = Config::load_from(f.path()).unwrap();
2158 assert_eq!(
2159 c.anthropic.credentials_path.unwrap(),
2160 std::path::Path::new("/etc/creds.json")
2161 );
2162
2163 let f2 = write_toml(
2165 r#"
2166 [anthropic]
2167 credentials_path = "~someone/creds.json"
2168 "#,
2169 );
2170 let c2 = Config::load_from(f2.path()).unwrap();
2171 assert_eq!(
2172 c2.anthropic.credentials_path.unwrap(),
2173 std::path::Path::new("~someone/creds.json")
2174 );
2175 }
2176
2177 #[test]
2178 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
2179 let p = resolved_path().expect("a config path must resolve");
2182 assert!(p.ends_with("config.toml"));
2183 let canonical = default_path().unwrap();
2184 let legacy = legacy_xdg_path().unwrap();
2185 assert!(
2186 p == canonical || p == legacy,
2187 "resolved to an unexpected location: {}",
2188 p.display()
2189 );
2190 }
2191
2192 #[test]
2193 fn misspelled_section_is_rejected_not_ignored() {
2194 let f = write_toml(
2197 r#"
2198 [openrouer]
2199 enabled = true
2200 api_key = "sk-or-v1-typo"
2201 "#,
2202 );
2203 let err = Config::load_from(f.path()).unwrap_err().to_string();
2204 assert!(
2205 err.contains("openrouer"),
2206 "error should name the typo: {err}"
2207 );
2208 }
2209
2210 #[test]
2211 fn invalid_toml_is_an_error_not_silent_defaults() {
2212 let f = write_toml("[zai\nenabled = true\n");
2213 assert!(Config::load_from(f.path()).is_err());
2214 }
2215
2216 #[test]
2217 fn a_missing_file_is_still_just_defaults() {
2218 let dir = tempfile::tempdir().unwrap();
2221 let missing = dir.path().join("nope").join("config.toml");
2222 let c = Config::load_from(&missing).unwrap();
2223 assert!(c.is_enabled(VendorId::Anthropic));
2224 }
2225
2226 #[test]
2227 fn kimi_appears_when_enabled() {
2228 let f = write_toml(
2229 r#"
2230 [kimi]
2231 enabled = true
2232 api_key = "sk-test"
2233 "#,
2234 );
2235 let c = Config::load_from(f.path()).unwrap();
2236 assert!(c.is_enabled(VendorId::Kimi));
2237 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
2238 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
2239 }
2240
2241 #[test]
2242 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
2243 let f = write_toml(
2244 r#"
2245 [deepseek]
2246 enabled = true
2247 api_key = "sk-ds"
2248
2249 [kimi]
2250 enabled = true
2251 api_key = "sk-kimi"
2252 "#,
2253 );
2254 let c = Config::load_from(f.path()).unwrap();
2255 assert_eq!(
2256 c.enabled_vendors(),
2257 vec![
2258 VendorId::Anthropic,
2259 VendorId::Openai,
2260 VendorId::Zai,
2261 VendorId::Openrouter,
2262 VendorId::Deepseek,
2263 VendorId::Kimi,
2264 ]
2265 );
2266 }
2267
2268 #[test]
2269 fn parses_anthropic_accounts_and_looks_them_up() {
2270 let f = write_toml(
2271 r#"
2272 [anthropic]
2273 enabled = true
2274
2275 [[anthropic.accounts]]
2276 label = "personal"
2277 credentials_path = "/creds/personal.json"
2278
2279 [[anthropic.accounts]]
2280 label = "work"
2281 credentials_path = "/creds/work.json"
2282 "#,
2283 );
2284 let c = Config::load_from(f.path()).unwrap();
2285 assert_eq!(c.anthropic.accounts.len(), 2);
2286 let work = c.anthropic.account("work").unwrap();
2287 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
2288 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
2290 assert!(err.contains("missing") && err.contains("work"), "{err}");
2291 }
2292
2293 #[test]
2294 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
2295 let f = write_toml(
2296 r#"
2297 [[anthropic.accounts]]
2298 label = "work"
2299 credentials_path = "/creds/work-one.json"
2300
2301 [[anthropic.accounts]]
2302 label = "work"
2303 credentials_path = "/creds/work-two.json"
2304 "#,
2305 );
2306 let err = Config::load_from(f.path()).unwrap_err().to_string();
2307 assert!(
2308 err.contains("duplicate anthropic account label \"work\""),
2309 "{err}"
2310 );
2311 }
2312
2313 #[test]
2314 fn account_label_rejects_path_like_names() {
2315 let cfg = AnthropicConfig::default();
2316 for bad in [
2317 "",
2318 ".",
2319 "..",
2320 "a/b",
2321 r"a\b",
2322 "C:work",
2323 "line\nbreak",
2324 "tab\tname",
2325 "usage.json",
2326 ".stale",
2327 ".last_error",
2328 ".fetch.lock",
2329 ] {
2330 let err = cfg.account(bad).unwrap_err();
2331 assert!(
2332 format!("{err:?}").contains("invalid anthropic account label"),
2333 "{bad:?} should be rejected as a label"
2334 );
2335 }
2336 }
2337
2338 #[test]
2339 fn anthropic_accounts_default_to_empty() {
2340 assert!(Config::default().anthropic.accounts.is_empty());
2343 assert!(Config::default().anthropic.accounts_dir.is_none());
2344 }
2345
2346 fn seed_account_dir(root: &std::path::Path, label: &str) {
2352 let dir = root.join(label);
2353 std::fs::create_dir_all(&dir).unwrap();
2354 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
2355 }
2356
2357 #[test]
2358 fn discovers_account_dirs_in_claude_config_dir_layout() {
2359 let td = tempfile::tempdir().unwrap();
2360 seed_account_dir(td.path(), "work");
2361 seed_account_dir(td.path(), "personal");
2362 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
2365 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
2367
2368 let cfg = AnthropicConfig {
2369 accounts_dir: Some(td.path().to_path_buf()),
2370 ..Default::default()
2371 };
2372 let all = cfg.all_accounts();
2373 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
2374 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
2375 assert_eq!(
2376 all[2].credentials_path,
2377 td.path().join("work").join(".credentials.json")
2378 );
2379 }
2380
2381 #[test]
2382 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
2383 let td = tempfile::tempdir().unwrap();
2384 seed_account_dir(td.path(), "work");
2385 let cfg = AnthropicConfig {
2386 accounts: vec![AnthropicAccount {
2387 label: "work".into(),
2388 credentials_path: "/explicit/work.json".into(),
2389 }],
2390 accounts_dir: Some(td.path().to_path_buf()),
2391 ..Default::default()
2392 };
2393 let all = cfg.all_accounts();
2394 assert_eq!(all.len(), 1, "no duplicate label");
2395 assert_eq!(
2396 all[0].credentials_path,
2397 std::path::Path::new("/explicit/work.json"),
2398 "explicit entry wins"
2399 );
2400 seed_account_dir(td.path(), "other");
2402 assert_eq!(cfg.account("other").unwrap().label, "other");
2403 }
2404
2405 #[test]
2406 fn missing_accounts_dir_is_silently_empty_not_an_error() {
2407 let cfg = AnthropicConfig {
2408 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
2409 ..Default::default()
2410 };
2411 assert!(cfg.all_accounts().is_empty());
2412 }
2413
2414 #[test]
2415 fn openai_account_auth_paths_are_tilde_expanded_on_load() {
2416 let f = write_toml(
2417 r#"
2418 [[openai.accounts]]
2419 label = "work"
2420 codex_auth_path = "~/.codex-work/auth.json"
2421 "#,
2422 );
2423 let c = Config::load_from(f.path()).unwrap();
2424 let home = crate::cache::home_dir().unwrap();
2425 assert_eq!(
2426 c.openai.accounts[0].codex_auth_path,
2427 home.join(".codex-work/auth.json")
2428 );
2429 }
2430
2431 #[test]
2432 fn accounts_dir_is_tilde_expanded_on_load() {
2433 let f = write_toml(
2434 r#"
2435 [anthropic]
2436 accounts_dir = "~/.config/ai-usagebar/accounts"
2437 "#,
2438 );
2439 let c = Config::load_from(f.path()).unwrap();
2440 let home = crate::cache::home_dir().unwrap();
2441 assert_eq!(
2442 c.anthropic.accounts_dir,
2443 Some(home.join(".config/ai-usagebar/accounts"))
2444 );
2445 }
2446
2447 #[test]
2448 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
2449 let f = write_toml(
2450 r#"
2451 [anthropic]
2452 desktop_profiles_dir = "~/.claude-acc/profiles"
2453 "#,
2454 );
2455 let c = Config::load_from(f.path()).unwrap();
2456 let home = crate::cache::home_dir().unwrap();
2457 assert_eq!(
2458 c.anthropic.desktop_profiles_dir,
2459 Some(home.join(".claude-acc/profiles"))
2460 );
2461 }
2462
2463 #[test]
2464 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
2465 let cfg = AnthropicConfig {
2466 accounts: vec![
2467 AnthropicAccount {
2468 label: "work".into(),
2469 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
2470 },
2471 AnthropicAccount {
2472 label: "personal".into(),
2473 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
2474 },
2475 ],
2476 ..Default::default()
2477 };
2478
2479 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
2480 assert!(
2481 matches!(&idle, CredsTarget::Named { config_dir, .. }
2482 if config_dir == std::path::Path::new("/tmp/accounts/work")),
2483 "{idle:?}"
2484 );
2485
2486 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
2488 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
2489
2490 assert_eq!(idle_cache.dir(), live_cache.dir());
2493 }
2494
2495 #[test]
2496 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
2497 let cfg = AnthropicConfig {
2498 accounts: vec![AnthropicAccount {
2499 label: "work".into(),
2500 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
2501 }],
2502 ..Default::default()
2503 };
2504 let (target, _) = cfg.account_target_with("work", None).unwrap();
2505 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
2506 }
2507
2508 fn config_example() -> PathBuf {
2512 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
2513 }
2514
2515 #[test]
2516 fn shipped_example_parses_as_a_real_config() {
2517 let c = Config::load_from(&config_example()).unwrap();
2522 assert!(!c.context.enabled);
2523 assert!(c.is_enabled(VendorId::Anthropic));
2524 assert!(c.is_enabled(VendorId::Openai));
2525 assert!(!c.is_enabled(VendorId::AnthropicApi));
2526 assert!(!c.is_enabled(VendorId::Deepseek));
2527 assert!(!c.is_enabled(VendorId::Kimi));
2528 assert!(!c.is_enabled(VendorId::Kilo));
2529 assert!(!c.is_enabled(VendorId::Novita));
2530 assert!(!c.is_enabled(VendorId::Moonshot));
2531 assert!(!c.is_enabled(VendorId::Grok));
2532 assert!(!c.is_enabled(VendorId::Cursor));
2533 assert!(!c.is_enabled(VendorId::Minimax));
2534 }
2535
2536 #[test]
2537 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
2538 let text = std::fs::read_to_string(config_example()).unwrap();
2543 let live: Vec<&str> = text
2544 .lines()
2545 .map(str::trim)
2546 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
2547 .collect();
2548 assert!(
2549 live.is_empty(),
2550 "admin_key_env must stay commented out while it is inert: {live:?}"
2551 );
2552 assert!(
2555 text.contains("admin_key_env") && text.contains("RESERVED"),
2556 "the example should keep describing admin_key_env as reserved"
2557 );
2558 }
2559
2560 #[test]
2561 fn admin_key_env_is_accepted_but_changes_nothing() {
2562 let f = write_toml(
2566 r#"
2567 [openai]
2568 admin_key_env = "SOME_ADMIN_KEY"
2569 "#,
2570 );
2571 let c = Config::load_from(f.path()).unwrap();
2572 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
2573 let default = OpenAiConfig::default();
2575 assert_eq!(c.openai.enabled, default.enabled);
2576 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
2577 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
2578 }
2579
2580 #[test]
2581 fn config_example_documents_every_vendor_without_secrets() {
2582 let raw = std::fs::read_to_string(config_example()).unwrap();
2583 let cfg = Config::load_from(&config_example()).unwrap();
2584 for id in VendorId::all() {
2587 let section = id.slug();
2588 assert!(
2589 raw.contains(&format!("[{section}]")),
2590 "config.example.toml has no [{section}] section"
2591 );
2592 }
2593
2594 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
2597 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
2598 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
2599 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
2600 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
2601 assert!(!cfg.supergrok.enabled);
2602 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
2603 assert_eq!(
2604 cfg.supergrok
2605 .grok_binary
2606 .file_name()
2607 .and_then(|p| p.to_str()),
2608 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
2609 );
2610 assert!(cfg.supergrok.auth_path.is_none());
2611 assert!(cfg.supergrok.config_path.is_none());
2612 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
2613 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
2614 }
2615
2616 #[test]
2617 fn supergrok_binary_must_not_be_empty() {
2618 let file = write_toml(
2619 r#"
2620 [supergrok]
2621 enabled = true
2622 grok_binary = ""
2623 "#,
2624 );
2625 let error = Config::load_from(file.path()).unwrap_err().to_string();
2626 assert!(error.contains("grok_binary must not be empty"));
2627 }
2628
2629 #[test]
2630 fn supergrok_paths_are_tilde_expanded() {
2631 let file = write_toml(
2632 r#"
2633 [supergrok]
2634 grok_binary = "~/bin/grok"
2635 auth_path = "~/.grok/auth.json"
2636 config_path = "~/.grok/config.toml"
2637 "#,
2638 );
2639 let config = Config::load_from(file.path()).unwrap();
2640 let home = crate::cache::home_dir().unwrap();
2641 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
2642 assert_eq!(
2643 config.supergrok.auth_path,
2644 Some(home.join(".grok/auth.json"))
2645 );
2646 assert_eq!(
2647 config.supergrok.config_path,
2648 Some(home.join(".grok/config.toml"))
2649 );
2650 }
2651
2652 #[test]
2653 fn kiro_db_path_is_tilde_expanded() {
2654 let f = write_toml(
2655 r#"
2656 [kiro]
2657 db_path = "~/kiro-data.sqlite3"
2658 "#,
2659 );
2660 let c = Config::load_from(f.path()).unwrap();
2661 let home = crate::cache::home_dir().unwrap();
2662 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
2663 }
2664
2665 #[test]
2666 fn kiro_appears_when_enabled() {
2667 let f = write_toml(
2668 r#"
2669 [kiro]
2670 enabled = true
2671 "#,
2672 );
2673 let c = Config::load_from(f.path()).unwrap();
2674 assert!(c.is_enabled(VendorId::Kiro));
2675 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
2676 }
2677
2678 #[test]
2679 fn cursor_db_path_is_tilde_expanded() {
2680 let f = write_toml(
2681 r#"
2682 [cursor]
2683 db_path = "~/cursor-state.vscdb"
2684 "#,
2685 );
2686 let c = Config::load_from(f.path()).unwrap();
2687 let home = crate::cache::home_dir().unwrap();
2688 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
2689 }
2690
2691 #[test]
2692 fn cursor_agent_auth_path_is_tilde_expanded() {
2693 let f = write_toml(
2694 r#"
2695 [cursor]
2696 agent_auth_path = "~/cursor-agent-auth.json"
2697 "#,
2698 );
2699 let c = Config::load_from(f.path()).unwrap();
2700 let home = crate::cache::home_dir().unwrap();
2701 assert_eq!(
2702 c.cursor.agent_auth_path,
2703 Some(home.join("cursor-agent-auth.json"))
2704 );
2705 }
2706
2707 #[test]
2708 fn cursor_appears_when_enabled() {
2709 let f = write_toml(
2710 r#"
2711 [cursor]
2712 enabled = true
2713 "#,
2714 );
2715 let c = Config::load_from(f.path()).unwrap();
2716 assert!(c.is_enabled(VendorId::Cursor));
2717 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
2718 }
2719
2720 #[test]
2721 fn add_account_appends_and_preserves_existing() {
2722 let mut doc: toml_edit::DocumentMut = r#"
2723# keep me
2724[anthropic]
2725enabled = true
2726
2727[[anthropic.accounts]]
2728label = "personal"
2729credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
2730"#
2731 .parse()
2732 .unwrap();
2733 add_anthropic_account_to_doc(
2734 &mut doc,
2735 "work",
2736 "~/.config/ai-usagebar/accounts/work/.credentials.json",
2737 )
2738 .unwrap();
2739 let rendered = doc.to_string();
2740 assert!(rendered.contains("# keep me"), "comment must survive");
2741 let f = write_toml(&rendered);
2743 let c = Config::load_from(f.path()).unwrap();
2744 let labels: Vec<&str> = c
2745 .anthropic
2746 .accounts
2747 .iter()
2748 .map(|a| a.label.as_str())
2749 .collect();
2750 assert_eq!(labels, vec!["personal", "work"]);
2751 }
2752
2753 #[test]
2754 fn add_account_to_empty_doc_is_loadable() {
2755 let mut doc = toml_edit::DocumentMut::new();
2756 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
2757 let f = write_toml(&doc.to_string());
2758 let c = Config::load_from(f.path()).unwrap();
2759 assert_eq!(c.anthropic.accounts.len(), 1);
2760 assert_eq!(c.anthropic.accounts[0].label, "solo");
2761 }
2762
2763 #[test]
2764 fn add_account_rejects_duplicate_label() {
2765 let mut doc: toml_edit::DocumentMut = r#"
2766[[anthropic.accounts]]
2767label = "work"
2768credentials_path = "~/w/.credentials.json"
2769"#
2770 .parse()
2771 .unwrap();
2772 assert!(
2773 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
2774 "a duplicate label must be rejected, not appended"
2775 );
2776 }
2777
2778 #[test]
2779 fn add_account_rejects_bad_label() {
2780 let mut doc = toml_edit::DocumentMut::new();
2781 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
2782 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
2783 }
2784
2785 #[test]
2786 fn tildify_collapses_home_only() {
2787 let home = Path::new("/Users/me");
2788 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
2789 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
2790 }
2791
2792 #[test]
2793 fn default_account_credentials_path_nests_under_config_dir() {
2794 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
2795 assert_eq!(
2796 default_account_credentials_path(cfg, "work"),
2797 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
2798 );
2799 }
2800}