1use std::collections::{BTreeMap, HashSet};
20use std::path::{Path, PathBuf};
21
22#[cfg(unix)]
23use std::os::unix::fs::{MetadataExt, PermissionsExt};
24
25use serde::{Deserialize, Serialize};
26
27use crate::anthropic::creds::CredsTarget;
28use crate::cache::Cache;
29use crate::error::{AppError, Result};
30use crate::vendor::VendorId;
31
32#[derive(Debug, Clone, Default, Deserialize, Serialize)]
39#[serde(default, deny_unknown_fields)]
40pub struct Config {
41 pub ui: UiConfig,
42 pub tray: TrayConfig,
43 pub context: ContextConfig,
44 pub anthropic: AnthropicConfig,
45 pub anthropic_api: AnthropicApiConfig,
46 pub openai: OpenAiConfig,
47 pub copilot: CopilotConfig,
48 pub zai: ZaiConfig,
49 pub openrouter: OpenRouterConfig,
50 pub deepseek: DeepseekConfig,
51 pub kimi: KimiConfig,
52 pub kilo: KiloConfig,
53 pub novita: NovitaConfig,
54 pub moonshot: MoonshotConfig,
55 pub grok: GrokConfig,
56 pub supergrok: SuperGrokConfig,
57 pub antigravity: AntigravityConfig,
58 pub cursor: CursorConfig,
59 pub minimax: MinimaxConfig,
60 pub kiro: KiroConfig,
61 pub nous: NousConfig,
62 #[serde(rename = "opencode-go")]
63 pub opencode_go: OpenCodeGoConfig,
64 pub commandcode: CommandCodeConfig,
65 pub ollama: OllamaConfig,
66 pub custom: Vec<CustomProviderConfig>,
68}
69
70#[derive(Debug, Clone, Default, Deserialize, Serialize)]
74#[serde(default)]
75pub struct UiConfig {
76 pub primary: Option<VendorId>,
78 pub overview_vendors: Option<Vec<VendorId>>,
82 pub vendor_box: Option<VendorBoxStyle>,
84}
85
86impl UiConfig {
87 pub fn vendor_box(&self) -> VendorBoxStyle {
88 self.vendor_box.unwrap_or_default()
89 }
90}
91
92#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
97#[serde(default)]
98pub struct TrayConfig {
99 pub shortcut: Option<String>,
102 pub refresh_minutes: Option<u64>,
105 pub updates: Option<UpdateMode>,
107}
108
109pub const TRAY_REFRESH_MINUTES: [u64; 3] = [1, 5, 10];
112const DEFAULT_TRAY_REFRESH_MINUTES: u64 = 5;
113
114impl TrayConfig {
115 pub fn refresh_minutes(&self) -> u64 {
116 self.refresh_minutes.unwrap_or(DEFAULT_TRAY_REFRESH_MINUTES)
117 }
118
119 pub fn updates(&self) -> UpdateMode {
120 self.updates.unwrap_or_default()
121 }
122}
123
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
127#[serde(rename_all = "lowercase")]
128pub enum UpdateMode {
129 Auto,
130 #[default]
131 Notify,
132 Off,
133}
134
135impl UpdateMode {
136 pub fn as_str(self) -> &'static str {
137 match self {
138 Self::Auto => "auto",
139 Self::Notify => "notify",
140 Self::Off => "off",
141 }
142 }
143
144 pub fn parse(text: &str) -> Option<Self> {
145 match text.trim().to_ascii_lowercase().as_str() {
146 "auto" => Some(Self::Auto),
147 "notify" => Some(Self::Notify),
148 "off" => Some(Self::Off),
149 _ => None,
150 }
151 }
152}
153
154#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
156#[serde(rename_all = "lowercase")]
157pub enum VendorBoxStyle {
158 #[default]
160 Sidebar,
161 Navbar,
163 None,
165}
166
167#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
170#[serde(rename_all = "lowercase")]
171pub enum ContextLayout {
172 #[default]
174 Full,
175 Split,
177 Bottom,
179}
180
181impl ContextLayout {
182 pub fn next(self) -> Self {
183 match self {
184 ContextLayout::Full => ContextLayout::Split,
185 ContextLayout::Split => ContextLayout::Bottom,
186 ContextLayout::Bottom => ContextLayout::Full,
187 }
188 }
189
190 pub fn label(self) -> &'static str {
191 match self {
192 ContextLayout::Full => "full",
193 ContextLayout::Split => "split",
194 ContextLayout::Bottom => "bottom",
195 }
196 }
197}
198
199#[derive(Debug, Clone, Default, Deserialize, Serialize)]
204#[serde(default)]
205pub struct ContextConfig {
206 pub enabled: bool,
209 pub projects_path: Option<PathBuf>,
211 pub context_window_tokens: Option<u64>,
214 pub model_context_window_tokens: BTreeMap<String, u64>,
217 pub layout: ContextLayout,
219}
220
221impl ContextConfig {
222 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
223 model
224 .and_then(|model| self.model_context_window_tokens.get(model).copied())
225 .filter(|tokens| *tokens > 0)
226 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
227 }
228}
229
230#[derive(Debug, Clone, Deserialize, Serialize)]
231#[serde(default)]
232pub struct AnthropicConfig {
233 pub enabled: bool,
234 pub credentials_path: Option<PathBuf>,
237 pub accounts: Vec<AnthropicAccount>,
241 pub accounts_dir: Option<PathBuf>,
249 pub show_default_account: bool,
255 pub desktop_profiles_dir: Option<PathBuf>,
261}
262
263impl Default for AnthropicConfig {
264 fn default() -> Self {
265 Self {
266 enabled: true,
267 credentials_path: None,
268 accounts: Vec::new(),
269 accounts_dir: None,
270 show_default_account: true,
271 desktop_profiles_dir: None,
272 }
273 }
274}
275
276#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
286pub struct AnthropicAccount {
287 pub label: String,
290 pub credentials_path: PathBuf,
294}
295
296impl AnthropicAccount {
297 pub fn config_dir(&self) -> PathBuf {
302 self.credentials_path
303 .parent()
304 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
305 }
306}
307
308impl AnthropicConfig {
309 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
315 let mut out = self.accounts.clone();
316 if let Some(dir) = &self.accounts_dir {
317 for acct in discover_accounts(dir) {
318 if !out.iter().any(|a| a.label == acct.label) {
319 out.push(acct);
320 }
321 }
322 }
323 out
324 }
325
326 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
331 validate_account_label(label)?;
332 let all = self.all_accounts();
333 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
334 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
335 AppError::Credentials(format!(
336 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
337 known labels: {known:?}"
338 ))
339 })
340 }
341
342 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
353 let active = crate::anthropic::cli_account::home_claude_json()
354 .ok()
355 .and_then(|path| {
356 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
357 });
358 self.account_target_with(label, active.as_deref())
359 }
360
361 pub fn account_target_with(
372 &self,
373 label: &str,
374 cli_active: Option<&str>,
375 ) -> Result<(CredsTarget, Cache)> {
376 let account = self.account(label)?;
377 let cache = Cache::for_vendor_account("anthropic", label)?;
378 if cli_active == Some(label) {
379 return Ok((
380 CredsTarget::Default(crate::anthropic::creds::default_path()?),
381 cache,
382 ));
383 }
384 Ok((
385 CredsTarget::Named {
386 config_dir: account.config_dir(),
387 path: account.credentials_path,
388 },
389 cache,
390 ))
391 }
392}
393
394pub fn validate_account_label(label: &str) -> Result<()> {
400 validate_account_label_for("anthropic", label)
401}
402
403fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
404 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
405 let bad = label.is_empty()
406 || label == "."
407 || label == ".."
408 || label.contains(['/', '\\'])
409 || label.contains(':')
410 || label.chars().any(char::is_control)
411 || RESERVED.contains(&label);
412 if bad {
413 return Err(AppError::Credentials(format!(
414 "invalid {vendor} account label {label:?}: must be a non-empty name \
415 without path separators, drive prefixes, control characters, or reserved cache names"
416 )));
417 }
418 Ok(())
419}
420
421fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
429 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
430 return Vec::new();
431 };
432 let mut found: Vec<AnthropicAccount> = entries
433 .flatten()
434 .filter_map(|entry| {
435 let path = entry.path();
436 if !path.is_dir() {
437 return None;
438 }
439 let label = path.file_name()?.to_str()?.to_string();
440 validate_account_label(&label).ok()?;
441 Some(AnthropicAccount {
442 label,
443 credentials_path: path.join(".credentials.json"),
444 })
445 })
446 .collect();
447 found.sort_by(|a, b| a.label.cmp(&b.label));
448 found
449}
450
451pub fn tildify(path: &Path, home: &Path) -> String {
455 path.strip_prefix(home)
456 .map(|rest| {
457 let rendered = rest.display().to_string();
458 #[cfg(windows)]
461 let rendered = rendered.replace('\\', "/");
462 format!("~/{rendered}")
463 })
464 .unwrap_or_else(|_| path.display().to_string())
465}
466
467pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
472 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
473 base.join("accounts").join(label).join(".credentials.json")
474}
475
476pub fn add_anthropic_account_to_doc(
482 doc: &mut toml_edit::DocumentMut,
483 label: &str,
484 credentials_path: &str,
485) -> Result<()> {
486 use toml_edit::{Item, Table, value};
487
488 validate_account_label(label)?;
489
490 let anthropic = doc
491 .entry("anthropic")
492 .or_insert_with(|| Item::Table(Table::new()));
493 let anthropic = anthropic
494 .as_table_mut()
495 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
496
497 let accounts = anthropic
498 .entry("accounts")
499 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
500 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
501 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
502 })?;
503
504 let exists = accounts
505 .iter()
506 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
507 if exists {
508 return Err(AppError::Credentials(format!(
509 "anthropic account {label:?} already exists in config.toml"
510 )));
511 }
512
513 let mut table = Table::new();
514 table["label"] = value(label);
515 table["credentials_path"] = value(credentials_path);
516 accounts.push(table);
517 Ok(())
518}
519
520pub(crate) fn set_bool(
524 doc: &mut toml_edit::DocumentMut,
525 section: &str,
526 key: &str,
527 new_value: bool,
528) -> Result<()> {
529 let table = doc
530 .entry(section)
531 .or_insert_with(toml_edit::table)
532 .as_table_mut()
533 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
534
535 if let Some(item) = table.get_mut(key)
536 && let Some(v) = item.as_value_mut()
537 {
538 let suffix = v.decor().suffix().cloned();
542 *v = toml_edit::Value::from(new_value);
543 v.decor_mut().set_prefix(" ");
544 if let Some(suffix) = suffix {
545 v.decor_mut().set_suffix(suffix);
546 }
547 return Ok(());
548 }
549 table.insert(key, toml_edit::value(new_value));
550 Ok(())
551}
552
553pub(crate) fn set_value(
559 doc: &mut toml_edit::DocumentMut,
560 section: &str,
561 key: &str,
562 new_value: Option<toml_edit::Value>,
563) -> Result<()> {
564 let table = doc
565 .entry(section)
566 .or_insert_with(toml_edit::table)
567 .as_table_mut()
568 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
569
570 let Some(mut new_value) = new_value else {
571 table.remove(key);
572 return Ok(());
573 };
574 if let Some(item) = table.get_mut(key)
575 && let Some(v) = item.as_value_mut()
576 {
577 let suffix = v.decor().suffix().cloned();
578 new_value.decor_mut().set_prefix(" ");
579 if let Some(suffix) = suffix {
580 new_value.decor_mut().set_suffix(suffix);
581 }
582 *v = new_value;
583 return Ok(());
584 }
585 table.insert(key, toml_edit::Item::Value(new_value));
586 Ok(())
587}
588
589pub fn set_tray_value(path: &Path, key: &str, value: Option<toml_edit::Value>) -> Result<()> {
595 let mut doc = read_config_document(path)?;
596 let before = doc.to_string();
597 set_value(&mut doc, "tray", key, value)?;
598 if doc.to_string() == before {
599 return Ok(());
600 }
601 write_config_document(path, &doc)
602}
603
604pub(crate) fn read_config_document(path: &Path) -> Result<toml_edit::DocumentMut> {
608 let original = match std::fs::read_to_string(path) {
609 Ok(contents) => contents,
610 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
611 Err(error) => return Err(AppError::io_at(path, error)),
612 };
613 if original.trim().is_empty() {
614 return Ok(toml_edit::DocumentMut::new());
615 }
616 original.parse().map_err(|e: toml_edit::TomlError| {
617 AppError::Other(format!("config.toml not parseable: {e}"))
618 })
619}
620
621pub(crate) fn write_config_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> {
625 let bytes = doc.to_string();
626 crate::cache::atomic_write(path, bytes.as_bytes())?;
627
628 #[cfg(unix)]
629 {
630 if let Ok(meta) = std::fs::metadata(path) {
631 let mut perms = meta.permissions();
632 perms.set_mode(0o600);
633 let _ = std::fs::set_permissions(path, perms);
634 }
635 }
636 Ok(())
637}
638
639pub fn enable_vendors_in(path: &Path, vendors: &[VendorId]) -> Result<Vec<VendorId>> {
647 let mut doc = read_config_document(path)?;
648 let before = doc.to_string();
649 let written: Vec<VendorId> = vendors
650 .iter()
651 .copied()
652 .filter(|vendor| !is_explicitly_disabled(&doc, *vendor))
653 .collect();
654 for vendor in &written {
655 set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
656 }
657 if doc.to_string() == before {
658 return Ok(written);
659 }
660 write_config_document(path, &doc)?;
661 Ok(written)
662}
663
664fn is_explicitly_disabled(doc: &toml_edit::DocumentMut, vendor: VendorId) -> bool {
675 doc.get(vendor.config_section())
676 .and_then(|section| section.get("enabled"))
677 .and_then(|enabled| enabled.as_bool())
678 == Some(false)
679}
680
681#[derive(Debug, Clone, Deserialize, Serialize)]
682#[serde(default)]
683pub struct OpenAiConfig {
684 pub enabled: bool,
685 pub codex_auth_path: Option<PathBuf>,
687 #[serde(default)]
692 pub accounts: Vec<OpenAiAccount>,
693 pub admin_key_env: String,
701}
702
703#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
714pub struct OpenAiAccount {
715 pub label: String,
718 pub codex_auth_path: PathBuf,
721}
722
723impl OpenAiConfig {
724 pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
728 let Some(label) = label else {
729 return match &self.codex_auth_path {
730 Some(path) => Ok(path.clone()),
731 None => crate::openai::creds::default_path(),
732 };
733 };
734 self.accounts
735 .iter()
736 .find(|account| account.label == label)
737 .map(|account| account.codex_auth_path.clone())
738 .ok_or_else(|| {
739 AppError::Credentials(format!(
740 "no OpenAI account named {label:?}. Add it under \
741 [[openai.accounts]], or drop --account to use the default login."
742 ))
743 })
744 }
745}
746
747impl Default for OpenAiConfig {
748 fn default() -> Self {
749 Self {
750 enabled: true,
751 codex_auth_path: None,
752 accounts: Vec::new(),
753 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
754 }
755 }
756}
757
758#[derive(Debug, Clone, Default, Deserialize, Serialize)]
762#[serde(default)]
763pub struct CopilotConfig {
764 pub enabled: bool,
765 pub gh_binary: Option<PathBuf>,
768}
769
770impl CopilotConfig {
771 pub fn resolve_token(&self) -> Result<String> {
772 self.resolve_token_with(
773 |name| std::env::var_os(name),
774 &crate::copilot::credentials::SystemGhAuthTokenRunner,
775 )
776 }
777
778 fn resolve_token_with(
779 &self,
780 environment: impl Fn(&str) -> Option<std::ffi::OsString>,
781 runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
782 ) -> Result<String> {
783 if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
784 let token = value.into_string().map_err(|_| {
785 AppError::Credentials(
786 "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
787 )
788 })?;
789 if !token.is_empty() {
790 return Ok(token);
791 }
792 }
793 crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref())
794 }
795}
796
797#[derive(Debug, Clone, Default, Deserialize, Serialize)]
798#[serde(default)]
799pub struct NousConfig {
800 pub enabled: bool,
801}
802
803#[derive(Debug, Clone, Deserialize, Serialize)]
804#[serde(default)]
805pub struct OpenCodeGoConfig {
806 pub enabled: bool,
807 pub api_key_env: String,
808 pub api_key: Option<String>,
809}
810
811#[derive(Debug, Clone, Default, Deserialize, Serialize)]
815#[serde(default)]
816pub struct CommandCodeConfig {
817 pub enabled: bool,
818 pub auth_paths: Option<Vec<PathBuf>>,
819}
820
821#[derive(Debug, Clone, Deserialize, Serialize)]
826#[serde(default)]
827pub struct OllamaConfig {
828 pub enabled: bool,
829 pub api_key_env: String,
830 pub api_key: Option<String>,
831 pub plan: String,
834}
835
836impl Default for OllamaConfig {
837 fn default() -> Self {
838 Self {
839 enabled: false,
840 api_key_env: "OLLAMA_API_KEY".to_string(),
841 api_key: None,
842 plan: "pro".to_string(),
843 }
844 }
845}
846
847impl Default for OpenCodeGoConfig {
848 fn default() -> Self {
849 Self {
850 enabled: false,
851 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
852 api_key: None,
853 }
854 }
855}
856
857#[derive(Debug, Clone, Deserialize, Serialize)]
858#[serde(default)]
859pub struct ZaiConfig {
860 pub enabled: bool,
861 pub api_key_env: String,
863 pub api_key: Option<String>,
866 pub plan_tier: Option<String>,
868}
869
870impl Default for ZaiConfig {
871 fn default() -> Self {
872 Self {
873 enabled: true,
874 api_key_env: "ZAI_API_KEY".to_string(),
875 api_key: None,
876 plan_tier: None,
877 }
878 }
879}
880
881#[derive(Debug, Clone, Deserialize, Serialize)]
882#[serde(default)]
883pub struct OpenRouterConfig {
884 pub enabled: bool,
885 pub accounts: Vec<OpenRouterAccount>,
888 pub show_default_account: bool,
892 pub api_key_env: String,
893 pub api_key: Option<String>,
894}
895
896impl Default for OpenRouterConfig {
897 fn default() -> Self {
898 Self {
899 enabled: true,
900 accounts: Vec::new(),
901 show_default_account: true,
902 api_key_env: "OPENROUTER_API_KEY".to_string(),
903 api_key: None,
904 }
905 }
906}
907
908#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
911pub struct OpenRouterAccount {
912 pub label: String,
914 #[serde(default)]
916 pub api_key_env: Option<String>,
917 #[serde(default)]
919 pub api_key: Option<String>,
920}
921
922impl OpenRouterConfig {
923 pub fn account(&self, label: &str) -> Result<&OpenRouterAccount> {
926 validate_account_label_for("openrouter", label)?;
927 self.accounts
928 .iter()
929 .find(|account| account.label == label)
930 .ok_or_else(|| {
931 let known: Vec<&str> = self
932 .accounts
933 .iter()
934 .map(|account| account.label.as_str())
935 .collect();
936 AppError::Credentials(format!(
937 "openrouter account {label:?} not found in [[openrouter.accounts]]; \
938 known labels: {known:?}"
939 ))
940 })
941 }
942
943 pub fn resolve_api_key(&self, label: Option<&str>) -> Result<String> {
946 match label {
947 None => resolve_api_key("OpenRouter", &self.api_key_env, self.api_key.as_deref()),
948 Some(label) => {
949 let account = self.account(label)?;
950 resolve_api_key_in_section(
951 &format!("OpenRouter account {label:?}"),
952 "[[openrouter.accounts]]",
953 account.api_key_env.as_deref().unwrap_or(""),
954 account.api_key.as_deref(),
955 )
956 }
957 }
958 }
959}
960
961#[derive(Debug, Clone, Deserialize, Serialize)]
962#[serde(default)]
963pub struct DeepseekConfig {
964 pub enabled: bool,
965 pub api_key_env: String,
966 pub api_key: Option<String>,
967}
968
969impl Default for DeepseekConfig {
970 fn default() -> Self {
971 Self {
972 enabled: false,
973 api_key_env: "DEEPSEEK_API_KEY".to_string(),
974 api_key: None,
975 }
976 }
977}
978
979#[derive(Debug, Clone, Deserialize, Serialize)]
980#[serde(default)]
981pub struct KimiConfig {
982 pub enabled: bool,
983 pub api_key_env: String,
984 pub api_key: Option<String>,
987 pub credentials_path: Option<PathBuf>,
991 pub region: String,
996}
997
998impl Default for KimiConfig {
999 fn default() -> Self {
1000 Self {
1001 enabled: false,
1002 api_key_env: "KIMI_API_KEY".to_string(),
1003 api_key: None,
1004 credentials_path: None,
1005 region: "auto".to_string(),
1006 }
1007 }
1008}
1009
1010#[derive(Debug, Clone, Deserialize, Serialize)]
1011#[serde(default)]
1012pub struct KiloConfig {
1013 pub enabled: bool,
1014 pub api_key_env: String,
1015 pub api_key: Option<String>,
1016 pub organization_id: Option<String>,
1019}
1020
1021impl Default for KiloConfig {
1022 fn default() -> Self {
1023 Self {
1026 enabled: false,
1027 api_key_env: "KILO_API_KEY".to_string(),
1028 api_key: None,
1029 organization_id: None,
1030 }
1031 }
1032}
1033
1034#[derive(Debug, Clone, Deserialize, Serialize)]
1035#[serde(default)]
1036pub struct NovitaConfig {
1037 pub enabled: bool,
1038 pub api_key_env: String,
1039 pub api_key: Option<String>,
1040}
1041
1042impl Default for NovitaConfig {
1043 fn default() -> Self {
1044 Self {
1046 enabled: false,
1047 api_key_env: "NOVITA_API_KEY".to_string(),
1048 api_key: None,
1049 }
1050 }
1051}
1052
1053#[derive(Debug, Clone, Deserialize, Serialize)]
1054#[serde(default)]
1055pub struct MinimaxConfig {
1056 pub enabled: bool,
1057 pub api_key_env: String,
1058 pub api_key: Option<String>,
1059 pub region: String,
1065}
1066
1067impl Default for MinimaxConfig {
1068 fn default() -> Self {
1069 Self {
1071 enabled: false,
1072 api_key_env: "MINIMAX_API_KEY".to_string(),
1073 api_key: None,
1074 region: "global".to_string(),
1075 }
1076 }
1077}
1078
1079#[derive(Debug, Clone, Deserialize, Serialize)]
1080#[serde(default)]
1081pub struct MoonshotConfig {
1082 pub enabled: bool,
1083 pub api_key_env: String,
1084 pub api_key: Option<String>,
1085 pub region: String,
1087}
1088
1089impl Default for MoonshotConfig {
1090 fn default() -> Self {
1091 Self {
1093 enabled: false,
1094 api_key_env: "MOONSHOT_API_KEY".to_string(),
1095 api_key: None,
1096 region: "global".to_string(),
1097 }
1098 }
1099}
1100
1101#[derive(Debug, Clone, Deserialize, Serialize)]
1102#[serde(default)]
1103pub struct GrokConfig {
1104 pub enabled: bool,
1105 pub api_key_env: String,
1107 pub api_key: Option<String>,
1108 pub team_id: Option<String>,
1111}
1112
1113impl Default for GrokConfig {
1114 fn default() -> Self {
1115 Self {
1117 enabled: false,
1118 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1119 api_key: None,
1120 team_id: None,
1121 }
1122 }
1123}
1124
1125#[derive(Debug, Clone, Deserialize, Serialize)]
1133#[serde(default)]
1134pub struct SuperGrokConfig {
1135 pub enabled: bool,
1136 pub grok_binary: PathBuf,
1140 pub auth_path: Option<PathBuf>,
1143 pub config_path: Option<PathBuf>,
1144}
1145
1146impl Default for SuperGrokConfig {
1147 fn default() -> Self {
1148 Self {
1149 enabled: false,
1150 grok_binary: default_grok_binary(),
1151 auth_path: None,
1152 config_path: None,
1153 }
1154 }
1155}
1156
1157fn default_grok_binary() -> PathBuf {
1158 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1159 let grok_home = std::env::var_os("GROK_HOME")
1160 .filter(|value| !value.is_empty())
1161 .map(PathBuf::from)
1162 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1163 grok_home
1164 .map(|home| home.join("bin").join(executable))
1165 .unwrap_or_else(|| PathBuf::from(executable))
1166}
1167
1168#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1176#[serde(default)]
1177pub struct AntigravityConfig {
1178 pub enabled: bool,
1179 pub oauth_client_id: Option<String>,
1181 pub oauth_client_secret: Option<String>,
1185}
1186
1187#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1197#[serde(default)]
1198pub struct CursorConfig {
1199 pub enabled: bool,
1200 pub db_path: Option<PathBuf>,
1204 pub agent_auth_path: Option<PathBuf>,
1209}
1210
1211#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1220#[serde(default)]
1221pub struct KiroConfig {
1222 pub enabled: bool,
1223 pub db_path: Option<PathBuf>,
1227}
1228
1229#[derive(Debug, Clone, Deserialize, Serialize)]
1230#[serde(default)]
1231pub struct AnthropicApiConfig {
1232 pub enabled: bool,
1233 pub api_key_env: String,
1236 pub api_key: Option<String>,
1237 pub monthly_limit: Option<f64>,
1240}
1241
1242impl Default for AnthropicApiConfig {
1243 fn default() -> Self {
1244 Self {
1246 enabled: false,
1247 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
1248 api_key: None,
1249 monthly_limit: None,
1250 }
1251 }
1252}
1253
1254#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1264#[serde(default, remote = "Self")]
1265pub struct CustomProviderConfig {
1266 pub id: String,
1268 pub name: String,
1270 pub short_name: String,
1273 pub brand: Option<String>,
1276 pub enabled: bool,
1277 pub url: String,
1279 pub allow_http: bool,
1280 pub api_key_env: String,
1282 pub api_key: Option<String>,
1283 pub auth_header: String,
1285 pub auth_scheme: String,
1287 pub headers: BTreeMap<String, String>,
1289 pub plan: Option<String>,
1291 pub plan_path: Option<String>,
1293 pub cache_ttl_secs: u64,
1295 pub metrics: Vec<CustomMetricSpec>,
1296 pub texts: Vec<CustomTextSpec>,
1297}
1298
1299impl Default for CustomProviderConfig {
1300 fn default() -> Self {
1301 Self {
1302 id: String::new(),
1303 name: String::new(),
1304 short_name: String::new(),
1305 brand: None,
1306 enabled: false,
1307 url: String::new(),
1308 allow_http: false,
1309 api_key_env: String::new(),
1310 api_key: None,
1311 auth_header: "Authorization".to_string(),
1312 auth_scheme: "Bearer".to_string(),
1313 headers: BTreeMap::new(),
1314 plan: None,
1315 plan_path: None,
1316 cache_ttl_secs: 60,
1317 metrics: Vec::new(),
1318 texts: Vec::new(),
1319 }
1320 }
1321}
1322
1323impl<'de> Deserialize<'de> for CustomProviderConfig {
1328 fn deserialize<D: serde::Deserializer<'de>>(
1329 deserializer: D,
1330 ) -> std::result::Result<Self, D::Error> {
1331 let mut this = Self::deserialize(deserializer)?;
1332 if this.name.is_empty() {
1333 this.name = this.id.clone();
1334 }
1335 Ok(this)
1336 }
1337}
1338
1339impl Serialize for CustomProviderConfig {
1340 fn serialize<S: serde::Serializer>(
1341 &self,
1342 serializer: S,
1343 ) -> std::result::Result<S::Ok, S::Error> {
1344 Self::serialize(self, serializer)
1345 }
1346}
1347
1348#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1352#[serde(default)]
1353pub struct CustomMetricSpec {
1354 pub label: String,
1355 pub used: Option<String>,
1356 pub limit: Option<String>,
1357 pub percent: Option<String>,
1358 pub resets_at: Option<String>,
1360 pub window_secs: Option<u64>,
1362}
1363
1364#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1366#[serde(default)]
1367pub struct CustomTextSpec {
1368 pub label: String,
1369 pub value: String,
1370}
1371
1372impl CustomProviderConfig {
1373 pub fn section_label(&self) -> String {
1375 format!("[[custom]] id = {:?}", self.id)
1376 }
1377
1378 pub fn resolve_api_key(&self) -> Result<String> {
1381 if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
1382 return Ok(key);
1383 }
1384 let advice = if self.api_key_env.is_empty() {
1385 "set `api_key`, or name an environment variable in `api_key_env`".to_string()
1386 } else {
1387 format!("export {} or set `api_key`", self.api_key_env)
1388 };
1389 Err(AppError::Credentials(format!(
1390 "custom {}: no API key. Either {advice} under {} in {}.",
1391 self.id,
1392 self.section_label(),
1393 config_path_hint()
1394 )))
1395 }
1396
1397 pub fn cache_ttl(&self) -> std::time::Duration {
1398 std::time::Duration::from_secs(self.cache_ttl_secs)
1399 }
1400
1401 fn validate(&self, index: usize) -> Result<()> {
1405 if !is_valid_custom_id(&self.id) {
1406 return Err(AppError::Other(format!(
1407 "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
1408 index + 1,
1409 self.id
1410 )));
1411 }
1412 let section = self.section_label();
1413 let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
1414
1415 if VendorId::all().iter().any(|v| v.slug() == self.id) {
1416 return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
1417 }
1418 let name_len = self.name.chars().count();
1419 if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
1420 return Err(bad(
1421 "name must be 1 to 48 characters without control characters".into(),
1422 ));
1423 }
1424 if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
1425 return Err(bad(format!(
1426 "short_name {:?} must be exactly 3 lowercase ASCII letters",
1427 self.short_name
1428 )));
1429 }
1430 if let Some(brand) = &self.brand
1431 && !VendorId::all().iter().any(|v| v.slug() == brand)
1432 {
1433 return Err(bad(format!(
1434 "brand {brand:?} must name a built-in vendor (it borrows that \
1435 vendor's mark); leave it unset to keep the short_name tag"
1436 )));
1437 }
1438 let url = reqwest::Url::parse(&self.url)
1439 .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
1440 match url.scheme() {
1441 "https" => {}
1442 "http" if self.allow_http => {}
1443 "http" => {
1444 return Err(bad(
1445 "url must use https:// (set allow_http = true to permit http://)".into(),
1446 ));
1447 }
1448 other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
1449 }
1450 if !url.username().is_empty() || url.password().is_some() {
1451 return Err(bad("url must not carry credentials (user:pass@)".into()));
1452 }
1453 if url.host_str().is_none() {
1454 return Err(bad("url has no host".into()));
1455 }
1456 if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
1457 return Err(bad(format!(
1458 "api_key_env {:?} is not a valid environment variable name",
1459 self.api_key_env
1460 )));
1461 }
1462 validate_header_name(§ion, "auth_header", &self.auth_header)?;
1463 if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
1464 return Err(bad(
1465 "auth_scheme contains characters that are not valid in an HTTP header".into(),
1466 ));
1467 }
1468 for (name, value) in &self.headers {
1469 validate_header_name(§ion, "headers", name)?;
1470 if name.eq_ignore_ascii_case(&self.auth_header) {
1471 return Err(bad(format!(
1472 "headers must not repeat auth_header {:?}",
1473 self.auth_header
1474 )));
1475 }
1476 if reqwest::header::HeaderValue::from_str(value).is_err() {
1477 return Err(bad(format!(
1478 "header {name:?} has a value that is not valid in an HTTP header"
1479 )));
1480 }
1481 }
1482 if let Some(plan) = &self.plan {
1483 validate_custom_label(§ion, "plan", plan)?;
1484 }
1485 if let Some(pointer) = &self.plan_path {
1486 validate_pointer(§ion, "plan_path", pointer)?;
1487 }
1488 if !(10..=3600).contains(&self.cache_ttl_secs) {
1489 return Err(bad(format!(
1490 "cache_ttl_secs must be between 10 and 3600, got {}",
1491 self.cache_ttl_secs
1492 )));
1493 }
1494 if self.metrics.is_empty() && self.texts.is_empty() {
1495 return Err(bad(
1496 "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
1497 ));
1498 }
1499 let mut metric_labels = HashSet::new();
1500 for metric in &self.metrics {
1501 validate_custom_label(§ion, "metric label", &metric.label)?;
1502 if !metric_labels.insert(metric.label.as_str()) {
1503 return Err(bad(format!("duplicate metric label {:?}", metric.label)));
1504 }
1505 let pair = (metric.used.is_some(), metric.limit.is_some());
1506 let well_formed = if metric.percent.is_some() {
1507 pair == (false, false)
1508 } else {
1509 pair == (true, true)
1510 };
1511 if !well_formed {
1512 return Err(bad(format!(
1513 "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
1514 metric.label
1515 )));
1516 }
1517 for (field, pointer) in [
1518 ("used", &metric.used),
1519 ("limit", &metric.limit),
1520 ("percent", &metric.percent),
1521 ("resets_at", &metric.resets_at),
1522 ] {
1523 if let Some(pointer) = pointer {
1524 validate_pointer(§ion, field, pointer)?;
1525 }
1526 }
1527 if let Some(secs) = metric.window_secs
1528 && secs < 60
1529 {
1530 return Err(bad(format!(
1531 "metric {:?} window_secs must be at least 60, got {secs}",
1532 metric.label
1533 )));
1534 }
1535 }
1536 let mut text_labels = HashSet::new();
1537 for text in &self.texts {
1538 validate_custom_label(§ion, "text label", &text.label)?;
1539 if !text_labels.insert(text.label.as_str()) {
1540 return Err(bad(format!("duplicate text label {:?}", text.label)));
1541 }
1542 validate_pointer(§ion, "value", &text.value)?;
1543 }
1544 Ok(())
1545 }
1546}
1547
1548fn is_valid_custom_id(id: &str) -> bool {
1549 let bytes = id.as_bytes();
1550 let Some(&first) = bytes.first() else {
1551 return false;
1552 };
1553 bytes.len() <= 32
1554 && (first.is_ascii_lowercase() || first.is_ascii_digit())
1555 && bytes
1556 .iter()
1557 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
1558}
1559
1560fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
1561 if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
1562 return Err(AppError::Other(format!(
1563 "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
1564 )));
1565 }
1566 Ok(())
1567}
1568
1569fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
1570 let len = label.chars().count();
1571 if len == 0 || len > 64 || label.chars().any(char::is_control) {
1572 return Err(AppError::Other(format!(
1573 "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
1574 )));
1575 }
1576 Ok(())
1577}
1578
1579fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
1580 if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
1581 return Err(AppError::Other(format!(
1582 "{section}: {field} {name:?} is not a valid HTTP header name"
1583 )));
1584 }
1585 Ok(())
1586}
1587
1588pub fn resolve_api_key(
1591 vendor_label: &str,
1592 env_var_name: &str,
1593 inline: Option<&str>,
1594) -> crate::error::Result<String> {
1595 let section = match vendor_label {
1596 "OpenCode Go" => "[opencode-go]".to_string(),
1597 _ => format!("[{}]", vendor_label.to_lowercase()),
1598 };
1599 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
1600}
1601
1602pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
1606 if is_valid_env_var_name(env_var_name)
1607 && let Ok(v) = std::env::var(env_var_name)
1608 && !v.is_empty()
1609 {
1610 return Some(v);
1611 }
1612 inline.filter(|v| !v.is_empty()).map(str::to_string)
1613}
1614
1615fn resolve_api_key_in_section(
1616 vendor_label: &str,
1617 section: &str,
1618 env_var_name: &str,
1619 inline: Option<&str>,
1620) -> crate::error::Result<String> {
1621 if let Some(key) = optional_api_key(env_var_name, inline) {
1622 return Ok(key);
1623 }
1624 let valid_env_name = is_valid_env_var_name(env_var_name);
1625 let advice = if valid_env_name {
1626 "set an API key in a valid environment variable or set `api_key`"
1627 } else {
1628 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
1629 };
1630 Err(crate::error::AppError::Credentials(format!(
1631 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
1632 config_path_hint()
1633 )))
1634}
1635
1636pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
1637 let mut chars = name.chars();
1638 let Some(first) = chars.next() else {
1639 return false;
1640 };
1641 (first.is_ascii_alphabetic() || first == '_')
1642 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1643}
1644
1645impl Config {
1646 pub fn load() -> Result<Self> {
1649 let Some(path) = resolved_path() else {
1650 return Ok(Self::default());
1651 };
1652 Self::load_from(&path)
1653 }
1654
1655 pub fn load_from(path: &std::path::Path) -> Result<Self> {
1656 match std::fs::read_to_string(path) {
1657 Ok(s) => {
1658 let mut config: Self = toml::from_str(&s)?;
1659 config.expand_paths();
1663 config.validate()?;
1664 #[cfg(unix)]
1665 config.protect_inline_secrets(path)?;
1666 crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
1670 Ok(config)
1671 }
1672 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1673 Err(e) => Err(AppError::io_at(path, e)),
1674 }
1675 }
1676
1677 fn expand_paths(&mut self) {
1678 expand_tilde_opt(&mut self.context.projects_path);
1679 expand_tilde_opt(&mut self.anthropic.credentials_path);
1680 expand_tilde_opt(&mut self.anthropic.accounts_dir);
1681 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
1682 expand_tilde_opt(&mut self.openai.codex_auth_path);
1683 expand_tilde_opt(&mut self.cursor.db_path);
1684 expand_tilde_opt(&mut self.cursor.agent_auth_path);
1685 expand_tilde_opt(&mut self.kiro.db_path);
1686 expand_tilde_opt(&mut self.kimi.credentials_path);
1687 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
1688 expand_tilde_opt(&mut self.supergrok.auth_path);
1689 expand_tilde_opt(&mut self.supergrok.config_path);
1690 for account in &mut self.anthropic.accounts {
1691 account.credentials_path = expand_tilde(&account.credentials_path);
1692 }
1693 for account in &mut self.openai.accounts {
1694 account.codex_auth_path = expand_tilde(&account.codex_auth_path);
1695 }
1696 }
1697
1698 #[cfg(unix)]
1702 fn has_inline_secrets(&self) -> bool {
1703 [
1704 self.zai.api_key.as_deref(),
1705 self.openrouter.api_key.as_deref(),
1706 self.deepseek.api_key.as_deref(),
1707 self.kimi.api_key.as_deref(),
1708 self.kilo.api_key.as_deref(),
1709 self.novita.api_key.as_deref(),
1710 self.minimax.api_key.as_deref(),
1711 self.moonshot.api_key.as_deref(),
1712 self.grok.api_key.as_deref(),
1713 self.anthropic_api.api_key.as_deref(),
1714 self.opencode_go.api_key.as_deref(),
1715 self.antigravity.oauth_client_secret.as_deref(),
1716 ]
1717 .into_iter()
1718 .chain(
1719 self.openrouter
1720 .accounts
1721 .iter()
1722 .map(|account| account.api_key.as_deref()),
1723 )
1724 .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
1725 .any(|key| key.is_some_and(|key| !key.is_empty()))
1726 }
1727
1728 fn custom_secret_env_vars(&self) -> Vec<String> {
1729 self.custom
1730 .iter()
1731 .filter(|c| !c.api_key_env.is_empty())
1732 .map(|c| c.api_key_env.clone())
1733 .collect()
1734 }
1735
1736 pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
1738 self.custom.iter().filter(|c| c.enabled)
1739 }
1740
1741 pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
1743 self.custom.iter().find(|c| c.id == id)
1744 }
1745
1746 #[cfg(unix)]
1747 fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
1748 if !self.has_inline_secrets() {
1749 return Ok(());
1750 }
1751
1752 let metadata = std::fs::metadata(path).map_err(|_| {
1753 AppError::Credentials(format!(
1754 "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
1755 path.display()
1756 ))
1757 })?;
1758 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
1759 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
1760 AppError::Credentials(format!(
1761 "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",
1762 path.display()
1763 ))
1764 })?;
1765 }
1766 Ok(())
1767 }
1768
1769 pub fn is_enabled(&self, id: VendorId) -> bool {
1770 match id {
1771 VendorId::Anthropic => self.anthropic.enabled,
1772 VendorId::AnthropicApi => self.anthropic_api.enabled,
1773 VendorId::Openai => self.openai.enabled,
1774 VendorId::Copilot => self.copilot.enabled,
1775 VendorId::Zai => self.zai.enabled,
1776 VendorId::Openrouter => self.openrouter.enabled,
1777 VendorId::Deepseek => self.deepseek.enabled,
1778 VendorId::Kimi => self.kimi.enabled,
1779 VendorId::Kilo => self.kilo.enabled,
1780 VendorId::Novita => self.novita.enabled,
1781 VendorId::Moonshot => self.moonshot.enabled,
1782 VendorId::Grok => self.grok.enabled,
1783 VendorId::Supergrok => self.supergrok.enabled,
1784 VendorId::Antigravity => self.antigravity.enabled,
1785 VendorId::Cursor => self.cursor.enabled,
1786 VendorId::Minimax => self.minimax.enabled,
1787 VendorId::Kiro => self.kiro.enabled,
1788 VendorId::NousResearch => self.nous.enabled,
1789 VendorId::OpenCodeGo => self.opencode_go.enabled,
1790 VendorId::CommandCode => self.commandcode.enabled,
1791 VendorId::Ollama => self.ollama.enabled,
1792 }
1793 }
1794
1795 pub fn api_key_env_for(&self, id: VendorId) -> &str {
1802 match id {
1803 VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
1804 VendorId::Zai => &self.zai.api_key_env,
1805 VendorId::Openrouter => &self.openrouter.api_key_env,
1806 VendorId::Deepseek => &self.deepseek.api_key_env,
1807 VendorId::Kimi => &self.kimi.api_key_env,
1808 VendorId::Kilo => &self.kilo.api_key_env,
1809 VendorId::Novita => &self.novita.api_key_env,
1810 VendorId::Moonshot => &self.moonshot.api_key_env,
1811 VendorId::Grok => &self.grok.api_key_env,
1812 VendorId::Minimax => &self.minimax.api_key_env,
1813 VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
1814 VendorId::Ollama => &self.ollama.api_key_env,
1815 VendorId::Anthropic
1818 | VendorId::Openai
1819 | VendorId::Copilot
1820 | VendorId::Supergrok
1821 | VendorId::Antigravity
1822 | VendorId::Cursor
1823 | VendorId::Kiro
1824 | VendorId::NousResearch
1825 | VendorId::CommandCode => id.api_key_env(),
1826 }
1827 }
1828
1829 pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
1833 let raw = match id {
1834 VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
1835 VendorId::Zai => self.zai.api_key.as_deref(),
1836 VendorId::Openrouter => self.openrouter.api_key.as_deref(),
1837 VendorId::Deepseek => self.deepseek.api_key.as_deref(),
1838 VendorId::Kimi => self.kimi.api_key.as_deref(),
1839 VendorId::Kilo => self.kilo.api_key.as_deref(),
1840 VendorId::Novita => self.novita.api_key.as_deref(),
1841 VendorId::Moonshot => self.moonshot.api_key.as_deref(),
1842 VendorId::Grok => self.grok.api_key.as_deref(),
1843 VendorId::Minimax => self.minimax.api_key.as_deref(),
1844 VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
1845 VendorId::Ollama => self.ollama.api_key.as_deref(),
1846 VendorId::Anthropic
1847 | VendorId::Openai
1848 | VendorId::Copilot
1849 | VendorId::Supergrok
1850 | VendorId::Antigravity
1851 | VendorId::Cursor
1852 | VendorId::Kiro
1853 | VendorId::NousResearch
1854 | VendorId::CommandCode => None,
1855 };
1856 raw.filter(|key| !key.is_empty())
1857 }
1858
1859 pub fn enabled_vendors(&self) -> Vec<VendorId> {
1860 VendorId::all()
1861 .iter()
1862 .copied()
1863 .filter(|id| self.is_enabled(*id))
1864 .collect()
1865 }
1866
1867 pub fn validate(&self) -> Result<()> {
1871 if let Some(minutes) = self.tray.refresh_minutes
1872 && !TRAY_REFRESH_MINUTES.contains(&minutes)
1873 {
1874 return Err(AppError::Other(format!(
1875 "[tray] refresh_minutes must be one of 1, 5 or 10, got {minutes}"
1876 )));
1877 }
1878 if self.context.context_window_tokens == Some(0) {
1879 return Err(AppError::Other(
1880 "[context] context_window_tokens must be greater than zero".into(),
1881 ));
1882 }
1883 for (model, tokens) in &self.context.model_context_window_tokens {
1884 if model.trim().is_empty() {
1885 return Err(AppError::Other(
1886 "[context] model_context_window_tokens keys must not be empty".into(),
1887 ));
1888 }
1889 if *tokens == 0 {
1890 return Err(AppError::Other(format!(
1891 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
1892 )));
1893 }
1894 }
1895 if let Some(limit) = self.anthropic_api.monthly_limit
1896 && (!limit.is_finite() || limit <= 0.0)
1897 {
1898 return Err(AppError::Other(
1899 "[anthropic_api] monthly_limit must be finite and greater than zero; \
1900 remove it to show spend without a limit"
1901 .into(),
1902 ));
1903 }
1904 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
1905 && !self.kimi.region.eq_ignore_ascii_case("auto")
1906 {
1907 return Err(AppError::Other(format!(
1908 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
1909 self.kimi.region
1910 )));
1911 }
1912 if !self.minimax.region.eq_ignore_ascii_case("global")
1913 && !self.minimax.region.eq_ignore_ascii_case("cn")
1914 {
1915 return Err(AppError::Other(format!(
1916 "[minimax] region must be \"global\" or \"cn\", got {:?}",
1917 self.minimax.region
1918 )));
1919 }
1920 if self.supergrok.grok_binary.as_os_str().is_empty() {
1921 return Err(AppError::Other(
1922 "[supergrok] grok_binary must not be empty".into(),
1923 ));
1924 }
1925 let mut labels = HashSet::new();
1926 for account in &self.anthropic.accounts {
1927 validate_account_label(&account.label)?;
1928 if !labels.insert(&account.label) {
1929 return Err(AppError::Credentials(format!(
1930 "duplicate anthropic account label {:?}",
1931 account.label
1932 )));
1933 }
1934 }
1935 let mut openai_labels = HashSet::new();
1936 for account in &self.openai.accounts {
1937 validate_account_label_for("openai", &account.label)?;
1938 if !openai_labels.insert(&account.label) {
1939 return Err(AppError::Credentials(format!(
1940 "duplicate openai account label {:?}",
1941 account.label
1942 )));
1943 }
1944 }
1945 let mut openrouter_labels = HashSet::new();
1946 for account in &self.openrouter.accounts {
1947 validate_account_label_for("openrouter", &account.label)?;
1948 if !openrouter_labels.insert(&account.label) {
1949 return Err(AppError::Credentials(format!(
1950 "duplicate openrouter account label {:?}",
1951 account.label
1952 )));
1953 }
1954 let has_env = account
1955 .api_key_env
1956 .as_deref()
1957 .is_some_and(|name| !name.is_empty());
1958 let has_inline = account
1959 .api_key
1960 .as_deref()
1961 .is_some_and(|key| !key.is_empty());
1962 if !has_env && !has_inline {
1963 return Err(AppError::Credentials(format!(
1964 "openrouter account {:?} must set api_key_env or api_key",
1965 account.label
1966 )));
1967 }
1968 }
1969 self.validate_custom()
1970 }
1971
1972 fn validate_custom(&self) -> Result<()> {
1976 let mut ids = HashSet::new();
1977 let mut short_names: HashSet<&str> =
1978 VendorId::all().iter().map(|v| v.short_name()).collect();
1979 for (index, custom) in self.custom.iter().enumerate() {
1980 custom.validate(index)?;
1981 if !ids.insert(custom.id.as_str()) {
1982 return Err(AppError::Other(format!(
1983 "{}: duplicate id",
1984 custom.section_label()
1985 )));
1986 }
1987 if !short_names.insert(custom.short_name.as_str()) {
1988 return Err(AppError::Other(format!(
1989 "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
1990 custom.section_label(),
1991 custom.short_name
1992 )));
1993 }
1994 }
1995 Ok(())
1996 }
1997}
1998
1999#[cfg(unix)]
2000#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2001enum InlineKeyPermissionDecision {
2002 Ok,
2003 Tighten,
2004}
2005
2006#[cfg(unix)]
2007fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
2008 if mode & 0o077 == 0 {
2009 InlineKeyPermissionDecision::Ok
2010 } else {
2011 InlineKeyPermissionDecision::Tighten
2012 }
2013}
2014
2015pub fn default_path() -> Option<PathBuf> {
2016 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
2017 Some(proj.config_dir().join("config.toml"))
2018}
2019
2020fn legacy_xdg_path() -> Option<PathBuf> {
2025 let home = crate::cache::home_dir().ok()?;
2026 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
2027}
2028
2029pub fn resolved_path() -> Option<PathBuf> {
2040 if let Some(path) = override_path() {
2041 return Some(path);
2042 }
2043 let canonical = default_path();
2044 if let Some(p) = &canonical
2045 && p.exists()
2046 {
2047 return canonical;
2048 }
2049 if let Some(legacy) = legacy_xdg_path()
2050 && legacy.exists()
2051 {
2052 return Some(legacy);
2053 }
2054 canonical
2055}
2056
2057static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
2058
2059pub fn set_override_path(path: &std::path::Path) {
2065 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2066 *slot = Some(path.to_path_buf());
2067 }
2068}
2069
2070#[doc(hidden)]
2073pub fn clear_override_path() {
2074 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2075 *slot = None;
2076 }
2077}
2078
2079fn override_path() -> Option<PathBuf> {
2080 PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
2081}
2082
2083#[doc(hidden)]
2089pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
2090 #[cfg(unix)]
2091 {
2092 use std::os::unix::ffi::{OsStrExt, OsStringExt};
2093 let rest = arg.as_bytes().strip_prefix(b"--config=")?;
2094 Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
2095 }
2096 #[cfg(windows)]
2097 {
2098 use std::os::windows::ffi::{OsStrExt, OsStringExt};
2099 const PREFIX: &[u16] = &[
2100 b'-' as u16,
2101 b'-' as u16,
2102 b'c' as u16,
2103 b'o' as u16,
2104 b'n' as u16,
2105 b'f' as u16,
2106 b'i' as u16,
2107 b'g' as u16,
2108 b'=' as u16,
2109 ];
2110 let wide: Vec<u16> = arg.encode_wide().collect();
2111 let rest = wide.strip_prefix(PREFIX)?;
2112 Some(std::ffi::OsString::from_wide(rest).into())
2113 }
2114 #[cfg(not(any(unix, windows)))]
2115 {
2116 Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
2117 }
2118}
2119
2120fn expand_tilde(p: &std::path::Path) -> PathBuf {
2123 let Some(s) = p.to_str() else {
2124 return p.to_path_buf();
2125 };
2126 let rest = if s == "~" {
2127 ""
2128 } else if let Some(r) = s.strip_prefix("~/") {
2129 r
2130 } else {
2131 return p.to_path_buf();
2132 };
2133 match crate::cache::home_dir() {
2134 Ok(home) if rest.is_empty() => home,
2135 Ok(home) => home.join(rest),
2136 Err(_) => p.to_path_buf(),
2137 }
2138}
2139
2140fn expand_tilde_opt(p: &mut Option<PathBuf>) {
2141 if let Some(inner) = p.as_ref() {
2142 *p = Some(expand_tilde(inner));
2143 }
2144}
2145
2146pub fn config_path_hint() -> String {
2151 resolved_path()
2152 .map(|p| p.display().to_string())
2153 .unwrap_or_else(|| "config.toml".to_string())
2154}
2155
2156#[cfg(test)]
2157mod tests {
2158 use super::*;
2159 use std::io::Write;
2160 use tempfile::NamedTempFile;
2161
2162 #[cfg(unix)]
2163 use std::os::unix::fs::{MetadataExt, PermissionsExt};
2164
2165 fn write_toml(s: &str) -> NamedTempFile {
2166 let mut f = NamedTempFile::new().unwrap();
2167 f.write_all(s.as_bytes()).unwrap();
2168 f.flush().unwrap();
2169 f
2170 }
2171
2172 #[test]
2176 fn openai_without_accounts_resolves_the_singular_path() {
2177 let explicit = OpenAiConfig {
2178 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2179 ..OpenAiConfig::default()
2180 };
2181 assert_eq!(
2182 explicit.resolve_auth_path(None).unwrap(),
2183 PathBuf::from("/tmp/codex/auth.json")
2184 );
2185
2186 let bare = OpenAiConfig::default();
2187 assert_eq!(
2188 bare.resolve_auth_path(None).unwrap(),
2189 crate::openai::creds::default_path().unwrap(),
2190 "no codex_auth_path must still mean ~/.codex/auth.json"
2191 );
2192 }
2193
2194 #[test]
2197 fn openai_named_accounts_resolve_their_own_auth_file() {
2198 let config: Config = toml::from_str(
2199 r#"
2200 [openai]
2201 codex_auth_path = "/tmp/personal/auth.json"
2202 [[openai.accounts]]
2203 label = "work"
2204 codex_auth_path = "/tmp/work/auth.json"
2205 "#,
2206 )
2207 .unwrap();
2208
2209 assert_eq!(
2210 config.openai.resolve_auth_path(Some("work")).unwrap(),
2211 PathBuf::from("/tmp/work/auth.json")
2212 );
2213 assert_eq!(
2214 config.openai.resolve_auth_path(None).unwrap(),
2215 PathBuf::from("/tmp/personal/auth.json")
2216 );
2217 }
2218
2219 #[test]
2222 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
2223 let config = OpenAiConfig {
2224 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
2225 accounts: vec![OpenAiAccount {
2226 label: "work".into(),
2227 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
2228 }],
2229 ..OpenAiConfig::default()
2230 };
2231 let err = config
2232 .resolve_auth_path(Some("nope"))
2233 .unwrap_err()
2234 .to_string();
2235 assert!(err.contains("nope"), "{err}");
2236 assert!(err.contains("[[openai.accounts]]"), "{err}");
2237 }
2238
2239 #[test]
2240 fn defaults_enable_only_the_four_core_vendors() {
2241 let c = Config::default();
2242 assert!(c.is_enabled(VendorId::Anthropic));
2243 assert!(c.is_enabled(VendorId::Openai));
2244 assert!(c.is_enabled(VendorId::Zai));
2245 assert!(c.is_enabled(VendorId::Openrouter));
2246 for opt_in in [
2247 VendorId::AnthropicApi,
2248 VendorId::Copilot,
2249 VendorId::Deepseek,
2250 VendorId::Kimi,
2251 VendorId::Kilo,
2252 VendorId::Novita,
2253 VendorId::Moonshot,
2254 VendorId::Grok,
2255 VendorId::Supergrok,
2256 VendorId::Cursor,
2257 VendorId::Minimax,
2258 VendorId::Kiro,
2259 ] {
2260 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
2261 }
2262 assert_eq!(c.enabled_vendors().len(), 4);
2263 }
2264
2265 #[test]
2266 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
2267 let config = Config::default();
2268 assert!(!config.is_enabled(VendorId::NousResearch));
2269 assert!(!config.is_enabled(VendorId::OpenCodeGo));
2270 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
2271 assert!(config.opencode_go.api_key.is_none());
2272 assert!(!config.is_enabled(VendorId::Copilot));
2273 }
2274
2275 #[cfg(unix)]
2276 #[test]
2277 fn inline_credentials_are_protected() {
2278 let mut config = Config::default();
2279 config.opencode_go.api_key = Some("<redacted>".to_string());
2280 assert!(config.has_inline_secrets());
2281 }
2282
2283 #[test]
2284 fn antigravity_oauth_client_overrides_parse() {
2285 let config: Config = toml::from_str(
2286 "[antigravity]
2287enabled = true
2288oauth_client_id = \"test-client\"
2289oauth_client_secret = \"test-client-secret\"
2290",
2291 )
2292 .unwrap();
2293 assert!(config.antigravity.enabled);
2294 assert_eq!(
2295 config.antigravity.oauth_client_id.as_deref(),
2296 Some("test-client")
2297 );
2298 assert_eq!(
2299 config.antigravity.oauth_client_secret.as_deref(),
2300 Some("test-client-secret")
2301 );
2302 let bare: Config = toml::from_str(
2303 "[antigravity]
2304enabled = true
2305",
2306 )
2307 .unwrap();
2308 assert!(bare.antigravity.oauth_client_id.is_none());
2309 assert!(bare.antigravity.oauth_client_secret.is_none());
2310 }
2311
2312 #[cfg(unix)]
2313 #[test]
2314 fn antigravity_inline_oauth_secret_receives_config_file_protection() {
2315 let mut config = Config::default();
2316 config.antigravity.oauth_client_id = Some("test-client".into());
2317 assert!(!config.has_inline_secrets());
2318 config.antigravity.oauth_client_secret = Some("<redacted>".into());
2319 assert!(config.has_inline_secrets());
2320 }
2321
2322 #[cfg(unix)]
2323 #[test]
2324 fn openrouter_named_inline_keys_receive_config_file_protection() {
2325 let mut config = Config::default();
2326 config.openrouter.accounts.push(OpenRouterAccount {
2327 label: "work".into(),
2328 api_key_env: None,
2329 api_key: Some("<redacted>".into()),
2330 });
2331 assert!(config.has_inline_secrets());
2332 }
2333
2334 #[test]
2335 fn missing_file_uses_defaults() {
2336 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
2337 let c = Config::load_from(path).unwrap();
2338 assert!(c.is_enabled(VendorId::Anthropic));
2339 }
2340
2341 #[test]
2342 fn parses_full_config() {
2343 let f = write_toml(
2344 r#"
2345 [anthropic]
2346 enabled = true
2347
2348 [openai]
2349 enabled = false
2350 admin_key_env = "MY_ADMIN_KEY"
2351
2352 [zai]
2353 enabled = true
2354 api_key_env = "MY_ZAI"
2355 plan_tier = "pro"
2356
2357 [openrouter]
2358 enabled = false
2359 "#,
2360 );
2361 let c = Config::load_from(f.path()).unwrap();
2362 assert!(c.is_enabled(VendorId::Anthropic));
2363 assert!(!c.is_enabled(VendorId::Openai));
2364 assert!(c.is_enabled(VendorId::Zai));
2365 assert!(!c.is_enabled(VendorId::Openrouter));
2366 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
2367 assert_eq!(c.zai.api_key_env, "MY_ZAI");
2368 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
2369 assert!(c.openrouter.accounts.is_empty());
2370 assert!(c.openrouter.show_default_account);
2371 }
2372
2373 #[test]
2374 fn partial_config_falls_back_to_defaults() {
2375 let f = write_toml(
2376 r#"[openai]
2377enabled = false
2378"#,
2379 );
2380 let c = Config::load_from(f.path()).unwrap();
2381 assert!(!c.is_enabled(VendorId::Openai));
2382 assert!(c.is_enabled(VendorId::Anthropic));
2384 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
2385 }
2386
2387 #[test]
2388 fn malformed_toml_returns_error() {
2389 let f = write_toml("this is not = = valid");
2390 assert!(Config::load_from(f.path()).is_err());
2391 }
2392
2393 #[cfg(unix)]
2394 #[test]
2395 fn load_from_tightens_world_readable_config_with_inline_api_key() {
2396 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
2397 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
2398
2399 Config::load_from(file.path()).unwrap();
2400
2401 assert_eq!(
2402 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
2403 0o600
2404 );
2405 }
2406
2407 #[cfg(unix)]
2408 #[test]
2409 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
2410 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
2411 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
2412
2413 Config::load_from(file.path()).unwrap();
2414
2415 assert_eq!(
2416 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
2417 0o644
2418 );
2419 }
2420
2421 #[cfg(unix)]
2422 #[test]
2423 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
2424 assert_eq!(
2425 inline_key_permission_decision(0o600),
2426 InlineKeyPermissionDecision::Ok
2427 );
2428 assert_eq!(
2429 inline_key_permission_decision(0o640),
2430 InlineKeyPermissionDecision::Tighten
2431 );
2432 assert_eq!(
2433 inline_key_permission_decision(0o604),
2434 InlineKeyPermissionDecision::Tighten
2435 );
2436 }
2437
2438 #[test]
2439 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
2440 for value in ["0", "-1", "inf", "nan"] {
2441 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
2442 let error = Config::load_from(file.path()).unwrap_err().to_string();
2443 assert!(error.contains("monthly_limit"), "value {value}: {error}");
2444 }
2445
2446 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
2447 assert_eq!(
2448 Config::load_from(file.path())
2449 .unwrap()
2450 .anthropic_api
2451 .monthly_limit,
2452 Some(1000.0)
2453 );
2454 }
2455
2456 #[test]
2457 fn minimax_region_accepts_only_known_instances() {
2458 for region in ["global", "GLOBAL", "cn", "CN"] {
2459 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
2460 assert_eq!(
2461 Config::load_from(file.path()).unwrap().minimax.region,
2462 region
2463 );
2464 }
2465
2466 for region in ["", "china", "us"] {
2467 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
2468 let error = Config::load_from(file.path()).unwrap_err().to_string();
2469 assert!(error.contains("[minimax] region"), "{error}");
2470 }
2471 }
2472
2473 #[test]
2474 fn kimi_region_accepts_auto_and_both_deployments() {
2475 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
2476 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
2477 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
2478 }
2479
2480 for region in ["", "us", "oversea"] {
2481 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
2482 let error = Config::load_from(file.path()).unwrap_err().to_string();
2483 assert!(error.contains("[kimi] region"), "{error}");
2484 }
2485 }
2486
2487 #[test]
2488 fn kimi_defaults_to_auto_region_and_no_credential_override() {
2489 let defaults = KimiConfig::default();
2490 assert_eq!(defaults.region, "auto");
2491 assert_eq!(defaults.credentials_path, None);
2492 assert!(!defaults.enabled);
2493 }
2494
2495 #[test]
2496 fn kimi_credentials_path_expands_a_tilde() {
2497 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
2498 let path = Config::load_from(file.path())
2499 .unwrap()
2500 .kimi
2501 .credentials_path
2502 .unwrap();
2503 assert!(!path.starts_with("~"), "{}", path.display());
2504 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
2505 }
2506
2507 #[test]
2508 fn optional_api_key_reports_absence_instead_of_failing() {
2509 assert_eq!(
2510 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", Some("inline")),
2511 Some("inline".to_string())
2512 );
2513 assert_eq!(
2514 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", None),
2515 None
2516 );
2517 assert_eq!(optional_api_key("KIMI_API_KEY_UNSET", Some("")), None);
2518 assert_eq!(
2521 optional_api_key("9INVALID", Some("inline")),
2522 Some("inline".to_string())
2523 );
2524 }
2525
2526 #[test]
2527 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
2528 let defaults = Config::default();
2529 assert!(!defaults.context.enabled);
2530 assert_eq!(
2531 defaults.context.window_tokens_for(Some("claude-test")),
2532 None
2533 );
2534
2535 let file = write_toml(
2536 r#"
2537 [context]
2538 enabled = true
2539 context_window_tokens = 200000
2540
2541 [context.model_context_window_tokens]
2542 claude-opus-1m = 1000000
2543 "claude exact id" = 300000
2544 "#,
2545 );
2546 let config = Config::load_from(file.path()).unwrap();
2547 assert!(config.context.enabled);
2548 assert_eq!(
2549 config.context.window_tokens_for(Some("claude-opus-1m")),
2550 Some(1_000_000)
2551 );
2552 assert_eq!(
2553 config.context.window_tokens_for(Some("claude exact id")),
2554 Some(300_000)
2555 );
2556 assert_eq!(
2557 config.context.window_tokens_for(Some("another-model")),
2558 Some(200_000)
2559 );
2560 }
2561
2562 #[test]
2563 fn context_layout_defaults_to_full_and_parses_each_variant() {
2564 assert_eq!(Config::default().context.layout, ContextLayout::Full);
2565 for (text, want) in [
2566 ("full", ContextLayout::Full),
2567 ("split", ContextLayout::Split),
2568 ("bottom", ContextLayout::Bottom),
2569 ] {
2570 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
2571 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
2572 }
2573 let file = write_toml("[context]\nlayout = \"floating\"\n");
2574 assert!(
2575 Config::load_from(file.path()).is_err(),
2576 "an unknown layout must be rejected, not silently defaulted"
2577 );
2578 }
2579
2580 #[test]
2581 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
2582 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
2583 for (text, want) in [
2584 ("sidebar", VendorBoxStyle::Sidebar),
2585 ("navbar", VendorBoxStyle::Navbar),
2586 ("none", VendorBoxStyle::None),
2587 ] {
2588 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
2589 assert_eq!(
2590 Config::load_from(file.path()).unwrap().ui.vendor_box(),
2591 want
2592 );
2593 }
2594 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
2595 assert!(
2596 Config::load_from(file.path()).is_err(),
2597 "an unknown vendor_box style must be rejected, not silently defaulted"
2598 );
2599 }
2600
2601 #[test]
2602 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
2603 for source in [
2604 "[context]\ncontext_window_tokens = 0\n",
2605 "[context.model_context_window_tokens]\nclaude = 0\n",
2606 "[context.model_context_window_tokens]\n\" \" = 200000\n",
2607 ] {
2608 let file = write_toml(source);
2609 let error = Config::load_from(file.path()).unwrap_err().to_string();
2610 assert!(error.contains("context"), "{error}");
2611 }
2612 }
2613
2614 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
2616 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
2617 M.lock().unwrap_or_else(|p| p.into_inner())
2618 }
2619
2620 #[test]
2621 fn resolve_api_key_prefers_env_over_inline() {
2622 let _g = env_guard();
2623 let var = "AI_USAGEBAR_TEST_ENV_WINS";
2625 unsafe { std::env::set_var(var, "from-env") };
2627 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
2628 unsafe { std::env::remove_var(var) };
2629 assert_eq!(got, "from-env");
2630 }
2631
2632 #[test]
2633 fn resolve_api_key_falls_back_to_inline() {
2634 let _g = env_guard();
2635 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
2636 unsafe { std::env::remove_var(var) };
2637 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
2638 assert_eq!(got, "inline-key");
2639 }
2640
2641 #[test]
2642 fn copilot_token_prefers_explicit_environment_over_gh_cli() {
2643 struct NeverRun;
2644 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
2645 fn run(
2646 &self,
2647 _: &crate::copilot::credentials::GhAuthTokenCommand,
2648 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
2649 panic!("environment override must not invoke gh")
2650 }
2651 }
2652
2653 let token = CopilotConfig::default()
2654 .resolve_token_with(
2655 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
2656 &NeverRun,
2657 )
2658 .unwrap();
2659 assert_eq!(token, "from-environment");
2660 }
2661
2662 #[test]
2663 fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
2664 struct FailedGh;
2665 impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
2666 fn run(
2667 &self,
2668 _: &crate::copilot::credentials::GhAuthTokenCommand,
2669 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
2670 Ok(crate::copilot::credentials::GhAuthTokenOutput {
2671 success: false,
2672 stdout: b"never-echo-gh-output".to_vec(),
2673 })
2674 }
2675 }
2676 let error = CopilotConfig::default()
2677 .resolve_token_with(|_| None, &FailedGh)
2678 .unwrap_err()
2679 .to_string();
2680 assert!(error.contains("gh auth login --web"));
2681 assert!(!error.contains("never-echo-gh-output"));
2682 }
2683
2684 #[test]
2685 fn resolve_api_key_errors_when_both_missing() {
2686 let _g = env_guard();
2687 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
2688 unsafe { std::env::remove_var(var) };
2689 let err = resolve_api_key("Zai", var, None).unwrap_err();
2690 match err {
2691 crate::error::AppError::Credentials(msg) => {
2692 assert!(
2693 msg.contains("api_key"),
2694 "error should suggest config field: {msg}"
2695 );
2696 }
2697 other => panic!("expected Credentials error, got {other:?}"),
2698 }
2699 }
2700
2701 #[test]
2702 fn resolve_api_key_uses_exact_opencode_go_section_name() {
2703 let _g = env_guard();
2704 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
2705 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
2706 let message = err.to_string();
2707 assert!(
2708 message.contains("[opencode-go]"),
2709 "wrong section hint: {message}"
2710 );
2711 assert!(
2712 !message.contains("[opencode go]"),
2713 "wrong section hint: {message}"
2714 );
2715 }
2716
2717 fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
2718 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
2719 M.lock().unwrap_or_else(|p| p.into_inner())
2720 }
2721
2722 struct ScopedPathOverride {
2728 _serial: std::sync::MutexGuard<'static, ()>,
2729 }
2730
2731 impl Drop for ScopedPathOverride {
2732 fn drop(&mut self) {
2733 clear_override_path();
2734 }
2735 }
2736
2737 fn scoped_path_override() -> ScopedPathOverride {
2738 ScopedPathOverride {
2739 _serial: path_override_guard(),
2740 }
2741 }
2742
2743 #[test]
2744 fn override_path_wins_over_canonical_and_legacy() {
2745 let _scoped = scoped_path_override();
2746 let file = NamedTempFile::new().unwrap();
2747 set_override_path(file.path());
2748 assert_eq!(resolved_path().as_deref(), Some(file.path()));
2749 assert_eq!(config_path_hint(), file.path().display().to_string());
2750 clear_override_path();
2751 let p = resolved_path().expect("a config path must resolve");
2753 assert!(p.ends_with("config.toml"));
2754 }
2755
2756 #[test]
2757 fn scoped_override_guard_clears_the_override_on_panic() {
2758 let hook = std::panic::take_hook();
2761 std::panic::set_hook(Box::new(|_| {}));
2762 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2763 let _scoped = scoped_path_override();
2764 set_override_path(std::path::Path::new("panicked-override.toml"));
2765 panic!("simulated mid-test failure");
2766 }))
2767 .is_err();
2768 std::panic::set_hook(hook);
2769 assert!(panicked, "the simulated failure must run");
2770 let _serial = path_override_guard();
2771 assert!(
2772 override_path().is_none(),
2773 "a panicking test must not leak the override into siblings"
2774 );
2775 }
2776
2777 #[test]
2778 fn config_path_hint_ends_with_config_toml() {
2779 let _g = path_override_guard();
2780 assert!(config_path_hint().ends_with("config.toml"));
2783 }
2784
2785 #[test]
2786 fn config_flag_value_splits_the_equals_form() {
2787 use std::ffi::OsStr;
2788 assert_eq!(
2789 config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
2790 Some(std::path::Path::new("work.toml"))
2791 );
2792 assert_eq!(
2793 config_flag_value(OsStr::new("--config=")).as_deref(),
2794 Some(std::path::Path::new(""))
2795 );
2796 assert_eq!(config_flag_value(OsStr::new("--config")), None);
2797 assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
2798 assert_eq!(config_flag_value(OsStr::new("account")), None);
2799 }
2800
2801 #[cfg(unix)]
2805 #[test]
2806 fn config_flag_value_keeps_undecodable_bytes_intact() {
2807 use std::ffi::OsString;
2808 use std::os::unix::ffi::{OsStrExt, OsStringExt};
2809 let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
2810 let value = config_flag_value(&raw).expect("prefix matches");
2811 assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
2812 }
2813
2814 #[cfg(windows)]
2815 #[test]
2816 fn config_flag_value_keeps_lone_surrogates_intact() {
2817 use std::ffi::OsString;
2818 use std::os::windows::ffi::{OsStrExt, OsStringExt};
2819 let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
2820 wide.push(0xDC00); wide.extend("x.toml".encode_utf16());
2822 let raw = OsString::from_wide(&wide);
2823 let value = config_flag_value(&raw).expect("prefix matches");
2824 let mut expected = vec![0xDC00u16];
2825 expected.extend("x.toml".encode_utf16());
2826 assert_eq!(
2827 value.as_os_str().encode_wide().collect::<Vec<_>>(),
2828 expected
2829 );
2830 }
2831
2832 #[test]
2833 fn resolve_api_key_treats_empty_env_as_unset() {
2834 let _g = env_guard();
2835 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
2836 unsafe { std::env::set_var(var, "") };
2837 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
2838 unsafe { std::env::remove_var(var) };
2839 assert_eq!(got, "inline");
2840 }
2841
2842 #[test]
2843 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
2844 let _g = env_guard();
2845 let bad = "sk-kimi-very-real-looking-pasted-secret";
2847 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
2848 let msg = err.to_string();
2849 assert!(
2850 msg.contains("invalid") && msg.contains("api_key_env"),
2851 "error should explain misconfiguration: {msg}"
2852 );
2853 assert!(
2854 !msg.contains(bad),
2855 "error must not echo the misconfigured value: {msg}"
2856 );
2857 assert!(msg.contains("valid environment variable name"));
2858 assert!(
2859 msg.contains("[kimi]"),
2860 "error should point at the lowercase TOML section: {msg}"
2861 );
2862 }
2863
2864 #[test]
2865 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
2866 let _g = env_guard();
2867 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
2868 assert_eq!(got, "inline-key");
2869 }
2870
2871 #[test]
2872 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
2873 let _g = env_guard();
2874 let pasted_secret = "sk_pasted_secret";
2877 unsafe { std::env::remove_var(pasted_secret) };
2878 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
2879 assert!(
2880 !err.to_string().contains(pasted_secret),
2881 "error must not echo configured api_key_env values"
2882 );
2883 }
2884
2885 #[test]
2886 fn is_valid_env_var_name_rules() {
2887 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
2889 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
2890 }
2891 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
2893 assert!(
2894 !is_valid_env_var_name(invalid),
2895 "{invalid} should be invalid"
2896 );
2897 }
2898 }
2899
2900 #[test]
2901 fn config_parses_with_inline_api_key_and_primary() {
2902 let f = write_toml(
2903 r#"
2904 [ui]
2905 primary = "openrouter"
2906
2907 [zai]
2908 enabled = true
2909 api_key_env = "MY_ZAI"
2910 api_key = "sk-zai-inline"
2911
2912 [openrouter]
2913 enabled = true
2914 api_key = "sk-or-inline"
2915 "#,
2916 );
2917 let c = Config::load_from(f.path()).unwrap();
2918 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
2919 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
2920 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
2921 }
2922
2923 #[test]
2924 fn openrouter_named_accounts_preserve_the_default_contract() {
2925 let f = write_toml(
2926 r#"
2927 [openrouter]
2928 enabled = true
2929 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
2930 api_key = "default-inline"
2931 show_default_account = false
2932
2933 [[openrouter.accounts]]
2934 label = "work"
2935 api_key_env = "OPENROUTER_WORK_API_KEY"
2936
2937 [[openrouter.accounts]]
2938 label = "personal"
2939 api_key = "personal-inline"
2940 "#,
2941 );
2942 let _g = env_guard();
2943 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
2944 let config = Config::load_from(f.path()).unwrap();
2945 assert!(!config.openrouter.show_default_account);
2946 assert_eq!(config.openrouter.accounts.len(), 2);
2947 assert_eq!(
2948 config.openrouter.resolve_api_key(None).unwrap(),
2949 "default-inline"
2950 );
2951 assert_eq!(
2952 config.openrouter.resolve_api_key(Some("personal")).unwrap(),
2953 "personal-inline"
2954 );
2955 }
2956
2957 #[test]
2958 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
2959 for source in [
2960 r#"
2961 [[openrouter.accounts]]
2962 label = "work"
2963 api_key = "one"
2964 [[openrouter.accounts]]
2965 label = "work"
2966 api_key = "two"
2967 "#,
2968 r#"
2969 [[openrouter.accounts]]
2970 label = "../work"
2971 api_key = "one"
2972 "#,
2973 r#"
2974 [[openrouter.accounts]]
2975 label = "work"
2976 "#,
2977 ] {
2978 let f = write_toml(source);
2979 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
2980 }
2981 }
2982
2983 #[test]
2984 fn openrouter_unknown_account_never_falls_back_to_default_key() {
2985 let mut config = OpenRouterConfig {
2986 api_key: Some("default-secret".into()),
2987 ..OpenRouterConfig::default()
2988 };
2989 config.accounts.push(OpenRouterAccount {
2990 label: "work".into(),
2991 api_key_env: None,
2992 api_key: Some("work-secret".into()),
2993 });
2994 let message = config
2995 .resolve_api_key(Some("missing"))
2996 .unwrap_err()
2997 .to_string();
2998 assert!(message.contains("missing") && message.contains("work"));
2999 assert!(!message.contains("default-secret"));
3000 assert!(!message.contains("work-secret"));
3001 }
3002
3003 #[test]
3004 fn openrouter_account_key_errors_do_not_echo_configured_values() {
3005 let config = OpenRouterConfig {
3006 accounts: vec![OpenRouterAccount {
3007 label: "work".into(),
3008 api_key_env: Some("sk_pasted_secret".into()),
3009 api_key: None,
3010 }],
3011 ..OpenRouterConfig::default()
3012 };
3013 let _g = env_guard();
3014 unsafe { std::env::remove_var("sk_pasted_secret") };
3015 let message = config
3016 .resolve_api_key(Some("work"))
3017 .unwrap_err()
3018 .to_string();
3019 assert!(message.contains("[[openrouter.accounts]]"));
3020 assert!(!message.contains("sk_pasted_secret"));
3021 }
3022
3023 #[test]
3024 fn enabled_vendors_preserves_canonical_order() {
3025 let c = Config::default();
3028 assert_eq!(
3029 c.enabled_vendors(),
3030 vec![
3031 VendorId::Anthropic,
3032 VendorId::Openai,
3033 VendorId::Zai,
3034 VendorId::Openrouter,
3035 ]
3036 );
3037 }
3038
3039 #[test]
3040 fn deepseek_appears_when_enabled() {
3041 let f = write_toml(
3042 r#"
3043 [deepseek]
3044 enabled = true
3045 api_key = "sk-test"
3046 "#,
3047 );
3048 let c = Config::load_from(f.path()).unwrap();
3049 assert!(c.is_enabled(VendorId::Deepseek));
3050 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
3051 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
3052 }
3053
3054 #[test]
3055 fn tilde_paths_are_expanded_on_load() {
3056 let f = write_toml(
3060 r#"
3061 [context]
3062 projects_path = "~/.claude/projects"
3063
3064 [anthropic]
3065 credentials_path = "~/.claude/.credentials.json"
3066
3067 [[anthropic.accounts]]
3068 label = "work"
3069 credentials_path = "~/work.json"
3070 "#,
3071 );
3072 let c = Config::load_from(f.path()).unwrap();
3073 let home = crate::cache::home_dir().unwrap();
3074
3075 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
3076 let got = c.anthropic.credentials_path.unwrap();
3077 assert_eq!(got, home.join(".claude/.credentials.json"));
3078 assert!(!got.to_string_lossy().contains('~'));
3079 assert_eq!(
3080 c.anthropic.accounts[0].credentials_path,
3081 home.join("work.json")
3082 );
3083 }
3084
3085 #[test]
3086 fn absolute_and_relative_paths_are_left_alone() {
3087 let f = write_toml(
3088 r#"
3089 [anthropic]
3090 credentials_path = "/etc/creds.json"
3091 "#,
3092 );
3093 let c = Config::load_from(f.path()).unwrap();
3094 assert_eq!(
3095 c.anthropic.credentials_path.unwrap(),
3096 std::path::Path::new("/etc/creds.json")
3097 );
3098
3099 let f2 = write_toml(
3101 r#"
3102 [anthropic]
3103 credentials_path = "~someone/creds.json"
3104 "#,
3105 );
3106 let c2 = Config::load_from(f2.path()).unwrap();
3107 assert_eq!(
3108 c2.anthropic.credentials_path.unwrap(),
3109 std::path::Path::new("~someone/creds.json")
3110 );
3111 }
3112
3113 #[test]
3114 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
3115 let _g = path_override_guard();
3116 let p = resolved_path().expect("a config path must resolve");
3119 assert!(p.ends_with("config.toml"));
3120 let canonical = default_path().unwrap();
3121 let legacy = legacy_xdg_path().unwrap();
3122 assert!(
3123 p == canonical || p == legacy,
3124 "resolved to an unexpected location: {}",
3125 p.display()
3126 );
3127 }
3128
3129 #[test]
3130 fn misspelled_section_is_rejected_not_ignored() {
3131 let f = write_toml(
3134 r#"
3135 [openrouer]
3136 enabled = true
3137 api_key = "sk-or-v1-typo"
3138 "#,
3139 );
3140 let err = Config::load_from(f.path()).unwrap_err().to_string();
3141 assert!(
3142 err.contains("openrouer"),
3143 "error should name the typo: {err}"
3144 );
3145 }
3146
3147 #[test]
3148 fn invalid_toml_is_an_error_not_silent_defaults() {
3149 let f = write_toml("[zai\nenabled = true\n");
3150 assert!(Config::load_from(f.path()).is_err());
3151 }
3152
3153 #[test]
3154 fn a_missing_file_is_still_just_defaults() {
3155 let dir = tempfile::tempdir().unwrap();
3158 let missing = dir.path().join("nope").join("config.toml");
3159 let c = Config::load_from(&missing).unwrap();
3160 assert!(c.is_enabled(VendorId::Anthropic));
3161 }
3162
3163 #[test]
3164 fn kimi_appears_when_enabled() {
3165 let f = write_toml(
3166 r#"
3167 [kimi]
3168 enabled = true
3169 api_key = "sk-test"
3170 "#,
3171 );
3172 let c = Config::load_from(f.path()).unwrap();
3173 assert!(c.is_enabled(VendorId::Kimi));
3174 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
3175 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
3176 }
3177
3178 #[test]
3179 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
3180 let f = write_toml(
3181 r#"
3182 [deepseek]
3183 enabled = true
3184 api_key = "sk-ds"
3185
3186 [kimi]
3187 enabled = true
3188 api_key = "sk-kimi"
3189 "#,
3190 );
3191 let c = Config::load_from(f.path()).unwrap();
3192 assert_eq!(
3193 c.enabled_vendors(),
3194 vec![
3195 VendorId::Anthropic,
3196 VendorId::Openai,
3197 VendorId::Zai,
3198 VendorId::Openrouter,
3199 VendorId::Deepseek,
3200 VendorId::Kimi,
3201 ]
3202 );
3203 }
3204
3205 #[test]
3206 fn parses_anthropic_accounts_and_looks_them_up() {
3207 let f = write_toml(
3208 r#"
3209 [anthropic]
3210 enabled = true
3211
3212 [[anthropic.accounts]]
3213 label = "personal"
3214 credentials_path = "/creds/personal.json"
3215
3216 [[anthropic.accounts]]
3217 label = "work"
3218 credentials_path = "/creds/work.json"
3219 "#,
3220 );
3221 let c = Config::load_from(f.path()).unwrap();
3222 assert_eq!(c.anthropic.accounts.len(), 2);
3223 let work = c.anthropic.account("work").unwrap();
3224 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
3225 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
3227 assert!(err.contains("missing") && err.contains("work"), "{err}");
3228 }
3229
3230 #[test]
3231 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
3232 let f = write_toml(
3233 r#"
3234 [[anthropic.accounts]]
3235 label = "work"
3236 credentials_path = "/creds/work-one.json"
3237
3238 [[anthropic.accounts]]
3239 label = "work"
3240 credentials_path = "/creds/work-two.json"
3241 "#,
3242 );
3243 let err = Config::load_from(f.path()).unwrap_err().to_string();
3244 assert!(
3245 err.contains("duplicate anthropic account label \"work\""),
3246 "{err}"
3247 );
3248 }
3249
3250 #[test]
3251 fn account_label_rejects_path_like_names() {
3252 let cfg = AnthropicConfig::default();
3253 for bad in [
3254 "",
3255 ".",
3256 "..",
3257 "a/b",
3258 r"a\b",
3259 "C:work",
3260 "line\nbreak",
3261 "tab\tname",
3262 "usage.json",
3263 ".stale",
3264 ".last_error",
3265 ".fetch.lock",
3266 ] {
3267 let err = cfg.account(bad).unwrap_err();
3268 assert!(
3269 format!("{err:?}").contains("invalid anthropic account label"),
3270 "{bad:?} should be rejected as a label"
3271 );
3272 }
3273 }
3274
3275 #[test]
3276 fn anthropic_accounts_default_to_empty() {
3277 assert!(Config::default().anthropic.accounts.is_empty());
3280 assert!(Config::default().anthropic.accounts_dir.is_none());
3281 }
3282
3283 fn seed_account_dir(root: &std::path::Path, label: &str) {
3289 let dir = root.join(label);
3290 std::fs::create_dir_all(&dir).unwrap();
3291 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
3292 }
3293
3294 #[test]
3295 fn discovers_account_dirs_in_claude_config_dir_layout() {
3296 let td = tempfile::tempdir().unwrap();
3297 seed_account_dir(td.path(), "work");
3298 seed_account_dir(td.path(), "personal");
3299 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
3302 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
3304
3305 let cfg = AnthropicConfig {
3306 accounts_dir: Some(td.path().to_path_buf()),
3307 ..Default::default()
3308 };
3309 let all = cfg.all_accounts();
3310 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
3311 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
3312 assert_eq!(
3313 all[2].credentials_path,
3314 td.path().join("work").join(".credentials.json")
3315 );
3316 }
3317
3318 #[test]
3319 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
3320 let td = tempfile::tempdir().unwrap();
3321 seed_account_dir(td.path(), "work");
3322 let cfg = AnthropicConfig {
3323 accounts: vec![AnthropicAccount {
3324 label: "work".into(),
3325 credentials_path: "/explicit/work.json".into(),
3326 }],
3327 accounts_dir: Some(td.path().to_path_buf()),
3328 ..Default::default()
3329 };
3330 let all = cfg.all_accounts();
3331 assert_eq!(all.len(), 1, "no duplicate label");
3332 assert_eq!(
3333 all[0].credentials_path,
3334 std::path::Path::new("/explicit/work.json"),
3335 "explicit entry wins"
3336 );
3337 seed_account_dir(td.path(), "other");
3339 assert_eq!(cfg.account("other").unwrap().label, "other");
3340 }
3341
3342 #[test]
3343 fn missing_accounts_dir_is_silently_empty_not_an_error() {
3344 let cfg = AnthropicConfig {
3345 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
3346 ..Default::default()
3347 };
3348 assert!(cfg.all_accounts().is_empty());
3349 }
3350
3351 #[test]
3352 fn openai_account_auth_paths_are_tilde_expanded_on_load() {
3353 let f = write_toml(
3354 r#"
3355 [[openai.accounts]]
3356 label = "work"
3357 codex_auth_path = "~/.codex-work/auth.json"
3358 "#,
3359 );
3360 let c = Config::load_from(f.path()).unwrap();
3361 let home = crate::cache::home_dir().unwrap();
3362 assert_eq!(
3363 c.openai.accounts[0].codex_auth_path,
3364 home.join(".codex-work/auth.json")
3365 );
3366 }
3367
3368 #[test]
3369 fn accounts_dir_is_tilde_expanded_on_load() {
3370 let f = write_toml(
3371 r#"
3372 [anthropic]
3373 accounts_dir = "~/.config/ai-usagebar/accounts"
3374 "#,
3375 );
3376 let c = Config::load_from(f.path()).unwrap();
3377 let home = crate::cache::home_dir().unwrap();
3378 assert_eq!(
3379 c.anthropic.accounts_dir,
3380 Some(home.join(".config/ai-usagebar/accounts"))
3381 );
3382 }
3383
3384 #[test]
3385 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
3386 let f = write_toml(
3387 r#"
3388 [anthropic]
3389 desktop_profiles_dir = "~/.claude-acc/profiles"
3390 "#,
3391 );
3392 let c = Config::load_from(f.path()).unwrap();
3393 let home = crate::cache::home_dir().unwrap();
3394 assert_eq!(
3395 c.anthropic.desktop_profiles_dir,
3396 Some(home.join(".claude-acc/profiles"))
3397 );
3398 }
3399
3400 #[test]
3401 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
3402 let cfg = AnthropicConfig {
3403 accounts: vec![
3404 AnthropicAccount {
3405 label: "work".into(),
3406 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
3407 },
3408 AnthropicAccount {
3409 label: "personal".into(),
3410 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
3411 },
3412 ],
3413 ..Default::default()
3414 };
3415
3416 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
3417 assert!(
3418 matches!(&idle, CredsTarget::Named { config_dir, .. }
3419 if config_dir == std::path::Path::new("/tmp/accounts/work")),
3420 "{idle:?}"
3421 );
3422
3423 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
3425 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
3426
3427 assert_eq!(idle_cache.dir(), live_cache.dir());
3430 }
3431
3432 #[test]
3433 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
3434 let cfg = AnthropicConfig {
3435 accounts: vec![AnthropicAccount {
3436 label: "work".into(),
3437 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
3438 }],
3439 ..Default::default()
3440 };
3441 let (target, _) = cfg.account_target_with("work", None).unwrap();
3442 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
3443 }
3444
3445 fn config_example() -> PathBuf {
3449 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
3450 }
3451
3452 #[test]
3453 fn shipped_example_parses_as_a_real_config() {
3454 let c = Config::load_from(&config_example()).unwrap();
3459 assert!(!c.context.enabled);
3460 assert!(c.is_enabled(VendorId::Anthropic));
3461 assert!(c.is_enabled(VendorId::Openai));
3462 assert!(!c.is_enabled(VendorId::AnthropicApi));
3463 assert!(!c.is_enabled(VendorId::Deepseek));
3464 assert!(!c.is_enabled(VendorId::Kimi));
3465 assert!(!c.is_enabled(VendorId::Kilo));
3466 assert!(!c.is_enabled(VendorId::Novita));
3467 assert!(!c.is_enabled(VendorId::Moonshot));
3468 assert!(!c.is_enabled(VendorId::Grok));
3469 assert!(!c.is_enabled(VendorId::Cursor));
3470 assert!(!c.is_enabled(VendorId::Minimax));
3471 }
3472
3473 #[test]
3474 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
3475 let text = std::fs::read_to_string(config_example()).unwrap();
3480 let live: Vec<&str> = text
3481 .lines()
3482 .map(str::trim)
3483 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
3484 .collect();
3485 assert!(
3486 live.is_empty(),
3487 "admin_key_env must stay commented out while it is inert: {live:?}"
3488 );
3489 assert!(
3492 text.contains("admin_key_env") && text.contains("RESERVED"),
3493 "the example should keep describing admin_key_env as reserved"
3494 );
3495 }
3496
3497 #[test]
3498 fn admin_key_env_is_accepted_but_changes_nothing() {
3499 let f = write_toml(
3503 r#"
3504 [openai]
3505 admin_key_env = "SOME_ADMIN_KEY"
3506 "#,
3507 );
3508 let c = Config::load_from(f.path()).unwrap();
3509 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
3510 let default = OpenAiConfig::default();
3512 assert_eq!(c.openai.enabled, default.enabled);
3513 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
3514 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
3515 }
3516
3517 #[test]
3518 fn config_example_documents_every_vendor_without_secrets() {
3519 let raw = std::fs::read_to_string(config_example()).unwrap();
3520 let cfg = Config::load_from(&config_example()).unwrap();
3521 for id in VendorId::all() {
3524 let section = id.slug();
3525 assert!(
3526 raw.contains(&format!("[{section}]")),
3527 "config.example.toml has no [{section}] section"
3528 );
3529 }
3530
3531 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
3534 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
3535 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
3536 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
3537 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
3538 assert!(!cfg.supergrok.enabled);
3539 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
3540 assert_eq!(
3541 cfg.supergrok
3542 .grok_binary
3543 .file_name()
3544 .and_then(|p| p.to_str()),
3545 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
3546 );
3547 assert!(cfg.supergrok.auth_path.is_none());
3548 assert!(cfg.supergrok.config_path.is_none());
3549 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
3550 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
3551 }
3552
3553 #[test]
3554 fn supergrok_binary_must_not_be_empty() {
3555 let file = write_toml(
3556 r#"
3557 [supergrok]
3558 enabled = true
3559 grok_binary = ""
3560 "#,
3561 );
3562 let error = Config::load_from(file.path()).unwrap_err().to_string();
3563 assert!(error.contains("grok_binary must not be empty"));
3564 }
3565
3566 #[test]
3567 fn supergrok_paths_are_tilde_expanded() {
3568 let file = write_toml(
3569 r#"
3570 [supergrok]
3571 grok_binary = "~/bin/grok"
3572 auth_path = "~/.grok/auth.json"
3573 config_path = "~/.grok/config.toml"
3574 "#,
3575 );
3576 let config = Config::load_from(file.path()).unwrap();
3577 let home = crate::cache::home_dir().unwrap();
3578 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
3579 assert_eq!(
3580 config.supergrok.auth_path,
3581 Some(home.join(".grok/auth.json"))
3582 );
3583 assert_eq!(
3584 config.supergrok.config_path,
3585 Some(home.join(".grok/config.toml"))
3586 );
3587 }
3588
3589 #[test]
3590 fn kiro_db_path_is_tilde_expanded() {
3591 let f = write_toml(
3592 r#"
3593 [kiro]
3594 db_path = "~/kiro-data.sqlite3"
3595 "#,
3596 );
3597 let c = Config::load_from(f.path()).unwrap();
3598 let home = crate::cache::home_dir().unwrap();
3599 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
3600 }
3601
3602 #[test]
3603 fn kiro_appears_when_enabled() {
3604 let f = write_toml(
3605 r#"
3606 [kiro]
3607 enabled = true
3608 "#,
3609 );
3610 let c = Config::load_from(f.path()).unwrap();
3611 assert!(c.is_enabled(VendorId::Kiro));
3612 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
3613 }
3614
3615 #[test]
3616 fn cursor_db_path_is_tilde_expanded() {
3617 let f = write_toml(
3618 r#"
3619 [cursor]
3620 db_path = "~/cursor-state.vscdb"
3621 "#,
3622 );
3623 let c = Config::load_from(f.path()).unwrap();
3624 let home = crate::cache::home_dir().unwrap();
3625 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
3626 }
3627
3628 #[test]
3629 fn cursor_agent_auth_path_is_tilde_expanded() {
3630 let f = write_toml(
3631 r#"
3632 [cursor]
3633 agent_auth_path = "~/cursor-agent-auth.json"
3634 "#,
3635 );
3636 let c = Config::load_from(f.path()).unwrap();
3637 let home = crate::cache::home_dir().unwrap();
3638 assert_eq!(
3639 c.cursor.agent_auth_path,
3640 Some(home.join("cursor-agent-auth.json"))
3641 );
3642 }
3643
3644 #[test]
3645 fn cursor_appears_when_enabled() {
3646 let f = write_toml(
3647 r#"
3648 [cursor]
3649 enabled = true
3650 "#,
3651 );
3652 let c = Config::load_from(f.path()).unwrap();
3653 assert!(c.is_enabled(VendorId::Cursor));
3654 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
3655 }
3656
3657 #[test]
3658 fn add_account_appends_and_preserves_existing() {
3659 let mut doc: toml_edit::DocumentMut = r#"
3660# keep me
3661[anthropic]
3662enabled = true
3663
3664[[anthropic.accounts]]
3665label = "personal"
3666credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
3667"#
3668 .parse()
3669 .unwrap();
3670 add_anthropic_account_to_doc(
3671 &mut doc,
3672 "work",
3673 "~/.config/ai-usagebar/accounts/work/.credentials.json",
3674 )
3675 .unwrap();
3676 let rendered = doc.to_string();
3677 assert!(rendered.contains("# keep me"), "comment must survive");
3678 let f = write_toml(&rendered);
3680 let c = Config::load_from(f.path()).unwrap();
3681 let labels: Vec<&str> = c
3682 .anthropic
3683 .accounts
3684 .iter()
3685 .map(|a| a.label.as_str())
3686 .collect();
3687 assert_eq!(labels, vec!["personal", "work"]);
3688 }
3689
3690 #[test]
3691 fn add_account_to_empty_doc_is_loadable() {
3692 let mut doc = toml_edit::DocumentMut::new();
3693 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
3694 let f = write_toml(&doc.to_string());
3695 let c = Config::load_from(f.path()).unwrap();
3696 assert_eq!(c.anthropic.accounts.len(), 1);
3697 assert_eq!(c.anthropic.accounts[0].label, "solo");
3698 }
3699
3700 #[test]
3701 fn add_account_rejects_duplicate_label() {
3702 let mut doc: toml_edit::DocumentMut = r#"
3703[[anthropic.accounts]]
3704label = "work"
3705credentials_path = "~/w/.credentials.json"
3706"#
3707 .parse()
3708 .unwrap();
3709 assert!(
3710 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
3711 "a duplicate label must be rejected, not appended"
3712 );
3713 }
3714
3715 #[test]
3716 fn add_account_rejects_bad_label() {
3717 let mut doc = toml_edit::DocumentMut::new();
3718 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
3719 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
3720 }
3721
3722 #[test]
3723 fn tildify_collapses_home_only() {
3724 let home = Path::new("/Users/me");
3725 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
3726 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
3727 }
3728
3729 #[test]
3730 fn default_account_credentials_path_nests_under_config_dir() {
3731 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
3732 assert_eq!(
3733 default_account_credentials_path(cfg, "work"),
3734 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
3735 );
3736 }
3737 const CUSTOM_BLOCK: &str = r#"
3740[[custom]]
3741id = "mytool"
3742name = "My Tool"
3743short_name = "myt"
3744enabled = true
3745url = "https://api.example.test/v1/usage"
3746api_key_env = "MYTOOL_API_KEY"
3747auth_header = "Authorization"
3748auth_scheme = "Bearer"
3749plan = "Pro"
3750cache_ttl_secs = 120
3751[custom.headers]
3752X-Org = "org_1"
3753[[custom.metrics]]
3754label = "Requests"
3755used = "/requests/used"
3756limit = "/requests/limit"
3757resets_at = "/requests/reset"
3758window_secs = 3600
3759[[custom.texts]]
3760label = "Tier"
3761value = "/tier"
3762"#;
3763
3764 fn custom_with(from: &str, to: &str) -> String {
3765 assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
3766 CUSTOM_BLOCK.replace(from, to)
3767 }
3768
3769 fn custom_error(toml: &str) -> String {
3770 Config::load_from(write_toml(toml).path())
3771 .unwrap_err()
3772 .to_string()
3773 }
3774
3775 fn assert_custom_rejected(toml: &str, needle: &str) {
3776 let msg = custom_error(toml);
3777 assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
3778 assert!(
3779 msg.contains("[[custom]]"),
3780 "the error must locate the section: {msg}"
3781 );
3782 }
3783
3784 #[test]
3785 fn custom_block_parses_every_field() {
3786 let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
3787 assert_eq!(config.custom.len(), 1);
3788 let c = &config.custom[0];
3789 assert_eq!(c.id, "mytool");
3790 assert_eq!(c.name, "My Tool");
3791 assert_eq!(c.short_name, "myt");
3792 assert_eq!(
3793 c.brand, None,
3794 "a custom provider has no mark unless it asks"
3795 );
3796 assert!(c.enabled);
3797 assert_eq!(c.url, "https://api.example.test/v1/usage");
3798 assert!(!c.allow_http);
3799 assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
3800 assert_eq!(c.api_key, None);
3801 assert_eq!(c.auth_header, "Authorization");
3802 assert_eq!(c.auth_scheme, "Bearer");
3803 assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
3804 assert_eq!(c.plan.as_deref(), Some("Pro"));
3805 assert_eq!(c.plan_path, None);
3806 assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
3807 assert_eq!(c.metrics.len(), 1);
3808 assert_eq!(c.metrics[0].label, "Requests");
3809 assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
3810 assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
3811 assert_eq!(c.metrics[0].percent, None);
3812 assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
3813 assert_eq!(c.metrics[0].window_secs, Some(3600));
3814 assert_eq!(c.texts.len(), 1);
3815 assert_eq!(c.texts[0].label, "Tier");
3816 assert_eq!(c.texts[0].value, "/tier");
3817 assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
3818 }
3819
3820 #[test]
3821 fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
3822 let config: Config = toml::from_str(
3823 r#"
3824 [[custom]]
3825 id = "bare"
3826 short_name = "bre"
3827 url = "https://example.test/u"
3828 [[custom.metrics]]
3829 label = "Q"
3830 percent = "/pct"
3831 "#,
3832 )
3833 .unwrap();
3834 let c = &config.custom[0];
3835 assert_eq!(c.name, "bare", "name must default to id on a plain parse");
3836 assert!(!c.enabled);
3837 assert!(!c.allow_http);
3838 assert_eq!(c.api_key_env, "");
3839 assert_eq!(c.auth_header, "Authorization");
3840 assert_eq!(c.auth_scheme, "Bearer");
3841 assert_eq!(c.cache_ttl_secs, 60);
3842 assert!(config.validate().is_ok());
3843 assert!(Config::default().custom.is_empty());
3844 }
3845
3846 #[test]
3847 fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
3848 let config = Config::load_from(
3849 write_toml(&custom_with(
3850 r#"short_name = "myt""#,
3851 "short_name = \"myt\"\nbrand = \"opencode-go\"",
3852 ))
3853 .path(),
3854 )
3855 .unwrap();
3856 assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
3857
3858 for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
3862 assert_custom_rejected(
3863 &custom_with(
3864 r#"short_name = "myt""#,
3865 &format!("short_name = \"myt\"\nbrand = {brand:?}"),
3866 ),
3867 "must name a built-in vendor",
3868 );
3869 }
3870 }
3871
3872 #[test]
3873 fn custom_rejects_a_malformed_id() {
3874 let long = "a".repeat(33);
3875 for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
3876 let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
3877 assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
3878 assert!(msg.contains("must match"), "{id:?}: {msg}");
3879 }
3880 }
3881
3882 #[test]
3883 fn custom_rejects_a_builtin_slug_as_id() {
3884 assert_custom_rejected(
3885 &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
3886 "is a built-in vendor",
3887 );
3888 assert_custom_rejected(
3889 &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
3890 "is a built-in vendor",
3891 );
3892 }
3893
3894 #[test]
3895 fn custom_rejects_duplicate_ids() {
3896 let twice = format!(
3897 "{}{}",
3898 CUSTOM_BLOCK,
3899 custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
3900 );
3901 assert_custom_rejected(&twice, "duplicate id");
3902 }
3903
3904 #[test]
3905 fn custom_rejects_a_name_over_48_chars() {
3906 let long = "n".repeat(49);
3907 assert_custom_rejected(
3908 &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
3909 "name must be 1 to 48 characters",
3910 );
3911 }
3912
3913 #[test]
3914 fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
3915 for short in ["my", "myto", "MYT", "m1t"] {
3916 assert_custom_rejected(
3917 &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
3918 "exactly 3 lowercase ASCII letters",
3919 );
3920 }
3921 }
3922
3923 #[test]
3924 fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
3925 assert_custom_rejected(
3926 &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
3927 "already used by a built-in vendor",
3928 );
3929 let twice = format!(
3930 "{}{}",
3931 CUSTOM_BLOCK,
3932 custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
3933 );
3934 assert_custom_rejected(&twice, "already used by a built-in vendor");
3935 }
3936
3937 #[test]
3938 fn custom_rejects_http_unless_allowed() {
3939 let plain = custom_with(
3940 r#"url = "https://api.example.test/v1/usage""#,
3941 r#"url = "http://localhost:8080/usage""#,
3942 );
3943 assert_custom_rejected(&plain, "url must use https://");
3944 let allowed = plain.replace(
3945 r#"url = "http://localhost:8080/usage""#,
3946 "url = \"http://localhost:8080/usage\"\nallow_http = true",
3947 );
3948 assert!(
3949 Config::load_from(write_toml(&allowed).path()).is_ok(),
3950 "allow_http must permit http://"
3951 );
3952 }
3953
3954 #[test]
3955 fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
3956 assert_custom_rejected(
3957 &custom_with(
3958 r#"url = "https://api.example.test/v1/usage""#,
3959 r#"url = "https://user:pw@api.example.test/v1/usage""#,
3960 ),
3961 "must not carry credentials",
3962 );
3963 assert_custom_rejected(
3964 &custom_with(
3965 r#"url = "https://api.example.test/v1/usage""#,
3966 r#"url = "not a url""#,
3967 ),
3968 "is not a valid URL",
3969 );
3970 assert_custom_rejected(
3971 &custom_with(
3972 r#"url = "https://api.example.test/v1/usage""#,
3973 r#"url = "ftp://api.example.test/v1/usage""#,
3974 ),
3975 "is not http or https",
3976 );
3977 }
3978
3979 #[test]
3980 fn custom_rejects_an_invalid_api_key_env() {
3981 assert_custom_rejected(
3982 &custom_with(
3983 r#"api_key_env = "MYTOOL_API_KEY""#,
3984 r#"api_key_env = "1BAD-NAME""#,
3985 ),
3986 "is not a valid environment variable name",
3987 );
3988 let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
3989 assert!(
3990 Config::load_from(write_toml(&none).path()).is_ok(),
3991 "an empty api_key_env means inline-only and is valid"
3992 );
3993 }
3994
3995 #[test]
3996 fn custom_rejects_an_invalid_auth_header_name() {
3997 assert_custom_rejected(
3998 &custom_with(
3999 r#"auth_header = "Authorization""#,
4000 r#"auth_header = "X Api Key""#,
4001 ),
4002 "auth_header \"X Api Key\" is not a valid HTTP header name",
4003 );
4004 }
4005
4006 #[test]
4007 fn custom_rejects_a_control_char_in_auth_scheme() {
4008 assert_custom_rejected(
4009 &custom_with(
4010 r#"auth_scheme = "Bearer""#,
4011 "auth_scheme = \"Bearer\\u0007\"",
4012 ),
4013 "auth_scheme contains characters that are not valid",
4014 );
4015 let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
4016 assert!(
4017 Config::load_from(write_toml(&bare).path()).is_ok(),
4018 "an empty scheme (bare key) is valid"
4019 );
4020 }
4021
4022 #[test]
4023 fn custom_rejects_a_bad_extra_header() {
4024 assert_custom_rejected(
4025 &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
4026 "headers must not repeat auth_header",
4027 );
4028 assert_custom_rejected(
4029 &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
4030 "is not a valid HTTP header name",
4031 );
4032 assert_custom_rejected(
4033 &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
4034 "has a value that is not valid in an HTTP header",
4035 );
4036 }
4037
4038 #[test]
4039 fn custom_rejects_cache_ttl_outside_10_to_3600() {
4040 for ttl in ["9", "3601"] {
4041 assert_custom_rejected(
4042 &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
4043 "cache_ttl_secs must be between 10 and 3600",
4044 );
4045 }
4046 }
4047
4048 #[test]
4049 fn custom_rejects_an_entry_with_no_metrics_or_texts() {
4050 let toml = r#"
4051[[custom]]
4052id = "empty"
4053short_name = "emp"
4054url = "https://example.test/u"
4055"#;
4056 assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
4057 }
4058
4059 #[test]
4060 fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
4061 assert_custom_rejected(
4062 &custom_with(
4063 r#"limit = "/requests/limit""#,
4064 "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
4065 ),
4066 "must set `percent`, or both `used` and `limit`",
4067 );
4068 assert_custom_rejected(
4069 &custom_with("limit = \"/requests/limit\"\n", ""),
4070 "must set `percent`, or both `used` and `limit`",
4071 );
4072 }
4073
4074 #[test]
4075 fn custom_rejects_a_pointer_without_a_leading_slash() {
4076 assert_custom_rejected(
4077 &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
4078 "used \"requests.used\" must be an RFC 6901 JSON Pointer",
4079 );
4080 assert_custom_rejected(
4081 &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
4082 "value \"tier\" must be an RFC 6901 JSON Pointer",
4083 );
4084 assert_custom_rejected(
4085 &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
4086 "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
4087 );
4088 assert_custom_rejected(
4089 &custom_with(
4090 r#"resets_at = "/requests/reset""#,
4091 "resets_at = \"/re\\u001bset\"",
4092 ),
4093 "resets_at",
4094 );
4095 }
4096
4097 #[test]
4098 fn custom_rejects_a_label_outside_1_to_64_chars() {
4099 let long = "l".repeat(65);
4100 assert_custom_rejected(
4101 &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
4102 "metric label",
4103 );
4104 assert_custom_rejected(
4105 &custom_with(r#"label = "Tier""#, r#"label = """#),
4106 "text label \"\" must be 1 to 64 characters",
4107 );
4108 }
4109
4110 #[test]
4111 fn custom_rejects_window_secs_under_60() {
4112 assert_custom_rejected(
4113 &custom_with("window_secs = 3600", "window_secs = 59"),
4114 "window_secs must be at least 60",
4115 );
4116 }
4117
4118 #[test]
4119 fn custom_rejects_duplicate_metric_and_text_labels() {
4120 let metric_twice = custom_with(
4121 "window_secs = 3600\n",
4122 "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
4123 );
4124 assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
4125 let text_twice =
4126 format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
4127 assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
4128 }
4129
4130 #[test]
4131 fn enabled_custom_and_custom_by_id_select_entries() {
4132 let two = format!(
4133 "{}{}",
4134 CUSTOM_BLOCK,
4135 custom_with(r#"id = "mytool""#, r#"id = "off""#)
4136 .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
4137 .replace("enabled = true", "enabled = false")
4138 );
4139 let config = Config::load_from(write_toml(&two).path()).unwrap();
4140 let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
4141 assert_eq!(enabled, ["mytool"]);
4142 assert_eq!(
4143 config.custom_by_id("off").map(|c| c.name.as_str()),
4144 Some("My Tool")
4145 );
4146 assert!(config.custom_by_id("nope").is_none());
4147 }
4148
4149 #[cfg(unix)]
4150 #[test]
4151 fn has_inline_secrets_sees_a_custom_inline_key() {
4152 let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
4153 assert!(!without.has_inline_secrets());
4154 let with: Config = toml::from_str(&custom_with(
4155 r#"api_key_env = "MYTOOL_API_KEY""#,
4156 "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
4157 ))
4158 .unwrap();
4159 assert!(with.has_inline_secrets());
4160 }
4161
4162 #[test]
4163 fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
4164 let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
4165 let mut spec = CustomProviderConfig {
4166 id: "mytool".into(),
4167 api_key_env: var.into(),
4168 api_key: Some("sk-inline-secret".into()),
4169 ..CustomProviderConfig::default()
4170 };
4171 unsafe { std::env::set_var(var, "sk-env-secret") };
4172 let from_env = spec.resolve_api_key();
4173 unsafe { std::env::remove_var(var) };
4174 assert_eq!(from_env.unwrap(), "sk-env-secret");
4175
4176 assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
4177
4178 spec.api_key = Some(String::new());
4179 let err = spec.resolve_api_key().unwrap_err();
4180 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
4181 let msg = err.to_string();
4182 assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
4183 assert!(msg.contains(var), "{msg}");
4184 assert!(!msg.contains("secret"), "{msg}");
4185
4186 spec.api_key_env = String::new();
4187 let msg = spec.resolve_api_key().unwrap_err().to_string();
4188 assert!(msg.contains("set `api_key`"), "{msg}");
4189 }
4190
4191 #[test]
4192 fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
4193 let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
4194 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
4195 let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
4196 Config::load_from(file.path()).unwrap();
4197 assert!(
4198 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
4199 "a custom provider's env var must be scrubbed from subprocesses"
4200 );
4201 }
4202
4203 #[test]
4208 fn every_config_section_parses_to_its_vendors_enabled_switch() {
4209 for vendor in VendorId::all() {
4210 let text = format!(
4211 "[{}]
4212enabled = true
4213",
4214 vendor.config_section()
4215 );
4216 let config: Config = toml::from_str(&text)
4217 .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
4218 assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
4219 let others = VendorId::all()
4220 .iter()
4221 .filter(|other| *other != vendor && config.is_enabled(**other))
4222 .count();
4223 assert_eq!(
4224 others,
4225 Config::default().enabled_vendors().len()
4226 - usize::from(Config::default().is_enabled(*vendor)),
4227 "[{}] enabled a different vendor",
4228 vendor.config_section()
4229 );
4230 }
4231 }
4232
4233 #[test]
4234 fn tray_section_parses_and_defaults_to_notify() {
4235 let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
4236 let config = Config::load_from(file.path()).unwrap();
4237 assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
4238 assert_eq!(config.tray.updates(), UpdateMode::Auto);
4239
4240 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4241 assert_eq!(empty.tray, TrayConfig::default());
4242 assert_eq!(empty.tray.updates(), UpdateMode::Notify);
4243 assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
4244 assert_eq!(UpdateMode::parse("weekly"), None);
4245 assert_eq!(UpdateMode::Auto.as_str(), "auto");
4246 }
4247
4248 #[test]
4249 fn tray_section_rejects_a_misspelled_mode() {
4250 let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
4251 assert!(Config::load_from(file.path()).is_err());
4252 }
4253
4254 #[test]
4255 fn tray_refresh_minutes_defaults_to_five_and_parses() {
4256 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4257 assert_eq!(empty.tray.refresh_minutes, None);
4258 assert_eq!(empty.tray.refresh_minutes(), 5);
4259
4260 let file = write_toml("[tray]\nrefresh_minutes = 10\n");
4261 let config = Config::load_from(file.path()).unwrap();
4262 assert_eq!(config.tray.refresh_minutes(), 10);
4263 }
4264
4265 #[test]
4266 fn tray_refresh_minutes_rejects_values_outside_the_menu() {
4267 for minutes in ["3", "0"] {
4268 let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
4269 let error = Config::load_from(file.path()).unwrap_err().to_string();
4270 assert!(error.contains("[tray] refresh_minutes"), "{error}");
4271 assert!(error.contains("1, 5 or 10"), "{error}");
4272 }
4273 }
4274
4275 #[test]
4276 fn set_tray_value_writes_refresh_minutes_as_an_integer() {
4277 let dir = tempfile::tempdir().unwrap();
4278 let path = dir.path().join("config.toml");
4279 std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
4280
4281 set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
4282 let text = std::fs::read_to_string(&path).unwrap();
4283 assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
4284 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
4285
4286 set_tray_value(&path, "refresh_minutes", None).unwrap();
4287 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
4288 }
4289
4290 #[test]
4291 fn set_tray_value_creates_replaces_and_removes_keys() {
4292 let dir = tempfile::tempdir().unwrap();
4293 let path = dir.path().join("config.toml");
4294 std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
4295
4296 set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
4297 let text = std::fs::read_to_string(&path).unwrap();
4298 assert!(text.contains("# primary = \"anthropic\""), "{text}");
4299 assert!(
4300 text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
4301 "{text}"
4302 );
4303
4304 set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
4305 set_tray_value(&path, "updates", Some("off".into())).unwrap();
4306 let config = Config::load_from(&path).unwrap();
4307 assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
4308 assert_eq!(config.tray.updates(), UpdateMode::Off);
4309
4310 set_tray_value(&path, "shortcut", None).unwrap();
4311 let text = std::fs::read_to_string(&path).unwrap();
4312 assert!(!text.contains("shortcut"), "{text}");
4313 assert!(text.contains("updates = \"off\""), "{text}");
4314
4315 let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
4317 set_tray_value(&path, "shortcut", None).unwrap();
4318 assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
4319 }
4320
4321 #[test]
4322 fn set_value_keeps_the_trailing_comment_when_replacing() {
4323 let mut doc: toml_edit::DocumentMut =
4324 "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
4325 set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
4326 assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
4327 }
4328
4329 #[test]
4330 fn enable_vendors_in_creates_a_missing_config() {
4331 let dir = tempfile::TempDir::new().unwrap();
4332 let path = dir.path().join("sub").join("config.toml");
4333
4334 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4335
4336 assert_eq!(
4337 std::fs::read_to_string(&path).unwrap(),
4338 "[grok]
4339enabled = true
4340"
4341 );
4342 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
4343 }
4344
4345 #[test]
4346 fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
4347 let dir = tempfile::TempDir::new().unwrap();
4348 let path = dir.path().join("config.toml");
4349 let original = "# my settings
4350[zai]
4351api_key = \"x\" # keep
4352enabled = false
4353";
4354 std::fs::write(&path, original).unwrap();
4355
4356 enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
4357
4358 let text = std::fs::read_to_string(&path).unwrap();
4359 assert!(
4360 text.starts_with(
4361 "# my settings
4362"
4363 ),
4364 "{text}"
4365 );
4366 assert!(
4367 text.contains(
4368 "api_key = \"x\" # keep
4369"
4370 ),
4371 "{text}"
4372 );
4373 assert!(
4374 text.contains(
4375 "[grok]
4376enabled = true
4377"
4378 ),
4379 "{text}"
4380 );
4381 assert!(
4382 text.contains(
4383 "[opencode-go]
4384enabled = true
4385"
4386 ),
4387 "{text}"
4388 );
4389 let config = Config::load_from(&path).unwrap();
4390 assert!(
4391 !config.is_enabled(VendorId::Zai),
4392 "never widens to false, never flips others"
4393 );
4394 assert!(config.is_enabled(VendorId::Grok));
4395 assert!(config.is_enabled(VendorId::OpenCodeGo));
4396 }
4397
4398 #[test]
4399 fn enable_vendors_in_leaves_an_explicit_false_alone() {
4400 let dir = tempfile::TempDir::new().unwrap();
4401 let path = dir.path().join("config.toml");
4402 let original = "[grok]
4403enabled = false # off
4404api_key = \"k\"
4405";
4406 std::fs::write(&path, original).unwrap();
4407
4408 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4412
4413 assert!(written.is_empty(), "{written:?}");
4414 assert_eq!(
4415 std::fs::read_to_string(&path).unwrap(),
4416 original,
4417 "the file must not be rewritten at all"
4418 );
4419 }
4420
4421 #[test]
4422 fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
4423 let dir = tempfile::TempDir::new().unwrap();
4424 let path = dir.path().join("config.toml");
4425 std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
4426
4427 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4428
4429 assert_eq!(written, vec![VendorId::Grok]);
4430 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
4431 }
4432
4433 #[test]
4434 fn enable_vendors_in_is_textually_idempotent() {
4435 let dir = tempfile::TempDir::new().unwrap();
4436 let path = dir.path().join("config.toml");
4437 let original = "[grok]
4438enabled = true
4439
4440# trailing
4441";
4442 std::fs::write(&path, original).unwrap();
4443 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
4444
4445 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4446
4447 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
4448 assert_eq!(
4449 std::fs::metadata(&path).unwrap().modified().unwrap(),
4450 before,
4451 "an unchanged document must not be rewritten"
4452 );
4453 }
4454
4455 #[test]
4456 fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
4457 let dir = tempfile::TempDir::new().unwrap();
4458 let path = dir.path().join("config.toml");
4459
4460 enable_vendors_in(&path, &[]).unwrap();
4461
4462 assert!(!path.exists());
4463 }
4464}