1use std::collections::{BTreeMap, HashSet};
22use std::path::{Path, PathBuf};
23
24#[cfg(unix)]
25use std::os::unix::fs::{MetadataExt, PermissionsExt};
26
27use serde::{Deserialize, Serialize};
28
29use crate::anthropic::creds::CredsTarget;
30use crate::balance::{DisplayPrefs, Headline};
31use crate::cache::Cache;
32use crate::error::{AppError, Result};
33use crate::vendor::VendorId;
34
35#[derive(Debug, Clone, Default, Deserialize, Serialize)]
42#[serde(default, deny_unknown_fields)]
43pub struct Config {
44 pub ui: UiConfig,
45 pub tray: TrayConfig,
46 pub context: ContextConfig,
47 pub anthropic: AnthropicConfig,
48 pub anthropic_api: AnthropicApiConfig,
49 pub openai: OpenAiConfig,
50 pub copilot: CopilotConfig,
51 pub zai: ZaiConfig,
52 pub openrouter: OpenRouterConfig,
53 pub deepseek: DeepseekConfig,
54 pub kimi: KimiConfig,
55 pub kilo: KiloConfig,
56 pub novita: NovitaConfig,
57 pub moonshot: MoonshotConfig,
58 pub grok: GrokConfig,
59 pub supergrok: SuperGrokConfig,
60 pub grokbot: GrokbotConfig,
61 pub antigravity: AntigravityConfig,
62 pub cursor: CursorConfig,
63 pub minimax: MinimaxConfig,
64 pub kiro: KiroConfig,
65 pub nous: NousConfig,
66 #[serde(rename = "opencode-go")]
67 pub opencode_go: OpenCodeGoConfig,
68 pub commandcode: CommandCodeConfig,
69 pub ollama: OllamaConfig,
70 pub orcarouter: OrcaRouterConfig,
71 pub modelstudio: ModelStudioConfig,
72 pub notifications: NotificationsConfig,
74 pub custom: Vec<CustomProviderConfig>,
76}
77
78#[derive(Debug, Clone, Default, Deserialize, Serialize)]
82#[serde(default)]
83pub struct UiConfig {
84 pub primary: Option<VendorId>,
86 pub overview_vendors: Option<Vec<VendorId>>,
90 pub vendor_box: Option<VendorBoxStyle>,
92}
93
94impl UiConfig {
95 pub fn vendor_box(&self) -> VendorBoxStyle {
96 self.vendor_box.unwrap_or_default()
97 }
98}
99
100#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
104#[serde(default)]
105pub struct TrayConfig {
106 pub shortcut: Option<String>,
109 pub refresh_minutes: Option<u64>,
112 pub updates: Option<UpdateMode>,
114 pub menu_bar_style: Option<String>,
116}
117
118pub const TRAY_REFRESH_MINUTES: [u64; 3] = [1, 5, 10];
121const DEFAULT_TRAY_REFRESH_MINUTES: u64 = 5;
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
127#[serde(default)]
128pub struct NotificationsConfig {
129 pub enabled: bool,
130 pub threshold: u8,
133}
134
135impl Default for NotificationsConfig {
136 fn default() -> Self {
137 Self {
138 enabled: true,
139 threshold: 97,
140 }
141 }
142}
143
144impl TrayConfig {
145 pub fn refresh_minutes(&self) -> u64 {
146 self.refresh_minutes.unwrap_or(DEFAULT_TRAY_REFRESH_MINUTES)
147 }
148
149 pub fn updates(&self) -> UpdateMode {
150 self.updates.unwrap_or_default()
151 }
152}
153
154#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
157#[serde(rename_all = "lowercase")]
158pub enum UpdateMode {
159 Auto,
160 #[default]
161 Notify,
162 Off,
163}
164
165impl UpdateMode {
166 pub fn as_str(self) -> &'static str {
167 match self {
168 Self::Auto => "auto",
169 Self::Notify => "notify",
170 Self::Off => "off",
171 }
172 }
173
174 pub fn parse(text: &str) -> Option<Self> {
175 match text.trim().to_ascii_lowercase().as_str() {
176 "auto" => Some(Self::Auto),
177 "notify" => Some(Self::Notify),
178 "off" => Some(Self::Off),
179 _ => None,
180 }
181 }
182}
183
184#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
186#[serde(rename_all = "lowercase")]
187pub enum VendorBoxStyle {
188 #[default]
190 Sidebar,
191 Navbar,
193 None,
195}
196
197#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
200#[serde(rename_all = "lowercase")]
201pub enum ContextLayout {
202 #[default]
204 Full,
205 Split,
207 Bottom,
209}
210
211impl ContextLayout {
212 pub fn next(self) -> Self {
213 match self {
214 ContextLayout::Full => ContextLayout::Split,
215 ContextLayout::Split => ContextLayout::Bottom,
216 ContextLayout::Bottom => ContextLayout::Full,
217 }
218 }
219
220 pub fn label(self) -> &'static str {
221 match self {
222 ContextLayout::Full => "full",
223 ContextLayout::Split => "split",
224 ContextLayout::Bottom => "bottom",
225 }
226 }
227}
228
229#[derive(Debug, Clone, Default, Deserialize, Serialize)]
234#[serde(default)]
235pub struct ContextConfig {
236 pub enabled: bool,
239 pub projects_path: Option<PathBuf>,
241 pub context_window_tokens: Option<u64>,
244 pub model_context_window_tokens: BTreeMap<String, u64>,
247 pub layout: ContextLayout,
249}
250
251impl ContextConfig {
252 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
253 model
254 .and_then(|model| self.model_context_window_tokens.get(model).copied())
255 .filter(|tokens| *tokens > 0)
256 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
257 }
258}
259
260#[derive(Debug, Clone, Deserialize, Serialize)]
261#[serde(default)]
262pub struct AnthropicConfig {
263 pub enabled: bool,
264 pub credentials_path: Option<PathBuf>,
267 pub accounts: Vec<AnthropicAccount>,
271 pub accounts_dir: Option<PathBuf>,
279 pub show_default_account: bool,
285 pub desktop_profiles_dir: Option<PathBuf>,
291}
292
293impl Default for AnthropicConfig {
294 fn default() -> Self {
295 Self {
296 enabled: true,
297 credentials_path: None,
298 accounts: Vec::new(),
299 accounts_dir: None,
300 show_default_account: true,
301 desktop_profiles_dir: None,
302 }
303 }
304}
305
306#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
316pub struct AnthropicAccount {
317 pub label: String,
320 pub credentials_path: PathBuf,
324}
325
326impl AnthropicAccount {
327 pub fn config_dir(&self) -> PathBuf {
332 self.credentials_path
333 .parent()
334 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
335 }
336}
337
338impl AnthropicConfig {
339 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
345 let mut out = self.accounts.clone();
346 if let Some(dir) = &self.accounts_dir {
347 for acct in discover_accounts(dir) {
348 if !out.iter().any(|a| a.label == acct.label) {
349 out.push(acct);
350 }
351 }
352 }
353 out
354 }
355
356 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
361 validate_account_label(label)?;
362 let all = self.all_accounts();
363 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
364 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
365 AppError::Credentials(format!(
366 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
367 known labels: {known:?}"
368 ))
369 })
370 }
371
372 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
383 let active = crate::anthropic::cli_account::home_claude_json()
384 .ok()
385 .and_then(|path| {
386 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
387 });
388 self.account_target_with(label, active.as_deref())
389 }
390
391 pub fn account_target_with(
399 &self,
400 label: &str,
401 cli_active: Option<&str>,
402 ) -> Result<(CredsTarget, Cache)> {
403 self.account_target_probing(label, cli_active, |path| path.exists())
404 }
405
406 pub fn account_target_probing(
426 &self,
427 label: &str,
428 cli_active: Option<&str>,
429 exists: impl Fn(&Path) -> bool,
430 ) -> Result<(CredsTarget, Cache)> {
431 let account = self.account(label)?;
432 let cache = Cache::for_vendor_account("anthropic", label)?;
433 if cli_active == Some(label) && !exists(&account.credentials_path) {
434 return Ok((
435 CredsTarget::Default(crate::anthropic::creds::default_path()?),
436 cache,
437 ));
438 }
439 Ok((
440 CredsTarget::Named {
441 config_dir: account.config_dir(),
442 path: account.credentials_path,
443 },
444 cache,
445 ))
446 }
447}
448
449pub fn validate_account_label(label: &str) -> Result<()> {
455 validate_account_label_for("anthropic", label)
456}
457
458fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
459 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
460 let bad = label.is_empty()
461 || label == "."
462 || label == ".."
463 || label.contains(['/', '\\'])
464 || label.contains(':')
465 || label.chars().any(char::is_control)
466 || RESERVED.contains(&label);
467 if bad {
468 return Err(AppError::Credentials(format!(
469 "invalid {vendor} account label {label:?}: must be a non-empty name \
470 without path separators, drive prefixes, control characters, or reserved cache names"
471 )));
472 }
473 Ok(())
474}
475
476fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
484 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
485 return Vec::new();
486 };
487 let mut found: Vec<AnthropicAccount> = entries
488 .flatten()
489 .filter_map(|entry| {
490 let path = entry.path();
491 if !path.is_dir() {
492 return None;
493 }
494 let label = path.file_name()?.to_str()?.to_string();
495 validate_account_label(&label).ok()?;
496 Some(AnthropicAccount {
497 label,
498 credentials_path: path.join(".credentials.json"),
499 })
500 })
501 .collect();
502 found.sort_by(|a, b| a.label.cmp(&b.label));
503 found
504}
505
506pub fn tildify(path: &Path, home: &Path) -> String {
510 path.strip_prefix(home)
511 .map(|rest| {
512 let rendered = rest.display().to_string();
513 #[cfg(windows)]
516 let rendered = rendered.replace('\\', "/");
517 format!("~/{rendered}")
518 })
519 .unwrap_or_else(|_| path.display().to_string())
520}
521
522pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
527 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
528 base.join("accounts").join(label).join(".credentials.json")
529}
530
531pub fn add_anthropic_account_to_doc(
537 doc: &mut toml_edit::DocumentMut,
538 label: &str,
539 credentials_path: &str,
540) -> Result<()> {
541 use toml_edit::{Item, Table, value};
542
543 validate_account_label(label)?;
544
545 let anthropic = doc
546 .entry("anthropic")
547 .or_insert_with(|| Item::Table(Table::new()));
548 let anthropic = anthropic
549 .as_table_mut()
550 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
551
552 let accounts = anthropic
553 .entry("accounts")
554 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
555 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
556 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
557 })?;
558
559 let exists = accounts
560 .iter()
561 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
562 if exists {
563 return Err(AppError::Credentials(format!(
564 "anthropic account {label:?} already exists in config.toml"
565 )));
566 }
567
568 let mut table = Table::new();
569 table["label"] = value(label);
570 table["credentials_path"] = value(credentials_path);
571 accounts.push(table);
572 Ok(())
573}
574
575pub fn default_codex_auth_path(home: &Path, label: &str) -> PathBuf {
579 home.join(format!(".codex-{label}")).join("auth.json")
580}
581
582pub fn add_openai_account_to_doc(
587 doc: &mut toml_edit::DocumentMut,
588 label: &str,
589 codex_auth_path: &str,
590) -> Result<()> {
591 use toml_edit::{Item, Table, value};
592
593 validate_account_label_for("openai", label)?;
594
595 let openai = doc
596 .entry("openai")
597 .or_insert_with(|| Item::Table(Table::new()));
598 let openai = openai
599 .as_table_mut()
600 .ok_or_else(|| AppError::Other("[openai] in config.toml is not a table".into()))?;
601
602 let accounts = openai
603 .entry("accounts")
604 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
605 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
606 AppError::Other("[[openai.accounts]] in config.toml is not an array of tables".into())
607 })?;
608
609 if accounts
610 .iter()
611 .any(|t| t.get("label").and_then(Item::as_str) == Some(label))
612 {
613 return Err(AppError::Credentials(format!(
614 "openai account {label:?} already exists in config.toml"
615 )));
616 }
617
618 let mut table = Table::new();
619 table["label"] = value(label);
620 table["codex_auth_path"] = value(codex_auth_path);
621 accounts.push(table);
622 Ok(())
623}
624
625pub(crate) fn set_bool(
629 doc: &mut toml_edit::DocumentMut,
630 section: &str,
631 key: &str,
632 new_value: bool,
633) -> Result<()> {
634 let table = doc
635 .entry(section)
636 .or_insert_with(toml_edit::table)
637 .as_table_mut()
638 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
639
640 if let Some(item) = table.get_mut(key)
641 && let Some(v) = item.as_value_mut()
642 {
643 let suffix = v.decor().suffix().cloned();
647 *v = toml_edit::Value::from(new_value);
648 v.decor_mut().set_prefix(" ");
649 if let Some(suffix) = suffix {
650 v.decor_mut().set_suffix(suffix);
651 }
652 return Ok(());
653 }
654 table.insert(key, toml_edit::value(new_value));
655 Ok(())
656}
657
658pub(crate) fn set_value(
664 doc: &mut toml_edit::DocumentMut,
665 section: &str,
666 key: &str,
667 new_value: Option<toml_edit::Value>,
668) -> Result<()> {
669 let table = doc
670 .entry(section)
671 .or_insert_with(toml_edit::table)
672 .as_table_mut()
673 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
674
675 let Some(mut new_value) = new_value else {
676 table.remove(key);
677 return Ok(());
678 };
679 if let Some(item) = table.get_mut(key)
680 && let Some(v) = item.as_value_mut()
681 {
682 let suffix = v.decor().suffix().cloned();
683 new_value.decor_mut().set_prefix(" ");
684 if let Some(suffix) = suffix {
685 new_value.decor_mut().set_suffix(suffix);
686 }
687 *v = new_value;
688 return Ok(());
689 }
690 table.insert(key, toml_edit::Item::Value(new_value));
691 Ok(())
692}
693
694pub fn set_tray_value(path: &Path, key: &str, value: Option<toml_edit::Value>) -> Result<()> {
700 let mut doc = read_config_document(path)?;
701 let before = doc.to_string();
702 set_value(&mut doc, "tray", key, value)?;
703 if doc.to_string() == before {
704 return Ok(());
705 }
706 write_config_document(path, &doc)
707}
708
709pub fn set_notification_value(path: &Path, key: &str, value: toml_edit::Value) -> Result<()> {
712 match key {
713 "enabled" if value.as_bool().is_some() => {}
714 "threshold" if value.as_integer().is_some_and(|n| (1..=100).contains(&n)) => {}
715 _ => {
716 return Err(AppError::Other(format!(
717 "invalid notification preference: {key}"
718 )));
719 }
720 }
721 let mut doc = read_config_document(path)?;
722 let before = doc.to_string();
723 set_value(&mut doc, "notifications", key, Some(value))?;
724 if doc.to_string() == before {
725 return Ok(());
726 }
727 write_config_document(path, &doc)
728}
729
730pub fn set_vendor_enabled(path: &Path, slug: &str, enabled: bool) -> Result<()> {
738 let mut doc = read_config_document(path)?;
739 let before = doc.to_string();
740 set_vendor_enabled_in_doc(&mut doc, slug, enabled)?;
741 if doc.to_string() == before {
742 return Ok(());
743 }
744 write_config_document(path, &doc)
745}
746
747pub fn set_vendor_enabled_in_doc(
752 doc: &mut toml_edit::DocumentMut,
753 slug: &str,
754 enabled: bool,
755) -> Result<()> {
756 let Some(vendor) = VendorId::from_slug(slug) else {
757 return Err(AppError::Other(format!(
758 "unknown provider {slug:?}: not a built-in vendor"
759 )));
760 };
761 set_bool(doc, vendor.config_section(), "enabled", enabled)
762}
763
764pub(crate) fn read_config_document(path: &Path) -> Result<toml_edit::DocumentMut> {
768 let original = match std::fs::read_to_string(path) {
769 Ok(contents) => contents,
770 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
771 Err(error) => return Err(AppError::io_at(path, error)),
772 };
773 if original.trim().is_empty() {
774 return Ok(toml_edit::DocumentMut::new());
775 }
776 original.parse().map_err(|e: toml_edit::TomlError| {
777 AppError::Other(format!("config.toml not parseable: {e}"))
778 })
779}
780
781pub(crate) fn write_config_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> {
785 let bytes = doc.to_string();
786 crate::cache::atomic_write(path, bytes.as_bytes())?;
787
788 #[cfg(unix)]
789 {
790 if let Ok(meta) = std::fs::metadata(path) {
791 let mut perms = meta.permissions();
792 perms.set_mode(0o600);
793 let _ = std::fs::set_permissions(path, perms);
794 }
795 }
796 Ok(())
797}
798
799pub fn enable_vendors_in(path: &Path, vendors: &[VendorId]) -> Result<Vec<VendorId>> {
807 let mut doc = read_config_document(path)?;
808 let before = doc.to_string();
809 let written: Vec<VendorId> = vendors
810 .iter()
811 .copied()
812 .filter(|vendor| !is_explicitly_disabled(&doc, *vendor))
813 .collect();
814 for vendor in &written {
815 set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
816 }
817 if doc.to_string() == before {
818 return Ok(written);
819 }
820 write_config_document(path, &doc)?;
821 Ok(written)
822}
823
824fn is_explicitly_disabled(doc: &toml_edit::DocumentMut, vendor: VendorId) -> bool {
835 doc.get(vendor.config_section())
836 .and_then(|section| section.get("enabled"))
837 .and_then(|enabled| enabled.as_bool())
838 == Some(false)
839}
840
841#[derive(Debug, Clone, Deserialize, Serialize)]
842#[serde(default)]
843pub struct OpenAiConfig {
844 pub enabled: bool,
845 pub codex_auth_path: Option<PathBuf>,
847 #[serde(default)]
852 pub accounts: Vec<OpenAiAccount>,
853 pub show_default_account: bool,
859 pub admin_key_env: String,
867}
868
869#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
880pub struct OpenAiAccount {
881 pub label: String,
884 pub codex_auth_path: PathBuf,
887}
888
889impl OpenAiConfig {
890 pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
894 let Some(label) = label else {
895 return match &self.codex_auth_path {
896 Some(path) => Ok(path.clone()),
897 None => crate::openai::creds::default_path(),
898 };
899 };
900 self.accounts
901 .iter()
902 .find(|account| account.label == label)
903 .map(|account| account.codex_auth_path.clone())
904 .ok_or_else(|| {
905 AppError::Credentials(format!(
906 "no OpenAI account named {label:?}. Add it under \
907 [[openai.accounts]], or drop --account to use the default login."
908 ))
909 })
910 }
911
912 pub fn fetch_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
919 let Some(label) = label else {
920 return self.resolve_auth_path(None);
921 };
922 let default = self.resolve_auth_path(None)?;
923 let active = crate::openai::account::resolve_active_label(&default, &self.accounts);
924 self.fetch_auth_path_probing(label, active.as_deref(), Path::exists)
925 }
926
927 pub fn fetch_auth_path_probing(
932 &self,
933 label: &str,
934 active: Option<&str>,
935 exists: impl Fn(&Path) -> bool,
936 ) -> Result<PathBuf> {
937 let own = self.resolve_auth_path(Some(label))?;
938 if active == Some(label) && !exists(&own) {
939 return self.resolve_auth_path(None);
940 }
941 Ok(own)
942 }
943}
944
945impl Default for OpenAiConfig {
946 fn default() -> Self {
947 Self {
948 enabled: true,
949 codex_auth_path: None,
950 accounts: Vec::new(),
951 show_default_account: true,
952 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
953 }
954 }
955}
956
957#[derive(Debug, Clone, Default, Deserialize, Serialize)]
961#[serde(default)]
962pub struct CopilotConfig {
963 pub enabled: bool,
964 pub gh_binary: Option<PathBuf>,
967}
968
969impl CopilotConfig {
970 pub fn resolve_token(&self) -> Result<String> {
971 self.resolve_token_with(
972 |name| std::env::var_os(name),
973 &crate::copilot::credentials::SystemGhAuthTokenRunner,
974 )
975 }
976
977 fn resolve_token_with(
978 &self,
979 environment: impl Fn(&str) -> Option<std::ffi::OsString>,
980 runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
981 ) -> Result<String> {
982 if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
983 let token = value.into_string().map_err(|_| {
984 AppError::Credentials(
985 "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
986 )
987 })?;
988 if !token.is_empty() {
989 return Ok(token);
990 }
991 }
992 crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref())
993 }
994}
995
996#[derive(Debug, Clone, Default, Deserialize, Serialize)]
997#[serde(default)]
998pub struct NousConfig {
999 pub enabled: bool,
1000}
1001
1002#[derive(Debug, Clone, Deserialize, Serialize)]
1003#[serde(default)]
1004pub struct OpenCodeGoConfig {
1005 pub enabled: bool,
1006 pub api_key_env: String,
1007 pub api_key: Option<String>,
1008}
1009
1010#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1015#[serde(default)]
1016pub struct CommandCodeConfig {
1017 pub enabled: bool,
1018 pub auth_paths: Option<Vec<PathBuf>>,
1019}
1020
1021#[derive(Debug, Clone, Deserialize, Serialize)]
1026#[serde(default)]
1027pub struct OllamaConfig {
1028 pub enabled: bool,
1029 pub api_key_env: String,
1030 pub api_key: Option<String>,
1031 pub plan: String,
1034}
1035
1036impl Default for OllamaConfig {
1037 fn default() -> Self {
1038 Self {
1039 enabled: false,
1040 api_key_env: "OLLAMA_API_KEY".to_string(),
1041 api_key: None,
1042 plan: "pro".to_string(),
1043 }
1044 }
1045}
1046
1047#[derive(Debug, Clone, Deserialize, Serialize)]
1050#[serde(default)]
1051pub struct OrcaRouterConfig {
1052 pub enabled: bool,
1053 pub accounts: Vec<ApiKeyAccount>,
1056 pub show_default_account: bool,
1060 pub api_key_env: String,
1061 pub api_key: Option<String>,
1062}
1063
1064impl Default for OrcaRouterConfig {
1065 fn default() -> Self {
1066 Self {
1067 enabled: false,
1068 accounts: Vec::new(),
1069 show_default_account: true,
1070 api_key_env: "ORCAROUTER_API_KEY".to_string(),
1071 api_key: None,
1072 }
1073 }
1074}
1075
1076#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1083#[serde(default)]
1084pub struct ModelStudioConfig {
1085 pub enabled: bool,
1088 pub config_dir: Option<PathBuf>,
1091}
1092
1093impl Default for OpenCodeGoConfig {
1094 fn default() -> Self {
1095 Self {
1096 enabled: false,
1097 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
1098 api_key: None,
1099 }
1100 }
1101}
1102
1103#[derive(Debug, Clone, Deserialize, Serialize)]
1104#[serde(default)]
1105pub struct ZaiConfig {
1106 pub enabled: bool,
1107 pub accounts: Vec<ApiKeyAccount>,
1110 pub show_default_account: bool,
1114 pub api_key_env: String,
1116 pub api_key: Option<String>,
1119 pub plan_tier: Option<String>,
1121}
1122
1123impl Default for ZaiConfig {
1124 fn default() -> Self {
1125 Self {
1126 enabled: true,
1127 accounts: Vec::new(),
1128 show_default_account: true,
1129 api_key_env: "ZAI_API_KEY".to_string(),
1130 api_key: None,
1131 plan_tier: None,
1132 }
1133 }
1134}
1135
1136#[derive(Debug, Clone, Deserialize, Serialize)]
1137#[serde(default)]
1138pub struct OpenRouterConfig {
1139 pub enabled: bool,
1140 pub accounts: Vec<ApiKeyAccount>,
1143 pub show_default_account: bool,
1147 pub api_key_env: String,
1148 pub api_key: Option<String>,
1149 pub headline: Headline,
1161}
1162
1163impl Default for OpenRouterConfig {
1164 fn default() -> Self {
1165 Self {
1166 enabled: true,
1167 accounts: Vec::new(),
1168 show_default_account: true,
1169 api_key_env: "OPENROUTER_API_KEY".to_string(),
1170 api_key: None,
1171 headline: Headline::Percent,
1172 }
1173 }
1174}
1175
1176#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1181pub struct ApiKeyAccount {
1182 pub label: String,
1184 #[serde(default)]
1186 pub api_key_env: Option<String>,
1187 #[serde(default)]
1189 pub api_key: Option<String>,
1190}
1191
1192fn api_key_account<'a>(
1196 slug: &str,
1197 accounts: &'a [ApiKeyAccount],
1198 label: &str,
1199) -> Result<&'a ApiKeyAccount> {
1200 validate_account_label_for(slug, label)?;
1201 accounts
1202 .iter()
1203 .find(|account| account.label == label)
1204 .ok_or_else(|| {
1205 let known: Vec<&str> = accounts
1206 .iter()
1207 .map(|account| account.label.as_str())
1208 .collect();
1209 AppError::Credentials(format!(
1210 "{slug} account {label:?} not found in [[{slug}.accounts]]; \
1211 known labels: {known:?}"
1212 ))
1213 })
1214}
1215
1216#[derive(Debug, Clone, Deserialize, Serialize)]
1217#[serde(default)]
1218pub struct DeepseekConfig {
1219 pub enabled: bool,
1220 pub accounts: Vec<ApiKeyAccount>,
1223 pub show_default_account: bool,
1227 pub api_key_env: String,
1228 pub api_key: Option<String>,
1229 pub display_limit: Option<f64>,
1232 pub headline: Headline,
1234}
1235
1236impl Default for DeepseekConfig {
1237 fn default() -> Self {
1238 Self {
1239 enabled: false,
1240 accounts: Vec::new(),
1241 show_default_account: true,
1242 api_key_env: "DEEPSEEK_API_KEY".to_string(),
1243 api_key: None,
1244 display_limit: None,
1245 headline: Headline::Amount,
1246 }
1247 }
1248}
1249
1250#[derive(Debug, Clone, Deserialize, Serialize)]
1251#[serde(default)]
1252pub struct KimiConfig {
1253 pub enabled: bool,
1254 pub api_key_env: String,
1255 pub api_key: Option<String>,
1258 pub credentials_path: Option<PathBuf>,
1262 pub region: String,
1267}
1268
1269impl Default for KimiConfig {
1270 fn default() -> Self {
1271 Self {
1272 enabled: false,
1273 api_key_env: "KIMI_API_KEY".to_string(),
1274 api_key: None,
1275 credentials_path: None,
1276 region: "auto".to_string(),
1277 }
1278 }
1279}
1280
1281#[derive(Debug, Clone, Deserialize, Serialize)]
1282#[serde(default)]
1283pub struct KiloConfig {
1284 pub enabled: bool,
1285 pub accounts: Vec<ApiKeyAccount>,
1288 pub show_default_account: bool,
1292 pub api_key_env: String,
1293 pub api_key: Option<String>,
1294 pub organization_id: Option<String>,
1297 pub display_limit: Option<f64>,
1300 pub headline: Headline,
1302}
1303
1304impl Default for KiloConfig {
1305 fn default() -> Self {
1306 Self {
1309 enabled: false,
1310 accounts: Vec::new(),
1311 show_default_account: true,
1312 api_key_env: "KILO_API_KEY".to_string(),
1313 api_key: None,
1314 organization_id: None,
1315 display_limit: None,
1316 headline: Headline::Amount,
1317 }
1318 }
1319}
1320
1321#[derive(Debug, Clone, Deserialize, Serialize)]
1322#[serde(default)]
1323pub struct NovitaConfig {
1324 pub enabled: bool,
1325 pub accounts: Vec<ApiKeyAccount>,
1328 pub show_default_account: bool,
1332 pub api_key_env: String,
1333 pub api_key: Option<String>,
1334 pub display_limit: Option<f64>,
1338 pub headline: Headline,
1340}
1341
1342impl Default for NovitaConfig {
1343 fn default() -> Self {
1344 Self {
1346 enabled: false,
1347 accounts: Vec::new(),
1348 show_default_account: true,
1349 api_key_env: "NOVITA_API_KEY".to_string(),
1350 api_key: None,
1351 display_limit: None,
1352 headline: Headline::Amount,
1353 }
1354 }
1355}
1356
1357#[derive(Debug, Clone, Deserialize, Serialize)]
1358#[serde(default)]
1359pub struct MinimaxConfig {
1360 pub enabled: bool,
1361 pub accounts: Vec<ApiKeyAccount>,
1364 pub show_default_account: bool,
1368 pub api_key_env: String,
1369 pub api_key: Option<String>,
1370 pub region: String,
1376}
1377
1378impl Default for MinimaxConfig {
1379 fn default() -> Self {
1380 Self {
1382 enabled: false,
1383 accounts: Vec::new(),
1384 show_default_account: true,
1385 api_key_env: "MINIMAX_API_KEY".to_string(),
1386 api_key: None,
1387 region: "global".to_string(),
1388 }
1389 }
1390}
1391
1392#[derive(Debug, Clone, Deserialize, Serialize)]
1393#[serde(default)]
1394pub struct MoonshotConfig {
1395 pub enabled: bool,
1396 pub accounts: Vec<ApiKeyAccount>,
1399 pub show_default_account: bool,
1403 pub api_key_env: String,
1404 pub api_key: Option<String>,
1405 pub region: String,
1407 pub display_limit: Option<f64>,
1410 pub headline: Headline,
1412}
1413
1414impl Default for MoonshotConfig {
1415 fn default() -> Self {
1416 Self {
1418 enabled: false,
1419 accounts: Vec::new(),
1420 show_default_account: true,
1421 api_key_env: "MOONSHOT_API_KEY".to_string(),
1422 api_key: None,
1423 region: "global".to_string(),
1424 display_limit: None,
1425 headline: Headline::Amount,
1426 }
1427 }
1428}
1429
1430#[derive(Debug, Clone, Deserialize, Serialize)]
1431#[serde(default)]
1432pub struct GrokConfig {
1433 pub enabled: bool,
1434 pub accounts: Vec<ApiKeyAccount>,
1437 pub show_default_account: bool,
1441 pub api_key_env: String,
1443 pub api_key: Option<String>,
1444 pub team_id: Option<String>,
1447 pub display_limit: Option<f64>,
1449 pub headline: Headline,
1451}
1452
1453impl Default for GrokConfig {
1454 fn default() -> Self {
1455 Self {
1457 enabled: false,
1458 accounts: Vec::new(),
1459 show_default_account: true,
1460 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1461 api_key: None,
1462 team_id: None,
1463 display_limit: None,
1464 headline: Headline::Amount,
1465 }
1466 }
1467}
1468
1469#[derive(Debug, Clone, Deserialize, Serialize)]
1477#[serde(default)]
1478pub struct SuperGrokConfig {
1479 pub enabled: bool,
1480 pub grok_binary: PathBuf,
1484 pub auth_path: Option<PathBuf>,
1487 pub config_path: Option<PathBuf>,
1488}
1489
1490impl Default for SuperGrokConfig {
1491 fn default() -> Self {
1492 Self {
1493 enabled: false,
1494 grok_binary: default_grok_binary(),
1495 auth_path: None,
1496 config_path: None,
1497 }
1498 }
1499}
1500
1501#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1507#[serde(default)]
1508pub struct GrokbotConfig {
1509 pub enabled: bool,
1512 pub secrets_path: Option<PathBuf>,
1517}
1518
1519fn default_grok_binary() -> PathBuf {
1520 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1521 let grok_home = std::env::var_os("GROK_HOME")
1522 .filter(|value| !value.is_empty())
1523 .map(PathBuf::from)
1524 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1525 grok_home
1526 .map(|home| home.join("bin").join(executable))
1527 .unwrap_or_else(|| PathBuf::from(executable))
1528}
1529
1530#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1538#[serde(default)]
1539pub struct AntigravityConfig {
1540 pub enabled: bool,
1541 pub oauth_client_id: Option<String>,
1543 pub oauth_client_secret: Option<String>,
1547}
1548
1549#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1559#[serde(default)]
1560pub struct CursorConfig {
1561 pub enabled: bool,
1562 pub db_path: Option<PathBuf>,
1566 pub agent_auth_path: Option<PathBuf>,
1571}
1572
1573#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1582#[serde(default)]
1583pub struct KiroConfig {
1584 pub enabled: bool,
1585 pub db_path: Option<PathBuf>,
1589}
1590
1591#[derive(Debug, Clone, Deserialize, Serialize)]
1592#[serde(default)]
1593pub struct AnthropicApiConfig {
1594 pub enabled: bool,
1595 pub api_key_env: String,
1598 pub api_key: Option<String>,
1599 pub monthly_limit: Option<f64>,
1602}
1603
1604impl Default for AnthropicApiConfig {
1605 fn default() -> Self {
1606 Self {
1608 enabled: false,
1609 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
1610 api_key: None,
1611 monthly_limit: None,
1612 }
1613 }
1614}
1615
1616#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1626#[serde(default, remote = "Self")]
1627pub struct CustomProviderConfig {
1628 pub id: String,
1630 pub name: String,
1632 pub short_name: String,
1635 pub brand: Option<String>,
1638 pub enabled: bool,
1639 pub url: String,
1641 pub allow_http: bool,
1642 pub api_key_env: String,
1644 pub api_key: Option<String>,
1645 pub auth_header: String,
1647 pub auth_scheme: String,
1649 pub headers: BTreeMap<String, String>,
1651 pub plan: Option<String>,
1653 pub plan_path: Option<String>,
1655 pub cache_ttl_secs: u64,
1657 pub metrics: Vec<CustomMetricSpec>,
1658 pub texts: Vec<CustomTextSpec>,
1659}
1660
1661impl Default for CustomProviderConfig {
1662 fn default() -> Self {
1663 Self {
1664 id: String::new(),
1665 name: String::new(),
1666 short_name: String::new(),
1667 brand: None,
1668 enabled: false,
1669 url: String::new(),
1670 allow_http: false,
1671 api_key_env: String::new(),
1672 api_key: None,
1673 auth_header: "Authorization".to_string(),
1674 auth_scheme: "Bearer".to_string(),
1675 headers: BTreeMap::new(),
1676 plan: None,
1677 plan_path: None,
1678 cache_ttl_secs: 60,
1679 metrics: Vec::new(),
1680 texts: Vec::new(),
1681 }
1682 }
1683}
1684
1685impl<'de> Deserialize<'de> for CustomProviderConfig {
1690 fn deserialize<D: serde::Deserializer<'de>>(
1691 deserializer: D,
1692 ) -> std::result::Result<Self, D::Error> {
1693 let mut this = Self::deserialize(deserializer)?;
1694 if this.name.is_empty() {
1695 this.name = this.id.clone();
1696 }
1697 Ok(this)
1698 }
1699}
1700
1701impl Serialize for CustomProviderConfig {
1702 fn serialize<S: serde::Serializer>(
1703 &self,
1704 serializer: S,
1705 ) -> std::result::Result<S::Ok, S::Error> {
1706 Self::serialize(self, serializer)
1707 }
1708}
1709
1710#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1714#[serde(default)]
1715pub struct CustomMetricSpec {
1716 pub label: String,
1717 pub used: Option<String>,
1718 pub limit: Option<String>,
1719 pub percent: Option<String>,
1720 pub resets_at: Option<String>,
1722 pub window_secs: Option<u64>,
1724}
1725
1726#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1728#[serde(default)]
1729pub struct CustomTextSpec {
1730 pub label: String,
1731 pub value: String,
1732}
1733
1734impl CustomProviderConfig {
1735 pub fn section_label(&self) -> String {
1737 format!("[[custom]] id = {:?}", self.id)
1738 }
1739
1740 pub fn resolve_api_key(&self) -> Result<String> {
1743 if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
1744 return Ok(key);
1745 }
1746 let advice = if self.api_key_env.is_empty() {
1747 "set `api_key`, or name an environment variable in `api_key_env`".to_string()
1748 } else {
1749 format!("export {} or set `api_key`", self.api_key_env)
1750 };
1751 Err(AppError::Credentials(format!(
1752 "custom {}: no API key. Either {advice} under {} in {}.",
1753 self.id,
1754 self.section_label(),
1755 config_path_hint()
1756 )))
1757 }
1758
1759 pub fn cache_ttl(&self) -> std::time::Duration {
1760 std::time::Duration::from_secs(self.cache_ttl_secs)
1761 }
1762
1763 fn validate(&self, index: usize) -> Result<()> {
1767 if !is_valid_custom_id(&self.id) {
1768 return Err(AppError::Other(format!(
1769 "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
1770 index + 1,
1771 self.id
1772 )));
1773 }
1774 let section = self.section_label();
1775 let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
1776
1777 if VendorId::all().iter().any(|v| v.slug() == self.id) {
1778 return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
1779 }
1780 let name_len = self.name.chars().count();
1781 if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
1782 return Err(bad(
1783 "name must be 1 to 48 characters without control characters".into(),
1784 ));
1785 }
1786 if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
1787 return Err(bad(format!(
1788 "short_name {:?} must be exactly 3 lowercase ASCII letters",
1789 self.short_name
1790 )));
1791 }
1792 if let Some(brand) = &self.brand
1793 && !VendorId::all().iter().any(|v| v.slug() == brand)
1794 {
1795 return Err(bad(format!(
1796 "brand {brand:?} must name a built-in vendor (it borrows that \
1797 vendor's mark); leave it unset to keep the short_name tag"
1798 )));
1799 }
1800 let url = reqwest::Url::parse(&self.url)
1801 .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
1802 match url.scheme() {
1803 "https" => {}
1804 "http" if self.allow_http => {}
1805 "http" => {
1806 return Err(bad(
1807 "url must use https:// (set allow_http = true to permit http://)".into(),
1808 ));
1809 }
1810 other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
1811 }
1812 if !url.username().is_empty() || url.password().is_some() {
1813 return Err(bad("url must not carry credentials (user:pass@)".into()));
1814 }
1815 if url.host_str().is_none() {
1816 return Err(bad("url has no host".into()));
1817 }
1818 if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
1819 return Err(bad(format!(
1820 "api_key_env {:?} is not a valid environment variable name",
1821 self.api_key_env
1822 )));
1823 }
1824 validate_header_name(§ion, "auth_header", &self.auth_header)?;
1825 if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
1826 return Err(bad(
1827 "auth_scheme contains characters that are not valid in an HTTP header".into(),
1828 ));
1829 }
1830 for (name, value) in &self.headers {
1831 validate_header_name(§ion, "headers", name)?;
1832 if name.eq_ignore_ascii_case(&self.auth_header) {
1833 return Err(bad(format!(
1834 "headers must not repeat auth_header {:?}",
1835 self.auth_header
1836 )));
1837 }
1838 if reqwest::header::HeaderValue::from_str(value).is_err() {
1839 return Err(bad(format!(
1840 "header {name:?} has a value that is not valid in an HTTP header"
1841 )));
1842 }
1843 }
1844 if let Some(plan) = &self.plan {
1845 validate_custom_label(§ion, "plan", plan)?;
1846 }
1847 if let Some(pointer) = &self.plan_path {
1848 validate_pointer(§ion, "plan_path", pointer)?;
1849 }
1850 if !(10..=3600).contains(&self.cache_ttl_secs) {
1851 return Err(bad(format!(
1852 "cache_ttl_secs must be between 10 and 3600, got {}",
1853 self.cache_ttl_secs
1854 )));
1855 }
1856 if self.metrics.is_empty() && self.texts.is_empty() {
1857 return Err(bad(
1858 "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
1859 ));
1860 }
1861 let mut metric_labels = HashSet::new();
1862 for metric in &self.metrics {
1863 validate_custom_label(§ion, "metric label", &metric.label)?;
1864 if !metric_labels.insert(metric.label.as_str()) {
1865 return Err(bad(format!("duplicate metric label {:?}", metric.label)));
1866 }
1867 let pair = (metric.used.is_some(), metric.limit.is_some());
1868 let well_formed = if metric.percent.is_some() {
1869 pair == (false, false)
1870 } else {
1871 pair == (true, true)
1872 };
1873 if !well_formed {
1874 return Err(bad(format!(
1875 "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
1876 metric.label
1877 )));
1878 }
1879 for (field, pointer) in [
1880 ("used", &metric.used),
1881 ("limit", &metric.limit),
1882 ("percent", &metric.percent),
1883 ("resets_at", &metric.resets_at),
1884 ] {
1885 if let Some(pointer) = pointer {
1886 validate_pointer(§ion, field, pointer)?;
1887 }
1888 }
1889 if let Some(secs) = metric.window_secs
1890 && secs < 60
1891 {
1892 return Err(bad(format!(
1893 "metric {:?} window_secs must be at least 60, got {secs}",
1894 metric.label
1895 )));
1896 }
1897 }
1898 let mut text_labels = HashSet::new();
1899 for text in &self.texts {
1900 validate_custom_label(§ion, "text label", &text.label)?;
1901 if !text_labels.insert(text.label.as_str()) {
1902 return Err(bad(format!("duplicate text label {:?}", text.label)));
1903 }
1904 validate_pointer(§ion, "value", &text.value)?;
1905 }
1906 Ok(())
1907 }
1908}
1909
1910fn is_valid_custom_id(id: &str) -> bool {
1911 let bytes = id.as_bytes();
1912 let Some(&first) = bytes.first() else {
1913 return false;
1914 };
1915 bytes.len() <= 32
1916 && (first.is_ascii_lowercase() || first.is_ascii_digit())
1917 && bytes
1918 .iter()
1919 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
1920}
1921
1922fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
1923 if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
1924 return Err(AppError::Other(format!(
1925 "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
1926 )));
1927 }
1928 Ok(())
1929}
1930
1931fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
1932 let len = label.chars().count();
1933 if len == 0 || len > 64 || label.chars().any(char::is_control) {
1934 return Err(AppError::Other(format!(
1935 "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
1936 )));
1937 }
1938 Ok(())
1939}
1940
1941fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
1942 if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
1943 return Err(AppError::Other(format!(
1944 "{section}: {field} {name:?} is not a valid HTTP header name"
1945 )));
1946 }
1947 Ok(())
1948}
1949
1950pub fn resolve_api_key(
1953 vendor_label: &str,
1954 env_var_name: &str,
1955 inline: Option<&str>,
1956) -> crate::error::Result<String> {
1957 let section = match vendor_label {
1958 "OpenCode Go" => "[opencode-go]".to_string(),
1959 _ => format!("[{}]", vendor_label.to_lowercase()),
1960 };
1961 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
1962}
1963
1964pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
1968 if is_valid_env_var_name(env_var_name)
1969 && let Ok(v) = std::env::var(env_var_name)
1970 && !v.is_empty()
1971 {
1972 return Some(v);
1973 }
1974 inline.filter(|v| !v.is_empty()).map(str::to_string)
1975}
1976
1977fn resolve_api_key_in_section(
1978 vendor_label: &str,
1979 section: &str,
1980 env_var_name: &str,
1981 inline: Option<&str>,
1982) -> crate::error::Result<String> {
1983 if let Some(key) = optional_api_key(env_var_name, inline) {
1984 return Ok(key);
1985 }
1986 let valid_env_name = is_valid_env_var_name(env_var_name);
1987 let advice = if valid_env_name {
1988 "set an API key in a valid environment variable or set `api_key`"
1989 } else {
1990 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
1991 };
1992 Err(crate::error::AppError::Credentials(format!(
1993 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
1994 config_path_hint()
1995 )))
1996}
1997
1998pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
1999 let mut chars = name.chars();
2000 let Some(first) = chars.next() else {
2001 return false;
2002 };
2003 (first.is_ascii_alphabetic() || first == '_')
2004 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2005}
2006
2007impl Config {
2008 pub fn load() -> Result<Self> {
2011 let Some(path) = resolved_path() else {
2012 return Ok(Self::default());
2013 };
2014 Self::load_from(&path)
2015 }
2016
2017 pub fn load_from(path: &std::path::Path) -> Result<Self> {
2018 match std::fs::read_to_string(path) {
2019 Ok(s) => {
2020 let mut config: Self = toml::from_str(&s)?;
2021 config.expand_paths();
2025 config.validate()?;
2026 #[cfg(unix)]
2027 config.protect_inline_secrets(path)?;
2028 crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
2032 Ok(config)
2033 }
2034 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
2035 Err(e) => Err(AppError::io_at(path, e)),
2036 }
2037 }
2038
2039 fn expand_paths(&mut self) {
2040 expand_tilde_opt(&mut self.context.projects_path);
2041 expand_tilde_opt(&mut self.anthropic.credentials_path);
2042 expand_tilde_opt(&mut self.anthropic.accounts_dir);
2043 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
2044 expand_tilde_opt(&mut self.openai.codex_auth_path);
2045 expand_tilde_opt(&mut self.cursor.db_path);
2046 expand_tilde_opt(&mut self.cursor.agent_auth_path);
2047 expand_tilde_opt(&mut self.kiro.db_path);
2048 expand_tilde_opt(&mut self.kimi.credentials_path);
2049 expand_tilde_opt(&mut self.grokbot.secrets_path);
2050 expand_tilde_opt(&mut self.modelstudio.config_dir);
2051 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
2052 expand_tilde_opt(&mut self.supergrok.auth_path);
2053 expand_tilde_opt(&mut self.supergrok.config_path);
2054 for account in &mut self.anthropic.accounts {
2055 account.credentials_path = expand_tilde(&account.credentials_path);
2056 }
2057 for account in &mut self.openai.accounts {
2058 account.codex_auth_path = expand_tilde(&account.codex_auth_path);
2059 }
2060 }
2061
2062 #[cfg(unix)]
2066 fn has_inline_secrets(&self) -> bool {
2067 [
2068 self.zai.api_key.as_deref(),
2069 self.openrouter.api_key.as_deref(),
2070 self.deepseek.api_key.as_deref(),
2071 self.kimi.api_key.as_deref(),
2072 self.kilo.api_key.as_deref(),
2073 self.novita.api_key.as_deref(),
2074 self.minimax.api_key.as_deref(),
2075 self.moonshot.api_key.as_deref(),
2076 self.grok.api_key.as_deref(),
2077 self.anthropic_api.api_key.as_deref(),
2078 self.opencode_go.api_key.as_deref(),
2079 self.orcarouter.api_key.as_deref(),
2080 self.antigravity.oauth_client_secret.as_deref(),
2081 ]
2082 .into_iter()
2083 .chain(
2084 Self::API_KEY_ACCOUNT_VENDORS
2085 .into_iter()
2086 .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2087 .map(|account| account.api_key.as_deref()),
2088 )
2089 .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
2090 .any(|key| key.is_some_and(|key| !key.is_empty()))
2091 }
2092
2093 fn custom_secret_env_vars(&self) -> Vec<String> {
2094 self.custom
2095 .iter()
2096 .filter(|c| !c.api_key_env.is_empty())
2097 .map(|c| c.api_key_env.clone())
2098 .collect()
2099 }
2100
2101 pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
2103 self.custom.iter().filter(|c| c.enabled)
2104 }
2105
2106 pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
2108 self.custom.iter().find(|c| c.id == id)
2109 }
2110
2111 #[cfg(unix)]
2112 fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
2113 if !self.has_inline_secrets() {
2114 return Ok(());
2115 }
2116
2117 let metadata = std::fs::metadata(path).map_err(|_| {
2118 AppError::Credentials(format!(
2119 "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
2120 path.display()
2121 ))
2122 })?;
2123 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
2124 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
2125 AppError::Credentials(format!(
2126 "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",
2127 path.display()
2128 ))
2129 })?;
2130 }
2131 Ok(())
2132 }
2133
2134 pub fn is_enabled(&self, id: VendorId) -> bool {
2135 match id {
2136 VendorId::Anthropic => self.anthropic.enabled,
2137 VendorId::AnthropicApi => self.anthropic_api.enabled,
2138 VendorId::Openai => self.openai.enabled,
2139 VendorId::Copilot => self.copilot.enabled,
2140 VendorId::Zai => self.zai.enabled,
2141 VendorId::Openrouter => self.openrouter.enabled,
2142 VendorId::Deepseek => self.deepseek.enabled,
2143 VendorId::Kimi => self.kimi.enabled,
2144 VendorId::Kilo => self.kilo.enabled,
2145 VendorId::Novita => self.novita.enabled,
2146 VendorId::Moonshot => self.moonshot.enabled,
2147 VendorId::Grok => self.grok.enabled,
2148 VendorId::Supergrok => self.supergrok.enabled,
2149 VendorId::Grokbot => self.grokbot.enabled,
2150 VendorId::Antigravity => self.antigravity.enabled,
2151 VendorId::Cursor => self.cursor.enabled,
2152 VendorId::Minimax => self.minimax.enabled,
2153 VendorId::Kiro => self.kiro.enabled,
2154 VendorId::NousResearch => self.nous.enabled,
2155 VendorId::OpenCodeGo => self.opencode_go.enabled,
2156 VendorId::CommandCode => self.commandcode.enabled,
2157 VendorId::Ollama => self.ollama.enabled,
2158 VendorId::OrcaRouter => self.orcarouter.enabled,
2159 VendorId::ModelStudio => self.modelstudio.enabled,
2160 }
2161 }
2162
2163 pub fn api_key_env_for(&self, id: VendorId) -> &str {
2170 match id {
2171 VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
2172 VendorId::Zai => &self.zai.api_key_env,
2173 VendorId::Openrouter => &self.openrouter.api_key_env,
2174 VendorId::Deepseek => &self.deepseek.api_key_env,
2175 VendorId::Kimi => &self.kimi.api_key_env,
2176 VendorId::Kilo => &self.kilo.api_key_env,
2177 VendorId::Novita => &self.novita.api_key_env,
2178 VendorId::Moonshot => &self.moonshot.api_key_env,
2179 VendorId::Grok => &self.grok.api_key_env,
2180 VendorId::Minimax => &self.minimax.api_key_env,
2181 VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
2182 VendorId::Ollama => &self.ollama.api_key_env,
2183 VendorId::OrcaRouter => &self.orcarouter.api_key_env,
2184 VendorId::Anthropic
2187 | VendorId::Openai
2188 | VendorId::Copilot
2189 | VendorId::Supergrok
2190 | VendorId::Grokbot
2191 | VendorId::Antigravity
2192 | VendorId::Cursor
2193 | VendorId::Kiro
2194 | VendorId::NousResearch
2195 | VendorId::CommandCode
2196 | VendorId::ModelStudio => id.api_key_env(),
2197 }
2198 }
2199
2200 pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
2204 let raw = match id {
2205 VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
2206 VendorId::Zai => self.zai.api_key.as_deref(),
2207 VendorId::Openrouter => self.openrouter.api_key.as_deref(),
2208 VendorId::Deepseek => self.deepseek.api_key.as_deref(),
2209 VendorId::Kimi => self.kimi.api_key.as_deref(),
2210 VendorId::Kilo => self.kilo.api_key.as_deref(),
2211 VendorId::Novita => self.novita.api_key.as_deref(),
2212 VendorId::Moonshot => self.moonshot.api_key.as_deref(),
2213 VendorId::Grok => self.grok.api_key.as_deref(),
2214 VendorId::Minimax => self.minimax.api_key.as_deref(),
2215 VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
2216 VendorId::Ollama => self.ollama.api_key.as_deref(),
2217 VendorId::OrcaRouter => self.orcarouter.api_key.as_deref(),
2218 VendorId::Anthropic
2219 | VendorId::Openai
2220 | VendorId::Copilot
2221 | VendorId::Supergrok
2222 | VendorId::Grokbot
2223 | VendorId::Antigravity
2224 | VendorId::Cursor
2225 | VendorId::Kiro
2226 | VendorId::NousResearch
2227 | VendorId::CommandCode
2228 | VendorId::ModelStudio => None,
2229 };
2230 raw.filter(|key| !key.is_empty())
2231 }
2232
2233 pub const API_KEY_ACCOUNT_VENDORS: [VendorId; 9] = [
2237 VendorId::Zai,
2238 VendorId::Openrouter,
2239 VendorId::Deepseek,
2240 VendorId::Kilo,
2241 VendorId::Novita,
2242 VendorId::Moonshot,
2243 VendorId::Grok,
2244 VendorId::Minimax,
2245 VendorId::OrcaRouter,
2246 ];
2247
2248 pub fn api_key_accounts(&self, id: VendorId) -> Option<&[ApiKeyAccount]> {
2251 match id {
2252 VendorId::Zai => Some(&self.zai.accounts),
2253 VendorId::Openrouter => Some(&self.openrouter.accounts),
2254 VendorId::Deepseek => Some(&self.deepseek.accounts),
2255 VendorId::Kilo => Some(&self.kilo.accounts),
2256 VendorId::Novita => Some(&self.novita.accounts),
2257 VendorId::Moonshot => Some(&self.moonshot.accounts),
2258 VendorId::Grok => Some(&self.grok.accounts),
2259 VendorId::Minimax => Some(&self.minimax.accounts),
2260 VendorId::OrcaRouter => Some(&self.orcarouter.accounts),
2261 _ => None,
2262 }
2263 }
2264
2265 pub fn show_default_api_key_account(&self, id: VendorId) -> bool {
2268 match id {
2269 VendorId::Zai => self.zai.show_default_account,
2270 VendorId::Openrouter => self.openrouter.show_default_account,
2271 VendorId::Deepseek => self.deepseek.show_default_account,
2272 VendorId::Kilo => self.kilo.show_default_account,
2273 VendorId::Novita => self.novita.show_default_account,
2274 VendorId::Moonshot => self.moonshot.show_default_account,
2275 VendorId::Grok => self.grok.show_default_account,
2276 VendorId::Minimax => self.minimax.show_default_account,
2277 VendorId::OrcaRouter => self.orcarouter.show_default_account,
2278 _ => true,
2279 }
2280 }
2281
2282 pub fn resolve_account_api_key_for(&self, id: VendorId, label: Option<&str>) -> Result<String> {
2288 let name = match id {
2291 VendorId::Zai => "Zai",
2292 other => other.display_name(),
2293 };
2294 let Some(label) = label else {
2295 return resolve_api_key(name, self.api_key_env_for(id), self.inline_api_key(id));
2296 };
2297 let slug = id.config_section();
2298 let account = api_key_account(slug, self.api_key_accounts(id).unwrap_or(&[]), label)?;
2299 resolve_api_key_in_section(
2301 &format!("{name} account {label:?}"),
2302 &format!("[[{slug}.accounts]]"),
2303 account.api_key_env.as_deref().unwrap_or(""),
2304 account.api_key.as_deref(),
2305 )
2306 }
2307
2308 pub fn display_prefs(&self, vendor: VendorId) -> DisplayPrefs {
2313 match vendor {
2314 VendorId::Deepseek => {
2315 DisplayPrefs::balance(self.deepseek.display_limit, self.deepseek.headline)
2316 }
2317 VendorId::Kilo => DisplayPrefs::balance(self.kilo.display_limit, self.kilo.headline),
2318 VendorId::Novita => {
2319 DisplayPrefs::balance(self.novita.display_limit, self.novita.headline)
2320 }
2321 VendorId::Moonshot => {
2322 DisplayPrefs::balance(self.moonshot.display_limit, self.moonshot.headline)
2323 }
2324 VendorId::Grok => DisplayPrefs::balance(self.grok.display_limit, self.grok.headline),
2325 VendorId::Openrouter => DisplayPrefs::balance(None, self.openrouter.headline),
2328 _ => DisplayPrefs::default(),
2329 }
2330 }
2331
2332 pub fn enabled_vendors(&self) -> Vec<VendorId> {
2333 VendorId::all()
2334 .iter()
2335 .copied()
2336 .filter(|id| self.is_enabled(*id))
2337 .collect()
2338 }
2339
2340 pub fn validate(&self) -> Result<()> {
2344 if let Some(minutes) = self.tray.refresh_minutes
2345 && !TRAY_REFRESH_MINUTES.contains(&minutes)
2346 {
2347 return Err(AppError::Other(format!(
2348 "[tray] refresh_minutes must be one of 1, 5 or 10, got {minutes}"
2349 )));
2350 }
2351 if !(1..=100).contains(&self.notifications.threshold) {
2352 return Err(AppError::Other(format!(
2353 "[notifications] threshold must be between 1 and 100, got {}",
2354 self.notifications.threshold
2355 )));
2356 }
2357 if self.context.context_window_tokens == Some(0) {
2358 return Err(AppError::Other(
2359 "[context] context_window_tokens must be greater than zero".into(),
2360 ));
2361 }
2362 for (model, tokens) in &self.context.model_context_window_tokens {
2363 if model.trim().is_empty() {
2364 return Err(AppError::Other(
2365 "[context] model_context_window_tokens keys must not be empty".into(),
2366 ));
2367 }
2368 if *tokens == 0 {
2369 return Err(AppError::Other(format!(
2370 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
2371 )));
2372 }
2373 }
2374 if let Some(limit) = self.anthropic_api.monthly_limit
2375 && (!limit.is_finite() || limit <= 0.0)
2376 {
2377 return Err(AppError::Other(
2378 "[anthropic_api] monthly_limit must be finite and greater than zero; \
2379 remove it to show spend without a limit"
2380 .into(),
2381 ));
2382 }
2383 for (section, limit) in [
2387 ("deepseek", self.deepseek.display_limit),
2388 ("kilo", self.kilo.display_limit),
2389 ("novita", self.novita.display_limit),
2390 ("moonshot", self.moonshot.display_limit),
2391 ("grok", self.grok.display_limit),
2392 ] {
2393 if let Some(limit) = limit
2394 && (!limit.is_finite() || limit <= 0.0)
2395 {
2396 return Err(AppError::Other(format!(
2397 "[{section}] display_limit must be finite and greater than zero; \
2398 remove it to show the balance without a limit"
2399 )));
2400 }
2401 }
2402 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
2403 && !self.kimi.region.eq_ignore_ascii_case("auto")
2404 {
2405 return Err(AppError::Other(format!(
2406 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
2407 self.kimi.region
2408 )));
2409 }
2410 if !self.minimax.region.eq_ignore_ascii_case("global")
2411 && !self.minimax.region.eq_ignore_ascii_case("cn")
2412 {
2413 return Err(AppError::Other(format!(
2414 "[minimax] region must be \"global\" or \"cn\", got {:?}",
2415 self.minimax.region
2416 )));
2417 }
2418 if self.supergrok.grok_binary.as_os_str().is_empty() {
2419 return Err(AppError::Other(
2420 "[supergrok] grok_binary must not be empty".into(),
2421 ));
2422 }
2423 let mut labels = HashSet::new();
2424 for account in &self.anthropic.accounts {
2425 validate_account_label(&account.label)?;
2426 if !labels.insert(&account.label) {
2427 return Err(AppError::Credentials(format!(
2428 "duplicate anthropic account label {:?}",
2429 account.label
2430 )));
2431 }
2432 }
2433 let mut openai_labels = HashSet::new();
2434 for account in &self.openai.accounts {
2435 validate_account_label_for("openai", &account.label)?;
2436 if !openai_labels.insert(&account.label) {
2437 return Err(AppError::Credentials(format!(
2438 "duplicate openai account label {:?}",
2439 account.label
2440 )));
2441 }
2442 }
2443 for id in Self::API_KEY_ACCOUNT_VENDORS {
2447 let slug = id.config_section();
2448 let mut labels = HashSet::new();
2449 for account in self.api_key_accounts(id).unwrap_or(&[]) {
2450 validate_account_label_for(slug, &account.label)?;
2451 if !labels.insert(&account.label) {
2452 return Err(AppError::Credentials(format!(
2453 "duplicate {slug} account label {:?}",
2454 account.label
2455 )));
2456 }
2457 let has_env = account
2458 .api_key_env
2459 .as_deref()
2460 .is_some_and(|name| !name.is_empty());
2461 let has_inline = account
2462 .api_key
2463 .as_deref()
2464 .is_some_and(|key| !key.is_empty());
2465 if !has_env && !has_inline {
2466 return Err(AppError::Credentials(format!(
2467 "{slug} account {:?} must set api_key_env or api_key",
2468 account.label
2469 )));
2470 }
2471 }
2472 }
2473 self.validate_custom()
2474 }
2475
2476 fn validate_custom(&self) -> Result<()> {
2480 let mut ids = HashSet::new();
2481 let mut short_names: HashSet<&str> =
2482 VendorId::all().iter().map(|v| v.short_name()).collect();
2483 for (index, custom) in self.custom.iter().enumerate() {
2484 custom.validate(index)?;
2485 if !ids.insert(custom.id.as_str()) {
2486 return Err(AppError::Other(format!(
2487 "{}: duplicate id",
2488 custom.section_label()
2489 )));
2490 }
2491 if !short_names.insert(custom.short_name.as_str()) {
2492 return Err(AppError::Other(format!(
2493 "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
2494 custom.section_label(),
2495 custom.short_name
2496 )));
2497 }
2498 }
2499 Ok(())
2500 }
2501}
2502
2503#[cfg(unix)]
2504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2505enum InlineKeyPermissionDecision {
2506 Ok,
2507 Tighten,
2508}
2509
2510#[cfg(unix)]
2511fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
2512 if mode & 0o077 == 0 {
2513 InlineKeyPermissionDecision::Ok
2514 } else {
2515 InlineKeyPermissionDecision::Tighten
2516 }
2517}
2518
2519pub fn default_path() -> Option<PathBuf> {
2520 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
2521 Some(proj.config_dir().join("config.toml"))
2522}
2523
2524fn legacy_xdg_path() -> Option<PathBuf> {
2529 let home = crate::cache::home_dir().ok()?;
2530 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
2531}
2532
2533pub fn resolved_path() -> Option<PathBuf> {
2544 if let Some(path) = override_path() {
2545 return Some(path);
2546 }
2547 let canonical = default_path();
2548 if let Some(p) = &canonical
2549 && p.exists()
2550 {
2551 return canonical;
2552 }
2553 if let Some(legacy) = legacy_xdg_path()
2554 && legacy.exists()
2555 {
2556 return Some(legacy);
2557 }
2558 canonical
2559}
2560
2561static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
2562
2563pub fn set_override_path(path: &std::path::Path) {
2569 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2570 *slot = Some(path.to_path_buf());
2571 }
2572}
2573
2574#[doc(hidden)]
2577pub fn clear_override_path() {
2578 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2579 *slot = None;
2580 }
2581}
2582
2583fn override_path() -> Option<PathBuf> {
2584 PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
2585}
2586
2587#[doc(hidden)]
2593pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
2594 #[cfg(unix)]
2595 {
2596 use std::os::unix::ffi::{OsStrExt, OsStringExt};
2597 let rest = arg.as_bytes().strip_prefix(b"--config=")?;
2598 Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
2599 }
2600 #[cfg(windows)]
2601 {
2602 use std::os::windows::ffi::{OsStrExt, OsStringExt};
2603 const PREFIX: &[u16] = &[
2604 b'-' as u16,
2605 b'-' as u16,
2606 b'c' as u16,
2607 b'o' as u16,
2608 b'n' as u16,
2609 b'f' as u16,
2610 b'i' as u16,
2611 b'g' as u16,
2612 b'=' as u16,
2613 ];
2614 let wide: Vec<u16> = arg.encode_wide().collect();
2615 let rest = wide.strip_prefix(PREFIX)?;
2616 Some(std::ffi::OsString::from_wide(rest).into())
2617 }
2618 #[cfg(not(any(unix, windows)))]
2619 {
2620 Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
2621 }
2622}
2623
2624fn expand_tilde(p: &std::path::Path) -> PathBuf {
2627 let Some(s) = p.to_str() else {
2628 return p.to_path_buf();
2629 };
2630 let rest = if s == "~" {
2631 ""
2632 } else if let Some(r) = s.strip_prefix("~/") {
2633 r
2634 } else {
2635 return p.to_path_buf();
2636 };
2637 match crate::cache::home_dir() {
2638 Ok(home) if rest.is_empty() => home,
2639 Ok(home) => home.join(rest),
2640 Err(_) => p.to_path_buf(),
2641 }
2642}
2643
2644fn expand_tilde_opt(p: &mut Option<PathBuf>) {
2645 if let Some(inner) = p.as_ref() {
2646 *p = Some(expand_tilde(inner));
2647 }
2648}
2649
2650pub fn config_path_hint() -> String {
2655 resolved_path()
2656 .map(|p| p.display().to_string())
2657 .unwrap_or_else(|| "config.toml".to_string())
2658}
2659
2660#[cfg(test)]
2661mod tests {
2662 use super::*;
2663 use std::io::Write;
2664 use tempfile::NamedTempFile;
2665
2666 #[cfg(unix)]
2667 use std::os::unix::fs::{MetadataExt, PermissionsExt};
2668
2669 fn write_toml(s: &str) -> NamedTempFile {
2670 let mut f = NamedTempFile::new().unwrap();
2671 f.write_all(s.as_bytes()).unwrap();
2672 f.flush().unwrap();
2673 f
2674 }
2675
2676 #[test]
2680 fn openai_without_accounts_resolves_the_singular_path() {
2681 let explicit = OpenAiConfig {
2682 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2683 ..OpenAiConfig::default()
2684 };
2685 assert_eq!(
2686 explicit.resolve_auth_path(None).unwrap(),
2687 PathBuf::from("/tmp/codex/auth.json")
2688 );
2689
2690 let bare = OpenAiConfig::default();
2691 assert_eq!(
2692 bare.resolve_auth_path(None).unwrap(),
2693 crate::openai::creds::default_path().unwrap(),
2694 "no codex_auth_path must still mean ~/.codex/auth.json"
2695 );
2696 }
2697
2698 #[test]
2701 fn openai_named_accounts_resolve_their_own_auth_file() {
2702 let config: Config = toml::from_str(
2703 r#"
2704 [openai]
2705 codex_auth_path = "/tmp/personal/auth.json"
2706 [[openai.accounts]]
2707 label = "work"
2708 codex_auth_path = "/tmp/work/auth.json"
2709 "#,
2710 )
2711 .unwrap();
2712
2713 assert_eq!(
2714 config.openai.resolve_auth_path(Some("work")).unwrap(),
2715 PathBuf::from("/tmp/work/auth.json")
2716 );
2717 assert_eq!(
2718 config.openai.resolve_auth_path(None).unwrap(),
2719 PathBuf::from("/tmp/personal/auth.json")
2720 );
2721 }
2722
2723 #[test]
2726 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
2727 let config = OpenAiConfig {
2728 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
2729 accounts: vec![OpenAiAccount {
2730 label: "work".into(),
2731 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
2732 }],
2733 ..OpenAiConfig::default()
2734 };
2735 let err = config
2736 .resolve_auth_path(Some("nope"))
2737 .unwrap_err()
2738 .to_string();
2739 assert!(err.contains("nope"), "{err}");
2740 assert!(err.contains("[[openai.accounts]]"), "{err}");
2741 }
2742
2743 fn two_codex_accounts() -> OpenAiConfig {
2744 OpenAiConfig {
2745 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2746 accounts: vec![
2747 OpenAiAccount {
2748 label: "main".into(),
2749 codex_auth_path: PathBuf::from("/tmp/codex-main/auth.json"),
2750 },
2751 OpenAiAccount {
2752 label: "work".into(),
2753 codex_auth_path: PathBuf::from("/tmp/codex-work/auth.json"),
2754 },
2755 ],
2756 ..OpenAiConfig::default()
2757 }
2758 }
2759
2760 #[test]
2761 fn the_active_codex_account_is_read_from_the_default_slot() {
2762 let config = two_codex_accounts();
2763 assert_eq!(
2764 config
2765 .fetch_auth_path_probing("work", Some("work"), |_| false)
2766 .unwrap(),
2767 PathBuf::from("/tmp/codex/auth.json")
2768 );
2769 assert_eq!(
2770 config
2771 .fetch_auth_path_probing("main", Some("work"), |_| false)
2772 .unwrap(),
2773 PathBuf::from("/tmp/codex-main/auth.json")
2774 );
2775 }
2776
2777 #[test]
2778 fn an_active_codex_account_with_its_own_file_keeps_reading_it() {
2779 let config = two_codex_accounts();
2780 assert_eq!(
2781 config
2782 .fetch_auth_path_probing("work", Some("work"), |_| true)
2783 .unwrap(),
2784 PathBuf::from("/tmp/codex-work/auth.json")
2785 );
2786 }
2787
2788 #[test]
2789 fn adding_an_openai_account_keeps_the_rest_of_the_file() {
2790 let mut doc: toml_edit::DocumentMut = "# mine\n[zai]\nenabled = true\n".parse().unwrap();
2791 add_openai_account_to_doc(&mut doc, "work", "~/.codex-work/auth.json").unwrap();
2792 let text = doc.to_string();
2793 assert!(
2794 text.starts_with("# mine\n[zai]\nenabled = true\n"),
2795 "{text}"
2796 );
2797 let parsed: Config = toml::from_str(&text).unwrap();
2798 assert_eq!(parsed.openai.accounts[0].label, "work");
2799 assert!(add_openai_account_to_doc(&mut doc, "work", "x").is_err());
2800 assert!(add_openai_account_to_doc(&mut doc, "../x", "x").is_err());
2801 }
2802
2803 #[test]
2804 fn defaults_enable_only_the_four_core_vendors() {
2805 let c = Config::default();
2806 assert!(c.is_enabled(VendorId::Anthropic));
2807 assert!(c.is_enabled(VendorId::Openai));
2808 assert!(c.is_enabled(VendorId::Zai));
2809 assert!(c.is_enabled(VendorId::Openrouter));
2810 for opt_in in [
2811 VendorId::AnthropicApi,
2812 VendorId::Copilot,
2813 VendorId::Deepseek,
2814 VendorId::Kimi,
2815 VendorId::Kilo,
2816 VendorId::Novita,
2817 VendorId::Moonshot,
2818 VendorId::Grok,
2819 VendorId::Supergrok,
2820 VendorId::Grokbot,
2821 VendorId::Cursor,
2822 VendorId::Minimax,
2823 VendorId::Kiro,
2824 VendorId::CommandCode,
2825 VendorId::OrcaRouter,
2826 VendorId::ModelStudio,
2827 ] {
2828 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
2829 }
2830 assert_eq!(c.enabled_vendors().len(), 4);
2831 }
2832
2833 #[test]
2834 fn commandcode_is_opt_in_when_loading_existing_configs() {
2835 let absent: Config = toml::from_str("[openai]\nenabled = true\n").unwrap();
2836 assert!(!absent.is_enabled(VendorId::CommandCode));
2837
2838 let opted_in: Config = toml::from_str("[commandcode]\nenabled = true\n").unwrap();
2839 assert!(opted_in.is_enabled(VendorId::CommandCode));
2840
2841 let opted_out: Config = toml::from_str("[commandcode]\nenabled = false\n").unwrap();
2842 assert!(!opted_out.is_enabled(VendorId::CommandCode));
2843 }
2844
2845 #[test]
2846 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
2847 let config = Config::default();
2848 assert!(!config.is_enabled(VendorId::NousResearch));
2849 assert!(!config.is_enabled(VendorId::OpenCodeGo));
2850 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
2851 assert!(config.opencode_go.api_key.is_none());
2852 assert!(!config.is_enabled(VendorId::Copilot));
2853 }
2854
2855 #[cfg(unix)]
2856 #[test]
2857 fn inline_credentials_are_protected() {
2858 let mut config = Config::default();
2859 config.opencode_go.api_key = Some("<redacted>".to_string());
2860 assert!(config.has_inline_secrets());
2861 }
2862
2863 #[test]
2864 fn antigravity_oauth_client_overrides_parse() {
2865 let config: Config = toml::from_str(
2866 "[antigravity]
2867enabled = true
2868oauth_client_id = \"test-client\"
2869oauth_client_secret = \"test-client-secret\"
2870",
2871 )
2872 .unwrap();
2873 assert!(config.antigravity.enabled);
2874 assert_eq!(
2875 config.antigravity.oauth_client_id.as_deref(),
2876 Some("test-client")
2877 );
2878 assert_eq!(
2879 config.antigravity.oauth_client_secret.as_deref(),
2880 Some("test-client-secret")
2881 );
2882 let bare: Config = toml::from_str(
2883 "[antigravity]
2884enabled = true
2885",
2886 )
2887 .unwrap();
2888 assert!(bare.antigravity.oauth_client_id.is_none());
2889 assert!(bare.antigravity.oauth_client_secret.is_none());
2890 }
2891
2892 #[cfg(unix)]
2893 #[test]
2894 fn antigravity_inline_oauth_secret_receives_config_file_protection() {
2895 let mut config = Config::default();
2896 config.antigravity.oauth_client_id = Some("test-client".into());
2897 assert!(!config.has_inline_secrets());
2898 config.antigravity.oauth_client_secret = Some("<redacted>".into());
2899 assert!(config.has_inline_secrets());
2900 }
2901
2902 #[cfg(unix)]
2903 #[test]
2904 fn openrouter_named_inline_keys_receive_config_file_protection() {
2905 let mut config = Config::default();
2906 config.openrouter.accounts.push(ApiKeyAccount {
2907 label: "work".into(),
2908 api_key_env: None,
2909 api_key: Some("<redacted>".into()),
2910 });
2911 assert!(config.has_inline_secrets());
2912 }
2913
2914 #[cfg(unix)]
2915 #[test]
2916 fn every_named_inline_api_key_receives_config_file_protection() {
2917 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
2918 let section = vendor.config_section();
2919 let config: Config = toml::from_str(&format!(
2920 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"<redacted>\"\n"
2921 ))
2922 .unwrap();
2923 assert!(config.has_inline_secrets(), "{vendor:?}");
2924 }
2925 }
2926
2927 #[test]
2928 fn missing_file_uses_defaults() {
2929 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
2930 let c = Config::load_from(path).unwrap();
2931 assert!(c.is_enabled(VendorId::Anthropic));
2932 }
2933
2934 #[test]
2935 fn parses_full_config() {
2936 let f = write_toml(
2937 r#"
2938 [anthropic]
2939 enabled = true
2940
2941 [openai]
2942 enabled = false
2943 admin_key_env = "MY_ADMIN_KEY"
2944
2945 [zai]
2946 enabled = true
2947 api_key_env = "MY_ZAI"
2948 plan_tier = "pro"
2949
2950 [openrouter]
2951 enabled = false
2952 "#,
2953 );
2954 let c = Config::load_from(f.path()).unwrap();
2955 assert!(c.is_enabled(VendorId::Anthropic));
2956 assert!(!c.is_enabled(VendorId::Openai));
2957 assert!(c.is_enabled(VendorId::Zai));
2958 assert!(!c.is_enabled(VendorId::Openrouter));
2959 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
2960 assert_eq!(c.zai.api_key_env, "MY_ZAI");
2961 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
2962 assert!(c.openrouter.accounts.is_empty());
2963 assert!(c.openrouter.show_default_account);
2964 }
2965
2966 #[test]
2967 fn partial_config_falls_back_to_defaults() {
2968 let f = write_toml(
2969 r#"[openai]
2970enabled = false
2971"#,
2972 );
2973 let c = Config::load_from(f.path()).unwrap();
2974 assert!(!c.is_enabled(VendorId::Openai));
2975 assert!(c.is_enabled(VendorId::Anthropic));
2977 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
2978 }
2979
2980 #[test]
2981 fn malformed_toml_returns_error() {
2982 let f = write_toml("this is not = = valid");
2983 assert!(Config::load_from(f.path()).is_err());
2984 }
2985
2986 #[cfg(unix)]
2987 #[test]
2988 fn load_from_tightens_world_readable_config_with_inline_api_key() {
2989 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
2990 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
2991
2992 Config::load_from(file.path()).unwrap();
2993
2994 assert_eq!(
2995 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
2996 0o600
2997 );
2998 }
2999
3000 #[cfg(unix)]
3001 #[test]
3002 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
3003 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
3004 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3005
3006 Config::load_from(file.path()).unwrap();
3007
3008 assert_eq!(
3009 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3010 0o644
3011 );
3012 }
3013
3014 #[cfg(unix)]
3015 #[test]
3016 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
3017 assert_eq!(
3018 inline_key_permission_decision(0o600),
3019 InlineKeyPermissionDecision::Ok
3020 );
3021 assert_eq!(
3022 inline_key_permission_decision(0o640),
3023 InlineKeyPermissionDecision::Tighten
3024 );
3025 assert_eq!(
3026 inline_key_permission_decision(0o604),
3027 InlineKeyPermissionDecision::Tighten
3028 );
3029 }
3030
3031 #[test]
3032 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
3033 for value in ["0", "-1", "inf", "nan"] {
3034 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
3035 let error = Config::load_from(file.path()).unwrap_err().to_string();
3036 assert!(error.contains("monthly_limit"), "value {value}: {error}");
3037 }
3038
3039 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
3040 assert_eq!(
3041 Config::load_from(file.path())
3042 .unwrap()
3043 .anthropic_api
3044 .monthly_limit,
3045 Some(1000.0)
3046 );
3047 }
3048
3049 #[test]
3050 fn display_limit_must_be_positive_and_finite_on_every_balance_vendor() {
3051 for section in ["deepseek", "kilo", "novita", "moonshot", "grok"] {
3053 for value in ["0", "-1", "inf", "nan"] {
3054 let file = write_toml(&format!("[{section}]\ndisplay_limit = {value}\n"));
3055 let error = Config::load_from(file.path()).unwrap_err().to_string();
3056 assert!(
3057 error.contains(&format!("[{section}] display_limit")),
3058 "{section} = {value}: {error}"
3059 );
3060 }
3061 let file = write_toml(&format!("[{section}]\ndisplay_limit = 200\n"));
3062 let config = Config::load_from(file.path()).unwrap();
3063 assert_eq!(
3064 config.display_prefs(vendor_of(section)).display_limit,
3065 Some(200.0),
3066 "{section}"
3067 );
3068 }
3069 }
3070
3071 #[test]
3073 fn display_limit_is_absent_until_the_user_states_one() {
3074 let config = Config::default();
3075 for vendor in VendorId::all() {
3076 assert_eq!(
3077 config.display_prefs(*vendor).display_limit,
3078 None,
3079 "{vendor:?}"
3080 );
3081 }
3082 }
3083
3084 #[test]
3087 fn the_default_headline_follows_the_kind_of_vendor() {
3088 let config = Config::default();
3089 for vendor in [
3090 VendorId::Deepseek,
3091 VendorId::Kilo,
3092 VendorId::Novita,
3093 VendorId::Moonshot,
3094 VendorId::Grok,
3095 ] {
3096 assert_eq!(
3097 config.display_prefs(vendor).headline,
3098 Headline::Amount,
3099 "{vendor:?}"
3100 );
3101 }
3102 assert_eq!(
3103 config.display_prefs(VendorId::Openrouter).headline,
3104 Headline::Percent
3105 );
3106 assert_eq!(
3107 config.display_prefs(VendorId::Anthropic),
3108 DisplayPrefs::default()
3109 );
3110 }
3111
3112 #[test]
3113 fn the_headline_is_configurable_per_vendor_and_a_typo_is_loud() {
3114 let file = write_toml("[deepseek]\nheadline = \"percent\"\n");
3115 assert_eq!(
3116 Config::load_from(file.path())
3117 .unwrap()
3118 .display_prefs(VendorId::Deepseek)
3119 .headline,
3120 Headline::Percent
3121 );
3122
3123 let file = write_toml("[openrouter]\nheadline = \"amount\"\n");
3124 assert_eq!(
3125 Config::load_from(file.path())
3126 .unwrap()
3127 .display_prefs(VendorId::Openrouter)
3128 .headline,
3129 Headline::Amount
3130 );
3131
3132 let file = write_toml("[deepseek]\nheadline = \"dollars\"\n");
3133 let error = Config::load_from(file.path()).unwrap_err().to_string();
3134 assert!(error.contains("headline"), "{error}");
3135 }
3136
3137 #[test]
3143 fn openrouter_has_no_display_limit_to_be_ignored() {
3144 let file = write_toml("[openrouter]\ndisplay_limit = 200\nheadline = \"percent\"\n");
3145 let config = Config::load_from(file.path()).unwrap();
3146 let prefs = config.display_prefs(VendorId::Openrouter);
3147 assert_eq!(prefs.display_limit, None);
3148 assert_eq!(prefs.headline, Headline::Percent);
3149 }
3150
3151 #[test]
3154 fn a_display_limit_alone_leaves_the_headline_where_it_was() {
3155 let file = write_toml("[deepseek]\ndisplay_limit = 200\n");
3156 let prefs = Config::load_from(file.path())
3157 .unwrap()
3158 .display_prefs(VendorId::Deepseek);
3159 assert_eq!(prefs.display_limit, Some(200.0));
3160 assert_eq!(prefs.headline, Headline::Amount);
3161 }
3162
3163 fn vendor_of(section: &str) -> VendorId {
3164 VendorId::all()
3165 .iter()
3166 .copied()
3167 .find(|vendor| vendor.config_section() == section)
3168 .unwrap_or_else(|| panic!("no vendor for [{section}]"))
3169 }
3170
3171 #[test]
3172 fn minimax_region_accepts_only_known_instances() {
3173 for region in ["global", "GLOBAL", "cn", "CN"] {
3174 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3175 assert_eq!(
3176 Config::load_from(file.path()).unwrap().minimax.region,
3177 region
3178 );
3179 }
3180
3181 for region in ["", "china", "us"] {
3182 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3183 let error = Config::load_from(file.path()).unwrap_err().to_string();
3184 assert!(error.contains("[minimax] region"), "{error}");
3185 }
3186 }
3187
3188 #[test]
3189 fn kimi_region_accepts_auto_and_both_deployments() {
3190 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
3191 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3192 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
3193 }
3194
3195 for region in ["", "us", "oversea"] {
3196 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3197 let error = Config::load_from(file.path()).unwrap_err().to_string();
3198 assert!(error.contains("[kimi] region"), "{error}");
3199 }
3200 }
3201
3202 #[test]
3203 fn kimi_defaults_to_auto_region_and_no_credential_override() {
3204 let defaults = KimiConfig::default();
3205 assert_eq!(defaults.region, "auto");
3206 assert_eq!(defaults.credentials_path, None);
3207 assert!(!defaults.enabled);
3208 }
3209
3210 #[test]
3211 fn kimi_credentials_path_expands_a_tilde() {
3212 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
3213 let path = Config::load_from(file.path())
3214 .unwrap()
3215 .kimi
3216 .credentials_path
3217 .unwrap();
3218 assert!(!path.starts_with("~"), "{}", path.display());
3219 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
3220 }
3221
3222 #[test]
3223 fn grokbot_is_opt_in_and_takes_no_api_key() {
3224 let defaults = GrokbotConfig::default();
3225 assert!(!defaults.enabled);
3226 assert_eq!(defaults.secrets_path, None);
3227 let config = Config::default();
3229 assert_eq!(config.api_key_env_for(VendorId::Grokbot), "");
3230 assert_eq!(config.inline_api_key(VendorId::Grokbot), None);
3231
3232 let file = write_toml("[grokbot]\nenabled = true\n");
3233 let config = Config::load_from(file.path()).unwrap();
3234 assert!(config.is_enabled(VendorId::Grokbot));
3235 assert!(config.enabled_vendors().contains(&VendorId::Grokbot));
3236 }
3237
3238 #[test]
3239 fn grokbot_secrets_path_expands_a_tilde() {
3240 let file = write_toml("[grokbot]\nsecrets_path = \"~/gb/secrets.json\"\n");
3241 let path = Config::load_from(file.path())
3242 .unwrap()
3243 .grokbot
3244 .secrets_path
3245 .unwrap();
3246 assert!(!path.starts_with("~"), "{}", path.display());
3247 assert!(path.ends_with("gb/secrets.json"), "{}", path.display());
3248 }
3249
3250 #[test]
3251 fn modelstudio_is_opt_in_and_takes_no_api_key() {
3252 let defaults = ModelStudioConfig::default();
3253 assert!(!defaults.enabled);
3254 assert_eq!(defaults.config_dir, None);
3255 let config = Config::default();
3257 assert_eq!(config.api_key_env_for(VendorId::ModelStudio), "");
3258 assert_eq!(config.inline_api_key(VendorId::ModelStudio), None);
3259
3260 let file = write_toml("[modelstudio]\nenabled = true\n");
3261 let config = Config::load_from(file.path()).unwrap();
3262 assert!(config.is_enabled(VendorId::ModelStudio));
3263 assert!(config.enabled_vendors().contains(&VendorId::ModelStudio));
3264 }
3265
3266 #[test]
3267 fn modelstudio_config_dir_expands_a_tilde() {
3268 let file = write_toml("[modelstudio]\nconfig_dir = \"~/bl\"\n");
3269 let path = Config::load_from(file.path())
3270 .unwrap()
3271 .modelstudio
3272 .config_dir
3273 .unwrap();
3274 assert!(!path.starts_with("~"), "{}", path.display());
3275 assert!(path.ends_with("bl"), "{}", path.display());
3276 }
3277
3278 #[test]
3279 fn optional_api_key_reports_absence_instead_of_failing() {
3280 assert_eq!(
3281 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", Some("inline")),
3282 Some("inline".to_string())
3283 );
3284 assert_eq!(
3285 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", None),
3286 None
3287 );
3288 assert_eq!(optional_api_key("KIMI_API_KEY_UNSET", Some("")), None);
3289 assert_eq!(
3292 optional_api_key("9INVALID", Some("inline")),
3293 Some("inline".to_string())
3294 );
3295 }
3296
3297 #[test]
3298 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
3299 let defaults = Config::default();
3300 assert!(!defaults.context.enabled);
3301 assert_eq!(
3302 defaults.context.window_tokens_for(Some("claude-test")),
3303 None
3304 );
3305
3306 let file = write_toml(
3307 r#"
3308 [context]
3309 enabled = true
3310 context_window_tokens = 200000
3311
3312 [context.model_context_window_tokens]
3313 claude-opus-1m = 1000000
3314 "claude exact id" = 300000
3315 "#,
3316 );
3317 let config = Config::load_from(file.path()).unwrap();
3318 assert!(config.context.enabled);
3319 assert_eq!(
3320 config.context.window_tokens_for(Some("claude-opus-1m")),
3321 Some(1_000_000)
3322 );
3323 assert_eq!(
3324 config.context.window_tokens_for(Some("claude exact id")),
3325 Some(300_000)
3326 );
3327 assert_eq!(
3328 config.context.window_tokens_for(Some("another-model")),
3329 Some(200_000)
3330 );
3331 }
3332
3333 #[test]
3334 fn context_layout_defaults_to_full_and_parses_each_variant() {
3335 assert_eq!(Config::default().context.layout, ContextLayout::Full);
3336 for (text, want) in [
3337 ("full", ContextLayout::Full),
3338 ("split", ContextLayout::Split),
3339 ("bottom", ContextLayout::Bottom),
3340 ] {
3341 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
3342 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
3343 }
3344 let file = write_toml("[context]\nlayout = \"floating\"\n");
3345 assert!(
3346 Config::load_from(file.path()).is_err(),
3347 "an unknown layout must be rejected, not silently defaulted"
3348 );
3349 }
3350
3351 #[test]
3352 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
3353 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
3354 for (text, want) in [
3355 ("sidebar", VendorBoxStyle::Sidebar),
3356 ("navbar", VendorBoxStyle::Navbar),
3357 ("none", VendorBoxStyle::None),
3358 ] {
3359 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
3360 assert_eq!(
3361 Config::load_from(file.path()).unwrap().ui.vendor_box(),
3362 want
3363 );
3364 }
3365 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
3366 assert!(
3367 Config::load_from(file.path()).is_err(),
3368 "an unknown vendor_box style must be rejected, not silently defaulted"
3369 );
3370 }
3371
3372 #[test]
3373 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
3374 for source in [
3375 "[context]\ncontext_window_tokens = 0\n",
3376 "[context.model_context_window_tokens]\nclaude = 0\n",
3377 "[context.model_context_window_tokens]\n\" \" = 200000\n",
3378 ] {
3379 let file = write_toml(source);
3380 let error = Config::load_from(file.path()).unwrap_err().to_string();
3381 assert!(error.contains("context"), "{error}");
3382 }
3383 }
3384
3385 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
3387 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
3388 M.lock().unwrap_or_else(|p| p.into_inner())
3389 }
3390
3391 #[test]
3392 fn resolve_api_key_prefers_env_over_inline() {
3393 let _g = env_guard();
3394 let var = "AI_USAGEBAR_TEST_ENV_WINS";
3396 unsafe { std::env::set_var(var, "from-env") };
3398 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
3399 unsafe { std::env::remove_var(var) };
3400 assert_eq!(got, "from-env");
3401 }
3402
3403 #[test]
3404 fn resolve_api_key_falls_back_to_inline() {
3405 let _g = env_guard();
3406 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
3407 unsafe { std::env::remove_var(var) };
3408 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
3409 assert_eq!(got, "inline-key");
3410 }
3411
3412 #[test]
3413 fn copilot_token_prefers_explicit_environment_over_gh_cli() {
3414 struct NeverRun;
3415 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
3416 fn run(
3417 &self,
3418 _: &crate::copilot::credentials::GhAuthTokenCommand,
3419 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3420 panic!("environment override must not invoke gh")
3421 }
3422 }
3423
3424 let token = CopilotConfig::default()
3425 .resolve_token_with(
3426 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
3427 &NeverRun,
3428 )
3429 .unwrap();
3430 assert_eq!(token, "from-environment");
3431 }
3432
3433 #[test]
3434 fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
3435 struct FailedGh;
3436 impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
3437 fn run(
3438 &self,
3439 _: &crate::copilot::credentials::GhAuthTokenCommand,
3440 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3441 Ok(crate::copilot::credentials::GhAuthTokenOutput {
3442 success: false,
3443 stdout: b"never-echo-gh-output".to_vec(),
3444 })
3445 }
3446 }
3447 let error = CopilotConfig::default()
3448 .resolve_token_with(|_| None, &FailedGh)
3449 .unwrap_err()
3450 .to_string();
3451 assert!(error.contains("gh auth login --web"));
3452 assert!(!error.contains("never-echo-gh-output"));
3453 }
3454
3455 #[test]
3456 fn resolve_api_key_errors_when_both_missing() {
3457 let _g = env_guard();
3458 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
3459 unsafe { std::env::remove_var(var) };
3460 let err = resolve_api_key("Zai", var, None).unwrap_err();
3461 match err {
3462 crate::error::AppError::Credentials(msg) => {
3463 assert!(
3464 msg.contains("api_key"),
3465 "error should suggest config field: {msg}"
3466 );
3467 }
3468 other => panic!("expected Credentials error, got {other:?}"),
3469 }
3470 }
3471
3472 #[test]
3473 fn resolve_api_key_uses_exact_opencode_go_section_name() {
3474 let _g = env_guard();
3475 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
3476 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
3477 let message = err.to_string();
3478 assert!(
3479 message.contains("[opencode-go]"),
3480 "wrong section hint: {message}"
3481 );
3482 assert!(
3483 !message.contains("[opencode go]"),
3484 "wrong section hint: {message}"
3485 );
3486 }
3487
3488 fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
3489 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
3490 M.lock().unwrap_or_else(|p| p.into_inner())
3491 }
3492
3493 struct ScopedPathOverride {
3499 _serial: std::sync::MutexGuard<'static, ()>,
3500 }
3501
3502 impl Drop for ScopedPathOverride {
3503 fn drop(&mut self) {
3504 clear_override_path();
3505 }
3506 }
3507
3508 fn scoped_path_override() -> ScopedPathOverride {
3509 ScopedPathOverride {
3510 _serial: path_override_guard(),
3511 }
3512 }
3513
3514 #[test]
3515 fn override_path_wins_over_canonical_and_legacy() {
3516 let _scoped = scoped_path_override();
3517 let file = NamedTempFile::new().unwrap();
3518 set_override_path(file.path());
3519 assert_eq!(resolved_path().as_deref(), Some(file.path()));
3520 assert_eq!(config_path_hint(), file.path().display().to_string());
3521 clear_override_path();
3522 let p = resolved_path().expect("a config path must resolve");
3524 assert!(p.ends_with("config.toml"));
3525 }
3526
3527 #[test]
3528 fn scoped_override_guard_clears_the_override_on_panic() {
3529 let hook = std::panic::take_hook();
3532 std::panic::set_hook(Box::new(|_| {}));
3533 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3534 let _scoped = scoped_path_override();
3535 set_override_path(std::path::Path::new("panicked-override.toml"));
3536 panic!("simulated mid-test failure");
3537 }))
3538 .is_err();
3539 std::panic::set_hook(hook);
3540 assert!(panicked, "the simulated failure must run");
3541 let _serial = path_override_guard();
3542 assert!(
3543 override_path().is_none(),
3544 "a panicking test must not leak the override into siblings"
3545 );
3546 }
3547
3548 #[test]
3549 fn config_path_hint_ends_with_config_toml() {
3550 let _g = path_override_guard();
3551 assert!(config_path_hint().ends_with("config.toml"));
3554 }
3555
3556 #[test]
3557 fn config_flag_value_splits_the_equals_form() {
3558 use std::ffi::OsStr;
3559 assert_eq!(
3560 config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
3561 Some(std::path::Path::new("work.toml"))
3562 );
3563 assert_eq!(
3564 config_flag_value(OsStr::new("--config=")).as_deref(),
3565 Some(std::path::Path::new(""))
3566 );
3567 assert_eq!(config_flag_value(OsStr::new("--config")), None);
3568 assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
3569 assert_eq!(config_flag_value(OsStr::new("account")), None);
3570 }
3571
3572 #[cfg(unix)]
3576 #[test]
3577 fn config_flag_value_keeps_undecodable_bytes_intact() {
3578 use std::ffi::OsString;
3579 use std::os::unix::ffi::{OsStrExt, OsStringExt};
3580 let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
3581 let value = config_flag_value(&raw).expect("prefix matches");
3582 assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
3583 }
3584
3585 #[cfg(windows)]
3586 #[test]
3587 fn config_flag_value_keeps_lone_surrogates_intact() {
3588 use std::ffi::OsString;
3589 use std::os::windows::ffi::{OsStrExt, OsStringExt};
3590 let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
3591 wide.push(0xDC00); wide.extend("x.toml".encode_utf16());
3593 let raw = OsString::from_wide(&wide);
3594 let value = config_flag_value(&raw).expect("prefix matches");
3595 let mut expected = vec![0xDC00u16];
3596 expected.extend("x.toml".encode_utf16());
3597 assert_eq!(
3598 value.as_os_str().encode_wide().collect::<Vec<_>>(),
3599 expected
3600 );
3601 }
3602
3603 #[test]
3604 fn resolve_api_key_treats_empty_env_as_unset() {
3605 let _g = env_guard();
3606 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
3607 unsafe { std::env::set_var(var, "") };
3608 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
3609 unsafe { std::env::remove_var(var) };
3610 assert_eq!(got, "inline");
3611 }
3612
3613 #[test]
3614 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
3615 let _g = env_guard();
3616 let bad = "sk-kimi-very-real-looking-pasted-secret";
3618 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
3619 let msg = err.to_string();
3620 assert!(
3621 msg.contains("invalid") && msg.contains("api_key_env"),
3622 "error should explain misconfiguration: {msg}"
3623 );
3624 assert!(
3625 !msg.contains(bad),
3626 "error must not echo the misconfigured value: {msg}"
3627 );
3628 assert!(msg.contains("valid environment variable name"));
3629 assert!(
3630 msg.contains("[kimi]"),
3631 "error should point at the lowercase TOML section: {msg}"
3632 );
3633 }
3634
3635 #[test]
3636 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
3637 let _g = env_guard();
3638 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
3639 assert_eq!(got, "inline-key");
3640 }
3641
3642 #[test]
3643 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
3644 let _g = env_guard();
3645 let pasted_secret = "sk_pasted_secret";
3648 unsafe { std::env::remove_var(pasted_secret) };
3649 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
3650 assert!(
3651 !err.to_string().contains(pasted_secret),
3652 "error must not echo configured api_key_env values"
3653 );
3654 }
3655
3656 #[test]
3657 fn is_valid_env_var_name_rules() {
3658 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
3660 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
3661 }
3662 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
3664 assert!(
3665 !is_valid_env_var_name(invalid),
3666 "{invalid} should be invalid"
3667 );
3668 }
3669 }
3670
3671 #[test]
3672 fn config_parses_with_inline_api_key_and_primary() {
3673 let f = write_toml(
3674 r#"
3675 [ui]
3676 primary = "openrouter"
3677
3678 [zai]
3679 enabled = true
3680 api_key_env = "MY_ZAI"
3681 api_key = "sk-zai-inline"
3682
3683 [openrouter]
3684 enabled = true
3685 api_key = "sk-or-inline"
3686 "#,
3687 );
3688 let c = Config::load_from(f.path()).unwrap();
3689 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
3690 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
3691 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
3692 }
3693
3694 #[test]
3695 fn openrouter_named_accounts_preserve_the_default_contract() {
3696 let f = write_toml(
3697 r#"
3698 [openrouter]
3699 enabled = true
3700 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
3701 api_key = "default-inline"
3702 show_default_account = false
3703
3704 [[openrouter.accounts]]
3705 label = "work"
3706 api_key_env = "OPENROUTER_WORK_API_KEY"
3707
3708 [[openrouter.accounts]]
3709 label = "personal"
3710 api_key = "personal-inline"
3711 "#,
3712 );
3713 let _g = env_guard();
3714 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
3715 let config = Config::load_from(f.path()).unwrap();
3716 assert!(!config.openrouter.show_default_account);
3717 assert_eq!(config.openrouter.accounts.len(), 2);
3718 assert_eq!(
3719 config
3720 .resolve_account_api_key_for(VendorId::Openrouter, None)
3721 .unwrap(),
3722 "default-inline"
3723 );
3724 assert_eq!(
3725 config
3726 .resolve_account_api_key_for(VendorId::Openrouter, Some("personal"))
3727 .unwrap(),
3728 "personal-inline"
3729 );
3730 }
3731
3732 #[test]
3733 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
3734 for source in [
3735 r#"
3736 [[openrouter.accounts]]
3737 label = "work"
3738 api_key = "one"
3739 [[openrouter.accounts]]
3740 label = "work"
3741 api_key = "two"
3742 "#,
3743 r#"
3744 [[openrouter.accounts]]
3745 label = "../work"
3746 api_key = "one"
3747 "#,
3748 r#"
3749 [[openrouter.accounts]]
3750 label = "work"
3751 "#,
3752 ] {
3753 let f = write_toml(source);
3754 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
3755 }
3756 }
3757
3758 #[test]
3759 fn openrouter_unknown_account_never_falls_back_to_default_key() {
3760 let mut config = Config::default();
3761 config.openrouter.api_key = Some("default-secret".into());
3762 config.openrouter.accounts.push(ApiKeyAccount {
3763 label: "work".into(),
3764 api_key_env: None,
3765 api_key: Some("work-secret".into()),
3766 });
3767 let message = config
3768 .resolve_account_api_key_for(VendorId::Openrouter, Some("missing"))
3769 .unwrap_err()
3770 .to_string();
3771 assert!(message.contains("missing") && message.contains("work"));
3772 assert!(!message.contains("default-secret"));
3773 assert!(!message.contains("work-secret"));
3774 }
3775
3776 #[test]
3777 fn openrouter_account_key_errors_do_not_echo_configured_values() {
3778 let mut config = Config::default();
3779 config.openrouter.accounts.push(ApiKeyAccount {
3780 label: "work".into(),
3781 api_key_env: Some("sk_pasted_secret".into()),
3782 api_key: None,
3783 });
3784 let _g = env_guard();
3785 unsafe { std::env::remove_var("sk_pasted_secret") };
3786 let message = config
3787 .resolve_account_api_key_for(VendorId::Openrouter, Some("work"))
3788 .unwrap_err()
3789 .to_string();
3790 assert!(message.contains("[[openrouter.accounts]]"));
3791 assert!(!message.contains("sk_pasted_secret"));
3792 }
3793
3794 #[test]
3795 fn every_api_key_account_vendor_validates_its_array_like_openrouter() {
3796 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
3797 let section = vendor.config_section();
3798 let parse = |accounts: &str| -> Result<Config> {
3799 let config: Config =
3800 toml::from_str(&format!("[{section}]\nenabled = true\n{accounts}")).unwrap();
3801 config.validate().map(|()| config)
3802 };
3803 let config = parse(&format!(
3804 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k\"\n"
3805 ))
3806 .unwrap();
3807 assert_eq!(
3808 config.api_key_accounts(vendor).unwrap().len(),
3809 1,
3810 "{vendor:?}"
3811 );
3812 assert!(config.show_default_api_key_account(vendor), "{vendor:?}");
3813
3814 let duplicate = parse(&format!(
3815 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"a\"\n\
3816 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"b\"\n"
3817 ))
3818 .unwrap_err()
3819 .to_string();
3820 assert!(
3821 duplicate.contains(&format!("duplicate {section} account label")),
3822 "{duplicate}"
3823 );
3824
3825 let keyless = parse(&format!("[[{section}.accounts]]\nlabel = \"work\"\n"))
3826 .unwrap_err()
3827 .to_string();
3828 assert!(
3829 keyless.contains("must set api_key_env or api_key"),
3830 "{keyless}"
3831 );
3832
3833 assert!(
3835 parse(&format!(
3836 "[[{section}.accounts]]\nlabel = \"../x\"\napi_key = \"k\"\n"
3837 ))
3838 .is_err(),
3839 "{vendor:?}"
3840 );
3841 }
3842 }
3843
3844 #[test]
3845 fn named_api_key_accounts_resolve_their_own_key_and_never_the_default() {
3846 let mut config = Config::default();
3847 config.deepseek.api_key_env.clear();
3848 config.deepseek.api_key = Some("default-key".into());
3849 config.deepseek.accounts.push(ApiKeyAccount {
3850 label: "work".into(),
3851 api_key_env: Some("AI_USAGEBAR_TEST_DEEPSEEK_WORK".into()),
3852 api_key: Some("work-inline".into()),
3853 });
3854 let _g = env_guard();
3855 unsafe { std::env::set_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK", "work-env") };
3856 let resolve = |label| config.resolve_account_api_key_for(VendorId::Deepseek, label);
3857 assert_eq!(resolve(None).unwrap(), "default-key");
3858 assert_eq!(resolve(Some("work")).unwrap(), "work-env", "env wins");
3859 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK") };
3860 assert_eq!(resolve(Some("work")).unwrap(), "work-inline");
3861
3862 let unknown = resolve(Some("typo")).unwrap_err().to_string();
3863 assert!(unknown.contains("[[deepseek.accounts]]"), "{unknown}");
3864 assert!(
3865 unknown.contains("\"work\""),
3866 "lists known labels: {unknown}"
3867 );
3868 assert!(!unknown.contains("default-key"), "{unknown}");
3869 }
3870
3871 #[test]
3872 fn the_default_key_error_keeps_each_vendors_historic_wording() {
3873 let mut config = Config::default();
3874 config.zai.api_key_env.clear();
3875 config.minimax.api_key_env.clear();
3876 let zai = config
3877 .resolve_account_api_key_for(VendorId::Zai, None)
3878 .unwrap_err()
3879 .to_string();
3880 let expected = resolve_api_key("Zai", "", None).unwrap_err().to_string();
3881 assert_eq!(zai, expected);
3882 assert!(zai.contains("[zai]"), "{zai}");
3883 let minimax = config
3884 .resolve_account_api_key_for(VendorId::Minimax, None)
3885 .unwrap_err()
3886 .to_string();
3887 assert_eq!(
3888 minimax,
3889 resolve_api_key("MiniMax", "", None)
3890 .unwrap_err()
3891 .to_string()
3892 );
3893 }
3894
3895 #[test]
3896 fn enabled_vendors_preserves_canonical_order() {
3897 let c = Config::default();
3900 assert_eq!(
3901 c.enabled_vendors(),
3902 vec![
3903 VendorId::Anthropic,
3904 VendorId::Openai,
3905 VendorId::Zai,
3906 VendorId::Openrouter,
3907 ]
3908 );
3909 }
3910
3911 #[test]
3912 fn deepseek_appears_when_enabled() {
3913 let f = write_toml(
3914 r#"
3915 [deepseek]
3916 enabled = true
3917 api_key = "sk-test"
3918 "#,
3919 );
3920 let c = Config::load_from(f.path()).unwrap();
3921 assert!(c.is_enabled(VendorId::Deepseek));
3922 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
3923 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
3924 }
3925
3926 #[test]
3927 fn tilde_paths_are_expanded_on_load() {
3928 let f = write_toml(
3932 r#"
3933 [context]
3934 projects_path = "~/.claude/projects"
3935
3936 [anthropic]
3937 credentials_path = "~/.claude/.credentials.json"
3938
3939 [[anthropic.accounts]]
3940 label = "work"
3941 credentials_path = "~/work.json"
3942 "#,
3943 );
3944 let c = Config::load_from(f.path()).unwrap();
3945 let home = crate::cache::home_dir().unwrap();
3946
3947 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
3948 let got = c.anthropic.credentials_path.unwrap();
3949 assert_eq!(got, home.join(".claude/.credentials.json"));
3950 assert!(!got.to_string_lossy().contains('~'));
3951 assert_eq!(
3952 c.anthropic.accounts[0].credentials_path,
3953 home.join("work.json")
3954 );
3955 }
3956
3957 #[test]
3958 fn absolute_and_relative_paths_are_left_alone() {
3959 let f = write_toml(
3960 r#"
3961 [anthropic]
3962 credentials_path = "/etc/creds.json"
3963 "#,
3964 );
3965 let c = Config::load_from(f.path()).unwrap();
3966 assert_eq!(
3967 c.anthropic.credentials_path.unwrap(),
3968 std::path::Path::new("/etc/creds.json")
3969 );
3970
3971 let f2 = write_toml(
3973 r#"
3974 [anthropic]
3975 credentials_path = "~someone/creds.json"
3976 "#,
3977 );
3978 let c2 = Config::load_from(f2.path()).unwrap();
3979 assert_eq!(
3980 c2.anthropic.credentials_path.unwrap(),
3981 std::path::Path::new("~someone/creds.json")
3982 );
3983 }
3984
3985 #[test]
3986 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
3987 let _g = path_override_guard();
3988 let p = resolved_path().expect("a config path must resolve");
3991 assert!(p.ends_with("config.toml"));
3992 let canonical = default_path().unwrap();
3993 let legacy = legacy_xdg_path().unwrap();
3994 assert!(
3995 p == canonical || p == legacy,
3996 "resolved to an unexpected location: {}",
3997 p.display()
3998 );
3999 }
4000
4001 #[test]
4002 fn misspelled_section_is_rejected_not_ignored() {
4003 let f = write_toml(
4006 r#"
4007 [openrouer]
4008 enabled = true
4009 api_key = "sk-or-v1-typo"
4010 "#,
4011 );
4012 let err = Config::load_from(f.path()).unwrap_err().to_string();
4013 assert!(
4014 err.contains("openrouer"),
4015 "error should name the typo: {err}"
4016 );
4017 }
4018
4019 #[test]
4020 fn invalid_toml_is_an_error_not_silent_defaults() {
4021 let f = write_toml("[zai\nenabled = true\n");
4022 assert!(Config::load_from(f.path()).is_err());
4023 }
4024
4025 #[test]
4026 fn a_missing_file_is_still_just_defaults() {
4027 let dir = tempfile::tempdir().unwrap();
4030 let missing = dir.path().join("nope").join("config.toml");
4031 let c = Config::load_from(&missing).unwrap();
4032 assert!(c.is_enabled(VendorId::Anthropic));
4033 }
4034
4035 #[test]
4036 fn kimi_appears_when_enabled() {
4037 let f = write_toml(
4038 r#"
4039 [kimi]
4040 enabled = true
4041 api_key = "sk-test"
4042 "#,
4043 );
4044 let c = Config::load_from(f.path()).unwrap();
4045 assert!(c.is_enabled(VendorId::Kimi));
4046 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
4047 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
4048 }
4049
4050 #[test]
4051 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
4052 let f = write_toml(
4053 r#"
4054 [deepseek]
4055 enabled = true
4056 api_key = "sk-ds"
4057
4058 [kimi]
4059 enabled = true
4060 api_key = "sk-kimi"
4061 "#,
4062 );
4063 let c = Config::load_from(f.path()).unwrap();
4064 assert_eq!(
4065 c.enabled_vendors(),
4066 vec![
4067 VendorId::Anthropic,
4068 VendorId::Openai,
4069 VendorId::Zai,
4070 VendorId::Openrouter,
4071 VendorId::Deepseek,
4072 VendorId::Kimi,
4073 ]
4074 );
4075 }
4076
4077 #[test]
4078 fn parses_anthropic_accounts_and_looks_them_up() {
4079 let f = write_toml(
4080 r#"
4081 [anthropic]
4082 enabled = true
4083
4084 [[anthropic.accounts]]
4085 label = "personal"
4086 credentials_path = "/creds/personal.json"
4087
4088 [[anthropic.accounts]]
4089 label = "work"
4090 credentials_path = "/creds/work.json"
4091 "#,
4092 );
4093 let c = Config::load_from(f.path()).unwrap();
4094 assert_eq!(c.anthropic.accounts.len(), 2);
4095 let work = c.anthropic.account("work").unwrap();
4096 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
4097 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
4099 assert!(err.contains("missing") && err.contains("work"), "{err}");
4100 }
4101
4102 #[test]
4103 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
4104 let f = write_toml(
4105 r#"
4106 [[anthropic.accounts]]
4107 label = "work"
4108 credentials_path = "/creds/work-one.json"
4109
4110 [[anthropic.accounts]]
4111 label = "work"
4112 credentials_path = "/creds/work-two.json"
4113 "#,
4114 );
4115 let err = Config::load_from(f.path()).unwrap_err().to_string();
4116 assert!(
4117 err.contains("duplicate anthropic account label \"work\""),
4118 "{err}"
4119 );
4120 }
4121
4122 #[test]
4123 fn account_label_rejects_path_like_names() {
4124 let cfg = AnthropicConfig::default();
4125 for bad in [
4126 "",
4127 ".",
4128 "..",
4129 "a/b",
4130 r"a\b",
4131 "C:work",
4132 "line\nbreak",
4133 "tab\tname",
4134 "usage.json",
4135 ".stale",
4136 ".last_error",
4137 ".fetch.lock",
4138 ] {
4139 let err = cfg.account(bad).unwrap_err();
4140 assert!(
4141 format!("{err:?}").contains("invalid anthropic account label"),
4142 "{bad:?} should be rejected as a label"
4143 );
4144 }
4145 }
4146
4147 #[test]
4148 fn anthropic_accounts_default_to_empty() {
4149 assert!(Config::default().anthropic.accounts.is_empty());
4152 assert!(Config::default().anthropic.accounts_dir.is_none());
4153 }
4154
4155 fn seed_account_dir(root: &std::path::Path, label: &str) {
4161 let dir = root.join(label);
4162 std::fs::create_dir_all(&dir).unwrap();
4163 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
4164 }
4165
4166 #[test]
4167 fn discovers_account_dirs_in_claude_config_dir_layout() {
4168 let td = tempfile::tempdir().unwrap();
4169 seed_account_dir(td.path(), "work");
4170 seed_account_dir(td.path(), "personal");
4171 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
4174 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
4176
4177 let cfg = AnthropicConfig {
4178 accounts_dir: Some(td.path().to_path_buf()),
4179 ..Default::default()
4180 };
4181 let all = cfg.all_accounts();
4182 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
4183 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
4184 assert_eq!(
4185 all[2].credentials_path,
4186 td.path().join("work").join(".credentials.json")
4187 );
4188 }
4189
4190 #[test]
4191 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
4192 let td = tempfile::tempdir().unwrap();
4193 seed_account_dir(td.path(), "work");
4194 let cfg = AnthropicConfig {
4195 accounts: vec![AnthropicAccount {
4196 label: "work".into(),
4197 credentials_path: "/explicit/work.json".into(),
4198 }],
4199 accounts_dir: Some(td.path().to_path_buf()),
4200 ..Default::default()
4201 };
4202 let all = cfg.all_accounts();
4203 assert_eq!(all.len(), 1, "no duplicate label");
4204 assert_eq!(
4205 all[0].credentials_path,
4206 std::path::Path::new("/explicit/work.json"),
4207 "explicit entry wins"
4208 );
4209 seed_account_dir(td.path(), "other");
4211 assert_eq!(cfg.account("other").unwrap().label, "other");
4212 }
4213
4214 #[test]
4215 fn missing_accounts_dir_is_silently_empty_not_an_error() {
4216 let cfg = AnthropicConfig {
4217 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
4218 ..Default::default()
4219 };
4220 assert!(cfg.all_accounts().is_empty());
4221 }
4222
4223 #[test]
4224 fn openai_account_auth_paths_are_tilde_expanded_on_load() {
4225 let f = write_toml(
4226 r#"
4227 [[openai.accounts]]
4228 label = "work"
4229 codex_auth_path = "~/.codex-work/auth.json"
4230 "#,
4231 );
4232 let c = Config::load_from(f.path()).unwrap();
4233 let home = crate::cache::home_dir().unwrap();
4234 assert_eq!(
4235 c.openai.accounts[0].codex_auth_path,
4236 home.join(".codex-work/auth.json")
4237 );
4238 }
4239
4240 #[test]
4241 fn accounts_dir_is_tilde_expanded_on_load() {
4242 let f = write_toml(
4243 r#"
4244 [anthropic]
4245 accounts_dir = "~/.config/ai-usagebar/accounts"
4246 "#,
4247 );
4248 let c = Config::load_from(f.path()).unwrap();
4249 let home = crate::cache::home_dir().unwrap();
4250 assert_eq!(
4251 c.anthropic.accounts_dir,
4252 Some(home.join(".config/ai-usagebar/accounts"))
4253 );
4254 }
4255
4256 #[test]
4257 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
4258 let f = write_toml(
4259 r#"
4260 [anthropic]
4261 desktop_profiles_dir = "~/.claude-acc/profiles"
4262 "#,
4263 );
4264 let c = Config::load_from(f.path()).unwrap();
4265 let home = crate::cache::home_dir().unwrap();
4266 assert_eq!(
4267 c.anthropic.desktop_profiles_dir,
4268 Some(home.join(".claude-acc/profiles"))
4269 );
4270 }
4271
4272 #[test]
4273 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
4274 let cfg = AnthropicConfig {
4275 accounts: vec![
4276 AnthropicAccount {
4277 label: "work".into(),
4278 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
4279 },
4280 AnthropicAccount {
4281 label: "personal".into(),
4282 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
4283 },
4284 ],
4285 ..Default::default()
4286 };
4287
4288 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
4289 assert!(
4290 matches!(&idle, CredsTarget::Named { config_dir, .. }
4291 if config_dir == std::path::Path::new("/tmp/accounts/work")),
4292 "{idle:?}"
4293 );
4294
4295 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
4297 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
4298
4299 assert_eq!(idle_cache.dir(), live_cache.dir());
4302 }
4303
4304 #[test]
4305 fn the_live_cli_account_keeps_its_own_slot_while_that_file_is_there() {
4306 let cfg = AnthropicConfig {
4311 accounts: vec![AnthropicAccount {
4312 label: "personal".into(),
4313 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
4314 }],
4315 ..Default::default()
4316 };
4317
4318 let (present, _) = cfg
4319 .account_target_probing("personal", Some("personal"), |_| true)
4320 .unwrap();
4321 assert!(
4322 matches!(&present, CredsTarget::Named { path, .. }
4323 if path == Path::new("/tmp/accounts/personal/.credentials.json")),
4324 "{present:?}"
4325 );
4326
4327 let (moved, _) = cfg
4329 .account_target_probing("personal", Some("personal"), |_| false)
4330 .unwrap();
4331 assert!(matches!(moved, CredsTarget::Default(_)), "{moved:?}");
4332 }
4333
4334 #[test]
4335 fn the_live_cli_accounts_own_file_is_probed_on_disk() {
4336 let creds = NamedTempFile::new().unwrap();
4340 let cfg = AnthropicConfig {
4341 accounts: vec![AnthropicAccount {
4342 label: "personal".into(),
4343 credentials_path: creds.path().to_path_buf(),
4344 }],
4345 ..Default::default()
4346 };
4347 let (target, _) = cfg
4348 .account_target_with("personal", Some("personal"))
4349 .unwrap();
4350 assert!(
4351 matches!(&target, CredsTarget::Named { path, .. } if path == creds.path()),
4352 "read {target:?} instead of the account's own file"
4353 );
4354 }
4355
4356 #[test]
4357 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
4358 let cfg = AnthropicConfig {
4359 accounts: vec![AnthropicAccount {
4360 label: "work".into(),
4361 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
4362 }],
4363 ..Default::default()
4364 };
4365 let (target, _) = cfg.account_target_with("work", None).unwrap();
4366 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
4367 }
4368
4369 fn config_example() -> PathBuf {
4373 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
4374 }
4375
4376 #[test]
4377 fn shipped_example_parses_as_a_real_config() {
4378 let c = Config::load_from(&config_example()).unwrap();
4383 assert!(!c.context.enabled);
4384 assert!(c.is_enabled(VendorId::Anthropic));
4385 assert!(c.is_enabled(VendorId::Openai));
4386 assert!(!c.is_enabled(VendorId::AnthropicApi));
4387 assert!(!c.is_enabled(VendorId::Deepseek));
4388 assert!(!c.is_enabled(VendorId::Kimi));
4389 assert!(!c.is_enabled(VendorId::Kilo));
4390 assert!(!c.is_enabled(VendorId::Novita));
4391 assert!(!c.is_enabled(VendorId::Moonshot));
4392 assert!(!c.is_enabled(VendorId::Grok));
4393 assert!(!c.is_enabled(VendorId::Cursor));
4394 assert!(!c.is_enabled(VendorId::Minimax));
4395 }
4396
4397 #[test]
4398 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
4399 let text = std::fs::read_to_string(config_example()).unwrap();
4404 let live: Vec<&str> = text
4405 .lines()
4406 .map(str::trim)
4407 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
4408 .collect();
4409 assert!(
4410 live.is_empty(),
4411 "admin_key_env must stay commented out while it is inert: {live:?}"
4412 );
4413 assert!(
4416 text.contains("admin_key_env") && text.contains("RESERVED"),
4417 "the example should keep describing admin_key_env as reserved"
4418 );
4419 }
4420
4421 #[test]
4422 fn admin_key_env_is_accepted_but_changes_nothing() {
4423 let f = write_toml(
4427 r#"
4428 [openai]
4429 admin_key_env = "SOME_ADMIN_KEY"
4430 "#,
4431 );
4432 let c = Config::load_from(f.path()).unwrap();
4433 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
4434 let default = OpenAiConfig::default();
4436 assert_eq!(c.openai.enabled, default.enabled);
4437 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
4438 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
4439 }
4440
4441 #[test]
4442 fn config_example_documents_every_vendor_without_secrets() {
4443 let raw = std::fs::read_to_string(config_example()).unwrap();
4444 let cfg = Config::load_from(&config_example()).unwrap();
4445 for id in VendorId::all() {
4448 let section = id.slug();
4449 assert!(
4450 raw.contains(&format!("[{section}]")),
4451 "config.example.toml has no [{section}] section"
4452 );
4453 }
4454
4455 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
4458 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
4459 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
4460 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
4461 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
4462 assert!(!cfg.supergrok.enabled);
4463 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
4464 assert_eq!(
4465 cfg.supergrok
4466 .grok_binary
4467 .file_name()
4468 .and_then(|p| p.to_str()),
4469 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
4470 );
4471 assert!(cfg.supergrok.auth_path.is_none());
4472 assert!(cfg.supergrok.config_path.is_none());
4473 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
4474 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
4475 }
4476
4477 #[test]
4478 fn supergrok_binary_must_not_be_empty() {
4479 let file = write_toml(
4480 r#"
4481 [supergrok]
4482 enabled = true
4483 grok_binary = ""
4484 "#,
4485 );
4486 let error = Config::load_from(file.path()).unwrap_err().to_string();
4487 assert!(error.contains("grok_binary must not be empty"));
4488 }
4489
4490 #[test]
4491 fn supergrok_paths_are_tilde_expanded() {
4492 let file = write_toml(
4493 r#"
4494 [supergrok]
4495 grok_binary = "~/bin/grok"
4496 auth_path = "~/.grok/auth.json"
4497 config_path = "~/.grok/config.toml"
4498 "#,
4499 );
4500 let config = Config::load_from(file.path()).unwrap();
4501 let home = crate::cache::home_dir().unwrap();
4502 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
4503 assert_eq!(
4504 config.supergrok.auth_path,
4505 Some(home.join(".grok/auth.json"))
4506 );
4507 assert_eq!(
4508 config.supergrok.config_path,
4509 Some(home.join(".grok/config.toml"))
4510 );
4511 }
4512
4513 #[test]
4514 fn kiro_db_path_is_tilde_expanded() {
4515 let f = write_toml(
4516 r#"
4517 [kiro]
4518 db_path = "~/kiro-data.sqlite3"
4519 "#,
4520 );
4521 let c = Config::load_from(f.path()).unwrap();
4522 let home = crate::cache::home_dir().unwrap();
4523 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
4524 }
4525
4526 #[test]
4527 fn kiro_appears_when_enabled() {
4528 let f = write_toml(
4529 r#"
4530 [kiro]
4531 enabled = true
4532 "#,
4533 );
4534 let c = Config::load_from(f.path()).unwrap();
4535 assert!(c.is_enabled(VendorId::Kiro));
4536 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
4537 }
4538
4539 #[test]
4540 fn cursor_db_path_is_tilde_expanded() {
4541 let f = write_toml(
4542 r#"
4543 [cursor]
4544 db_path = "~/cursor-state.vscdb"
4545 "#,
4546 );
4547 let c = Config::load_from(f.path()).unwrap();
4548 let home = crate::cache::home_dir().unwrap();
4549 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
4550 }
4551
4552 #[test]
4553 fn cursor_agent_auth_path_is_tilde_expanded() {
4554 let f = write_toml(
4555 r#"
4556 [cursor]
4557 agent_auth_path = "~/cursor-agent-auth.json"
4558 "#,
4559 );
4560 let c = Config::load_from(f.path()).unwrap();
4561 let home = crate::cache::home_dir().unwrap();
4562 assert_eq!(
4563 c.cursor.agent_auth_path,
4564 Some(home.join("cursor-agent-auth.json"))
4565 );
4566 }
4567
4568 #[test]
4569 fn cursor_appears_when_enabled() {
4570 let f = write_toml(
4571 r#"
4572 [cursor]
4573 enabled = true
4574 "#,
4575 );
4576 let c = Config::load_from(f.path()).unwrap();
4577 assert!(c.is_enabled(VendorId::Cursor));
4578 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
4579 }
4580
4581 #[test]
4582 fn add_account_appends_and_preserves_existing() {
4583 let mut doc: toml_edit::DocumentMut = r#"
4584# keep me
4585[anthropic]
4586enabled = true
4587
4588[[anthropic.accounts]]
4589label = "personal"
4590credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
4591"#
4592 .parse()
4593 .unwrap();
4594 add_anthropic_account_to_doc(
4595 &mut doc,
4596 "work",
4597 "~/.config/ai-usagebar/accounts/work/.credentials.json",
4598 )
4599 .unwrap();
4600 let rendered = doc.to_string();
4601 assert!(rendered.contains("# keep me"), "comment must survive");
4602 let f = write_toml(&rendered);
4604 let c = Config::load_from(f.path()).unwrap();
4605 let labels: Vec<&str> = c
4606 .anthropic
4607 .accounts
4608 .iter()
4609 .map(|a| a.label.as_str())
4610 .collect();
4611 assert_eq!(labels, vec!["personal", "work"]);
4612 }
4613
4614 #[test]
4615 fn add_account_to_empty_doc_is_loadable() {
4616 let mut doc = toml_edit::DocumentMut::new();
4617 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
4618 let f = write_toml(&doc.to_string());
4619 let c = Config::load_from(f.path()).unwrap();
4620 assert_eq!(c.anthropic.accounts.len(), 1);
4621 assert_eq!(c.anthropic.accounts[0].label, "solo");
4622 }
4623
4624 #[test]
4625 fn add_account_rejects_duplicate_label() {
4626 let mut doc: toml_edit::DocumentMut = r#"
4627[[anthropic.accounts]]
4628label = "work"
4629credentials_path = "~/w/.credentials.json"
4630"#
4631 .parse()
4632 .unwrap();
4633 assert!(
4634 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
4635 "a duplicate label must be rejected, not appended"
4636 );
4637 }
4638
4639 #[test]
4640 fn add_account_rejects_bad_label() {
4641 let mut doc = toml_edit::DocumentMut::new();
4642 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
4643 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
4644 }
4645
4646 #[test]
4647 fn tildify_collapses_home_only() {
4648 let home = Path::new("/Users/me");
4649 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
4650 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
4651 }
4652
4653 #[test]
4654 fn default_account_credentials_path_nests_under_config_dir() {
4655 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
4656 assert_eq!(
4657 default_account_credentials_path(cfg, "work"),
4658 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
4659 );
4660 }
4661 const CUSTOM_BLOCK: &str = r#"
4664[[custom]]
4665id = "mytool"
4666name = "My Tool"
4667short_name = "myt"
4668enabled = true
4669url = "https://api.example.test/v1/usage"
4670api_key_env = "MYTOOL_API_KEY"
4671auth_header = "Authorization"
4672auth_scheme = "Bearer"
4673plan = "Pro"
4674cache_ttl_secs = 120
4675[custom.headers]
4676X-Org = "org_1"
4677[[custom.metrics]]
4678label = "Requests"
4679used = "/requests/used"
4680limit = "/requests/limit"
4681resets_at = "/requests/reset"
4682window_secs = 3600
4683[[custom.texts]]
4684label = "Tier"
4685value = "/tier"
4686"#;
4687
4688 fn custom_with(from: &str, to: &str) -> String {
4689 assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
4690 CUSTOM_BLOCK.replace(from, to)
4691 }
4692
4693 fn custom_error(toml: &str) -> String {
4694 Config::load_from(write_toml(toml).path())
4695 .unwrap_err()
4696 .to_string()
4697 }
4698
4699 fn assert_custom_rejected(toml: &str, needle: &str) {
4700 let msg = custom_error(toml);
4701 assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
4702 assert!(
4703 msg.contains("[[custom]]"),
4704 "the error must locate the section: {msg}"
4705 );
4706 }
4707
4708 #[test]
4709 fn custom_block_parses_every_field() {
4710 let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
4711 assert_eq!(config.custom.len(), 1);
4712 let c = &config.custom[0];
4713 assert_eq!(c.id, "mytool");
4714 assert_eq!(c.name, "My Tool");
4715 assert_eq!(c.short_name, "myt");
4716 assert_eq!(
4717 c.brand, None,
4718 "a custom provider has no mark unless it asks"
4719 );
4720 assert!(c.enabled);
4721 assert_eq!(c.url, "https://api.example.test/v1/usage");
4722 assert!(!c.allow_http);
4723 assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
4724 assert_eq!(c.api_key, None);
4725 assert_eq!(c.auth_header, "Authorization");
4726 assert_eq!(c.auth_scheme, "Bearer");
4727 assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
4728 assert_eq!(c.plan.as_deref(), Some("Pro"));
4729 assert_eq!(c.plan_path, None);
4730 assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
4731 assert_eq!(c.metrics.len(), 1);
4732 assert_eq!(c.metrics[0].label, "Requests");
4733 assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
4734 assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
4735 assert_eq!(c.metrics[0].percent, None);
4736 assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
4737 assert_eq!(c.metrics[0].window_secs, Some(3600));
4738 assert_eq!(c.texts.len(), 1);
4739 assert_eq!(c.texts[0].label, "Tier");
4740 assert_eq!(c.texts[0].value, "/tier");
4741 assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
4742 }
4743
4744 #[test]
4745 fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
4746 let config: Config = toml::from_str(
4747 r#"
4748 [[custom]]
4749 id = "bare"
4750 short_name = "bre"
4751 url = "https://example.test/u"
4752 [[custom.metrics]]
4753 label = "Q"
4754 percent = "/pct"
4755 "#,
4756 )
4757 .unwrap();
4758 let c = &config.custom[0];
4759 assert_eq!(c.name, "bare", "name must default to id on a plain parse");
4760 assert!(!c.enabled);
4761 assert!(!c.allow_http);
4762 assert_eq!(c.api_key_env, "");
4763 assert_eq!(c.auth_header, "Authorization");
4764 assert_eq!(c.auth_scheme, "Bearer");
4765 assert_eq!(c.cache_ttl_secs, 60);
4766 assert!(config.validate().is_ok());
4767 assert!(Config::default().custom.is_empty());
4768 }
4769
4770 #[test]
4771 fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
4772 let config = Config::load_from(
4773 write_toml(&custom_with(
4774 r#"short_name = "myt""#,
4775 "short_name = \"myt\"\nbrand = \"opencode-go\"",
4776 ))
4777 .path(),
4778 )
4779 .unwrap();
4780 assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
4781
4782 for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
4786 assert_custom_rejected(
4787 &custom_with(
4788 r#"short_name = "myt""#,
4789 &format!("short_name = \"myt\"\nbrand = {brand:?}"),
4790 ),
4791 "must name a built-in vendor",
4792 );
4793 }
4794 }
4795
4796 #[test]
4797 fn custom_rejects_a_malformed_id() {
4798 let long = "a".repeat(33);
4799 for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
4800 let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
4801 assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
4802 assert!(msg.contains("must match"), "{id:?}: {msg}");
4803 }
4804 }
4805
4806 #[test]
4807 fn custom_rejects_a_builtin_slug_as_id() {
4808 assert_custom_rejected(
4809 &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
4810 "is a built-in vendor",
4811 );
4812 assert_custom_rejected(
4813 &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
4814 "is a built-in vendor",
4815 );
4816 }
4817
4818 #[test]
4819 fn custom_rejects_duplicate_ids() {
4820 let twice = format!(
4821 "{}{}",
4822 CUSTOM_BLOCK,
4823 custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
4824 );
4825 assert_custom_rejected(&twice, "duplicate id");
4826 }
4827
4828 #[test]
4829 fn custom_rejects_a_name_over_48_chars() {
4830 let long = "n".repeat(49);
4831 assert_custom_rejected(
4832 &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
4833 "name must be 1 to 48 characters",
4834 );
4835 }
4836
4837 #[test]
4838 fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
4839 for short in ["my", "myto", "MYT", "m1t"] {
4840 assert_custom_rejected(
4841 &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
4842 "exactly 3 lowercase ASCII letters",
4843 );
4844 }
4845 }
4846
4847 #[test]
4848 fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
4849 assert_custom_rejected(
4850 &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
4851 "already used by a built-in vendor",
4852 );
4853 let twice = format!(
4854 "{}{}",
4855 CUSTOM_BLOCK,
4856 custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
4857 );
4858 assert_custom_rejected(&twice, "already used by a built-in vendor");
4859 }
4860
4861 #[test]
4862 fn custom_rejects_http_unless_allowed() {
4863 let plain = custom_with(
4864 r#"url = "https://api.example.test/v1/usage""#,
4865 r#"url = "http://localhost:8080/usage""#,
4866 );
4867 assert_custom_rejected(&plain, "url must use https://");
4868 let allowed = plain.replace(
4869 r#"url = "http://localhost:8080/usage""#,
4870 "url = \"http://localhost:8080/usage\"\nallow_http = true",
4871 );
4872 assert!(
4873 Config::load_from(write_toml(&allowed).path()).is_ok(),
4874 "allow_http must permit http://"
4875 );
4876 }
4877
4878 #[test]
4879 fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
4880 assert_custom_rejected(
4881 &custom_with(
4882 r#"url = "https://api.example.test/v1/usage""#,
4883 r#"url = "https://user:pw@api.example.test/v1/usage""#,
4884 ),
4885 "must not carry credentials",
4886 );
4887 assert_custom_rejected(
4888 &custom_with(
4889 r#"url = "https://api.example.test/v1/usage""#,
4890 r#"url = "not a url""#,
4891 ),
4892 "is not a valid URL",
4893 );
4894 assert_custom_rejected(
4895 &custom_with(
4896 r#"url = "https://api.example.test/v1/usage""#,
4897 r#"url = "ftp://api.example.test/v1/usage""#,
4898 ),
4899 "is not http or https",
4900 );
4901 }
4902
4903 #[test]
4904 fn custom_rejects_an_invalid_api_key_env() {
4905 assert_custom_rejected(
4906 &custom_with(
4907 r#"api_key_env = "MYTOOL_API_KEY""#,
4908 r#"api_key_env = "1BAD-NAME""#,
4909 ),
4910 "is not a valid environment variable name",
4911 );
4912 let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
4913 assert!(
4914 Config::load_from(write_toml(&none).path()).is_ok(),
4915 "an empty api_key_env means inline-only and is valid"
4916 );
4917 }
4918
4919 #[test]
4920 fn custom_rejects_an_invalid_auth_header_name() {
4921 assert_custom_rejected(
4922 &custom_with(
4923 r#"auth_header = "Authorization""#,
4924 r#"auth_header = "X Api Key""#,
4925 ),
4926 "auth_header \"X Api Key\" is not a valid HTTP header name",
4927 );
4928 }
4929
4930 #[test]
4931 fn custom_rejects_a_control_char_in_auth_scheme() {
4932 assert_custom_rejected(
4933 &custom_with(
4934 r#"auth_scheme = "Bearer""#,
4935 "auth_scheme = \"Bearer\\u0007\"",
4936 ),
4937 "auth_scheme contains characters that are not valid",
4938 );
4939 let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
4940 assert!(
4941 Config::load_from(write_toml(&bare).path()).is_ok(),
4942 "an empty scheme (bare key) is valid"
4943 );
4944 }
4945
4946 #[test]
4947 fn custom_rejects_a_bad_extra_header() {
4948 assert_custom_rejected(
4949 &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
4950 "headers must not repeat auth_header",
4951 );
4952 assert_custom_rejected(
4953 &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
4954 "is not a valid HTTP header name",
4955 );
4956 assert_custom_rejected(
4957 &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
4958 "has a value that is not valid in an HTTP header",
4959 );
4960 }
4961
4962 #[test]
4963 fn custom_rejects_cache_ttl_outside_10_to_3600() {
4964 for ttl in ["9", "3601"] {
4965 assert_custom_rejected(
4966 &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
4967 "cache_ttl_secs must be between 10 and 3600",
4968 );
4969 }
4970 }
4971
4972 #[test]
4973 fn custom_rejects_an_entry_with_no_metrics_or_texts() {
4974 let toml = r#"
4975[[custom]]
4976id = "empty"
4977short_name = "emp"
4978url = "https://example.test/u"
4979"#;
4980 assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
4981 }
4982
4983 #[test]
4984 fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
4985 assert_custom_rejected(
4986 &custom_with(
4987 r#"limit = "/requests/limit""#,
4988 "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
4989 ),
4990 "must set `percent`, or both `used` and `limit`",
4991 );
4992 assert_custom_rejected(
4993 &custom_with("limit = \"/requests/limit\"\n", ""),
4994 "must set `percent`, or both `used` and `limit`",
4995 );
4996 }
4997
4998 #[test]
4999 fn custom_rejects_a_pointer_without_a_leading_slash() {
5000 assert_custom_rejected(
5001 &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
5002 "used \"requests.used\" must be an RFC 6901 JSON Pointer",
5003 );
5004 assert_custom_rejected(
5005 &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
5006 "value \"tier\" must be an RFC 6901 JSON Pointer",
5007 );
5008 assert_custom_rejected(
5009 &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
5010 "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
5011 );
5012 assert_custom_rejected(
5013 &custom_with(
5014 r#"resets_at = "/requests/reset""#,
5015 "resets_at = \"/re\\u001bset\"",
5016 ),
5017 "resets_at",
5018 );
5019 }
5020
5021 #[test]
5022 fn custom_rejects_a_label_outside_1_to_64_chars() {
5023 let long = "l".repeat(65);
5024 assert_custom_rejected(
5025 &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
5026 "metric label",
5027 );
5028 assert_custom_rejected(
5029 &custom_with(r#"label = "Tier""#, r#"label = """#),
5030 "text label \"\" must be 1 to 64 characters",
5031 );
5032 }
5033
5034 #[test]
5035 fn custom_rejects_window_secs_under_60() {
5036 assert_custom_rejected(
5037 &custom_with("window_secs = 3600", "window_secs = 59"),
5038 "window_secs must be at least 60",
5039 );
5040 }
5041
5042 #[test]
5043 fn custom_rejects_duplicate_metric_and_text_labels() {
5044 let metric_twice = custom_with(
5045 "window_secs = 3600\n",
5046 "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
5047 );
5048 assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
5049 let text_twice =
5050 format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
5051 assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
5052 }
5053
5054 #[test]
5055 fn enabled_custom_and_custom_by_id_select_entries() {
5056 let two = format!(
5057 "{}{}",
5058 CUSTOM_BLOCK,
5059 custom_with(r#"id = "mytool""#, r#"id = "off""#)
5060 .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
5061 .replace("enabled = true", "enabled = false")
5062 );
5063 let config = Config::load_from(write_toml(&two).path()).unwrap();
5064 let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
5065 assert_eq!(enabled, ["mytool"]);
5066 assert_eq!(
5067 config.custom_by_id("off").map(|c| c.name.as_str()),
5068 Some("My Tool")
5069 );
5070 assert!(config.custom_by_id("nope").is_none());
5071 }
5072
5073 #[cfg(unix)]
5074 #[test]
5075 fn has_inline_secrets_sees_a_custom_inline_key() {
5076 let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
5077 assert!(!without.has_inline_secrets());
5078 let with: Config = toml::from_str(&custom_with(
5079 r#"api_key_env = "MYTOOL_API_KEY""#,
5080 "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
5081 ))
5082 .unwrap();
5083 assert!(with.has_inline_secrets());
5084 }
5085
5086 #[test]
5087 fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
5088 let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
5089 let mut spec = CustomProviderConfig {
5090 id: "mytool".into(),
5091 api_key_env: var.into(),
5092 api_key: Some("sk-inline-secret".into()),
5093 ..CustomProviderConfig::default()
5094 };
5095 unsafe { std::env::set_var(var, "sk-env-secret") };
5096 let from_env = spec.resolve_api_key();
5097 unsafe { std::env::remove_var(var) };
5098 assert_eq!(from_env.unwrap(), "sk-env-secret");
5099
5100 assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
5101
5102 spec.api_key = Some(String::new());
5103 let err = spec.resolve_api_key().unwrap_err();
5104 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
5105 let msg = err.to_string();
5106 assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
5107 assert!(msg.contains(var), "{msg}");
5108 assert!(!msg.contains("secret"), "{msg}");
5109
5110 spec.api_key_env = String::new();
5111 let msg = spec.resolve_api_key().unwrap_err().to_string();
5112 assert!(msg.contains("set `api_key`"), "{msg}");
5113 }
5114
5115 #[test]
5116 fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
5117 let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
5118 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
5119 let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
5120 Config::load_from(file.path()).unwrap();
5121 assert!(
5122 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
5123 "a custom provider's env var must be scrubbed from subprocesses"
5124 );
5125 }
5126
5127 #[test]
5132 fn every_config_section_parses_to_its_vendors_enabled_switch() {
5133 for vendor in VendorId::all() {
5134 let text = format!(
5135 "[{}]
5136enabled = true
5137",
5138 vendor.config_section()
5139 );
5140 let config: Config = toml::from_str(&text)
5141 .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
5142 assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
5143 let others = VendorId::all()
5144 .iter()
5145 .filter(|other| *other != vendor && config.is_enabled(**other))
5146 .count();
5147 assert_eq!(
5148 others,
5149 Config::default().enabled_vendors().len()
5150 - usize::from(Config::default().is_enabled(*vendor)),
5151 "[{}] enabled a different vendor",
5152 vendor.config_section()
5153 );
5154 }
5155 }
5156
5157 #[test]
5158 fn tray_section_parses_and_defaults_to_notify() {
5159 let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
5160 let config = Config::load_from(file.path()).unwrap();
5161 assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
5162 assert_eq!(config.tray.updates(), UpdateMode::Auto);
5163
5164 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
5165 assert_eq!(empty.tray, TrayConfig::default());
5166 assert_eq!(empty.tray.updates(), UpdateMode::Notify);
5167 assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
5168 assert_eq!(UpdateMode::parse("weekly"), None);
5169 assert_eq!(UpdateMode::Auto.as_str(), "auto");
5170 }
5171
5172 #[test]
5173 fn tray_ignores_removed_menu_bar_keys_for_back_compatibility() {
5174 let legacy = write_toml(
5175 "[tray]\nmenu_bar_show_all = false\nmenu_bar_hide_value = true\nmenu_bar_names = \"short\"\nmenu_bar_provider = \"anthropic\"\nmenu_bar_window = \"weekly\"\n",
5176 );
5177 let config = Config::load_from(legacy.path()).unwrap();
5178 assert_eq!(config.tray, TrayConfig::default());
5179 }
5180
5181 #[test]
5182 fn tray_section_rejects_a_misspelled_mode() {
5183 let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
5184 assert!(Config::load_from(file.path()).is_err());
5185 }
5186
5187 #[test]
5188 fn tray_refresh_minutes_defaults_to_five_and_parses() {
5189 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
5190 assert_eq!(empty.tray.refresh_minutes, None);
5191 assert_eq!(empty.tray.refresh_minutes(), 5);
5192
5193 let file = write_toml("[tray]\nrefresh_minutes = 10\n");
5194 let config = Config::load_from(file.path()).unwrap();
5195 assert_eq!(config.tray.refresh_minutes(), 10);
5196 }
5197
5198 #[test]
5199 fn tray_refresh_minutes_rejects_values_outside_the_menu() {
5200 for minutes in ["3", "0"] {
5201 let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
5202 let error = Config::load_from(file.path()).unwrap_err().to_string();
5203 assert!(error.contains("[tray] refresh_minutes"), "{error}");
5204 assert!(error.contains("1, 5 or 10"), "{error}");
5205 }
5206 }
5207
5208 #[test]
5209 fn notifications_default_on_at_97_and_parse_overrides() {
5210 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
5211 assert!(empty.notifications.enabled);
5212 assert_eq!(empty.notifications.threshold, 97);
5213
5214 let file = write_toml("[notifications]\nenabled = false\nthreshold = 100\n");
5215 let config = Config::load_from(file.path()).unwrap();
5216 assert!(!config.notifications.enabled);
5217 assert_eq!(config.notifications.threshold, 100);
5218 }
5219
5220 #[test]
5221 fn notifications_threshold_rejects_values_outside_1_to_100() {
5222 for threshold in ["0", "101", "255"] {
5223 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
5224 let error = Config::load_from(file.path()).unwrap_err().to_string();
5225 assert!(
5226 error.contains("[notifications] threshold must be between 1 and 100"),
5227 "threshold {threshold}: {error}"
5228 );
5229 }
5230 for threshold in ["1", "50", "100"] {
5232 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
5233 let config = Config::load_from(file.path()).unwrap();
5234 assert_eq!(config.notifications.threshold.to_string(), threshold);
5235 }
5236 }
5237
5238 #[test]
5239 fn notification_preferences_round_trip_without_changing_other_sections() {
5240 let dir = tempfile::tempdir().unwrap();
5241 let path = dir.path().join("config.toml");
5242 std::fs::write(&path, "# keep this\n[tray]\nrefresh_minutes = 10\n").unwrap();
5243 set_notification_value(&path, "enabled", false.into()).unwrap();
5244 set_notification_value(&path, "threshold", 85i64.into()).unwrap();
5245 let text = std::fs::read_to_string(&path).unwrap();
5246 assert!(text.contains("# keep this\n[tray]\nrefresh_minutes = 10"));
5247 let config = Config::load_from(&path).unwrap();
5248 assert!(!config.notifications.enabled);
5249 assert_eq!(config.notifications.threshold, 85);
5250 assert!(set_notification_value(&path, "threshold", 101i64.into()).is_err());
5251 assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
5252 }
5253
5254 #[test]
5259 fn vendor_enabled_round_trips_and_rejects_unknown_slugs() {
5260 let dir = tempfile::tempdir().unwrap();
5261 let path = dir.path().join("config.toml");
5262 std::fs::write(&path, "# keep\n[zai]\nenabled = true # mine\n").unwrap();
5263
5264 set_vendor_enabled(&path, "zai", false).unwrap();
5265 let text = std::fs::read_to_string(&path).unwrap();
5266 assert!(text.starts_with("# keep\n"), "{text}");
5267 assert!(text.contains("enabled = false # mine"), "{text}");
5268 assert!(!config_enabled(&path, VendorId::Zai));
5269
5270 set_vendor_enabled(&path, "grok", true).unwrap();
5271 let config = Config::load_from(&path).unwrap();
5272 assert!(config.is_enabled(VendorId::Grok));
5273 assert!(
5274 !config.is_enabled(VendorId::Deepseek),
5275 "only the named vendor moves; untouched opt-in vendors stay off"
5276 );
5277 assert!(
5278 config.is_enabled(VendorId::Openai),
5279 "an untouched default-on vendor is not switched off either"
5280 );
5281
5282 for bad in ["", "custom", "not-a-vendor", "anthropic "] {
5283 let error = set_vendor_enabled(&path, bad, true)
5284 .unwrap_err()
5285 .to_string();
5286 assert!(error.contains("unknown provider"), "{bad}: {error}");
5287 }
5288 assert!(
5290 std::fs::read_to_string(&path)
5291 .unwrap()
5292 .contains("enabled = false # mine")
5293 );
5294 }
5295
5296 #[test]
5299 fn set_vendor_enabled_in_doc_targets_the_section_and_stays_idempotent() {
5300 let dir = tempfile::tempdir().unwrap();
5301 let path = dir.path().join("config.toml");
5302 std::fs::write(&path, "[grok]\nenabled = true\napi_key = \"k\"\n").unwrap();
5303 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
5304
5305 set_vendor_enabled(&path, "grok", true).unwrap();
5306
5307 assert_eq!(
5308 std::fs::read_to_string(&path).unwrap(),
5309 "[grok]\nenabled = true\napi_key = \"k\"\n"
5310 );
5311 assert_eq!(
5312 std::fs::metadata(&path).unwrap().modified().unwrap(),
5313 before
5314 );
5315
5316 let mut doc = toml_edit::DocumentMut::new();
5317 set_vendor_enabled_in_doc(&mut doc, "opencode-go", true).unwrap();
5318 assert_eq!(doc.to_string(), "[opencode-go]\nenabled = true\n");
5319 assert!(set_vendor_enabled_in_doc(&mut doc, "mytool", true).is_err());
5320 }
5321
5322 fn config_enabled(path: &std::path::Path, vendor: VendorId) -> bool {
5323 Config::load_from(path).unwrap().is_enabled(vendor)
5324 }
5325
5326 #[test]
5327 fn set_tray_value_writes_refresh_minutes_as_an_integer() {
5328 let dir = tempfile::tempdir().unwrap();
5329 let path = dir.path().join("config.toml");
5330 std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
5331
5332 set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
5333 let text = std::fs::read_to_string(&path).unwrap();
5334 assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
5335 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
5336
5337 set_tray_value(&path, "refresh_minutes", None).unwrap();
5338 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
5339 }
5340
5341 #[test]
5342 fn set_tray_value_creates_replaces_and_removes_keys() {
5343 let dir = tempfile::tempdir().unwrap();
5344 let path = dir.path().join("config.toml");
5345 std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
5346
5347 set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
5348 let text = std::fs::read_to_string(&path).unwrap();
5349 assert!(text.contains("# primary = \"anthropic\""), "{text}");
5350 assert!(
5351 text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
5352 "{text}"
5353 );
5354
5355 set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
5356 set_tray_value(&path, "updates", Some("off".into())).unwrap();
5357 let config = Config::load_from(&path).unwrap();
5358 assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
5359 assert_eq!(config.tray.updates(), UpdateMode::Off);
5360
5361 set_tray_value(&path, "shortcut", None).unwrap();
5362 let text = std::fs::read_to_string(&path).unwrap();
5363 assert!(!text.contains("shortcut"), "{text}");
5364 assert!(text.contains("updates = \"off\""), "{text}");
5365
5366 let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
5368 set_tray_value(&path, "shortcut", None).unwrap();
5369 assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
5370 }
5371
5372 #[test]
5373 fn set_value_keeps_the_trailing_comment_when_replacing() {
5374 let mut doc: toml_edit::DocumentMut =
5375 "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
5376 set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
5377 assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
5378 }
5379
5380 #[test]
5381 fn enable_vendors_in_creates_a_missing_config() {
5382 let dir = tempfile::TempDir::new().unwrap();
5383 let path = dir.path().join("sub").join("config.toml");
5384
5385 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5386
5387 assert_eq!(
5388 std::fs::read_to_string(&path).unwrap(),
5389 "[grok]
5390enabled = true
5391"
5392 );
5393 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
5394 }
5395
5396 #[test]
5397 fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
5398 let dir = tempfile::TempDir::new().unwrap();
5399 let path = dir.path().join("config.toml");
5400 let original = "# my settings
5401[zai]
5402api_key = \"x\" # keep
5403enabled = false
5404";
5405 std::fs::write(&path, original).unwrap();
5406
5407 enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
5408
5409 let text = std::fs::read_to_string(&path).unwrap();
5410 assert!(
5411 text.starts_with(
5412 "# my settings
5413"
5414 ),
5415 "{text}"
5416 );
5417 assert!(
5418 text.contains(
5419 "api_key = \"x\" # keep
5420"
5421 ),
5422 "{text}"
5423 );
5424 assert!(
5425 text.contains(
5426 "[grok]
5427enabled = true
5428"
5429 ),
5430 "{text}"
5431 );
5432 assert!(
5433 text.contains(
5434 "[opencode-go]
5435enabled = true
5436"
5437 ),
5438 "{text}"
5439 );
5440 let config = Config::load_from(&path).unwrap();
5441 assert!(
5442 !config.is_enabled(VendorId::Zai),
5443 "never widens to false, never flips others"
5444 );
5445 assert!(config.is_enabled(VendorId::Grok));
5446 assert!(config.is_enabled(VendorId::OpenCodeGo));
5447 }
5448
5449 #[test]
5450 fn enable_vendors_in_leaves_an_explicit_false_alone() {
5451 let dir = tempfile::TempDir::new().unwrap();
5452 let path = dir.path().join("config.toml");
5453 let original = "[grok]
5454enabled = false # off
5455api_key = \"k\"
5456";
5457 std::fs::write(&path, original).unwrap();
5458
5459 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5463
5464 assert!(written.is_empty(), "{written:?}");
5465 assert_eq!(
5466 std::fs::read_to_string(&path).unwrap(),
5467 original,
5468 "the file must not be rewritten at all"
5469 );
5470 }
5471
5472 #[test]
5473 fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
5474 let dir = tempfile::TempDir::new().unwrap();
5475 let path = dir.path().join("config.toml");
5476 std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
5477
5478 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5479
5480 assert_eq!(written, vec![VendorId::Grok]);
5481 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
5482 }
5483
5484 #[test]
5485 fn enable_vendors_in_is_textually_idempotent() {
5486 let dir = tempfile::TempDir::new().unwrap();
5487 let path = dir.path().join("config.toml");
5488 let original = "[grok]
5489enabled = true
5490
5491# trailing
5492";
5493 std::fs::write(&path, original).unwrap();
5494 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
5495
5496 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5497
5498 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
5499 assert_eq!(
5500 std::fs::metadata(&path).unwrap().modified().unwrap(),
5501 before,
5502 "an unchanged document must not be rewritten"
5503 );
5504 }
5505
5506 #[test]
5507 fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
5508 let dir = tempfile::TempDir::new().unwrap();
5509 let path = dir.path().join("config.toml");
5510
5511 enable_vendors_in(&path, &[]).unwrap();
5512
5513 assert!(!path.exists());
5514 }
5515}