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 pub menu_bar_provider: Option<String>,
119 pub menu_bar_show_all: Option<bool>,
121 pub menu_bar_hide_value: bool,
123 pub menu_bar_window: Option<String>,
125}
126
127pub const TRAY_REFRESH_MINUTES: [u64; 3] = [1, 5, 10];
130const DEFAULT_TRAY_REFRESH_MINUTES: u64 = 5;
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
136#[serde(default)]
137pub struct NotificationsConfig {
138 pub enabled: bool,
139 pub threshold: u8,
142}
143
144impl Default for NotificationsConfig {
145 fn default() -> Self {
146 Self {
147 enabled: true,
148 threshold: 97,
149 }
150 }
151}
152
153impl TrayConfig {
154 pub fn menu_bar_show_all(&self) -> bool {
155 self.menu_bar_show_all.unwrap_or(true)
156 }
157
158 pub fn refresh_minutes(&self) -> u64 {
159 self.refresh_minutes.unwrap_or(DEFAULT_TRAY_REFRESH_MINUTES)
160 }
161
162 pub fn updates(&self) -> UpdateMode {
163 self.updates.unwrap_or_default()
164 }
165}
166
167#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
170#[serde(rename_all = "lowercase")]
171pub enum UpdateMode {
172 Auto,
173 #[default]
174 Notify,
175 Off,
176}
177
178impl UpdateMode {
179 pub fn as_str(self) -> &'static str {
180 match self {
181 Self::Auto => "auto",
182 Self::Notify => "notify",
183 Self::Off => "off",
184 }
185 }
186
187 pub fn parse(text: &str) -> Option<Self> {
188 match text.trim().to_ascii_lowercase().as_str() {
189 "auto" => Some(Self::Auto),
190 "notify" => Some(Self::Notify),
191 "off" => Some(Self::Off),
192 _ => None,
193 }
194 }
195}
196
197#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
199#[serde(rename_all = "lowercase")]
200pub enum VendorBoxStyle {
201 #[default]
203 Sidebar,
204 Navbar,
206 None,
208}
209
210#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
213#[serde(rename_all = "lowercase")]
214pub enum ContextLayout {
215 #[default]
217 Full,
218 Split,
220 Bottom,
222}
223
224impl ContextLayout {
225 pub fn next(self) -> Self {
226 match self {
227 ContextLayout::Full => ContextLayout::Split,
228 ContextLayout::Split => ContextLayout::Bottom,
229 ContextLayout::Bottom => ContextLayout::Full,
230 }
231 }
232
233 pub fn label(self) -> &'static str {
234 match self {
235 ContextLayout::Full => "full",
236 ContextLayout::Split => "split",
237 ContextLayout::Bottom => "bottom",
238 }
239 }
240}
241
242#[derive(Debug, Clone, Default, Deserialize, Serialize)]
247#[serde(default)]
248pub struct ContextConfig {
249 pub enabled: bool,
252 pub projects_path: Option<PathBuf>,
254 pub context_window_tokens: Option<u64>,
257 pub model_context_window_tokens: BTreeMap<String, u64>,
260 pub layout: ContextLayout,
262}
263
264impl ContextConfig {
265 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
266 model
267 .and_then(|model| self.model_context_window_tokens.get(model).copied())
268 .filter(|tokens| *tokens > 0)
269 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
270 }
271}
272
273#[derive(Debug, Clone, Deserialize, Serialize)]
274#[serde(default)]
275pub struct AnthropicConfig {
276 pub enabled: bool,
277 pub credentials_path: Option<PathBuf>,
280 pub accounts: Vec<AnthropicAccount>,
284 pub accounts_dir: Option<PathBuf>,
292 pub show_default_account: bool,
298 pub desktop_profiles_dir: Option<PathBuf>,
304}
305
306impl Default for AnthropicConfig {
307 fn default() -> Self {
308 Self {
309 enabled: true,
310 credentials_path: None,
311 accounts: Vec::new(),
312 accounts_dir: None,
313 show_default_account: true,
314 desktop_profiles_dir: None,
315 }
316 }
317}
318
319#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
329pub struct AnthropicAccount {
330 pub label: String,
333 pub credentials_path: PathBuf,
337}
338
339impl AnthropicAccount {
340 pub fn config_dir(&self) -> PathBuf {
345 self.credentials_path
346 .parent()
347 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
348 }
349}
350
351impl AnthropicConfig {
352 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
358 let mut out = self.accounts.clone();
359 if let Some(dir) = &self.accounts_dir {
360 for acct in discover_accounts(dir) {
361 if !out.iter().any(|a| a.label == acct.label) {
362 out.push(acct);
363 }
364 }
365 }
366 out
367 }
368
369 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
374 validate_account_label(label)?;
375 let all = self.all_accounts();
376 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
377 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
378 AppError::Credentials(format!(
379 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
380 known labels: {known:?}"
381 ))
382 })
383 }
384
385 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
396 let active = crate::anthropic::cli_account::home_claude_json()
397 .ok()
398 .and_then(|path| {
399 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
400 });
401 self.account_target_with(label, active.as_deref())
402 }
403
404 pub fn account_target_with(
412 &self,
413 label: &str,
414 cli_active: Option<&str>,
415 ) -> Result<(CredsTarget, Cache)> {
416 self.account_target_probing(label, cli_active, |path| path.exists())
417 }
418
419 pub fn account_target_probing(
439 &self,
440 label: &str,
441 cli_active: Option<&str>,
442 exists: impl Fn(&Path) -> bool,
443 ) -> Result<(CredsTarget, Cache)> {
444 let account = self.account(label)?;
445 let cache = Cache::for_vendor_account("anthropic", label)?;
446 if cli_active == Some(label) && !exists(&account.credentials_path) {
447 return Ok((
448 CredsTarget::Default(crate::anthropic::creds::default_path()?),
449 cache,
450 ));
451 }
452 Ok((
453 CredsTarget::Named {
454 config_dir: account.config_dir(),
455 path: account.credentials_path,
456 },
457 cache,
458 ))
459 }
460}
461
462pub fn validate_account_label(label: &str) -> Result<()> {
468 validate_account_label_for("anthropic", label)
469}
470
471fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
472 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
473 let bad = label.is_empty()
474 || label == "."
475 || label == ".."
476 || label.contains(['/', '\\'])
477 || label.contains(':')
478 || label.chars().any(char::is_control)
479 || RESERVED.contains(&label);
480 if bad {
481 return Err(AppError::Credentials(format!(
482 "invalid {vendor} account label {label:?}: must be a non-empty name \
483 without path separators, drive prefixes, control characters, or reserved cache names"
484 )));
485 }
486 Ok(())
487}
488
489fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
497 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
498 return Vec::new();
499 };
500 let mut found: Vec<AnthropicAccount> = entries
501 .flatten()
502 .filter_map(|entry| {
503 let path = entry.path();
504 if !path.is_dir() {
505 return None;
506 }
507 let label = path.file_name()?.to_str()?.to_string();
508 validate_account_label(&label).ok()?;
509 Some(AnthropicAccount {
510 label,
511 credentials_path: path.join(".credentials.json"),
512 })
513 })
514 .collect();
515 found.sort_by(|a, b| a.label.cmp(&b.label));
516 found
517}
518
519pub fn tildify(path: &Path, home: &Path) -> String {
523 path.strip_prefix(home)
524 .map(|rest| {
525 let rendered = rest.display().to_string();
526 #[cfg(windows)]
529 let rendered = rendered.replace('\\', "/");
530 format!("~/{rendered}")
531 })
532 .unwrap_or_else(|_| path.display().to_string())
533}
534
535pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
540 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
541 base.join("accounts").join(label).join(".credentials.json")
542}
543
544pub fn add_anthropic_account_to_doc(
550 doc: &mut toml_edit::DocumentMut,
551 label: &str,
552 credentials_path: &str,
553) -> Result<()> {
554 use toml_edit::{Item, Table, value};
555
556 validate_account_label(label)?;
557
558 let anthropic = doc
559 .entry("anthropic")
560 .or_insert_with(|| Item::Table(Table::new()));
561 let anthropic = anthropic
562 .as_table_mut()
563 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
564
565 let accounts = anthropic
566 .entry("accounts")
567 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
568 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
569 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
570 })?;
571
572 let exists = accounts
573 .iter()
574 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
575 if exists {
576 return Err(AppError::Credentials(format!(
577 "anthropic account {label:?} already exists in config.toml"
578 )));
579 }
580
581 let mut table = Table::new();
582 table["label"] = value(label);
583 table["credentials_path"] = value(credentials_path);
584 accounts.push(table);
585 Ok(())
586}
587
588pub fn default_codex_auth_path(home: &Path, label: &str) -> PathBuf {
592 home.join(format!(".codex-{label}")).join("auth.json")
593}
594
595pub fn add_openai_account_to_doc(
600 doc: &mut toml_edit::DocumentMut,
601 label: &str,
602 codex_auth_path: &str,
603) -> Result<()> {
604 use toml_edit::{Item, Table, value};
605
606 validate_account_label_for("openai", label)?;
607
608 let openai = doc
609 .entry("openai")
610 .or_insert_with(|| Item::Table(Table::new()));
611 let openai = openai
612 .as_table_mut()
613 .ok_or_else(|| AppError::Other("[openai] in config.toml is not a table".into()))?;
614
615 let accounts = openai
616 .entry("accounts")
617 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
618 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
619 AppError::Other("[[openai.accounts]] in config.toml is not an array of tables".into())
620 })?;
621
622 if accounts
623 .iter()
624 .any(|t| t.get("label").and_then(Item::as_str) == Some(label))
625 {
626 return Err(AppError::Credentials(format!(
627 "openai account {label:?} already exists in config.toml"
628 )));
629 }
630
631 let mut table = Table::new();
632 table["label"] = value(label);
633 table["codex_auth_path"] = value(codex_auth_path);
634 accounts.push(table);
635 Ok(())
636}
637
638pub(crate) fn set_bool(
642 doc: &mut toml_edit::DocumentMut,
643 section: &str,
644 key: &str,
645 new_value: bool,
646) -> Result<()> {
647 let table = doc
648 .entry(section)
649 .or_insert_with(toml_edit::table)
650 .as_table_mut()
651 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
652
653 if let Some(item) = table.get_mut(key)
654 && let Some(v) = item.as_value_mut()
655 {
656 let suffix = v.decor().suffix().cloned();
660 *v = toml_edit::Value::from(new_value);
661 v.decor_mut().set_prefix(" ");
662 if let Some(suffix) = suffix {
663 v.decor_mut().set_suffix(suffix);
664 }
665 return Ok(());
666 }
667 table.insert(key, toml_edit::value(new_value));
668 Ok(())
669}
670
671pub(crate) fn set_value(
677 doc: &mut toml_edit::DocumentMut,
678 section: &str,
679 key: &str,
680 new_value: Option<toml_edit::Value>,
681) -> Result<()> {
682 let table = doc
683 .entry(section)
684 .or_insert_with(toml_edit::table)
685 .as_table_mut()
686 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
687
688 let Some(mut new_value) = new_value else {
689 table.remove(key);
690 return Ok(());
691 };
692 if let Some(item) = table.get_mut(key)
693 && let Some(v) = item.as_value_mut()
694 {
695 let suffix = v.decor().suffix().cloned();
696 new_value.decor_mut().set_prefix(" ");
697 if let Some(suffix) = suffix {
698 new_value.decor_mut().set_suffix(suffix);
699 }
700 *v = new_value;
701 return Ok(());
702 }
703 table.insert(key, toml_edit::Item::Value(new_value));
704 Ok(())
705}
706
707pub fn set_tray_value(path: &Path, key: &str, value: Option<toml_edit::Value>) -> Result<()> {
713 let mut doc = read_config_document(path)?;
714 let before = doc.to_string();
715 set_value(&mut doc, "tray", key, value)?;
716 if doc.to_string() == before {
717 return Ok(());
718 }
719 write_config_document(path, &doc)
720}
721
722pub fn set_notification_value(path: &Path, key: &str, value: toml_edit::Value) -> Result<()> {
725 match key {
726 "enabled" if value.as_bool().is_some() => {}
727 "threshold" if value.as_integer().is_some_and(|n| (1..=100).contains(&n)) => {}
728 _ => {
729 return Err(AppError::Other(format!(
730 "invalid notification preference: {key}"
731 )));
732 }
733 }
734 let mut doc = read_config_document(path)?;
735 let before = doc.to_string();
736 set_value(&mut doc, "notifications", key, Some(value))?;
737 if doc.to_string() == before {
738 return Ok(());
739 }
740 write_config_document(path, &doc)
741}
742
743pub(crate) fn read_config_document(path: &Path) -> Result<toml_edit::DocumentMut> {
747 let original = match std::fs::read_to_string(path) {
748 Ok(contents) => contents,
749 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
750 Err(error) => return Err(AppError::io_at(path, error)),
751 };
752 if original.trim().is_empty() {
753 return Ok(toml_edit::DocumentMut::new());
754 }
755 original.parse().map_err(|e: toml_edit::TomlError| {
756 AppError::Other(format!("config.toml not parseable: {e}"))
757 })
758}
759
760pub(crate) fn write_config_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> {
764 let bytes = doc.to_string();
765 crate::cache::atomic_write(path, bytes.as_bytes())?;
766
767 #[cfg(unix)]
768 {
769 if let Ok(meta) = std::fs::metadata(path) {
770 let mut perms = meta.permissions();
771 perms.set_mode(0o600);
772 let _ = std::fs::set_permissions(path, perms);
773 }
774 }
775 Ok(())
776}
777
778pub fn enable_vendors_in(path: &Path, vendors: &[VendorId]) -> Result<Vec<VendorId>> {
786 let mut doc = read_config_document(path)?;
787 let before = doc.to_string();
788 let written: Vec<VendorId> = vendors
789 .iter()
790 .copied()
791 .filter(|vendor| !is_explicitly_disabled(&doc, *vendor))
792 .collect();
793 for vendor in &written {
794 set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
795 }
796 if doc.to_string() == before {
797 return Ok(written);
798 }
799 write_config_document(path, &doc)?;
800 Ok(written)
801}
802
803fn is_explicitly_disabled(doc: &toml_edit::DocumentMut, vendor: VendorId) -> bool {
814 doc.get(vendor.config_section())
815 .and_then(|section| section.get("enabled"))
816 .and_then(|enabled| enabled.as_bool())
817 == Some(false)
818}
819
820#[derive(Debug, Clone, Deserialize, Serialize)]
821#[serde(default)]
822pub struct OpenAiConfig {
823 pub enabled: bool,
824 pub codex_auth_path: Option<PathBuf>,
826 #[serde(default)]
831 pub accounts: Vec<OpenAiAccount>,
832 pub show_default_account: bool,
838 pub admin_key_env: String,
846}
847
848#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
859pub struct OpenAiAccount {
860 pub label: String,
863 pub codex_auth_path: PathBuf,
866}
867
868impl OpenAiConfig {
869 pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
873 let Some(label) = label else {
874 return match &self.codex_auth_path {
875 Some(path) => Ok(path.clone()),
876 None => crate::openai::creds::default_path(),
877 };
878 };
879 self.accounts
880 .iter()
881 .find(|account| account.label == label)
882 .map(|account| account.codex_auth_path.clone())
883 .ok_or_else(|| {
884 AppError::Credentials(format!(
885 "no OpenAI account named {label:?}. Add it under \
886 [[openai.accounts]], or drop --account to use the default login."
887 ))
888 })
889 }
890
891 pub fn fetch_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
898 let Some(label) = label else {
899 return self.resolve_auth_path(None);
900 };
901 let default = self.resolve_auth_path(None)?;
902 let active = crate::openai::account::resolve_active_label(&default, &self.accounts);
903 self.fetch_auth_path_probing(label, active.as_deref(), Path::exists)
904 }
905
906 pub fn fetch_auth_path_probing(
911 &self,
912 label: &str,
913 active: Option<&str>,
914 exists: impl Fn(&Path) -> bool,
915 ) -> Result<PathBuf> {
916 let own = self.resolve_auth_path(Some(label))?;
917 if active == Some(label) && !exists(&own) {
918 return self.resolve_auth_path(None);
919 }
920 Ok(own)
921 }
922}
923
924impl Default for OpenAiConfig {
925 fn default() -> Self {
926 Self {
927 enabled: true,
928 codex_auth_path: None,
929 accounts: Vec::new(),
930 show_default_account: true,
931 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
932 }
933 }
934}
935
936#[derive(Debug, Clone, Default, Deserialize, Serialize)]
940#[serde(default)]
941pub struct CopilotConfig {
942 pub enabled: bool,
943 pub gh_binary: Option<PathBuf>,
946}
947
948impl CopilotConfig {
949 pub fn resolve_token(&self) -> Result<String> {
950 self.resolve_token_with(
951 |name| std::env::var_os(name),
952 &crate::copilot::credentials::SystemGhAuthTokenRunner,
953 )
954 }
955
956 fn resolve_token_with(
957 &self,
958 environment: impl Fn(&str) -> Option<std::ffi::OsString>,
959 runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
960 ) -> Result<String> {
961 if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
962 let token = value.into_string().map_err(|_| {
963 AppError::Credentials(
964 "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
965 )
966 })?;
967 if !token.is_empty() {
968 return Ok(token);
969 }
970 }
971 crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref())
972 }
973}
974
975#[derive(Debug, Clone, Default, Deserialize, Serialize)]
976#[serde(default)]
977pub struct NousConfig {
978 pub enabled: bool,
979}
980
981#[derive(Debug, Clone, Deserialize, Serialize)]
982#[serde(default)]
983pub struct OpenCodeGoConfig {
984 pub enabled: bool,
985 pub api_key_env: String,
986 pub api_key: Option<String>,
987}
988
989#[derive(Debug, Clone, Deserialize, Serialize)]
995#[serde(default)]
996pub struct CommandCodeConfig {
997 pub enabled: bool,
998 pub auth_paths: Option<Vec<PathBuf>>,
999}
1000
1001impl Default for CommandCodeConfig {
1002 fn default() -> Self {
1003 Self {
1004 enabled: true,
1005 auth_paths: None,
1006 }
1007 }
1008}
1009
1010#[derive(Debug, Clone, Deserialize, Serialize)]
1015#[serde(default)]
1016pub struct OllamaConfig {
1017 pub enabled: bool,
1018 pub api_key_env: String,
1019 pub api_key: Option<String>,
1020 pub plan: String,
1023}
1024
1025impl Default for OllamaConfig {
1026 fn default() -> Self {
1027 Self {
1028 enabled: false,
1029 api_key_env: "OLLAMA_API_KEY".to_string(),
1030 api_key: None,
1031 plan: "pro".to_string(),
1032 }
1033 }
1034}
1035
1036#[derive(Debug, Clone, Deserialize, Serialize)]
1039#[serde(default)]
1040pub struct OrcaRouterConfig {
1041 pub enabled: bool,
1042 pub api_key_env: String,
1043 pub api_key: Option<String>,
1044}
1045
1046impl Default for OrcaRouterConfig {
1047 fn default() -> Self {
1048 Self {
1049 enabled: false,
1050 api_key_env: "ORCAROUTER_API_KEY".to_string(),
1051 api_key: None,
1052 }
1053 }
1054}
1055
1056#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1063#[serde(default)]
1064pub struct ModelStudioConfig {
1065 pub enabled: bool,
1068 pub config_dir: Option<PathBuf>,
1071}
1072
1073impl Default for OpenCodeGoConfig {
1074 fn default() -> Self {
1075 Self {
1076 enabled: false,
1077 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
1078 api_key: None,
1079 }
1080 }
1081}
1082
1083#[derive(Debug, Clone, Deserialize, Serialize)]
1084#[serde(default)]
1085pub struct ZaiConfig {
1086 pub enabled: bool,
1087 pub api_key_env: String,
1089 pub api_key: Option<String>,
1092 pub plan_tier: Option<String>,
1094}
1095
1096impl Default for ZaiConfig {
1097 fn default() -> Self {
1098 Self {
1099 enabled: true,
1100 api_key_env: "ZAI_API_KEY".to_string(),
1101 api_key: None,
1102 plan_tier: None,
1103 }
1104 }
1105}
1106
1107#[derive(Debug, Clone, Deserialize, Serialize)]
1108#[serde(default)]
1109pub struct OpenRouterConfig {
1110 pub enabled: bool,
1111 pub accounts: Vec<OpenRouterAccount>,
1114 pub show_default_account: bool,
1118 pub api_key_env: String,
1119 pub api_key: Option<String>,
1120 pub headline: Headline,
1132}
1133
1134impl Default for OpenRouterConfig {
1135 fn default() -> Self {
1136 Self {
1137 enabled: true,
1138 accounts: Vec::new(),
1139 show_default_account: true,
1140 api_key_env: "OPENROUTER_API_KEY".to_string(),
1141 api_key: None,
1142 headline: Headline::Percent,
1143 }
1144 }
1145}
1146
1147#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1150pub struct OpenRouterAccount {
1151 pub label: String,
1153 #[serde(default)]
1155 pub api_key_env: Option<String>,
1156 #[serde(default)]
1158 pub api_key: Option<String>,
1159}
1160
1161impl OpenRouterConfig {
1162 pub fn account(&self, label: &str) -> Result<&OpenRouterAccount> {
1165 validate_account_label_for("openrouter", label)?;
1166 self.accounts
1167 .iter()
1168 .find(|account| account.label == label)
1169 .ok_or_else(|| {
1170 let known: Vec<&str> = self
1171 .accounts
1172 .iter()
1173 .map(|account| account.label.as_str())
1174 .collect();
1175 AppError::Credentials(format!(
1176 "openrouter account {label:?} not found in [[openrouter.accounts]]; \
1177 known labels: {known:?}"
1178 ))
1179 })
1180 }
1181
1182 pub fn resolve_api_key(&self, label: Option<&str>) -> Result<String> {
1185 match label {
1186 None => resolve_api_key("OpenRouter", &self.api_key_env, self.api_key.as_deref()),
1187 Some(label) => {
1188 let account = self.account(label)?;
1189 resolve_api_key_in_section(
1190 &format!("OpenRouter account {label:?}"),
1191 "[[openrouter.accounts]]",
1192 account.api_key_env.as_deref().unwrap_or(""),
1193 account.api_key.as_deref(),
1194 )
1195 }
1196 }
1197 }
1198}
1199
1200#[derive(Debug, Clone, Deserialize, Serialize)]
1201#[serde(default)]
1202pub struct DeepseekConfig {
1203 pub enabled: bool,
1204 pub api_key_env: String,
1205 pub api_key: Option<String>,
1206 pub display_limit: Option<f64>,
1209 pub headline: Headline,
1211}
1212
1213impl Default for DeepseekConfig {
1214 fn default() -> Self {
1215 Self {
1216 enabled: false,
1217 api_key_env: "DEEPSEEK_API_KEY".to_string(),
1218 api_key: None,
1219 display_limit: None,
1220 headline: Headline::Amount,
1221 }
1222 }
1223}
1224
1225#[derive(Debug, Clone, Deserialize, Serialize)]
1226#[serde(default)]
1227pub struct KimiConfig {
1228 pub enabled: bool,
1229 pub api_key_env: String,
1230 pub api_key: Option<String>,
1233 pub credentials_path: Option<PathBuf>,
1237 pub region: String,
1242}
1243
1244impl Default for KimiConfig {
1245 fn default() -> Self {
1246 Self {
1247 enabled: false,
1248 api_key_env: "KIMI_API_KEY".to_string(),
1249 api_key: None,
1250 credentials_path: None,
1251 region: "auto".to_string(),
1252 }
1253 }
1254}
1255
1256#[derive(Debug, Clone, Deserialize, Serialize)]
1257#[serde(default)]
1258pub struct KiloConfig {
1259 pub enabled: bool,
1260 pub api_key_env: String,
1261 pub api_key: Option<String>,
1262 pub organization_id: Option<String>,
1265 pub display_limit: Option<f64>,
1268 pub headline: Headline,
1270}
1271
1272impl Default for KiloConfig {
1273 fn default() -> Self {
1274 Self {
1277 enabled: false,
1278 api_key_env: "KILO_API_KEY".to_string(),
1279 api_key: None,
1280 organization_id: None,
1281 display_limit: None,
1282 headline: Headline::Amount,
1283 }
1284 }
1285}
1286
1287#[derive(Debug, Clone, Deserialize, Serialize)]
1288#[serde(default)]
1289pub struct NovitaConfig {
1290 pub enabled: bool,
1291 pub api_key_env: String,
1292 pub api_key: Option<String>,
1293 pub display_limit: Option<f64>,
1297 pub headline: Headline,
1299}
1300
1301impl Default for NovitaConfig {
1302 fn default() -> Self {
1303 Self {
1305 enabled: false,
1306 api_key_env: "NOVITA_API_KEY".to_string(),
1307 api_key: None,
1308 display_limit: None,
1309 headline: Headline::Amount,
1310 }
1311 }
1312}
1313
1314#[derive(Debug, Clone, Deserialize, Serialize)]
1315#[serde(default)]
1316pub struct MinimaxConfig {
1317 pub enabled: bool,
1318 pub api_key_env: String,
1319 pub api_key: Option<String>,
1320 pub region: String,
1326}
1327
1328impl Default for MinimaxConfig {
1329 fn default() -> Self {
1330 Self {
1332 enabled: false,
1333 api_key_env: "MINIMAX_API_KEY".to_string(),
1334 api_key: None,
1335 region: "global".to_string(),
1336 }
1337 }
1338}
1339
1340#[derive(Debug, Clone, Deserialize, Serialize)]
1341#[serde(default)]
1342pub struct MoonshotConfig {
1343 pub enabled: bool,
1344 pub api_key_env: String,
1345 pub api_key: Option<String>,
1346 pub region: String,
1348 pub display_limit: Option<f64>,
1351 pub headline: Headline,
1353}
1354
1355impl Default for MoonshotConfig {
1356 fn default() -> Self {
1357 Self {
1359 enabled: false,
1360 api_key_env: "MOONSHOT_API_KEY".to_string(),
1361 api_key: None,
1362 region: "global".to_string(),
1363 display_limit: None,
1364 headline: Headline::Amount,
1365 }
1366 }
1367}
1368
1369#[derive(Debug, Clone, Deserialize, Serialize)]
1370#[serde(default)]
1371pub struct GrokConfig {
1372 pub enabled: bool,
1373 pub api_key_env: String,
1375 pub api_key: Option<String>,
1376 pub team_id: Option<String>,
1379 pub display_limit: Option<f64>,
1381 pub headline: Headline,
1383}
1384
1385impl Default for GrokConfig {
1386 fn default() -> Self {
1387 Self {
1389 enabled: false,
1390 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1391 api_key: None,
1392 team_id: None,
1393 display_limit: None,
1394 headline: Headline::Amount,
1395 }
1396 }
1397}
1398
1399#[derive(Debug, Clone, Deserialize, Serialize)]
1407#[serde(default)]
1408pub struct SuperGrokConfig {
1409 pub enabled: bool,
1410 pub grok_binary: PathBuf,
1414 pub auth_path: Option<PathBuf>,
1417 pub config_path: Option<PathBuf>,
1418}
1419
1420impl Default for SuperGrokConfig {
1421 fn default() -> Self {
1422 Self {
1423 enabled: false,
1424 grok_binary: default_grok_binary(),
1425 auth_path: None,
1426 config_path: None,
1427 }
1428 }
1429}
1430
1431#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1437#[serde(default)]
1438pub struct GrokbotConfig {
1439 pub enabled: bool,
1442 pub secrets_path: Option<PathBuf>,
1447}
1448
1449fn default_grok_binary() -> PathBuf {
1450 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1451 let grok_home = std::env::var_os("GROK_HOME")
1452 .filter(|value| !value.is_empty())
1453 .map(PathBuf::from)
1454 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1455 grok_home
1456 .map(|home| home.join("bin").join(executable))
1457 .unwrap_or_else(|| PathBuf::from(executable))
1458}
1459
1460#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1468#[serde(default)]
1469pub struct AntigravityConfig {
1470 pub enabled: bool,
1471 pub oauth_client_id: Option<String>,
1473 pub oauth_client_secret: Option<String>,
1477}
1478
1479#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1489#[serde(default)]
1490pub struct CursorConfig {
1491 pub enabled: bool,
1492 pub db_path: Option<PathBuf>,
1496 pub agent_auth_path: Option<PathBuf>,
1501}
1502
1503#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1512#[serde(default)]
1513pub struct KiroConfig {
1514 pub enabled: bool,
1515 pub db_path: Option<PathBuf>,
1519}
1520
1521#[derive(Debug, Clone, Deserialize, Serialize)]
1522#[serde(default)]
1523pub struct AnthropicApiConfig {
1524 pub enabled: bool,
1525 pub api_key_env: String,
1528 pub api_key: Option<String>,
1529 pub monthly_limit: Option<f64>,
1532}
1533
1534impl Default for AnthropicApiConfig {
1535 fn default() -> Self {
1536 Self {
1538 enabled: false,
1539 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
1540 api_key: None,
1541 monthly_limit: None,
1542 }
1543 }
1544}
1545
1546#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1556#[serde(default, remote = "Self")]
1557pub struct CustomProviderConfig {
1558 pub id: String,
1560 pub name: String,
1562 pub short_name: String,
1565 pub brand: Option<String>,
1568 pub enabled: bool,
1569 pub url: String,
1571 pub allow_http: bool,
1572 pub api_key_env: String,
1574 pub api_key: Option<String>,
1575 pub auth_header: String,
1577 pub auth_scheme: String,
1579 pub headers: BTreeMap<String, String>,
1581 pub plan: Option<String>,
1583 pub plan_path: Option<String>,
1585 pub cache_ttl_secs: u64,
1587 pub metrics: Vec<CustomMetricSpec>,
1588 pub texts: Vec<CustomTextSpec>,
1589}
1590
1591impl Default for CustomProviderConfig {
1592 fn default() -> Self {
1593 Self {
1594 id: String::new(),
1595 name: String::new(),
1596 short_name: String::new(),
1597 brand: None,
1598 enabled: false,
1599 url: String::new(),
1600 allow_http: false,
1601 api_key_env: String::new(),
1602 api_key: None,
1603 auth_header: "Authorization".to_string(),
1604 auth_scheme: "Bearer".to_string(),
1605 headers: BTreeMap::new(),
1606 plan: None,
1607 plan_path: None,
1608 cache_ttl_secs: 60,
1609 metrics: Vec::new(),
1610 texts: Vec::new(),
1611 }
1612 }
1613}
1614
1615impl<'de> Deserialize<'de> for CustomProviderConfig {
1620 fn deserialize<D: serde::Deserializer<'de>>(
1621 deserializer: D,
1622 ) -> std::result::Result<Self, D::Error> {
1623 let mut this = Self::deserialize(deserializer)?;
1624 if this.name.is_empty() {
1625 this.name = this.id.clone();
1626 }
1627 Ok(this)
1628 }
1629}
1630
1631impl Serialize for CustomProviderConfig {
1632 fn serialize<S: serde::Serializer>(
1633 &self,
1634 serializer: S,
1635 ) -> std::result::Result<S::Ok, S::Error> {
1636 Self::serialize(self, serializer)
1637 }
1638}
1639
1640#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1644#[serde(default)]
1645pub struct CustomMetricSpec {
1646 pub label: String,
1647 pub used: Option<String>,
1648 pub limit: Option<String>,
1649 pub percent: Option<String>,
1650 pub resets_at: Option<String>,
1652 pub window_secs: Option<u64>,
1654}
1655
1656#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1658#[serde(default)]
1659pub struct CustomTextSpec {
1660 pub label: String,
1661 pub value: String,
1662}
1663
1664impl CustomProviderConfig {
1665 pub fn section_label(&self) -> String {
1667 format!("[[custom]] id = {:?}", self.id)
1668 }
1669
1670 pub fn resolve_api_key(&self) -> Result<String> {
1673 if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
1674 return Ok(key);
1675 }
1676 let advice = if self.api_key_env.is_empty() {
1677 "set `api_key`, or name an environment variable in `api_key_env`".to_string()
1678 } else {
1679 format!("export {} or set `api_key`", self.api_key_env)
1680 };
1681 Err(AppError::Credentials(format!(
1682 "custom {}: no API key. Either {advice} under {} in {}.",
1683 self.id,
1684 self.section_label(),
1685 config_path_hint()
1686 )))
1687 }
1688
1689 pub fn cache_ttl(&self) -> std::time::Duration {
1690 std::time::Duration::from_secs(self.cache_ttl_secs)
1691 }
1692
1693 fn validate(&self, index: usize) -> Result<()> {
1697 if !is_valid_custom_id(&self.id) {
1698 return Err(AppError::Other(format!(
1699 "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
1700 index + 1,
1701 self.id
1702 )));
1703 }
1704 let section = self.section_label();
1705 let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
1706
1707 if VendorId::all().iter().any(|v| v.slug() == self.id) {
1708 return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
1709 }
1710 let name_len = self.name.chars().count();
1711 if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
1712 return Err(bad(
1713 "name must be 1 to 48 characters without control characters".into(),
1714 ));
1715 }
1716 if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
1717 return Err(bad(format!(
1718 "short_name {:?} must be exactly 3 lowercase ASCII letters",
1719 self.short_name
1720 )));
1721 }
1722 if let Some(brand) = &self.brand
1723 && !VendorId::all().iter().any(|v| v.slug() == brand)
1724 {
1725 return Err(bad(format!(
1726 "brand {brand:?} must name a built-in vendor (it borrows that \
1727 vendor's mark); leave it unset to keep the short_name tag"
1728 )));
1729 }
1730 let url = reqwest::Url::parse(&self.url)
1731 .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
1732 match url.scheme() {
1733 "https" => {}
1734 "http" if self.allow_http => {}
1735 "http" => {
1736 return Err(bad(
1737 "url must use https:// (set allow_http = true to permit http://)".into(),
1738 ));
1739 }
1740 other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
1741 }
1742 if !url.username().is_empty() || url.password().is_some() {
1743 return Err(bad("url must not carry credentials (user:pass@)".into()));
1744 }
1745 if url.host_str().is_none() {
1746 return Err(bad("url has no host".into()));
1747 }
1748 if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
1749 return Err(bad(format!(
1750 "api_key_env {:?} is not a valid environment variable name",
1751 self.api_key_env
1752 )));
1753 }
1754 validate_header_name(§ion, "auth_header", &self.auth_header)?;
1755 if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
1756 return Err(bad(
1757 "auth_scheme contains characters that are not valid in an HTTP header".into(),
1758 ));
1759 }
1760 for (name, value) in &self.headers {
1761 validate_header_name(§ion, "headers", name)?;
1762 if name.eq_ignore_ascii_case(&self.auth_header) {
1763 return Err(bad(format!(
1764 "headers must not repeat auth_header {:?}",
1765 self.auth_header
1766 )));
1767 }
1768 if reqwest::header::HeaderValue::from_str(value).is_err() {
1769 return Err(bad(format!(
1770 "header {name:?} has a value that is not valid in an HTTP header"
1771 )));
1772 }
1773 }
1774 if let Some(plan) = &self.plan {
1775 validate_custom_label(§ion, "plan", plan)?;
1776 }
1777 if let Some(pointer) = &self.plan_path {
1778 validate_pointer(§ion, "plan_path", pointer)?;
1779 }
1780 if !(10..=3600).contains(&self.cache_ttl_secs) {
1781 return Err(bad(format!(
1782 "cache_ttl_secs must be between 10 and 3600, got {}",
1783 self.cache_ttl_secs
1784 )));
1785 }
1786 if self.metrics.is_empty() && self.texts.is_empty() {
1787 return Err(bad(
1788 "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
1789 ));
1790 }
1791 let mut metric_labels = HashSet::new();
1792 for metric in &self.metrics {
1793 validate_custom_label(§ion, "metric label", &metric.label)?;
1794 if !metric_labels.insert(metric.label.as_str()) {
1795 return Err(bad(format!("duplicate metric label {:?}", metric.label)));
1796 }
1797 let pair = (metric.used.is_some(), metric.limit.is_some());
1798 let well_formed = if metric.percent.is_some() {
1799 pair == (false, false)
1800 } else {
1801 pair == (true, true)
1802 };
1803 if !well_formed {
1804 return Err(bad(format!(
1805 "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
1806 metric.label
1807 )));
1808 }
1809 for (field, pointer) in [
1810 ("used", &metric.used),
1811 ("limit", &metric.limit),
1812 ("percent", &metric.percent),
1813 ("resets_at", &metric.resets_at),
1814 ] {
1815 if let Some(pointer) = pointer {
1816 validate_pointer(§ion, field, pointer)?;
1817 }
1818 }
1819 if let Some(secs) = metric.window_secs
1820 && secs < 60
1821 {
1822 return Err(bad(format!(
1823 "metric {:?} window_secs must be at least 60, got {secs}",
1824 metric.label
1825 )));
1826 }
1827 }
1828 let mut text_labels = HashSet::new();
1829 for text in &self.texts {
1830 validate_custom_label(§ion, "text label", &text.label)?;
1831 if !text_labels.insert(text.label.as_str()) {
1832 return Err(bad(format!("duplicate text label {:?}", text.label)));
1833 }
1834 validate_pointer(§ion, "value", &text.value)?;
1835 }
1836 Ok(())
1837 }
1838}
1839
1840fn is_valid_custom_id(id: &str) -> bool {
1841 let bytes = id.as_bytes();
1842 let Some(&first) = bytes.first() else {
1843 return false;
1844 };
1845 bytes.len() <= 32
1846 && (first.is_ascii_lowercase() || first.is_ascii_digit())
1847 && bytes
1848 .iter()
1849 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
1850}
1851
1852fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
1853 if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
1854 return Err(AppError::Other(format!(
1855 "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
1856 )));
1857 }
1858 Ok(())
1859}
1860
1861fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
1862 let len = label.chars().count();
1863 if len == 0 || len > 64 || label.chars().any(char::is_control) {
1864 return Err(AppError::Other(format!(
1865 "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
1866 )));
1867 }
1868 Ok(())
1869}
1870
1871fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
1872 if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
1873 return Err(AppError::Other(format!(
1874 "{section}: {field} {name:?} is not a valid HTTP header name"
1875 )));
1876 }
1877 Ok(())
1878}
1879
1880pub fn resolve_api_key(
1883 vendor_label: &str,
1884 env_var_name: &str,
1885 inline: Option<&str>,
1886) -> crate::error::Result<String> {
1887 let section = match vendor_label {
1888 "OpenCode Go" => "[opencode-go]".to_string(),
1889 _ => format!("[{}]", vendor_label.to_lowercase()),
1890 };
1891 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
1892}
1893
1894pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
1898 if is_valid_env_var_name(env_var_name)
1899 && let Ok(v) = std::env::var(env_var_name)
1900 && !v.is_empty()
1901 {
1902 return Some(v);
1903 }
1904 inline.filter(|v| !v.is_empty()).map(str::to_string)
1905}
1906
1907fn resolve_api_key_in_section(
1908 vendor_label: &str,
1909 section: &str,
1910 env_var_name: &str,
1911 inline: Option<&str>,
1912) -> crate::error::Result<String> {
1913 if let Some(key) = optional_api_key(env_var_name, inline) {
1914 return Ok(key);
1915 }
1916 let valid_env_name = is_valid_env_var_name(env_var_name);
1917 let advice = if valid_env_name {
1918 "set an API key in a valid environment variable or set `api_key`"
1919 } else {
1920 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
1921 };
1922 Err(crate::error::AppError::Credentials(format!(
1923 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
1924 config_path_hint()
1925 )))
1926}
1927
1928pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
1929 let mut chars = name.chars();
1930 let Some(first) = chars.next() else {
1931 return false;
1932 };
1933 (first.is_ascii_alphabetic() || first == '_')
1934 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1935}
1936
1937impl Config {
1938 pub fn load() -> Result<Self> {
1941 let Some(path) = resolved_path() else {
1942 return Ok(Self::default());
1943 };
1944 Self::load_from(&path)
1945 }
1946
1947 pub fn load_from(path: &std::path::Path) -> Result<Self> {
1948 match std::fs::read_to_string(path) {
1949 Ok(s) => {
1950 let mut config: Self = toml::from_str(&s)?;
1951 config.expand_paths();
1955 config.validate()?;
1956 #[cfg(unix)]
1957 config.protect_inline_secrets(path)?;
1958 crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
1962 Ok(config)
1963 }
1964 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1965 Err(e) => Err(AppError::io_at(path, e)),
1966 }
1967 }
1968
1969 fn expand_paths(&mut self) {
1970 expand_tilde_opt(&mut self.context.projects_path);
1971 expand_tilde_opt(&mut self.anthropic.credentials_path);
1972 expand_tilde_opt(&mut self.anthropic.accounts_dir);
1973 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
1974 expand_tilde_opt(&mut self.openai.codex_auth_path);
1975 expand_tilde_opt(&mut self.cursor.db_path);
1976 expand_tilde_opt(&mut self.cursor.agent_auth_path);
1977 expand_tilde_opt(&mut self.kiro.db_path);
1978 expand_tilde_opt(&mut self.kimi.credentials_path);
1979 expand_tilde_opt(&mut self.grokbot.secrets_path);
1980 expand_tilde_opt(&mut self.modelstudio.config_dir);
1981 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
1982 expand_tilde_opt(&mut self.supergrok.auth_path);
1983 expand_tilde_opt(&mut self.supergrok.config_path);
1984 for account in &mut self.anthropic.accounts {
1985 account.credentials_path = expand_tilde(&account.credentials_path);
1986 }
1987 for account in &mut self.openai.accounts {
1988 account.codex_auth_path = expand_tilde(&account.codex_auth_path);
1989 }
1990 }
1991
1992 #[cfg(unix)]
1996 fn has_inline_secrets(&self) -> bool {
1997 [
1998 self.zai.api_key.as_deref(),
1999 self.openrouter.api_key.as_deref(),
2000 self.deepseek.api_key.as_deref(),
2001 self.kimi.api_key.as_deref(),
2002 self.kilo.api_key.as_deref(),
2003 self.novita.api_key.as_deref(),
2004 self.minimax.api_key.as_deref(),
2005 self.moonshot.api_key.as_deref(),
2006 self.grok.api_key.as_deref(),
2007 self.anthropic_api.api_key.as_deref(),
2008 self.opencode_go.api_key.as_deref(),
2009 self.orcarouter.api_key.as_deref(),
2010 self.antigravity.oauth_client_secret.as_deref(),
2011 ]
2012 .into_iter()
2013 .chain(
2014 self.openrouter
2015 .accounts
2016 .iter()
2017 .map(|account| account.api_key.as_deref()),
2018 )
2019 .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
2020 .any(|key| key.is_some_and(|key| !key.is_empty()))
2021 }
2022
2023 fn custom_secret_env_vars(&self) -> Vec<String> {
2024 self.custom
2025 .iter()
2026 .filter(|c| !c.api_key_env.is_empty())
2027 .map(|c| c.api_key_env.clone())
2028 .collect()
2029 }
2030
2031 pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
2033 self.custom.iter().filter(|c| c.enabled)
2034 }
2035
2036 pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
2038 self.custom.iter().find(|c| c.id == id)
2039 }
2040
2041 #[cfg(unix)]
2042 fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
2043 if !self.has_inline_secrets() {
2044 return Ok(());
2045 }
2046
2047 let metadata = std::fs::metadata(path).map_err(|_| {
2048 AppError::Credentials(format!(
2049 "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
2050 path.display()
2051 ))
2052 })?;
2053 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
2054 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
2055 AppError::Credentials(format!(
2056 "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",
2057 path.display()
2058 ))
2059 })?;
2060 }
2061 Ok(())
2062 }
2063
2064 pub fn is_enabled(&self, id: VendorId) -> bool {
2065 match id {
2066 VendorId::Anthropic => self.anthropic.enabled,
2067 VendorId::AnthropicApi => self.anthropic_api.enabled,
2068 VendorId::Openai => self.openai.enabled,
2069 VendorId::Copilot => self.copilot.enabled,
2070 VendorId::Zai => self.zai.enabled,
2071 VendorId::Openrouter => self.openrouter.enabled,
2072 VendorId::Deepseek => self.deepseek.enabled,
2073 VendorId::Kimi => self.kimi.enabled,
2074 VendorId::Kilo => self.kilo.enabled,
2075 VendorId::Novita => self.novita.enabled,
2076 VendorId::Moonshot => self.moonshot.enabled,
2077 VendorId::Grok => self.grok.enabled,
2078 VendorId::Supergrok => self.supergrok.enabled,
2079 VendorId::Grokbot => self.grokbot.enabled,
2080 VendorId::Antigravity => self.antigravity.enabled,
2081 VendorId::Cursor => self.cursor.enabled,
2082 VendorId::Minimax => self.minimax.enabled,
2083 VendorId::Kiro => self.kiro.enabled,
2084 VendorId::NousResearch => self.nous.enabled,
2085 VendorId::OpenCodeGo => self.opencode_go.enabled,
2086 VendorId::CommandCode => self.commandcode.enabled,
2087 VendorId::Ollama => self.ollama.enabled,
2088 VendorId::OrcaRouter => self.orcarouter.enabled,
2089 VendorId::ModelStudio => self.modelstudio.enabled,
2090 }
2091 }
2092
2093 pub fn api_key_env_for(&self, id: VendorId) -> &str {
2100 match id {
2101 VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
2102 VendorId::Zai => &self.zai.api_key_env,
2103 VendorId::Openrouter => &self.openrouter.api_key_env,
2104 VendorId::Deepseek => &self.deepseek.api_key_env,
2105 VendorId::Kimi => &self.kimi.api_key_env,
2106 VendorId::Kilo => &self.kilo.api_key_env,
2107 VendorId::Novita => &self.novita.api_key_env,
2108 VendorId::Moonshot => &self.moonshot.api_key_env,
2109 VendorId::Grok => &self.grok.api_key_env,
2110 VendorId::Minimax => &self.minimax.api_key_env,
2111 VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
2112 VendorId::Ollama => &self.ollama.api_key_env,
2113 VendorId::OrcaRouter => &self.orcarouter.api_key_env,
2114 VendorId::Anthropic
2117 | VendorId::Openai
2118 | VendorId::Copilot
2119 | VendorId::Supergrok
2120 | VendorId::Grokbot
2121 | VendorId::Antigravity
2122 | VendorId::Cursor
2123 | VendorId::Kiro
2124 | VendorId::NousResearch
2125 | VendorId::CommandCode
2126 | VendorId::ModelStudio => id.api_key_env(),
2127 }
2128 }
2129
2130 pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
2134 let raw = match id {
2135 VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
2136 VendorId::Zai => self.zai.api_key.as_deref(),
2137 VendorId::Openrouter => self.openrouter.api_key.as_deref(),
2138 VendorId::Deepseek => self.deepseek.api_key.as_deref(),
2139 VendorId::Kimi => self.kimi.api_key.as_deref(),
2140 VendorId::Kilo => self.kilo.api_key.as_deref(),
2141 VendorId::Novita => self.novita.api_key.as_deref(),
2142 VendorId::Moonshot => self.moonshot.api_key.as_deref(),
2143 VendorId::Grok => self.grok.api_key.as_deref(),
2144 VendorId::Minimax => self.minimax.api_key.as_deref(),
2145 VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
2146 VendorId::Ollama => self.ollama.api_key.as_deref(),
2147 VendorId::OrcaRouter => self.orcarouter.api_key.as_deref(),
2148 VendorId::Anthropic
2149 | VendorId::Openai
2150 | VendorId::Copilot
2151 | VendorId::Supergrok
2152 | VendorId::Grokbot
2153 | VendorId::Antigravity
2154 | VendorId::Cursor
2155 | VendorId::Kiro
2156 | VendorId::NousResearch
2157 | VendorId::CommandCode
2158 | VendorId::ModelStudio => None,
2159 };
2160 raw.filter(|key| !key.is_empty())
2161 }
2162
2163 pub fn display_prefs(&self, vendor: VendorId) -> DisplayPrefs {
2168 match vendor {
2169 VendorId::Deepseek => {
2170 DisplayPrefs::balance(self.deepseek.display_limit, self.deepseek.headline)
2171 }
2172 VendorId::Kilo => DisplayPrefs::balance(self.kilo.display_limit, self.kilo.headline),
2173 VendorId::Novita => {
2174 DisplayPrefs::balance(self.novita.display_limit, self.novita.headline)
2175 }
2176 VendorId::Moonshot => {
2177 DisplayPrefs::balance(self.moonshot.display_limit, self.moonshot.headline)
2178 }
2179 VendorId::Grok => DisplayPrefs::balance(self.grok.display_limit, self.grok.headline),
2180 VendorId::Openrouter => DisplayPrefs::balance(None, self.openrouter.headline),
2183 _ => DisplayPrefs::default(),
2184 }
2185 }
2186
2187 pub fn enabled_vendors(&self) -> Vec<VendorId> {
2188 VendorId::all()
2189 .iter()
2190 .copied()
2191 .filter(|id| self.is_enabled(*id))
2192 .collect()
2193 }
2194
2195 pub fn validate(&self) -> Result<()> {
2199 if let Some(minutes) = self.tray.refresh_minutes
2200 && !TRAY_REFRESH_MINUTES.contains(&minutes)
2201 {
2202 return Err(AppError::Other(format!(
2203 "[tray] refresh_minutes must be one of 1, 5 or 10, got {minutes}"
2204 )));
2205 }
2206 if !(1..=100).contains(&self.notifications.threshold) {
2207 return Err(AppError::Other(format!(
2208 "[notifications] threshold must be between 1 and 100, got {}",
2209 self.notifications.threshold
2210 )));
2211 }
2212 if self.context.context_window_tokens == Some(0) {
2213 return Err(AppError::Other(
2214 "[context] context_window_tokens must be greater than zero".into(),
2215 ));
2216 }
2217 for (model, tokens) in &self.context.model_context_window_tokens {
2218 if model.trim().is_empty() {
2219 return Err(AppError::Other(
2220 "[context] model_context_window_tokens keys must not be empty".into(),
2221 ));
2222 }
2223 if *tokens == 0 {
2224 return Err(AppError::Other(format!(
2225 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
2226 )));
2227 }
2228 }
2229 if let Some(limit) = self.anthropic_api.monthly_limit
2230 && (!limit.is_finite() || limit <= 0.0)
2231 {
2232 return Err(AppError::Other(
2233 "[anthropic_api] monthly_limit must be finite and greater than zero; \
2234 remove it to show spend without a limit"
2235 .into(),
2236 ));
2237 }
2238 for (section, limit) in [
2242 ("deepseek", self.deepseek.display_limit),
2243 ("kilo", self.kilo.display_limit),
2244 ("novita", self.novita.display_limit),
2245 ("moonshot", self.moonshot.display_limit),
2246 ("grok", self.grok.display_limit),
2247 ] {
2248 if let Some(limit) = limit
2249 && (!limit.is_finite() || limit <= 0.0)
2250 {
2251 return Err(AppError::Other(format!(
2252 "[{section}] display_limit must be finite and greater than zero; \
2253 remove it to show the balance without a limit"
2254 )));
2255 }
2256 }
2257 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
2258 && !self.kimi.region.eq_ignore_ascii_case("auto")
2259 {
2260 return Err(AppError::Other(format!(
2261 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
2262 self.kimi.region
2263 )));
2264 }
2265 if !self.minimax.region.eq_ignore_ascii_case("global")
2266 && !self.minimax.region.eq_ignore_ascii_case("cn")
2267 {
2268 return Err(AppError::Other(format!(
2269 "[minimax] region must be \"global\" or \"cn\", got {:?}",
2270 self.minimax.region
2271 )));
2272 }
2273 if self.supergrok.grok_binary.as_os_str().is_empty() {
2274 return Err(AppError::Other(
2275 "[supergrok] grok_binary must not be empty".into(),
2276 ));
2277 }
2278 let mut labels = HashSet::new();
2279 for account in &self.anthropic.accounts {
2280 validate_account_label(&account.label)?;
2281 if !labels.insert(&account.label) {
2282 return Err(AppError::Credentials(format!(
2283 "duplicate anthropic account label {:?}",
2284 account.label
2285 )));
2286 }
2287 }
2288 let mut openai_labels = HashSet::new();
2289 for account in &self.openai.accounts {
2290 validate_account_label_for("openai", &account.label)?;
2291 if !openai_labels.insert(&account.label) {
2292 return Err(AppError::Credentials(format!(
2293 "duplicate openai account label {:?}",
2294 account.label
2295 )));
2296 }
2297 }
2298 let mut openrouter_labels = HashSet::new();
2299 for account in &self.openrouter.accounts {
2300 validate_account_label_for("openrouter", &account.label)?;
2301 if !openrouter_labels.insert(&account.label) {
2302 return Err(AppError::Credentials(format!(
2303 "duplicate openrouter account label {:?}",
2304 account.label
2305 )));
2306 }
2307 let has_env = account
2308 .api_key_env
2309 .as_deref()
2310 .is_some_and(|name| !name.is_empty());
2311 let has_inline = account
2312 .api_key
2313 .as_deref()
2314 .is_some_and(|key| !key.is_empty());
2315 if !has_env && !has_inline {
2316 return Err(AppError::Credentials(format!(
2317 "openrouter account {:?} must set api_key_env or api_key",
2318 account.label
2319 )));
2320 }
2321 }
2322 self.validate_custom()
2323 }
2324
2325 fn validate_custom(&self) -> Result<()> {
2329 let mut ids = HashSet::new();
2330 let mut short_names: HashSet<&str> =
2331 VendorId::all().iter().map(|v| v.short_name()).collect();
2332 for (index, custom) in self.custom.iter().enumerate() {
2333 custom.validate(index)?;
2334 if !ids.insert(custom.id.as_str()) {
2335 return Err(AppError::Other(format!(
2336 "{}: duplicate id",
2337 custom.section_label()
2338 )));
2339 }
2340 if !short_names.insert(custom.short_name.as_str()) {
2341 return Err(AppError::Other(format!(
2342 "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
2343 custom.section_label(),
2344 custom.short_name
2345 )));
2346 }
2347 }
2348 Ok(())
2349 }
2350}
2351
2352#[cfg(unix)]
2353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2354enum InlineKeyPermissionDecision {
2355 Ok,
2356 Tighten,
2357}
2358
2359#[cfg(unix)]
2360fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
2361 if mode & 0o077 == 0 {
2362 InlineKeyPermissionDecision::Ok
2363 } else {
2364 InlineKeyPermissionDecision::Tighten
2365 }
2366}
2367
2368pub fn default_path() -> Option<PathBuf> {
2369 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
2370 Some(proj.config_dir().join("config.toml"))
2371}
2372
2373fn legacy_xdg_path() -> Option<PathBuf> {
2378 let home = crate::cache::home_dir().ok()?;
2379 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
2380}
2381
2382pub fn resolved_path() -> Option<PathBuf> {
2393 if let Some(path) = override_path() {
2394 return Some(path);
2395 }
2396 let canonical = default_path();
2397 if let Some(p) = &canonical
2398 && p.exists()
2399 {
2400 return canonical;
2401 }
2402 if let Some(legacy) = legacy_xdg_path()
2403 && legacy.exists()
2404 {
2405 return Some(legacy);
2406 }
2407 canonical
2408}
2409
2410static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
2411
2412pub fn set_override_path(path: &std::path::Path) {
2418 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2419 *slot = Some(path.to_path_buf());
2420 }
2421}
2422
2423#[doc(hidden)]
2426pub fn clear_override_path() {
2427 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2428 *slot = None;
2429 }
2430}
2431
2432fn override_path() -> Option<PathBuf> {
2433 PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
2434}
2435
2436#[doc(hidden)]
2442pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
2443 #[cfg(unix)]
2444 {
2445 use std::os::unix::ffi::{OsStrExt, OsStringExt};
2446 let rest = arg.as_bytes().strip_prefix(b"--config=")?;
2447 Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
2448 }
2449 #[cfg(windows)]
2450 {
2451 use std::os::windows::ffi::{OsStrExt, OsStringExt};
2452 const PREFIX: &[u16] = &[
2453 b'-' as u16,
2454 b'-' as u16,
2455 b'c' as u16,
2456 b'o' as u16,
2457 b'n' as u16,
2458 b'f' as u16,
2459 b'i' as u16,
2460 b'g' as u16,
2461 b'=' as u16,
2462 ];
2463 let wide: Vec<u16> = arg.encode_wide().collect();
2464 let rest = wide.strip_prefix(PREFIX)?;
2465 Some(std::ffi::OsString::from_wide(rest).into())
2466 }
2467 #[cfg(not(any(unix, windows)))]
2468 {
2469 Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
2470 }
2471}
2472
2473fn expand_tilde(p: &std::path::Path) -> PathBuf {
2476 let Some(s) = p.to_str() else {
2477 return p.to_path_buf();
2478 };
2479 let rest = if s == "~" {
2480 ""
2481 } else if let Some(r) = s.strip_prefix("~/") {
2482 r
2483 } else {
2484 return p.to_path_buf();
2485 };
2486 match crate::cache::home_dir() {
2487 Ok(home) if rest.is_empty() => home,
2488 Ok(home) => home.join(rest),
2489 Err(_) => p.to_path_buf(),
2490 }
2491}
2492
2493fn expand_tilde_opt(p: &mut Option<PathBuf>) {
2494 if let Some(inner) = p.as_ref() {
2495 *p = Some(expand_tilde(inner));
2496 }
2497}
2498
2499pub fn config_path_hint() -> String {
2504 resolved_path()
2505 .map(|p| p.display().to_string())
2506 .unwrap_or_else(|| "config.toml".to_string())
2507}
2508
2509#[cfg(test)]
2510mod tests {
2511 use super::*;
2512 use std::io::Write;
2513 use tempfile::NamedTempFile;
2514
2515 #[cfg(unix)]
2516 use std::os::unix::fs::{MetadataExt, PermissionsExt};
2517
2518 fn write_toml(s: &str) -> NamedTempFile {
2519 let mut f = NamedTempFile::new().unwrap();
2520 f.write_all(s.as_bytes()).unwrap();
2521 f.flush().unwrap();
2522 f
2523 }
2524
2525 #[test]
2529 fn openai_without_accounts_resolves_the_singular_path() {
2530 let explicit = OpenAiConfig {
2531 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2532 ..OpenAiConfig::default()
2533 };
2534 assert_eq!(
2535 explicit.resolve_auth_path(None).unwrap(),
2536 PathBuf::from("/tmp/codex/auth.json")
2537 );
2538
2539 let bare = OpenAiConfig::default();
2540 assert_eq!(
2541 bare.resolve_auth_path(None).unwrap(),
2542 crate::openai::creds::default_path().unwrap(),
2543 "no codex_auth_path must still mean ~/.codex/auth.json"
2544 );
2545 }
2546
2547 #[test]
2550 fn openai_named_accounts_resolve_their_own_auth_file() {
2551 let config: Config = toml::from_str(
2552 r#"
2553 [openai]
2554 codex_auth_path = "/tmp/personal/auth.json"
2555 [[openai.accounts]]
2556 label = "work"
2557 codex_auth_path = "/tmp/work/auth.json"
2558 "#,
2559 )
2560 .unwrap();
2561
2562 assert_eq!(
2563 config.openai.resolve_auth_path(Some("work")).unwrap(),
2564 PathBuf::from("/tmp/work/auth.json")
2565 );
2566 assert_eq!(
2567 config.openai.resolve_auth_path(None).unwrap(),
2568 PathBuf::from("/tmp/personal/auth.json")
2569 );
2570 }
2571
2572 #[test]
2575 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
2576 let config = OpenAiConfig {
2577 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
2578 accounts: vec![OpenAiAccount {
2579 label: "work".into(),
2580 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
2581 }],
2582 ..OpenAiConfig::default()
2583 };
2584 let err = config
2585 .resolve_auth_path(Some("nope"))
2586 .unwrap_err()
2587 .to_string();
2588 assert!(err.contains("nope"), "{err}");
2589 assert!(err.contains("[[openai.accounts]]"), "{err}");
2590 }
2591
2592 fn two_codex_accounts() -> OpenAiConfig {
2593 OpenAiConfig {
2594 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2595 accounts: vec![
2596 OpenAiAccount {
2597 label: "main".into(),
2598 codex_auth_path: PathBuf::from("/tmp/codex-main/auth.json"),
2599 },
2600 OpenAiAccount {
2601 label: "work".into(),
2602 codex_auth_path: PathBuf::from("/tmp/codex-work/auth.json"),
2603 },
2604 ],
2605 ..OpenAiConfig::default()
2606 }
2607 }
2608
2609 #[test]
2610 fn the_active_codex_account_is_read_from_the_default_slot() {
2611 let config = two_codex_accounts();
2612 assert_eq!(
2613 config
2614 .fetch_auth_path_probing("work", Some("work"), |_| false)
2615 .unwrap(),
2616 PathBuf::from("/tmp/codex/auth.json")
2617 );
2618 assert_eq!(
2619 config
2620 .fetch_auth_path_probing("main", Some("work"), |_| false)
2621 .unwrap(),
2622 PathBuf::from("/tmp/codex-main/auth.json")
2623 );
2624 }
2625
2626 #[test]
2627 fn an_active_codex_account_with_its_own_file_keeps_reading_it() {
2628 let config = two_codex_accounts();
2629 assert_eq!(
2630 config
2631 .fetch_auth_path_probing("work", Some("work"), |_| true)
2632 .unwrap(),
2633 PathBuf::from("/tmp/codex-work/auth.json")
2634 );
2635 }
2636
2637 #[test]
2638 fn adding_an_openai_account_keeps_the_rest_of_the_file() {
2639 let mut doc: toml_edit::DocumentMut = "# mine\n[zai]\nenabled = true\n".parse().unwrap();
2640 add_openai_account_to_doc(&mut doc, "work", "~/.codex-work/auth.json").unwrap();
2641 let text = doc.to_string();
2642 assert!(
2643 text.starts_with("# mine\n[zai]\nenabled = true\n"),
2644 "{text}"
2645 );
2646 let parsed: Config = toml::from_str(&text).unwrap();
2647 assert_eq!(parsed.openai.accounts[0].label, "work");
2648 assert!(add_openai_account_to_doc(&mut doc, "work", "x").is_err());
2649 assert!(add_openai_account_to_doc(&mut doc, "../x", "x").is_err());
2650 }
2651
2652 #[test]
2653 fn defaults_enable_only_the_five_core_vendors() {
2654 let c = Config::default();
2655 assert!(c.is_enabled(VendorId::Anthropic));
2656 assert!(c.is_enabled(VendorId::Openai));
2657 assert!(c.is_enabled(VendorId::Zai));
2658 assert!(c.is_enabled(VendorId::Openrouter));
2659 assert!(c.is_enabled(VendorId::CommandCode));
2660 for opt_in in [
2661 VendorId::AnthropicApi,
2662 VendorId::Copilot,
2663 VendorId::Deepseek,
2664 VendorId::Kimi,
2665 VendorId::Kilo,
2666 VendorId::Novita,
2667 VendorId::Moonshot,
2668 VendorId::Grok,
2669 VendorId::Supergrok,
2670 VendorId::Grokbot,
2671 VendorId::Cursor,
2672 VendorId::Minimax,
2673 VendorId::Kiro,
2674 VendorId::OrcaRouter,
2675 VendorId::ModelStudio,
2676 ] {
2677 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
2678 }
2679 assert_eq!(c.enabled_vendors().len(), 5);
2680 }
2681
2682 #[test]
2683 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
2684 let config = Config::default();
2685 assert!(!config.is_enabled(VendorId::NousResearch));
2686 assert!(!config.is_enabled(VendorId::OpenCodeGo));
2687 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
2688 assert!(config.opencode_go.api_key.is_none());
2689 assert!(!config.is_enabled(VendorId::Copilot));
2690 }
2691
2692 #[cfg(unix)]
2693 #[test]
2694 fn inline_credentials_are_protected() {
2695 let mut config = Config::default();
2696 config.opencode_go.api_key = Some("<redacted>".to_string());
2697 assert!(config.has_inline_secrets());
2698 }
2699
2700 #[test]
2701 fn antigravity_oauth_client_overrides_parse() {
2702 let config: Config = toml::from_str(
2703 "[antigravity]
2704enabled = true
2705oauth_client_id = \"test-client\"
2706oauth_client_secret = \"test-client-secret\"
2707",
2708 )
2709 .unwrap();
2710 assert!(config.antigravity.enabled);
2711 assert_eq!(
2712 config.antigravity.oauth_client_id.as_deref(),
2713 Some("test-client")
2714 );
2715 assert_eq!(
2716 config.antigravity.oauth_client_secret.as_deref(),
2717 Some("test-client-secret")
2718 );
2719 let bare: Config = toml::from_str(
2720 "[antigravity]
2721enabled = true
2722",
2723 )
2724 .unwrap();
2725 assert!(bare.antigravity.oauth_client_id.is_none());
2726 assert!(bare.antigravity.oauth_client_secret.is_none());
2727 }
2728
2729 #[cfg(unix)]
2730 #[test]
2731 fn antigravity_inline_oauth_secret_receives_config_file_protection() {
2732 let mut config = Config::default();
2733 config.antigravity.oauth_client_id = Some("test-client".into());
2734 assert!(!config.has_inline_secrets());
2735 config.antigravity.oauth_client_secret = Some("<redacted>".into());
2736 assert!(config.has_inline_secrets());
2737 }
2738
2739 #[cfg(unix)]
2740 #[test]
2741 fn openrouter_named_inline_keys_receive_config_file_protection() {
2742 let mut config = Config::default();
2743 config.openrouter.accounts.push(OpenRouterAccount {
2744 label: "work".into(),
2745 api_key_env: None,
2746 api_key: Some("<redacted>".into()),
2747 });
2748 assert!(config.has_inline_secrets());
2749 }
2750
2751 #[test]
2752 fn missing_file_uses_defaults() {
2753 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
2754 let c = Config::load_from(path).unwrap();
2755 assert!(c.is_enabled(VendorId::Anthropic));
2756 }
2757
2758 #[test]
2759 fn parses_full_config() {
2760 let f = write_toml(
2761 r#"
2762 [anthropic]
2763 enabled = true
2764
2765 [openai]
2766 enabled = false
2767 admin_key_env = "MY_ADMIN_KEY"
2768
2769 [zai]
2770 enabled = true
2771 api_key_env = "MY_ZAI"
2772 plan_tier = "pro"
2773
2774 [openrouter]
2775 enabled = false
2776 "#,
2777 );
2778 let c = Config::load_from(f.path()).unwrap();
2779 assert!(c.is_enabled(VendorId::Anthropic));
2780 assert!(!c.is_enabled(VendorId::Openai));
2781 assert!(c.is_enabled(VendorId::Zai));
2782 assert!(!c.is_enabled(VendorId::Openrouter));
2783 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
2784 assert_eq!(c.zai.api_key_env, "MY_ZAI");
2785 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
2786 assert!(c.openrouter.accounts.is_empty());
2787 assert!(c.openrouter.show_default_account);
2788 }
2789
2790 #[test]
2791 fn partial_config_falls_back_to_defaults() {
2792 let f = write_toml(
2793 r#"[openai]
2794enabled = false
2795"#,
2796 );
2797 let c = Config::load_from(f.path()).unwrap();
2798 assert!(!c.is_enabled(VendorId::Openai));
2799 assert!(c.is_enabled(VendorId::Anthropic));
2801 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
2802 }
2803
2804 #[test]
2805 fn malformed_toml_returns_error() {
2806 let f = write_toml("this is not = = valid");
2807 assert!(Config::load_from(f.path()).is_err());
2808 }
2809
2810 #[cfg(unix)]
2811 #[test]
2812 fn load_from_tightens_world_readable_config_with_inline_api_key() {
2813 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
2814 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
2815
2816 Config::load_from(file.path()).unwrap();
2817
2818 assert_eq!(
2819 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
2820 0o600
2821 );
2822 }
2823
2824 #[cfg(unix)]
2825 #[test]
2826 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
2827 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
2828 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
2829
2830 Config::load_from(file.path()).unwrap();
2831
2832 assert_eq!(
2833 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
2834 0o644
2835 );
2836 }
2837
2838 #[cfg(unix)]
2839 #[test]
2840 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
2841 assert_eq!(
2842 inline_key_permission_decision(0o600),
2843 InlineKeyPermissionDecision::Ok
2844 );
2845 assert_eq!(
2846 inline_key_permission_decision(0o640),
2847 InlineKeyPermissionDecision::Tighten
2848 );
2849 assert_eq!(
2850 inline_key_permission_decision(0o604),
2851 InlineKeyPermissionDecision::Tighten
2852 );
2853 }
2854
2855 #[test]
2856 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
2857 for value in ["0", "-1", "inf", "nan"] {
2858 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
2859 let error = Config::load_from(file.path()).unwrap_err().to_string();
2860 assert!(error.contains("monthly_limit"), "value {value}: {error}");
2861 }
2862
2863 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
2864 assert_eq!(
2865 Config::load_from(file.path())
2866 .unwrap()
2867 .anthropic_api
2868 .monthly_limit,
2869 Some(1000.0)
2870 );
2871 }
2872
2873 #[test]
2874 fn display_limit_must_be_positive_and_finite_on_every_balance_vendor() {
2875 for section in ["deepseek", "kilo", "novita", "moonshot", "grok"] {
2877 for value in ["0", "-1", "inf", "nan"] {
2878 let file = write_toml(&format!("[{section}]\ndisplay_limit = {value}\n"));
2879 let error = Config::load_from(file.path()).unwrap_err().to_string();
2880 assert!(
2881 error.contains(&format!("[{section}] display_limit")),
2882 "{section} = {value}: {error}"
2883 );
2884 }
2885 let file = write_toml(&format!("[{section}]\ndisplay_limit = 200\n"));
2886 let config = Config::load_from(file.path()).unwrap();
2887 assert_eq!(
2888 config.display_prefs(vendor_of(section)).display_limit,
2889 Some(200.0),
2890 "{section}"
2891 );
2892 }
2893 }
2894
2895 #[test]
2897 fn display_limit_is_absent_until_the_user_states_one() {
2898 let config = Config::default();
2899 for vendor in VendorId::all() {
2900 assert_eq!(
2901 config.display_prefs(*vendor).display_limit,
2902 None,
2903 "{vendor:?}"
2904 );
2905 }
2906 }
2907
2908 #[test]
2911 fn the_default_headline_follows_the_kind_of_vendor() {
2912 let config = Config::default();
2913 for vendor in [
2914 VendorId::Deepseek,
2915 VendorId::Kilo,
2916 VendorId::Novita,
2917 VendorId::Moonshot,
2918 VendorId::Grok,
2919 ] {
2920 assert_eq!(
2921 config.display_prefs(vendor).headline,
2922 Headline::Amount,
2923 "{vendor:?}"
2924 );
2925 }
2926 assert_eq!(
2927 config.display_prefs(VendorId::Openrouter).headline,
2928 Headline::Percent
2929 );
2930 assert_eq!(
2931 config.display_prefs(VendorId::Anthropic),
2932 DisplayPrefs::default()
2933 );
2934 }
2935
2936 #[test]
2937 fn the_headline_is_configurable_per_vendor_and_a_typo_is_loud() {
2938 let file = write_toml("[deepseek]\nheadline = \"percent\"\n");
2939 assert_eq!(
2940 Config::load_from(file.path())
2941 .unwrap()
2942 .display_prefs(VendorId::Deepseek)
2943 .headline,
2944 Headline::Percent
2945 );
2946
2947 let file = write_toml("[openrouter]\nheadline = \"amount\"\n");
2948 assert_eq!(
2949 Config::load_from(file.path())
2950 .unwrap()
2951 .display_prefs(VendorId::Openrouter)
2952 .headline,
2953 Headline::Amount
2954 );
2955
2956 let file = write_toml("[deepseek]\nheadline = \"dollars\"\n");
2957 let error = Config::load_from(file.path()).unwrap_err().to_string();
2958 assert!(error.contains("headline"), "{error}");
2959 }
2960
2961 #[test]
2967 fn openrouter_has_no_display_limit_to_be_ignored() {
2968 let file = write_toml("[openrouter]\ndisplay_limit = 200\nheadline = \"percent\"\n");
2969 let config = Config::load_from(file.path()).unwrap();
2970 let prefs = config.display_prefs(VendorId::Openrouter);
2971 assert_eq!(prefs.display_limit, None);
2972 assert_eq!(prefs.headline, Headline::Percent);
2973 }
2974
2975 #[test]
2978 fn a_display_limit_alone_leaves_the_headline_where_it_was() {
2979 let file = write_toml("[deepseek]\ndisplay_limit = 200\n");
2980 let prefs = Config::load_from(file.path())
2981 .unwrap()
2982 .display_prefs(VendorId::Deepseek);
2983 assert_eq!(prefs.display_limit, Some(200.0));
2984 assert_eq!(prefs.headline, Headline::Amount);
2985 }
2986
2987 fn vendor_of(section: &str) -> VendorId {
2988 VendorId::all()
2989 .iter()
2990 .copied()
2991 .find(|vendor| vendor.config_section() == section)
2992 .unwrap_or_else(|| panic!("no vendor for [{section}]"))
2993 }
2994
2995 #[test]
2996 fn minimax_region_accepts_only_known_instances() {
2997 for region in ["global", "GLOBAL", "cn", "CN"] {
2998 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
2999 assert_eq!(
3000 Config::load_from(file.path()).unwrap().minimax.region,
3001 region
3002 );
3003 }
3004
3005 for region in ["", "china", "us"] {
3006 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3007 let error = Config::load_from(file.path()).unwrap_err().to_string();
3008 assert!(error.contains("[minimax] region"), "{error}");
3009 }
3010 }
3011
3012 #[test]
3013 fn kimi_region_accepts_auto_and_both_deployments() {
3014 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
3015 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3016 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
3017 }
3018
3019 for region in ["", "us", "oversea"] {
3020 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3021 let error = Config::load_from(file.path()).unwrap_err().to_string();
3022 assert!(error.contains("[kimi] region"), "{error}");
3023 }
3024 }
3025
3026 #[test]
3027 fn kimi_defaults_to_auto_region_and_no_credential_override() {
3028 let defaults = KimiConfig::default();
3029 assert_eq!(defaults.region, "auto");
3030 assert_eq!(defaults.credentials_path, None);
3031 assert!(!defaults.enabled);
3032 }
3033
3034 #[test]
3035 fn kimi_credentials_path_expands_a_tilde() {
3036 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
3037 let path = Config::load_from(file.path())
3038 .unwrap()
3039 .kimi
3040 .credentials_path
3041 .unwrap();
3042 assert!(!path.starts_with("~"), "{}", path.display());
3043 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
3044 }
3045
3046 #[test]
3047 fn grokbot_is_opt_in_and_takes_no_api_key() {
3048 let defaults = GrokbotConfig::default();
3049 assert!(!defaults.enabled);
3050 assert_eq!(defaults.secrets_path, None);
3051 let config = Config::default();
3053 assert_eq!(config.api_key_env_for(VendorId::Grokbot), "");
3054 assert_eq!(config.inline_api_key(VendorId::Grokbot), None);
3055
3056 let file = write_toml("[grokbot]\nenabled = true\n");
3057 let config = Config::load_from(file.path()).unwrap();
3058 assert!(config.is_enabled(VendorId::Grokbot));
3059 assert!(config.enabled_vendors().contains(&VendorId::Grokbot));
3060 }
3061
3062 #[test]
3063 fn grokbot_secrets_path_expands_a_tilde() {
3064 let file = write_toml("[grokbot]\nsecrets_path = \"~/gb/secrets.json\"\n");
3065 let path = Config::load_from(file.path())
3066 .unwrap()
3067 .grokbot
3068 .secrets_path
3069 .unwrap();
3070 assert!(!path.starts_with("~"), "{}", path.display());
3071 assert!(path.ends_with("gb/secrets.json"), "{}", path.display());
3072 }
3073
3074 #[test]
3075 fn modelstudio_is_opt_in_and_takes_no_api_key() {
3076 let defaults = ModelStudioConfig::default();
3077 assert!(!defaults.enabled);
3078 assert_eq!(defaults.config_dir, None);
3079 let config = Config::default();
3081 assert_eq!(config.api_key_env_for(VendorId::ModelStudio), "");
3082 assert_eq!(config.inline_api_key(VendorId::ModelStudio), None);
3083
3084 let file = write_toml("[modelstudio]\nenabled = true\n");
3085 let config = Config::load_from(file.path()).unwrap();
3086 assert!(config.is_enabled(VendorId::ModelStudio));
3087 assert!(config.enabled_vendors().contains(&VendorId::ModelStudio));
3088 }
3089
3090 #[test]
3091 fn modelstudio_config_dir_expands_a_tilde() {
3092 let file = write_toml("[modelstudio]\nconfig_dir = \"~/bl\"\n");
3093 let path = Config::load_from(file.path())
3094 .unwrap()
3095 .modelstudio
3096 .config_dir
3097 .unwrap();
3098 assert!(!path.starts_with("~"), "{}", path.display());
3099 assert!(path.ends_with("bl"), "{}", path.display());
3100 }
3101
3102 #[test]
3103 fn optional_api_key_reports_absence_instead_of_failing() {
3104 assert_eq!(
3105 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", Some("inline")),
3106 Some("inline".to_string())
3107 );
3108 assert_eq!(
3109 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", None),
3110 None
3111 );
3112 assert_eq!(optional_api_key("KIMI_API_KEY_UNSET", Some("")), None);
3113 assert_eq!(
3116 optional_api_key("9INVALID", Some("inline")),
3117 Some("inline".to_string())
3118 );
3119 }
3120
3121 #[test]
3122 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
3123 let defaults = Config::default();
3124 assert!(!defaults.context.enabled);
3125 assert_eq!(
3126 defaults.context.window_tokens_for(Some("claude-test")),
3127 None
3128 );
3129
3130 let file = write_toml(
3131 r#"
3132 [context]
3133 enabled = true
3134 context_window_tokens = 200000
3135
3136 [context.model_context_window_tokens]
3137 claude-opus-1m = 1000000
3138 "claude exact id" = 300000
3139 "#,
3140 );
3141 let config = Config::load_from(file.path()).unwrap();
3142 assert!(config.context.enabled);
3143 assert_eq!(
3144 config.context.window_tokens_for(Some("claude-opus-1m")),
3145 Some(1_000_000)
3146 );
3147 assert_eq!(
3148 config.context.window_tokens_for(Some("claude exact id")),
3149 Some(300_000)
3150 );
3151 assert_eq!(
3152 config.context.window_tokens_for(Some("another-model")),
3153 Some(200_000)
3154 );
3155 }
3156
3157 #[test]
3158 fn context_layout_defaults_to_full_and_parses_each_variant() {
3159 assert_eq!(Config::default().context.layout, ContextLayout::Full);
3160 for (text, want) in [
3161 ("full", ContextLayout::Full),
3162 ("split", ContextLayout::Split),
3163 ("bottom", ContextLayout::Bottom),
3164 ] {
3165 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
3166 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
3167 }
3168 let file = write_toml("[context]\nlayout = \"floating\"\n");
3169 assert!(
3170 Config::load_from(file.path()).is_err(),
3171 "an unknown layout must be rejected, not silently defaulted"
3172 );
3173 }
3174
3175 #[test]
3176 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
3177 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
3178 for (text, want) in [
3179 ("sidebar", VendorBoxStyle::Sidebar),
3180 ("navbar", VendorBoxStyle::Navbar),
3181 ("none", VendorBoxStyle::None),
3182 ] {
3183 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
3184 assert_eq!(
3185 Config::load_from(file.path()).unwrap().ui.vendor_box(),
3186 want
3187 );
3188 }
3189 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
3190 assert!(
3191 Config::load_from(file.path()).is_err(),
3192 "an unknown vendor_box style must be rejected, not silently defaulted"
3193 );
3194 }
3195
3196 #[test]
3197 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
3198 for source in [
3199 "[context]\ncontext_window_tokens = 0\n",
3200 "[context.model_context_window_tokens]\nclaude = 0\n",
3201 "[context.model_context_window_tokens]\n\" \" = 200000\n",
3202 ] {
3203 let file = write_toml(source);
3204 let error = Config::load_from(file.path()).unwrap_err().to_string();
3205 assert!(error.contains("context"), "{error}");
3206 }
3207 }
3208
3209 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
3211 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
3212 M.lock().unwrap_or_else(|p| p.into_inner())
3213 }
3214
3215 #[test]
3216 fn resolve_api_key_prefers_env_over_inline() {
3217 let _g = env_guard();
3218 let var = "AI_USAGEBAR_TEST_ENV_WINS";
3220 unsafe { std::env::set_var(var, "from-env") };
3222 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
3223 unsafe { std::env::remove_var(var) };
3224 assert_eq!(got, "from-env");
3225 }
3226
3227 #[test]
3228 fn resolve_api_key_falls_back_to_inline() {
3229 let _g = env_guard();
3230 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
3231 unsafe { std::env::remove_var(var) };
3232 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
3233 assert_eq!(got, "inline-key");
3234 }
3235
3236 #[test]
3237 fn copilot_token_prefers_explicit_environment_over_gh_cli() {
3238 struct NeverRun;
3239 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
3240 fn run(
3241 &self,
3242 _: &crate::copilot::credentials::GhAuthTokenCommand,
3243 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3244 panic!("environment override must not invoke gh")
3245 }
3246 }
3247
3248 let token = CopilotConfig::default()
3249 .resolve_token_with(
3250 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
3251 &NeverRun,
3252 )
3253 .unwrap();
3254 assert_eq!(token, "from-environment");
3255 }
3256
3257 #[test]
3258 fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
3259 struct FailedGh;
3260 impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
3261 fn run(
3262 &self,
3263 _: &crate::copilot::credentials::GhAuthTokenCommand,
3264 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3265 Ok(crate::copilot::credentials::GhAuthTokenOutput {
3266 success: false,
3267 stdout: b"never-echo-gh-output".to_vec(),
3268 })
3269 }
3270 }
3271 let error = CopilotConfig::default()
3272 .resolve_token_with(|_| None, &FailedGh)
3273 .unwrap_err()
3274 .to_string();
3275 assert!(error.contains("gh auth login --web"));
3276 assert!(!error.contains("never-echo-gh-output"));
3277 }
3278
3279 #[test]
3280 fn resolve_api_key_errors_when_both_missing() {
3281 let _g = env_guard();
3282 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
3283 unsafe { std::env::remove_var(var) };
3284 let err = resolve_api_key("Zai", var, None).unwrap_err();
3285 match err {
3286 crate::error::AppError::Credentials(msg) => {
3287 assert!(
3288 msg.contains("api_key"),
3289 "error should suggest config field: {msg}"
3290 );
3291 }
3292 other => panic!("expected Credentials error, got {other:?}"),
3293 }
3294 }
3295
3296 #[test]
3297 fn resolve_api_key_uses_exact_opencode_go_section_name() {
3298 let _g = env_guard();
3299 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
3300 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
3301 let message = err.to_string();
3302 assert!(
3303 message.contains("[opencode-go]"),
3304 "wrong section hint: {message}"
3305 );
3306 assert!(
3307 !message.contains("[opencode go]"),
3308 "wrong section hint: {message}"
3309 );
3310 }
3311
3312 fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
3313 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
3314 M.lock().unwrap_or_else(|p| p.into_inner())
3315 }
3316
3317 struct ScopedPathOverride {
3323 _serial: std::sync::MutexGuard<'static, ()>,
3324 }
3325
3326 impl Drop for ScopedPathOverride {
3327 fn drop(&mut self) {
3328 clear_override_path();
3329 }
3330 }
3331
3332 fn scoped_path_override() -> ScopedPathOverride {
3333 ScopedPathOverride {
3334 _serial: path_override_guard(),
3335 }
3336 }
3337
3338 #[test]
3339 fn override_path_wins_over_canonical_and_legacy() {
3340 let _scoped = scoped_path_override();
3341 let file = NamedTempFile::new().unwrap();
3342 set_override_path(file.path());
3343 assert_eq!(resolved_path().as_deref(), Some(file.path()));
3344 assert_eq!(config_path_hint(), file.path().display().to_string());
3345 clear_override_path();
3346 let p = resolved_path().expect("a config path must resolve");
3348 assert!(p.ends_with("config.toml"));
3349 }
3350
3351 #[test]
3352 fn scoped_override_guard_clears_the_override_on_panic() {
3353 let hook = std::panic::take_hook();
3356 std::panic::set_hook(Box::new(|_| {}));
3357 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3358 let _scoped = scoped_path_override();
3359 set_override_path(std::path::Path::new("panicked-override.toml"));
3360 panic!("simulated mid-test failure");
3361 }))
3362 .is_err();
3363 std::panic::set_hook(hook);
3364 assert!(panicked, "the simulated failure must run");
3365 let _serial = path_override_guard();
3366 assert!(
3367 override_path().is_none(),
3368 "a panicking test must not leak the override into siblings"
3369 );
3370 }
3371
3372 #[test]
3373 fn config_path_hint_ends_with_config_toml() {
3374 let _g = path_override_guard();
3375 assert!(config_path_hint().ends_with("config.toml"));
3378 }
3379
3380 #[test]
3381 fn config_flag_value_splits_the_equals_form() {
3382 use std::ffi::OsStr;
3383 assert_eq!(
3384 config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
3385 Some(std::path::Path::new("work.toml"))
3386 );
3387 assert_eq!(
3388 config_flag_value(OsStr::new("--config=")).as_deref(),
3389 Some(std::path::Path::new(""))
3390 );
3391 assert_eq!(config_flag_value(OsStr::new("--config")), None);
3392 assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
3393 assert_eq!(config_flag_value(OsStr::new("account")), None);
3394 }
3395
3396 #[cfg(unix)]
3400 #[test]
3401 fn config_flag_value_keeps_undecodable_bytes_intact() {
3402 use std::ffi::OsString;
3403 use std::os::unix::ffi::{OsStrExt, OsStringExt};
3404 let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
3405 let value = config_flag_value(&raw).expect("prefix matches");
3406 assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
3407 }
3408
3409 #[cfg(windows)]
3410 #[test]
3411 fn config_flag_value_keeps_lone_surrogates_intact() {
3412 use std::ffi::OsString;
3413 use std::os::windows::ffi::{OsStrExt, OsStringExt};
3414 let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
3415 wide.push(0xDC00); wide.extend("x.toml".encode_utf16());
3417 let raw = OsString::from_wide(&wide);
3418 let value = config_flag_value(&raw).expect("prefix matches");
3419 let mut expected = vec![0xDC00u16];
3420 expected.extend("x.toml".encode_utf16());
3421 assert_eq!(
3422 value.as_os_str().encode_wide().collect::<Vec<_>>(),
3423 expected
3424 );
3425 }
3426
3427 #[test]
3428 fn resolve_api_key_treats_empty_env_as_unset() {
3429 let _g = env_guard();
3430 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
3431 unsafe { std::env::set_var(var, "") };
3432 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
3433 unsafe { std::env::remove_var(var) };
3434 assert_eq!(got, "inline");
3435 }
3436
3437 #[test]
3438 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
3439 let _g = env_guard();
3440 let bad = "sk-kimi-very-real-looking-pasted-secret";
3442 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
3443 let msg = err.to_string();
3444 assert!(
3445 msg.contains("invalid") && msg.contains("api_key_env"),
3446 "error should explain misconfiguration: {msg}"
3447 );
3448 assert!(
3449 !msg.contains(bad),
3450 "error must not echo the misconfigured value: {msg}"
3451 );
3452 assert!(msg.contains("valid environment variable name"));
3453 assert!(
3454 msg.contains("[kimi]"),
3455 "error should point at the lowercase TOML section: {msg}"
3456 );
3457 }
3458
3459 #[test]
3460 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
3461 let _g = env_guard();
3462 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
3463 assert_eq!(got, "inline-key");
3464 }
3465
3466 #[test]
3467 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
3468 let _g = env_guard();
3469 let pasted_secret = "sk_pasted_secret";
3472 unsafe { std::env::remove_var(pasted_secret) };
3473 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
3474 assert!(
3475 !err.to_string().contains(pasted_secret),
3476 "error must not echo configured api_key_env values"
3477 );
3478 }
3479
3480 #[test]
3481 fn is_valid_env_var_name_rules() {
3482 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
3484 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
3485 }
3486 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
3488 assert!(
3489 !is_valid_env_var_name(invalid),
3490 "{invalid} should be invalid"
3491 );
3492 }
3493 }
3494
3495 #[test]
3496 fn config_parses_with_inline_api_key_and_primary() {
3497 let f = write_toml(
3498 r#"
3499 [ui]
3500 primary = "openrouter"
3501
3502 [zai]
3503 enabled = true
3504 api_key_env = "MY_ZAI"
3505 api_key = "sk-zai-inline"
3506
3507 [openrouter]
3508 enabled = true
3509 api_key = "sk-or-inline"
3510 "#,
3511 );
3512 let c = Config::load_from(f.path()).unwrap();
3513 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
3514 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
3515 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
3516 }
3517
3518 #[test]
3519 fn openrouter_named_accounts_preserve_the_default_contract() {
3520 let f = write_toml(
3521 r#"
3522 [openrouter]
3523 enabled = true
3524 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
3525 api_key = "default-inline"
3526 show_default_account = false
3527
3528 [[openrouter.accounts]]
3529 label = "work"
3530 api_key_env = "OPENROUTER_WORK_API_KEY"
3531
3532 [[openrouter.accounts]]
3533 label = "personal"
3534 api_key = "personal-inline"
3535 "#,
3536 );
3537 let _g = env_guard();
3538 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
3539 let config = Config::load_from(f.path()).unwrap();
3540 assert!(!config.openrouter.show_default_account);
3541 assert_eq!(config.openrouter.accounts.len(), 2);
3542 assert_eq!(
3543 config.openrouter.resolve_api_key(None).unwrap(),
3544 "default-inline"
3545 );
3546 assert_eq!(
3547 config.openrouter.resolve_api_key(Some("personal")).unwrap(),
3548 "personal-inline"
3549 );
3550 }
3551
3552 #[test]
3553 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
3554 for source in [
3555 r#"
3556 [[openrouter.accounts]]
3557 label = "work"
3558 api_key = "one"
3559 [[openrouter.accounts]]
3560 label = "work"
3561 api_key = "two"
3562 "#,
3563 r#"
3564 [[openrouter.accounts]]
3565 label = "../work"
3566 api_key = "one"
3567 "#,
3568 r#"
3569 [[openrouter.accounts]]
3570 label = "work"
3571 "#,
3572 ] {
3573 let f = write_toml(source);
3574 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
3575 }
3576 }
3577
3578 #[test]
3579 fn openrouter_unknown_account_never_falls_back_to_default_key() {
3580 let mut config = OpenRouterConfig {
3581 api_key: Some("default-secret".into()),
3582 ..OpenRouterConfig::default()
3583 };
3584 config.accounts.push(OpenRouterAccount {
3585 label: "work".into(),
3586 api_key_env: None,
3587 api_key: Some("work-secret".into()),
3588 });
3589 let message = config
3590 .resolve_api_key(Some("missing"))
3591 .unwrap_err()
3592 .to_string();
3593 assert!(message.contains("missing") && message.contains("work"));
3594 assert!(!message.contains("default-secret"));
3595 assert!(!message.contains("work-secret"));
3596 }
3597
3598 #[test]
3599 fn openrouter_account_key_errors_do_not_echo_configured_values() {
3600 let config = OpenRouterConfig {
3601 accounts: vec![OpenRouterAccount {
3602 label: "work".into(),
3603 api_key_env: Some("sk_pasted_secret".into()),
3604 api_key: None,
3605 }],
3606 ..OpenRouterConfig::default()
3607 };
3608 let _g = env_guard();
3609 unsafe { std::env::remove_var("sk_pasted_secret") };
3610 let message = config
3611 .resolve_api_key(Some("work"))
3612 .unwrap_err()
3613 .to_string();
3614 assert!(message.contains("[[openrouter.accounts]]"));
3615 assert!(!message.contains("sk_pasted_secret"));
3616 }
3617
3618 #[test]
3619 fn enabled_vendors_preserves_canonical_order() {
3620 let c = Config::default();
3623 assert_eq!(
3624 c.enabled_vendors(),
3625 vec![
3626 VendorId::Anthropic,
3627 VendorId::Openai,
3628 VendorId::Zai,
3629 VendorId::Openrouter,
3630 VendorId::CommandCode,
3631 ]
3632 );
3633 }
3634
3635 #[test]
3636 fn deepseek_appears_when_enabled() {
3637 let f = write_toml(
3638 r#"
3639 [deepseek]
3640 enabled = true
3641 api_key = "sk-test"
3642 "#,
3643 );
3644 let c = Config::load_from(f.path()).unwrap();
3645 assert!(c.is_enabled(VendorId::Deepseek));
3646 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
3647 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
3648 }
3649
3650 #[test]
3651 fn tilde_paths_are_expanded_on_load() {
3652 let f = write_toml(
3656 r#"
3657 [context]
3658 projects_path = "~/.claude/projects"
3659
3660 [anthropic]
3661 credentials_path = "~/.claude/.credentials.json"
3662
3663 [[anthropic.accounts]]
3664 label = "work"
3665 credentials_path = "~/work.json"
3666 "#,
3667 );
3668 let c = Config::load_from(f.path()).unwrap();
3669 let home = crate::cache::home_dir().unwrap();
3670
3671 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
3672 let got = c.anthropic.credentials_path.unwrap();
3673 assert_eq!(got, home.join(".claude/.credentials.json"));
3674 assert!(!got.to_string_lossy().contains('~'));
3675 assert_eq!(
3676 c.anthropic.accounts[0].credentials_path,
3677 home.join("work.json")
3678 );
3679 }
3680
3681 #[test]
3682 fn absolute_and_relative_paths_are_left_alone() {
3683 let f = write_toml(
3684 r#"
3685 [anthropic]
3686 credentials_path = "/etc/creds.json"
3687 "#,
3688 );
3689 let c = Config::load_from(f.path()).unwrap();
3690 assert_eq!(
3691 c.anthropic.credentials_path.unwrap(),
3692 std::path::Path::new("/etc/creds.json")
3693 );
3694
3695 let f2 = write_toml(
3697 r#"
3698 [anthropic]
3699 credentials_path = "~someone/creds.json"
3700 "#,
3701 );
3702 let c2 = Config::load_from(f2.path()).unwrap();
3703 assert_eq!(
3704 c2.anthropic.credentials_path.unwrap(),
3705 std::path::Path::new("~someone/creds.json")
3706 );
3707 }
3708
3709 #[test]
3710 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
3711 let _g = path_override_guard();
3712 let p = resolved_path().expect("a config path must resolve");
3715 assert!(p.ends_with("config.toml"));
3716 let canonical = default_path().unwrap();
3717 let legacy = legacy_xdg_path().unwrap();
3718 assert!(
3719 p == canonical || p == legacy,
3720 "resolved to an unexpected location: {}",
3721 p.display()
3722 );
3723 }
3724
3725 #[test]
3726 fn misspelled_section_is_rejected_not_ignored() {
3727 let f = write_toml(
3730 r#"
3731 [openrouer]
3732 enabled = true
3733 api_key = "sk-or-v1-typo"
3734 "#,
3735 );
3736 let err = Config::load_from(f.path()).unwrap_err().to_string();
3737 assert!(
3738 err.contains("openrouer"),
3739 "error should name the typo: {err}"
3740 );
3741 }
3742
3743 #[test]
3744 fn invalid_toml_is_an_error_not_silent_defaults() {
3745 let f = write_toml("[zai\nenabled = true\n");
3746 assert!(Config::load_from(f.path()).is_err());
3747 }
3748
3749 #[test]
3750 fn a_missing_file_is_still_just_defaults() {
3751 let dir = tempfile::tempdir().unwrap();
3754 let missing = dir.path().join("nope").join("config.toml");
3755 let c = Config::load_from(&missing).unwrap();
3756 assert!(c.is_enabled(VendorId::Anthropic));
3757 }
3758
3759 #[test]
3760 fn kimi_appears_when_enabled() {
3761 let f = write_toml(
3762 r#"
3763 [kimi]
3764 enabled = true
3765 api_key = "sk-test"
3766 "#,
3767 );
3768 let c = Config::load_from(f.path()).unwrap();
3769 assert!(c.is_enabled(VendorId::Kimi));
3770 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
3771 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
3772 }
3773
3774 #[test]
3775 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
3776 let f = write_toml(
3777 r#"
3778 [deepseek]
3779 enabled = true
3780 api_key = "sk-ds"
3781
3782 [kimi]
3783 enabled = true
3784 api_key = "sk-kimi"
3785 "#,
3786 );
3787 let c = Config::load_from(f.path()).unwrap();
3788 assert_eq!(
3789 c.enabled_vendors(),
3790 vec![
3791 VendorId::Anthropic,
3792 VendorId::Openai,
3793 VendorId::Zai,
3794 VendorId::Openrouter,
3795 VendorId::Deepseek,
3796 VendorId::Kimi,
3797 VendorId::CommandCode,
3798 ]
3799 );
3800 }
3801
3802 #[test]
3803 fn parses_anthropic_accounts_and_looks_them_up() {
3804 let f = write_toml(
3805 r#"
3806 [anthropic]
3807 enabled = true
3808
3809 [[anthropic.accounts]]
3810 label = "personal"
3811 credentials_path = "/creds/personal.json"
3812
3813 [[anthropic.accounts]]
3814 label = "work"
3815 credentials_path = "/creds/work.json"
3816 "#,
3817 );
3818 let c = Config::load_from(f.path()).unwrap();
3819 assert_eq!(c.anthropic.accounts.len(), 2);
3820 let work = c.anthropic.account("work").unwrap();
3821 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
3822 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
3824 assert!(err.contains("missing") && err.contains("work"), "{err}");
3825 }
3826
3827 #[test]
3828 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
3829 let f = write_toml(
3830 r#"
3831 [[anthropic.accounts]]
3832 label = "work"
3833 credentials_path = "/creds/work-one.json"
3834
3835 [[anthropic.accounts]]
3836 label = "work"
3837 credentials_path = "/creds/work-two.json"
3838 "#,
3839 );
3840 let err = Config::load_from(f.path()).unwrap_err().to_string();
3841 assert!(
3842 err.contains("duplicate anthropic account label \"work\""),
3843 "{err}"
3844 );
3845 }
3846
3847 #[test]
3848 fn account_label_rejects_path_like_names() {
3849 let cfg = AnthropicConfig::default();
3850 for bad in [
3851 "",
3852 ".",
3853 "..",
3854 "a/b",
3855 r"a\b",
3856 "C:work",
3857 "line\nbreak",
3858 "tab\tname",
3859 "usage.json",
3860 ".stale",
3861 ".last_error",
3862 ".fetch.lock",
3863 ] {
3864 let err = cfg.account(bad).unwrap_err();
3865 assert!(
3866 format!("{err:?}").contains("invalid anthropic account label"),
3867 "{bad:?} should be rejected as a label"
3868 );
3869 }
3870 }
3871
3872 #[test]
3873 fn anthropic_accounts_default_to_empty() {
3874 assert!(Config::default().anthropic.accounts.is_empty());
3877 assert!(Config::default().anthropic.accounts_dir.is_none());
3878 }
3879
3880 fn seed_account_dir(root: &std::path::Path, label: &str) {
3886 let dir = root.join(label);
3887 std::fs::create_dir_all(&dir).unwrap();
3888 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
3889 }
3890
3891 #[test]
3892 fn discovers_account_dirs_in_claude_config_dir_layout() {
3893 let td = tempfile::tempdir().unwrap();
3894 seed_account_dir(td.path(), "work");
3895 seed_account_dir(td.path(), "personal");
3896 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
3899 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
3901
3902 let cfg = AnthropicConfig {
3903 accounts_dir: Some(td.path().to_path_buf()),
3904 ..Default::default()
3905 };
3906 let all = cfg.all_accounts();
3907 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
3908 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
3909 assert_eq!(
3910 all[2].credentials_path,
3911 td.path().join("work").join(".credentials.json")
3912 );
3913 }
3914
3915 #[test]
3916 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
3917 let td = tempfile::tempdir().unwrap();
3918 seed_account_dir(td.path(), "work");
3919 let cfg = AnthropicConfig {
3920 accounts: vec![AnthropicAccount {
3921 label: "work".into(),
3922 credentials_path: "/explicit/work.json".into(),
3923 }],
3924 accounts_dir: Some(td.path().to_path_buf()),
3925 ..Default::default()
3926 };
3927 let all = cfg.all_accounts();
3928 assert_eq!(all.len(), 1, "no duplicate label");
3929 assert_eq!(
3930 all[0].credentials_path,
3931 std::path::Path::new("/explicit/work.json"),
3932 "explicit entry wins"
3933 );
3934 seed_account_dir(td.path(), "other");
3936 assert_eq!(cfg.account("other").unwrap().label, "other");
3937 }
3938
3939 #[test]
3940 fn missing_accounts_dir_is_silently_empty_not_an_error() {
3941 let cfg = AnthropicConfig {
3942 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
3943 ..Default::default()
3944 };
3945 assert!(cfg.all_accounts().is_empty());
3946 }
3947
3948 #[test]
3949 fn openai_account_auth_paths_are_tilde_expanded_on_load() {
3950 let f = write_toml(
3951 r#"
3952 [[openai.accounts]]
3953 label = "work"
3954 codex_auth_path = "~/.codex-work/auth.json"
3955 "#,
3956 );
3957 let c = Config::load_from(f.path()).unwrap();
3958 let home = crate::cache::home_dir().unwrap();
3959 assert_eq!(
3960 c.openai.accounts[0].codex_auth_path,
3961 home.join(".codex-work/auth.json")
3962 );
3963 }
3964
3965 #[test]
3966 fn accounts_dir_is_tilde_expanded_on_load() {
3967 let f = write_toml(
3968 r#"
3969 [anthropic]
3970 accounts_dir = "~/.config/ai-usagebar/accounts"
3971 "#,
3972 );
3973 let c = Config::load_from(f.path()).unwrap();
3974 let home = crate::cache::home_dir().unwrap();
3975 assert_eq!(
3976 c.anthropic.accounts_dir,
3977 Some(home.join(".config/ai-usagebar/accounts"))
3978 );
3979 }
3980
3981 #[test]
3982 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
3983 let f = write_toml(
3984 r#"
3985 [anthropic]
3986 desktop_profiles_dir = "~/.claude-acc/profiles"
3987 "#,
3988 );
3989 let c = Config::load_from(f.path()).unwrap();
3990 let home = crate::cache::home_dir().unwrap();
3991 assert_eq!(
3992 c.anthropic.desktop_profiles_dir,
3993 Some(home.join(".claude-acc/profiles"))
3994 );
3995 }
3996
3997 #[test]
3998 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
3999 let cfg = AnthropicConfig {
4000 accounts: vec![
4001 AnthropicAccount {
4002 label: "work".into(),
4003 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
4004 },
4005 AnthropicAccount {
4006 label: "personal".into(),
4007 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
4008 },
4009 ],
4010 ..Default::default()
4011 };
4012
4013 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
4014 assert!(
4015 matches!(&idle, CredsTarget::Named { config_dir, .. }
4016 if config_dir == std::path::Path::new("/tmp/accounts/work")),
4017 "{idle:?}"
4018 );
4019
4020 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
4022 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
4023
4024 assert_eq!(idle_cache.dir(), live_cache.dir());
4027 }
4028
4029 #[test]
4030 fn the_live_cli_account_keeps_its_own_slot_while_that_file_is_there() {
4031 let cfg = AnthropicConfig {
4036 accounts: vec![AnthropicAccount {
4037 label: "personal".into(),
4038 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
4039 }],
4040 ..Default::default()
4041 };
4042
4043 let (present, _) = cfg
4044 .account_target_probing("personal", Some("personal"), |_| true)
4045 .unwrap();
4046 assert!(
4047 matches!(&present, CredsTarget::Named { path, .. }
4048 if path == Path::new("/tmp/accounts/personal/.credentials.json")),
4049 "{present:?}"
4050 );
4051
4052 let (moved, _) = cfg
4054 .account_target_probing("personal", Some("personal"), |_| false)
4055 .unwrap();
4056 assert!(matches!(moved, CredsTarget::Default(_)), "{moved:?}");
4057 }
4058
4059 #[test]
4060 fn the_live_cli_accounts_own_file_is_probed_on_disk() {
4061 let creds = NamedTempFile::new().unwrap();
4065 let cfg = AnthropicConfig {
4066 accounts: vec![AnthropicAccount {
4067 label: "personal".into(),
4068 credentials_path: creds.path().to_path_buf(),
4069 }],
4070 ..Default::default()
4071 };
4072 let (target, _) = cfg
4073 .account_target_with("personal", Some("personal"))
4074 .unwrap();
4075 assert!(
4076 matches!(&target, CredsTarget::Named { path, .. } if path == creds.path()),
4077 "read {target:?} instead of the account's own file"
4078 );
4079 }
4080
4081 #[test]
4082 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
4083 let cfg = AnthropicConfig {
4084 accounts: vec![AnthropicAccount {
4085 label: "work".into(),
4086 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
4087 }],
4088 ..Default::default()
4089 };
4090 let (target, _) = cfg.account_target_with("work", None).unwrap();
4091 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
4092 }
4093
4094 fn config_example() -> PathBuf {
4098 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
4099 }
4100
4101 #[test]
4102 fn shipped_example_parses_as_a_real_config() {
4103 let c = Config::load_from(&config_example()).unwrap();
4108 assert!(!c.context.enabled);
4109 assert!(c.is_enabled(VendorId::Anthropic));
4110 assert!(c.is_enabled(VendorId::Openai));
4111 assert!(!c.is_enabled(VendorId::AnthropicApi));
4112 assert!(!c.is_enabled(VendorId::Deepseek));
4113 assert!(!c.is_enabled(VendorId::Kimi));
4114 assert!(!c.is_enabled(VendorId::Kilo));
4115 assert!(!c.is_enabled(VendorId::Novita));
4116 assert!(!c.is_enabled(VendorId::Moonshot));
4117 assert!(!c.is_enabled(VendorId::Grok));
4118 assert!(!c.is_enabled(VendorId::Cursor));
4119 assert!(!c.is_enabled(VendorId::Minimax));
4120 }
4121
4122 #[test]
4123 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
4124 let text = std::fs::read_to_string(config_example()).unwrap();
4129 let live: Vec<&str> = text
4130 .lines()
4131 .map(str::trim)
4132 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
4133 .collect();
4134 assert!(
4135 live.is_empty(),
4136 "admin_key_env must stay commented out while it is inert: {live:?}"
4137 );
4138 assert!(
4141 text.contains("admin_key_env") && text.contains("RESERVED"),
4142 "the example should keep describing admin_key_env as reserved"
4143 );
4144 }
4145
4146 #[test]
4147 fn admin_key_env_is_accepted_but_changes_nothing() {
4148 let f = write_toml(
4152 r#"
4153 [openai]
4154 admin_key_env = "SOME_ADMIN_KEY"
4155 "#,
4156 );
4157 let c = Config::load_from(f.path()).unwrap();
4158 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
4159 let default = OpenAiConfig::default();
4161 assert_eq!(c.openai.enabled, default.enabled);
4162 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
4163 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
4164 }
4165
4166 #[test]
4167 fn config_example_documents_every_vendor_without_secrets() {
4168 let raw = std::fs::read_to_string(config_example()).unwrap();
4169 let cfg = Config::load_from(&config_example()).unwrap();
4170 for id in VendorId::all() {
4173 let section = id.slug();
4174 assert!(
4175 raw.contains(&format!("[{section}]")),
4176 "config.example.toml has no [{section}] section"
4177 );
4178 }
4179
4180 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
4183 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
4184 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
4185 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
4186 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
4187 assert!(!cfg.supergrok.enabled);
4188 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
4189 assert_eq!(
4190 cfg.supergrok
4191 .grok_binary
4192 .file_name()
4193 .and_then(|p| p.to_str()),
4194 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
4195 );
4196 assert!(cfg.supergrok.auth_path.is_none());
4197 assert!(cfg.supergrok.config_path.is_none());
4198 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
4199 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
4200 }
4201
4202 #[test]
4203 fn supergrok_binary_must_not_be_empty() {
4204 let file = write_toml(
4205 r#"
4206 [supergrok]
4207 enabled = true
4208 grok_binary = ""
4209 "#,
4210 );
4211 let error = Config::load_from(file.path()).unwrap_err().to_string();
4212 assert!(error.contains("grok_binary must not be empty"));
4213 }
4214
4215 #[test]
4216 fn supergrok_paths_are_tilde_expanded() {
4217 let file = write_toml(
4218 r#"
4219 [supergrok]
4220 grok_binary = "~/bin/grok"
4221 auth_path = "~/.grok/auth.json"
4222 config_path = "~/.grok/config.toml"
4223 "#,
4224 );
4225 let config = Config::load_from(file.path()).unwrap();
4226 let home = crate::cache::home_dir().unwrap();
4227 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
4228 assert_eq!(
4229 config.supergrok.auth_path,
4230 Some(home.join(".grok/auth.json"))
4231 );
4232 assert_eq!(
4233 config.supergrok.config_path,
4234 Some(home.join(".grok/config.toml"))
4235 );
4236 }
4237
4238 #[test]
4239 fn kiro_db_path_is_tilde_expanded() {
4240 let f = write_toml(
4241 r#"
4242 [kiro]
4243 db_path = "~/kiro-data.sqlite3"
4244 "#,
4245 );
4246 let c = Config::load_from(f.path()).unwrap();
4247 let home = crate::cache::home_dir().unwrap();
4248 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
4249 }
4250
4251 #[test]
4252 fn kiro_appears_when_enabled() {
4253 let f = write_toml(
4254 r#"
4255 [kiro]
4256 enabled = true
4257 "#,
4258 );
4259 let c = Config::load_from(f.path()).unwrap();
4260 assert!(c.is_enabled(VendorId::Kiro));
4261 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
4262 }
4263
4264 #[test]
4265 fn cursor_db_path_is_tilde_expanded() {
4266 let f = write_toml(
4267 r#"
4268 [cursor]
4269 db_path = "~/cursor-state.vscdb"
4270 "#,
4271 );
4272 let c = Config::load_from(f.path()).unwrap();
4273 let home = crate::cache::home_dir().unwrap();
4274 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
4275 }
4276
4277 #[test]
4278 fn cursor_agent_auth_path_is_tilde_expanded() {
4279 let f = write_toml(
4280 r#"
4281 [cursor]
4282 agent_auth_path = "~/cursor-agent-auth.json"
4283 "#,
4284 );
4285 let c = Config::load_from(f.path()).unwrap();
4286 let home = crate::cache::home_dir().unwrap();
4287 assert_eq!(
4288 c.cursor.agent_auth_path,
4289 Some(home.join("cursor-agent-auth.json"))
4290 );
4291 }
4292
4293 #[test]
4294 fn cursor_appears_when_enabled() {
4295 let f = write_toml(
4296 r#"
4297 [cursor]
4298 enabled = true
4299 "#,
4300 );
4301 let c = Config::load_from(f.path()).unwrap();
4302 assert!(c.is_enabled(VendorId::Cursor));
4303 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
4304 }
4305
4306 #[test]
4307 fn add_account_appends_and_preserves_existing() {
4308 let mut doc: toml_edit::DocumentMut = r#"
4309# keep me
4310[anthropic]
4311enabled = true
4312
4313[[anthropic.accounts]]
4314label = "personal"
4315credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
4316"#
4317 .parse()
4318 .unwrap();
4319 add_anthropic_account_to_doc(
4320 &mut doc,
4321 "work",
4322 "~/.config/ai-usagebar/accounts/work/.credentials.json",
4323 )
4324 .unwrap();
4325 let rendered = doc.to_string();
4326 assert!(rendered.contains("# keep me"), "comment must survive");
4327 let f = write_toml(&rendered);
4329 let c = Config::load_from(f.path()).unwrap();
4330 let labels: Vec<&str> = c
4331 .anthropic
4332 .accounts
4333 .iter()
4334 .map(|a| a.label.as_str())
4335 .collect();
4336 assert_eq!(labels, vec!["personal", "work"]);
4337 }
4338
4339 #[test]
4340 fn add_account_to_empty_doc_is_loadable() {
4341 let mut doc = toml_edit::DocumentMut::new();
4342 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
4343 let f = write_toml(&doc.to_string());
4344 let c = Config::load_from(f.path()).unwrap();
4345 assert_eq!(c.anthropic.accounts.len(), 1);
4346 assert_eq!(c.anthropic.accounts[0].label, "solo");
4347 }
4348
4349 #[test]
4350 fn add_account_rejects_duplicate_label() {
4351 let mut doc: toml_edit::DocumentMut = r#"
4352[[anthropic.accounts]]
4353label = "work"
4354credentials_path = "~/w/.credentials.json"
4355"#
4356 .parse()
4357 .unwrap();
4358 assert!(
4359 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
4360 "a duplicate label must be rejected, not appended"
4361 );
4362 }
4363
4364 #[test]
4365 fn add_account_rejects_bad_label() {
4366 let mut doc = toml_edit::DocumentMut::new();
4367 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
4368 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
4369 }
4370
4371 #[test]
4372 fn tildify_collapses_home_only() {
4373 let home = Path::new("/Users/me");
4374 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
4375 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
4376 }
4377
4378 #[test]
4379 fn default_account_credentials_path_nests_under_config_dir() {
4380 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
4381 assert_eq!(
4382 default_account_credentials_path(cfg, "work"),
4383 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
4384 );
4385 }
4386 const CUSTOM_BLOCK: &str = r#"
4389[[custom]]
4390id = "mytool"
4391name = "My Tool"
4392short_name = "myt"
4393enabled = true
4394url = "https://api.example.test/v1/usage"
4395api_key_env = "MYTOOL_API_KEY"
4396auth_header = "Authorization"
4397auth_scheme = "Bearer"
4398plan = "Pro"
4399cache_ttl_secs = 120
4400[custom.headers]
4401X-Org = "org_1"
4402[[custom.metrics]]
4403label = "Requests"
4404used = "/requests/used"
4405limit = "/requests/limit"
4406resets_at = "/requests/reset"
4407window_secs = 3600
4408[[custom.texts]]
4409label = "Tier"
4410value = "/tier"
4411"#;
4412
4413 fn custom_with(from: &str, to: &str) -> String {
4414 assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
4415 CUSTOM_BLOCK.replace(from, to)
4416 }
4417
4418 fn custom_error(toml: &str) -> String {
4419 Config::load_from(write_toml(toml).path())
4420 .unwrap_err()
4421 .to_string()
4422 }
4423
4424 fn assert_custom_rejected(toml: &str, needle: &str) {
4425 let msg = custom_error(toml);
4426 assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
4427 assert!(
4428 msg.contains("[[custom]]"),
4429 "the error must locate the section: {msg}"
4430 );
4431 }
4432
4433 #[test]
4434 fn custom_block_parses_every_field() {
4435 let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
4436 assert_eq!(config.custom.len(), 1);
4437 let c = &config.custom[0];
4438 assert_eq!(c.id, "mytool");
4439 assert_eq!(c.name, "My Tool");
4440 assert_eq!(c.short_name, "myt");
4441 assert_eq!(
4442 c.brand, None,
4443 "a custom provider has no mark unless it asks"
4444 );
4445 assert!(c.enabled);
4446 assert_eq!(c.url, "https://api.example.test/v1/usage");
4447 assert!(!c.allow_http);
4448 assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
4449 assert_eq!(c.api_key, None);
4450 assert_eq!(c.auth_header, "Authorization");
4451 assert_eq!(c.auth_scheme, "Bearer");
4452 assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
4453 assert_eq!(c.plan.as_deref(), Some("Pro"));
4454 assert_eq!(c.plan_path, None);
4455 assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
4456 assert_eq!(c.metrics.len(), 1);
4457 assert_eq!(c.metrics[0].label, "Requests");
4458 assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
4459 assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
4460 assert_eq!(c.metrics[0].percent, None);
4461 assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
4462 assert_eq!(c.metrics[0].window_secs, Some(3600));
4463 assert_eq!(c.texts.len(), 1);
4464 assert_eq!(c.texts[0].label, "Tier");
4465 assert_eq!(c.texts[0].value, "/tier");
4466 assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
4467 }
4468
4469 #[test]
4470 fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
4471 let config: Config = toml::from_str(
4472 r#"
4473 [[custom]]
4474 id = "bare"
4475 short_name = "bre"
4476 url = "https://example.test/u"
4477 [[custom.metrics]]
4478 label = "Q"
4479 percent = "/pct"
4480 "#,
4481 )
4482 .unwrap();
4483 let c = &config.custom[0];
4484 assert_eq!(c.name, "bare", "name must default to id on a plain parse");
4485 assert!(!c.enabled);
4486 assert!(!c.allow_http);
4487 assert_eq!(c.api_key_env, "");
4488 assert_eq!(c.auth_header, "Authorization");
4489 assert_eq!(c.auth_scheme, "Bearer");
4490 assert_eq!(c.cache_ttl_secs, 60);
4491 assert!(config.validate().is_ok());
4492 assert!(Config::default().custom.is_empty());
4493 }
4494
4495 #[test]
4496 fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
4497 let config = Config::load_from(
4498 write_toml(&custom_with(
4499 r#"short_name = "myt""#,
4500 "short_name = \"myt\"\nbrand = \"opencode-go\"",
4501 ))
4502 .path(),
4503 )
4504 .unwrap();
4505 assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
4506
4507 for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
4511 assert_custom_rejected(
4512 &custom_with(
4513 r#"short_name = "myt""#,
4514 &format!("short_name = \"myt\"\nbrand = {brand:?}"),
4515 ),
4516 "must name a built-in vendor",
4517 );
4518 }
4519 }
4520
4521 #[test]
4522 fn custom_rejects_a_malformed_id() {
4523 let long = "a".repeat(33);
4524 for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
4525 let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
4526 assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
4527 assert!(msg.contains("must match"), "{id:?}: {msg}");
4528 }
4529 }
4530
4531 #[test]
4532 fn custom_rejects_a_builtin_slug_as_id() {
4533 assert_custom_rejected(
4534 &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
4535 "is a built-in vendor",
4536 );
4537 assert_custom_rejected(
4538 &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
4539 "is a built-in vendor",
4540 );
4541 }
4542
4543 #[test]
4544 fn custom_rejects_duplicate_ids() {
4545 let twice = format!(
4546 "{}{}",
4547 CUSTOM_BLOCK,
4548 custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
4549 );
4550 assert_custom_rejected(&twice, "duplicate id");
4551 }
4552
4553 #[test]
4554 fn custom_rejects_a_name_over_48_chars() {
4555 let long = "n".repeat(49);
4556 assert_custom_rejected(
4557 &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
4558 "name must be 1 to 48 characters",
4559 );
4560 }
4561
4562 #[test]
4563 fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
4564 for short in ["my", "myto", "MYT", "m1t"] {
4565 assert_custom_rejected(
4566 &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
4567 "exactly 3 lowercase ASCII letters",
4568 );
4569 }
4570 }
4571
4572 #[test]
4573 fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
4574 assert_custom_rejected(
4575 &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
4576 "already used by a built-in vendor",
4577 );
4578 let twice = format!(
4579 "{}{}",
4580 CUSTOM_BLOCK,
4581 custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
4582 );
4583 assert_custom_rejected(&twice, "already used by a built-in vendor");
4584 }
4585
4586 #[test]
4587 fn custom_rejects_http_unless_allowed() {
4588 let plain = custom_with(
4589 r#"url = "https://api.example.test/v1/usage""#,
4590 r#"url = "http://localhost:8080/usage""#,
4591 );
4592 assert_custom_rejected(&plain, "url must use https://");
4593 let allowed = plain.replace(
4594 r#"url = "http://localhost:8080/usage""#,
4595 "url = \"http://localhost:8080/usage\"\nallow_http = true",
4596 );
4597 assert!(
4598 Config::load_from(write_toml(&allowed).path()).is_ok(),
4599 "allow_http must permit http://"
4600 );
4601 }
4602
4603 #[test]
4604 fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
4605 assert_custom_rejected(
4606 &custom_with(
4607 r#"url = "https://api.example.test/v1/usage""#,
4608 r#"url = "https://user:pw@api.example.test/v1/usage""#,
4609 ),
4610 "must not carry credentials",
4611 );
4612 assert_custom_rejected(
4613 &custom_with(
4614 r#"url = "https://api.example.test/v1/usage""#,
4615 r#"url = "not a url""#,
4616 ),
4617 "is not a valid URL",
4618 );
4619 assert_custom_rejected(
4620 &custom_with(
4621 r#"url = "https://api.example.test/v1/usage""#,
4622 r#"url = "ftp://api.example.test/v1/usage""#,
4623 ),
4624 "is not http or https",
4625 );
4626 }
4627
4628 #[test]
4629 fn custom_rejects_an_invalid_api_key_env() {
4630 assert_custom_rejected(
4631 &custom_with(
4632 r#"api_key_env = "MYTOOL_API_KEY""#,
4633 r#"api_key_env = "1BAD-NAME""#,
4634 ),
4635 "is not a valid environment variable name",
4636 );
4637 let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
4638 assert!(
4639 Config::load_from(write_toml(&none).path()).is_ok(),
4640 "an empty api_key_env means inline-only and is valid"
4641 );
4642 }
4643
4644 #[test]
4645 fn custom_rejects_an_invalid_auth_header_name() {
4646 assert_custom_rejected(
4647 &custom_with(
4648 r#"auth_header = "Authorization""#,
4649 r#"auth_header = "X Api Key""#,
4650 ),
4651 "auth_header \"X Api Key\" is not a valid HTTP header name",
4652 );
4653 }
4654
4655 #[test]
4656 fn custom_rejects_a_control_char_in_auth_scheme() {
4657 assert_custom_rejected(
4658 &custom_with(
4659 r#"auth_scheme = "Bearer""#,
4660 "auth_scheme = \"Bearer\\u0007\"",
4661 ),
4662 "auth_scheme contains characters that are not valid",
4663 );
4664 let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
4665 assert!(
4666 Config::load_from(write_toml(&bare).path()).is_ok(),
4667 "an empty scheme (bare key) is valid"
4668 );
4669 }
4670
4671 #[test]
4672 fn custom_rejects_a_bad_extra_header() {
4673 assert_custom_rejected(
4674 &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
4675 "headers must not repeat auth_header",
4676 );
4677 assert_custom_rejected(
4678 &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
4679 "is not a valid HTTP header name",
4680 );
4681 assert_custom_rejected(
4682 &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
4683 "has a value that is not valid in an HTTP header",
4684 );
4685 }
4686
4687 #[test]
4688 fn custom_rejects_cache_ttl_outside_10_to_3600() {
4689 for ttl in ["9", "3601"] {
4690 assert_custom_rejected(
4691 &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
4692 "cache_ttl_secs must be between 10 and 3600",
4693 );
4694 }
4695 }
4696
4697 #[test]
4698 fn custom_rejects_an_entry_with_no_metrics_or_texts() {
4699 let toml = r#"
4700[[custom]]
4701id = "empty"
4702short_name = "emp"
4703url = "https://example.test/u"
4704"#;
4705 assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
4706 }
4707
4708 #[test]
4709 fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
4710 assert_custom_rejected(
4711 &custom_with(
4712 r#"limit = "/requests/limit""#,
4713 "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
4714 ),
4715 "must set `percent`, or both `used` and `limit`",
4716 );
4717 assert_custom_rejected(
4718 &custom_with("limit = \"/requests/limit\"\n", ""),
4719 "must set `percent`, or both `used` and `limit`",
4720 );
4721 }
4722
4723 #[test]
4724 fn custom_rejects_a_pointer_without_a_leading_slash() {
4725 assert_custom_rejected(
4726 &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
4727 "used \"requests.used\" must be an RFC 6901 JSON Pointer",
4728 );
4729 assert_custom_rejected(
4730 &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
4731 "value \"tier\" must be an RFC 6901 JSON Pointer",
4732 );
4733 assert_custom_rejected(
4734 &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
4735 "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
4736 );
4737 assert_custom_rejected(
4738 &custom_with(
4739 r#"resets_at = "/requests/reset""#,
4740 "resets_at = \"/re\\u001bset\"",
4741 ),
4742 "resets_at",
4743 );
4744 }
4745
4746 #[test]
4747 fn custom_rejects_a_label_outside_1_to_64_chars() {
4748 let long = "l".repeat(65);
4749 assert_custom_rejected(
4750 &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
4751 "metric label",
4752 );
4753 assert_custom_rejected(
4754 &custom_with(r#"label = "Tier""#, r#"label = """#),
4755 "text label \"\" must be 1 to 64 characters",
4756 );
4757 }
4758
4759 #[test]
4760 fn custom_rejects_window_secs_under_60() {
4761 assert_custom_rejected(
4762 &custom_with("window_secs = 3600", "window_secs = 59"),
4763 "window_secs must be at least 60",
4764 );
4765 }
4766
4767 #[test]
4768 fn custom_rejects_duplicate_metric_and_text_labels() {
4769 let metric_twice = custom_with(
4770 "window_secs = 3600\n",
4771 "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
4772 );
4773 assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
4774 let text_twice =
4775 format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
4776 assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
4777 }
4778
4779 #[test]
4780 fn enabled_custom_and_custom_by_id_select_entries() {
4781 let two = format!(
4782 "{}{}",
4783 CUSTOM_BLOCK,
4784 custom_with(r#"id = "mytool""#, r#"id = "off""#)
4785 .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
4786 .replace("enabled = true", "enabled = false")
4787 );
4788 let config = Config::load_from(write_toml(&two).path()).unwrap();
4789 let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
4790 assert_eq!(enabled, ["mytool"]);
4791 assert_eq!(
4792 config.custom_by_id("off").map(|c| c.name.as_str()),
4793 Some("My Tool")
4794 );
4795 assert!(config.custom_by_id("nope").is_none());
4796 }
4797
4798 #[cfg(unix)]
4799 #[test]
4800 fn has_inline_secrets_sees_a_custom_inline_key() {
4801 let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
4802 assert!(!without.has_inline_secrets());
4803 let with: Config = toml::from_str(&custom_with(
4804 r#"api_key_env = "MYTOOL_API_KEY""#,
4805 "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
4806 ))
4807 .unwrap();
4808 assert!(with.has_inline_secrets());
4809 }
4810
4811 #[test]
4812 fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
4813 let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
4814 let mut spec = CustomProviderConfig {
4815 id: "mytool".into(),
4816 api_key_env: var.into(),
4817 api_key: Some("sk-inline-secret".into()),
4818 ..CustomProviderConfig::default()
4819 };
4820 unsafe { std::env::set_var(var, "sk-env-secret") };
4821 let from_env = spec.resolve_api_key();
4822 unsafe { std::env::remove_var(var) };
4823 assert_eq!(from_env.unwrap(), "sk-env-secret");
4824
4825 assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
4826
4827 spec.api_key = Some(String::new());
4828 let err = spec.resolve_api_key().unwrap_err();
4829 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
4830 let msg = err.to_string();
4831 assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
4832 assert!(msg.contains(var), "{msg}");
4833 assert!(!msg.contains("secret"), "{msg}");
4834
4835 spec.api_key_env = String::new();
4836 let msg = spec.resolve_api_key().unwrap_err().to_string();
4837 assert!(msg.contains("set `api_key`"), "{msg}");
4838 }
4839
4840 #[test]
4841 fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
4842 let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
4843 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
4844 let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
4845 Config::load_from(file.path()).unwrap();
4846 assert!(
4847 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
4848 "a custom provider's env var must be scrubbed from subprocesses"
4849 );
4850 }
4851
4852 #[test]
4857 fn every_config_section_parses_to_its_vendors_enabled_switch() {
4858 for vendor in VendorId::all() {
4859 let text = format!(
4860 "[{}]
4861enabled = true
4862",
4863 vendor.config_section()
4864 );
4865 let config: Config = toml::from_str(&text)
4866 .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
4867 assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
4868 let others = VendorId::all()
4869 .iter()
4870 .filter(|other| *other != vendor && config.is_enabled(**other))
4871 .count();
4872 assert_eq!(
4873 others,
4874 Config::default().enabled_vendors().len()
4875 - usize::from(Config::default().is_enabled(*vendor)),
4876 "[{}] enabled a different vendor",
4877 vendor.config_section()
4878 );
4879 }
4880 }
4881
4882 #[test]
4883 fn tray_section_parses_and_defaults_to_notify() {
4884 let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
4885 let config = Config::load_from(file.path()).unwrap();
4886 assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
4887 assert_eq!(config.tray.updates(), UpdateMode::Auto);
4888
4889 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4890 assert_eq!(empty.tray, TrayConfig::default());
4891 assert_eq!(empty.tray.updates(), UpdateMode::Notify);
4892 assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
4893 assert_eq!(UpdateMode::parse("weekly"), None);
4894 assert_eq!(UpdateMode::Auto.as_str(), "auto");
4895 }
4896
4897 #[test]
4898 fn tray_section_rejects_a_misspelled_mode() {
4899 let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
4900 assert!(Config::load_from(file.path()).is_err());
4901 }
4902
4903 #[test]
4904 fn tray_refresh_minutes_defaults_to_five_and_parses() {
4905 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4906 assert_eq!(empty.tray.refresh_minutes, None);
4907 assert_eq!(empty.tray.refresh_minutes(), 5);
4908
4909 let file = write_toml("[tray]\nrefresh_minutes = 10\n");
4910 let config = Config::load_from(file.path()).unwrap();
4911 assert_eq!(config.tray.refresh_minutes(), 10);
4912 }
4913
4914 #[test]
4915 fn tray_refresh_minutes_rejects_values_outside_the_menu() {
4916 for minutes in ["3", "0"] {
4917 let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
4918 let error = Config::load_from(file.path()).unwrap_err().to_string();
4919 assert!(error.contains("[tray] refresh_minutes"), "{error}");
4920 assert!(error.contains("1, 5 or 10"), "{error}");
4921 }
4922 }
4923
4924 #[test]
4925 fn notifications_default_on_at_97_and_parse_overrides() {
4926 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4927 assert!(empty.notifications.enabled);
4928 assert_eq!(empty.notifications.threshold, 97);
4929
4930 let file = write_toml("[notifications]\nenabled = false\nthreshold = 100\n");
4931 let config = Config::load_from(file.path()).unwrap();
4932 assert!(!config.notifications.enabled);
4933 assert_eq!(config.notifications.threshold, 100);
4934 }
4935
4936 #[test]
4937 fn notifications_threshold_rejects_values_outside_1_to_100() {
4938 for threshold in ["0", "101", "255"] {
4939 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
4940 let error = Config::load_from(file.path()).unwrap_err().to_string();
4941 assert!(
4942 error.contains("[notifications] threshold must be between 1 and 100"),
4943 "threshold {threshold}: {error}"
4944 );
4945 }
4946 for threshold in ["1", "50", "100"] {
4948 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
4949 let config = Config::load_from(file.path()).unwrap();
4950 assert_eq!(config.notifications.threshold.to_string(), threshold);
4951 }
4952 }
4953
4954 #[test]
4955 fn notification_preferences_round_trip_without_changing_other_sections() {
4956 let dir = tempfile::tempdir().unwrap();
4957 let path = dir.path().join("config.toml");
4958 std::fs::write(&path, "# keep this\n[tray]\nrefresh_minutes = 10\n").unwrap();
4959 set_notification_value(&path, "enabled", false.into()).unwrap();
4960 set_notification_value(&path, "threshold", 85i64.into()).unwrap();
4961 let text = std::fs::read_to_string(&path).unwrap();
4962 assert!(text.contains("# keep this\n[tray]\nrefresh_minutes = 10"));
4963 let config = Config::load_from(&path).unwrap();
4964 assert!(!config.notifications.enabled);
4965 assert_eq!(config.notifications.threshold, 85);
4966 assert!(set_notification_value(&path, "threshold", 101i64.into()).is_err());
4967 assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
4968 }
4969
4970 #[test]
4971 fn set_tray_value_writes_refresh_minutes_as_an_integer() {
4972 let dir = tempfile::tempdir().unwrap();
4973 let path = dir.path().join("config.toml");
4974 std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
4975
4976 set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
4977 let text = std::fs::read_to_string(&path).unwrap();
4978 assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
4979 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
4980
4981 set_tray_value(&path, "refresh_minutes", None).unwrap();
4982 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
4983 }
4984
4985 #[test]
4986 fn set_tray_value_creates_replaces_and_removes_keys() {
4987 let dir = tempfile::tempdir().unwrap();
4988 let path = dir.path().join("config.toml");
4989 std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
4990
4991 set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
4992 let text = std::fs::read_to_string(&path).unwrap();
4993 assert!(text.contains("# primary = \"anthropic\""), "{text}");
4994 assert!(
4995 text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
4996 "{text}"
4997 );
4998
4999 set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
5000 set_tray_value(&path, "updates", Some("off".into())).unwrap();
5001 let config = Config::load_from(&path).unwrap();
5002 assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
5003 assert_eq!(config.tray.updates(), UpdateMode::Off);
5004
5005 set_tray_value(&path, "shortcut", None).unwrap();
5006 let text = std::fs::read_to_string(&path).unwrap();
5007 assert!(!text.contains("shortcut"), "{text}");
5008 assert!(text.contains("updates = \"off\""), "{text}");
5009
5010 let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
5012 set_tray_value(&path, "shortcut", None).unwrap();
5013 assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
5014 }
5015
5016 #[test]
5017 fn set_value_keeps_the_trailing_comment_when_replacing() {
5018 let mut doc: toml_edit::DocumentMut =
5019 "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
5020 set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
5021 assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
5022 }
5023
5024 #[test]
5025 fn enable_vendors_in_creates_a_missing_config() {
5026 let dir = tempfile::TempDir::new().unwrap();
5027 let path = dir.path().join("sub").join("config.toml");
5028
5029 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5030
5031 assert_eq!(
5032 std::fs::read_to_string(&path).unwrap(),
5033 "[grok]
5034enabled = true
5035"
5036 );
5037 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
5038 }
5039
5040 #[test]
5041 fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
5042 let dir = tempfile::TempDir::new().unwrap();
5043 let path = dir.path().join("config.toml");
5044 let original = "# my settings
5045[zai]
5046api_key = \"x\" # keep
5047enabled = false
5048";
5049 std::fs::write(&path, original).unwrap();
5050
5051 enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
5052
5053 let text = std::fs::read_to_string(&path).unwrap();
5054 assert!(
5055 text.starts_with(
5056 "# my settings
5057"
5058 ),
5059 "{text}"
5060 );
5061 assert!(
5062 text.contains(
5063 "api_key = \"x\" # keep
5064"
5065 ),
5066 "{text}"
5067 );
5068 assert!(
5069 text.contains(
5070 "[grok]
5071enabled = true
5072"
5073 ),
5074 "{text}"
5075 );
5076 assert!(
5077 text.contains(
5078 "[opencode-go]
5079enabled = true
5080"
5081 ),
5082 "{text}"
5083 );
5084 let config = Config::load_from(&path).unwrap();
5085 assert!(
5086 !config.is_enabled(VendorId::Zai),
5087 "never widens to false, never flips others"
5088 );
5089 assert!(config.is_enabled(VendorId::Grok));
5090 assert!(config.is_enabled(VendorId::OpenCodeGo));
5091 }
5092
5093 #[test]
5094 fn enable_vendors_in_leaves_an_explicit_false_alone() {
5095 let dir = tempfile::TempDir::new().unwrap();
5096 let path = dir.path().join("config.toml");
5097 let original = "[grok]
5098enabled = false # off
5099api_key = \"k\"
5100";
5101 std::fs::write(&path, original).unwrap();
5102
5103 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5107
5108 assert!(written.is_empty(), "{written:?}");
5109 assert_eq!(
5110 std::fs::read_to_string(&path).unwrap(),
5111 original,
5112 "the file must not be rewritten at all"
5113 );
5114 }
5115
5116 #[test]
5117 fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
5118 let dir = tempfile::TempDir::new().unwrap();
5119 let path = dir.path().join("config.toml");
5120 std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
5121
5122 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5123
5124 assert_eq!(written, vec![VendorId::Grok]);
5125 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
5126 }
5127
5128 #[test]
5129 fn enable_vendors_in_is_textually_idempotent() {
5130 let dir = tempfile::TempDir::new().unwrap();
5131 let path = dir.path().join("config.toml");
5132 let original = "[grok]
5133enabled = true
5134
5135# trailing
5136";
5137 std::fs::write(&path, original).unwrap();
5138 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
5139
5140 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5141
5142 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
5143 assert_eq!(
5144 std::fs::metadata(&path).unwrap().modified().unwrap(),
5145 before,
5146 "an unchanged document must not be rewritten"
5147 );
5148 }
5149
5150 #[test]
5151 fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
5152 let dir = tempfile::TempDir::new().unwrap();
5153 let path = dir.path().join("config.toml");
5154
5155 enable_vendors_in(&path, &[]).unwrap();
5156
5157 assert!(!path.exists());
5158 }
5159}