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