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