1use std::collections::{BTreeMap, HashSet};
23use std::path::{Path, PathBuf};
24
25#[cfg(unix)]
26use std::os::unix::fs::{MetadataExt, PermissionsExt};
27
28use serde::{Deserialize, Serialize};
29
30use crate::anthropic::creds::CredsTarget;
31use crate::balance::{DisplayPrefs, Headline};
32use crate::cache::Cache;
33use crate::error::{AppError, Result};
34use crate::vendor::VendorId;
35
36#[derive(Debug, Clone, Default, Deserialize, Serialize)]
43#[serde(default, deny_unknown_fields)]
44pub struct Config {
45 pub ui: UiConfig,
46 pub tray: TrayConfig,
47 pub context: ContextConfig,
48 pub anthropic: AnthropicConfig,
49 pub anthropic_api: AnthropicApiConfig,
50 pub openai: OpenAiConfig,
51 pub copilot: CopilotConfig,
52 pub zai: ZaiConfig,
53 pub openrouter: OpenRouterConfig,
54 pub deepseek: DeepseekConfig,
55 pub deepinfra: DeepInfraConfig,
56 pub kimi: KimiConfig,
57 pub kilo: KiloConfig,
58 pub novita: NovitaConfig,
59 pub synthetic: SyntheticConfig,
60 pub moonshot: MoonshotConfig,
61 pub grok: GrokConfig,
62 pub supergrok: SuperGrokConfig,
63 pub grokbot: GrokbotConfig,
64 pub antigravity: AntigravityConfig,
65 pub cursor: CursorConfig,
66 pub minimax: MinimaxConfig,
67 pub kiro: KiroConfig,
68 pub nous: NousConfig,
69 #[serde(rename = "opencode-go")]
70 pub opencode_go: OpenCodeGoConfig,
71 pub commandcode: CommandCodeConfig,
72 pub ollama: OllamaConfig,
73 pub orcarouter: OrcaRouterConfig,
74 pub modelstudio: ModelStudioConfig,
75 pub lyceum: LyceumConfig,
76 pub devin: DevinConfig,
77 pub droid: DroidConfig,
78 #[serde(rename = "muse-code")]
79 pub muse: MuseConfig,
80 pub notifications: NotificationsConfig,
82 pub custom: Vec<CustomProviderConfig>,
84 pub cline: ClineConfig,
85}
86
87#[derive(Debug, Clone, Default, Deserialize, Serialize)]
91#[serde(default)]
92pub struct UiConfig {
93 pub primary: Option<VendorId>,
95 pub overview_vendors: Option<Vec<VendorId>>,
100 pub vendor_box: Option<VendorBoxStyle>,
102}
103
104impl UiConfig {
105 pub fn vendor_box(&self) -> VendorBoxStyle {
106 self.vendor_box.unwrap_or_default()
107 }
108}
109
110#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
114#[serde(default)]
115pub struct TrayConfig {
116 pub shortcut: Option<String>,
119 pub refresh_minutes: Option<u64>,
122 pub updates: Option<UpdateMode>,
124 pub menu_bar_style: Option<String>,
127 pub menu_bar_short_name: Option<bool>,
130}
131
132pub const TRAY_REFRESH_MINUTES: [u64; 6] = [1, 5, 10, 15, 30, 60];
135const DEFAULT_TRAY_REFRESH_MINUTES: u64 = 5;
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
141#[serde(default)]
142pub struct NotificationsConfig {
143 pub enabled: bool,
144 pub threshold: u8,
147}
148
149impl Default for NotificationsConfig {
150 fn default() -> Self {
151 Self {
152 enabled: true,
153 threshold: 97,
154 }
155 }
156}
157
158impl TrayConfig {
159 pub fn refresh_minutes(&self) -> u64 {
160 self.refresh_minutes.unwrap_or(DEFAULT_TRAY_REFRESH_MINUTES)
161 }
162
163 pub fn updates(&self) -> UpdateMode {
164 self.updates.unwrap_or_default()
165 }
166
167 pub fn menu_bar_short_name(&self) -> bool {
168 self.menu_bar_short_name.unwrap_or(true)
169 }
170}
171
172#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
175#[serde(rename_all = "lowercase")]
176pub enum UpdateMode {
177 Auto,
178 #[default]
179 Notify,
180 Off,
181}
182
183impl UpdateMode {
184 pub fn as_str(self) -> &'static str {
185 match self {
186 Self::Auto => "auto",
187 Self::Notify => "notify",
188 Self::Off => "off",
189 }
190 }
191
192 pub fn parse(text: &str) -> Option<Self> {
193 match text.trim().to_ascii_lowercase().as_str() {
194 "auto" => Some(Self::Auto),
195 "notify" => Some(Self::Notify),
196 "off" => Some(Self::Off),
197 _ => None,
198 }
199 }
200}
201
202#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
204#[serde(rename_all = "lowercase")]
205pub enum VendorBoxStyle {
206 #[default]
208 Sidebar,
209 Navbar,
211 None,
213}
214
215#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
218#[serde(rename_all = "lowercase")]
219pub enum ContextLayout {
220 #[default]
222 Full,
223 Split,
225 Bottom,
227}
228
229impl ContextLayout {
230 pub fn next(self) -> Self {
231 match self {
232 ContextLayout::Full => ContextLayout::Split,
233 ContextLayout::Split => ContextLayout::Bottom,
234 ContextLayout::Bottom => ContextLayout::Full,
235 }
236 }
237
238 pub fn label(self) -> &'static str {
239 match self {
240 ContextLayout::Full => "full",
241 ContextLayout::Split => "split",
242 ContextLayout::Bottom => "bottom",
243 }
244 }
245}
246
247#[derive(Debug, Clone, Default, Deserialize, Serialize)]
252#[serde(default)]
253pub struct ContextConfig {
254 pub enabled: bool,
257 pub projects_path: Option<PathBuf>,
259 pub context_window_tokens: Option<u64>,
262 pub model_context_window_tokens: BTreeMap<String, u64>,
265 pub layout: ContextLayout,
267}
268
269impl ContextConfig {
270 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
271 model
272 .and_then(|model| self.model_context_window_tokens.get(model).copied())
273 .filter(|tokens| *tokens > 0)
274 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
275 }
276}
277
278#[derive(Debug, Clone, Deserialize, Serialize)]
279#[serde(default)]
280pub struct AnthropicConfig {
281 pub enabled: bool,
282 pub credentials_path: Option<PathBuf>,
285 pub accounts: Vec<AnthropicAccount>,
289 pub accounts_dir: Option<PathBuf>,
297 pub show_default_account: bool,
303 pub desktop_profiles_dir: Option<PathBuf>,
309 pub desktop_prepare_binary: Option<PathBuf>,
312}
313
314impl Default for AnthropicConfig {
315 fn default() -> Self {
316 Self {
317 enabled: true,
318 credentials_path: None,
319 accounts: Vec::new(),
320 accounts_dir: None,
321 show_default_account: true,
322 desktop_profiles_dir: None,
323 desktop_prepare_binary: None,
324 }
325 }
326}
327
328#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
338pub struct AnthropicAccount {
339 pub label: String,
342 pub credentials_path: PathBuf,
346}
347
348impl AnthropicAccount {
349 pub fn config_dir(&self) -> PathBuf {
354 self.credentials_path
355 .parent()
356 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
357 }
358}
359
360impl AnthropicConfig {
361 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
367 let mut out = self.accounts.clone();
368 if let Some(dir) = &self.accounts_dir {
369 for acct in discover_accounts(dir) {
370 if !out.iter().any(|a| a.label == acct.label) {
371 out.push(acct);
372 }
373 }
374 }
375 out
376 }
377
378 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
383 validate_account_label(label)?;
384 let all = self.all_accounts();
385 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
386 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
387 AppError::Credentials(format!(
388 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
389 known labels: {known:?}"
390 ))
391 })
392 }
393
394 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
405 self.account_target_with(label, self.active_cli_label().as_deref())
406 }
407
408 pub fn active_cli_label(&self) -> Option<String> {
414 crate::anthropic::cli_account::home_claude_json()
415 .ok()
416 .and_then(|path| {
417 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
418 })
419 }
420
421 pub fn default_creds_target(&self) -> CredsTarget {
425 match self.credentials_path.clone() {
426 Some(path) => CredsTarget::Explicit(path),
427 None => {
428 CredsTarget::Default(crate::anthropic::creds::default_path().unwrap_or_default())
429 }
430 }
431 }
432
433 pub fn account_target_with(
441 &self,
442 label: &str,
443 cli_active: Option<&str>,
444 ) -> Result<(CredsTarget, Cache)> {
445 self.account_target_probing(label, cli_active, |path| path.exists())
446 }
447
448 pub fn account_target_probing(
468 &self,
469 label: &str,
470 cli_active: Option<&str>,
471 exists: impl Fn(&Path) -> bool,
472 ) -> Result<(CredsTarget, Cache)> {
473 let account = self.account(label)?;
474 let cache = Cache::for_vendor_account("anthropic", label)?;
475 if cli_active == Some(label) && !exists(&account.credentials_path) {
476 return Ok((
477 CredsTarget::Default(crate::anthropic::creds::default_path()?),
478 cache,
479 ));
480 }
481 Ok((
482 CredsTarget::Named {
483 config_dir: account.config_dir(),
484 path: account.credentials_path,
485 },
486 cache,
487 ))
488 }
489}
490
491pub fn validate_account_label(label: &str) -> Result<()> {
497 validate_account_label_for("anthropic", label)
498}
499
500fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
501 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
502 let bad = label.is_empty()
503 || label == "."
504 || label == ".."
505 || label.contains(['/', '\\'])
506 || label.contains(':')
507 || label.chars().any(char::is_control)
508 || RESERVED.contains(&label);
509 if bad {
510 return Err(AppError::Credentials(format!(
511 "invalid {vendor} account label {label:?}: must be a non-empty name \
512 without path separators, drive prefixes, control characters, or reserved cache names"
513 )));
514 }
515 Ok(())
516}
517
518fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
526 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
527 return Vec::new();
528 };
529 let mut found: Vec<AnthropicAccount> = entries
530 .flatten()
531 .filter_map(|entry| {
532 let path = entry.path();
533 if !path.is_dir() {
534 return None;
535 }
536 let label = path.file_name()?.to_str()?.to_string();
537 validate_account_label(&label).ok()?;
538 Some(AnthropicAccount {
539 label,
540 credentials_path: path.join(".credentials.json"),
541 })
542 })
543 .collect();
544 found.sort_by(|a, b| a.label.cmp(&b.label));
545 found
546}
547
548pub fn tildify(path: &Path, home: &Path) -> String {
552 path.strip_prefix(home)
553 .map(|rest| {
554 let rendered = rest.display().to_string();
555 #[cfg(windows)]
558 let rendered = rendered.replace('\\', "/");
559 format!("~/{rendered}")
560 })
561 .unwrap_or_else(|_| path.display().to_string())
562}
563
564pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
569 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
570 base.join("accounts").join(label).join(".credentials.json")
571}
572
573pub fn add_anthropic_account_to_doc(
579 doc: &mut toml_edit::DocumentMut,
580 label: &str,
581 credentials_path: &str,
582) -> Result<()> {
583 use toml_edit::{Item, Table, value};
584
585 validate_account_label(label)?;
586
587 let anthropic = doc
588 .entry("anthropic")
589 .or_insert_with(|| Item::Table(Table::new()));
590 let anthropic = anthropic
591 .as_table_mut()
592 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
593
594 let accounts = anthropic
595 .entry("accounts")
596 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
597 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
598 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
599 })?;
600
601 let exists = accounts
602 .iter()
603 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
604 if exists {
605 return Err(AppError::Credentials(format!(
606 "anthropic account {label:?} already exists in config.toml"
607 )));
608 }
609
610 let mut table = Table::new();
611 table["label"] = value(label);
612 table["credentials_path"] = value(credentials_path);
613 accounts.push(table);
614 Ok(())
615}
616
617pub fn default_codex_auth_path(home: &Path, label: &str) -> PathBuf {
621 home.join(format!(".codex-{label}")).join("auth.json")
622}
623
624pub fn add_openai_account_to_doc(
629 doc: &mut toml_edit::DocumentMut,
630 label: &str,
631 codex_auth_path: &str,
632) -> Result<()> {
633 use toml_edit::{Item, Table, value};
634
635 validate_account_label_for("openai", label)?;
636
637 let openai = doc
638 .entry("openai")
639 .or_insert_with(|| Item::Table(Table::new()));
640 let openai = openai
641 .as_table_mut()
642 .ok_or_else(|| AppError::Other("[openai] in config.toml is not a table".into()))?;
643
644 let accounts = openai
645 .entry("accounts")
646 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
647 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
648 AppError::Other("[[openai.accounts]] in config.toml is not an array of tables".into())
649 })?;
650
651 if accounts
652 .iter()
653 .any(|t| t.get("label").and_then(Item::as_str) == Some(label))
654 {
655 return Err(AppError::Credentials(format!(
656 "openai account {label:?} already exists in config.toml"
657 )));
658 }
659
660 let mut table = Table::new();
661 table["label"] = value(label);
662 table["codex_auth_path"] = value(codex_auth_path);
663 accounts.push(table);
664 Ok(())
665}
666
667pub(crate) fn set_bool(
671 doc: &mut toml_edit::DocumentMut,
672 section: &str,
673 key: &str,
674 new_value: bool,
675) -> Result<()> {
676 let table = doc
677 .entry(section)
678 .or_insert_with(toml_edit::table)
679 .as_table_mut()
680 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
681
682 if let Some(item) = table.get_mut(key)
683 && let Some(v) = item.as_value_mut()
684 {
685 let suffix = v.decor().suffix().cloned();
689 *v = toml_edit::Value::from(new_value);
690 v.decor_mut().set_prefix(" ");
691 if let Some(suffix) = suffix {
692 v.decor_mut().set_suffix(suffix);
693 }
694 return Ok(());
695 }
696 table.insert(key, toml_edit::value(new_value));
697 Ok(())
698}
699
700pub(crate) fn set_value(
706 doc: &mut toml_edit::DocumentMut,
707 section: &str,
708 key: &str,
709 new_value: Option<toml_edit::Value>,
710) -> Result<()> {
711 let table = doc
712 .entry(section)
713 .or_insert_with(toml_edit::table)
714 .as_table_mut()
715 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
716
717 let Some(mut new_value) = new_value else {
718 table.remove(key);
719 return Ok(());
720 };
721 if let Some(item) = table.get_mut(key)
722 && let Some(v) = item.as_value_mut()
723 {
724 let suffix = v.decor().suffix().cloned();
725 new_value.decor_mut().set_prefix(" ");
726 if let Some(suffix) = suffix {
727 new_value.decor_mut().set_suffix(suffix);
728 }
729 *v = new_value;
730 return Ok(());
731 }
732 table.insert(key, toml_edit::Item::Value(new_value));
733 Ok(())
734}
735
736pub fn set_tray_value(path: &Path, key: &str, value: Option<toml_edit::Value>) -> Result<()> {
742 let mut doc = read_config_document(path)?;
743 let before = doc.to_string();
744 set_value(&mut doc, "tray", key, value)?;
745 if doc.to_string() == before {
746 return Ok(());
747 }
748 write_config_document(path, &doc)
749}
750
751pub fn set_notification_value(path: &Path, key: &str, value: toml_edit::Value) -> Result<()> {
754 match key {
755 "enabled" if value.as_bool().is_some() => {}
756 "threshold" if value.as_integer().is_some_and(|n| (1..=100).contains(&n)) => {}
757 _ => {
758 return Err(AppError::Other(format!(
759 "invalid notification preference: {key}"
760 )));
761 }
762 }
763 let mut doc = read_config_document(path)?;
764 let before = doc.to_string();
765 set_value(&mut doc, "notifications", key, Some(value))?;
766 if doc.to_string() == before {
767 return Ok(());
768 }
769 write_config_document(path, &doc)
770}
771
772pub fn set_vendor_enabled(path: &Path, slug: &str, enabled: bool) -> Result<()> {
780 let mut doc = read_config_document(path)?;
781 let before = doc.to_string();
782 set_vendor_enabled_in_doc(&mut doc, slug, enabled)?;
783 if doc.to_string() == before {
784 return Ok(());
785 }
786 write_config_document(path, &doc)
787}
788
789pub fn set_vendor_enabled_in_doc(
794 doc: &mut toml_edit::DocumentMut,
795 slug: &str,
796 enabled: bool,
797) -> Result<()> {
798 let Some(vendor) = VendorId::from_slug(slug) else {
799 return Err(AppError::Other(format!(
800 "unknown provider {slug:?}: not a built-in vendor"
801 )));
802 };
803 set_bool(doc, vendor.config_section(), "enabled", enabled)
804}
805
806pub(crate) fn toml_error_summary(
812 input: &str,
813 span: Option<std::ops::Range<usize>>,
814 message: &str,
815) -> String {
816 let message = message.trim_end();
817 match span.and_then(|range| input.get(..range.start)) {
818 Some(before) => {
819 let line = before.matches('\n').count() + 1;
820 let line_start = before.rfind('\n').map_or(0, |i| i + 1);
821 let column = before[line_start..].chars().count() + 1;
822 format!("line {line}, column {column}: {message}")
823 }
824 None => message.to_string(),
825 }
826}
827
828pub(crate) fn read_config_document(path: &Path) -> Result<toml_edit::DocumentMut> {
832 let original = match std::fs::read_to_string(path) {
833 Ok(contents) => contents,
834 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
835 Err(error) => return Err(AppError::io_at(path, error)),
836 };
837 if original.trim().is_empty() {
838 return Ok(toml_edit::DocumentMut::new());
839 }
840 original.parse().map_err(|e: toml_edit::TomlError| {
841 let summary = toml_error_summary(&original, e.span(), e.message());
842 AppError::Other(format!("config.toml not parseable: {summary}"))
843 })
844}
845
846pub(crate) fn write_config_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> {
850 let bytes = doc.to_string();
851 crate::cache::atomic_write(path, bytes.as_bytes())?;
852
853 #[cfg(unix)]
854 {
855 if let Ok(meta) = std::fs::metadata(path) {
856 let mut perms = meta.permissions();
857 perms.set_mode(0o600);
858 let _ = std::fs::set_permissions(path, perms);
859 }
860 }
861 Ok(())
862}
863
864pub fn enable_vendors_in(path: &Path, vendors: &[VendorId]) -> Result<Vec<VendorId>> {
872 let mut doc = read_config_document(path)?;
873 let before = doc.to_string();
874 let written: Vec<VendorId> = vendors
875 .iter()
876 .copied()
877 .filter(|vendor| !is_explicitly_disabled(&doc, *vendor))
878 .collect();
879 for vendor in &written {
880 set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
881 }
882 if doc.to_string() == before {
883 return Ok(written);
884 }
885 write_config_document(path, &doc)?;
886 Ok(written)
887}
888
889fn is_explicitly_disabled(doc: &toml_edit::DocumentMut, vendor: VendorId) -> bool {
900 doc.get(vendor.config_section())
901 .and_then(|section| section.get("enabled"))
902 .and_then(|enabled| enabled.as_bool())
903 == Some(false)
904}
905
906#[derive(Debug, Clone, Deserialize, Serialize)]
907#[serde(default)]
908pub struct OpenAiConfig {
909 pub enabled: bool,
910 pub codex_auth_path: Option<PathBuf>,
912 #[serde(default)]
917 pub accounts: Vec<OpenAiAccount>,
918 pub show_default_account: bool,
924 pub admin_key_env: String,
932}
933
934#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
945pub struct OpenAiAccount {
946 pub label: String,
949 pub codex_auth_path: PathBuf,
952}
953
954impl OpenAiConfig {
955 pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
959 let Some(label) = label else {
960 return match &self.codex_auth_path {
961 Some(path) => Ok(path.clone()),
962 None => crate::openai::creds::default_path(),
963 };
964 };
965 self.accounts
966 .iter()
967 .find(|account| account.label == label)
968 .map(|account| account.codex_auth_path.clone())
969 .ok_or_else(|| {
970 AppError::Credentials(format!(
971 "no OpenAI account named {label:?}. Add it under \
972 [[openai.accounts]], or drop --account to use the default login."
973 ))
974 })
975 }
976
977 pub fn fetch_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
984 let Some(label) = label else {
985 return self.resolve_auth_path(None);
986 };
987 let default = self.resolve_auth_path(None)?;
988 let active = crate::openai::account::resolve_active_label(&default, &self.accounts);
989 self.fetch_auth_path_probing(label, active.as_deref(), Path::exists)
990 }
991
992 pub fn fetch_auth_path_probing(
997 &self,
998 label: &str,
999 active: Option<&str>,
1000 exists: impl Fn(&Path) -> bool,
1001 ) -> Result<PathBuf> {
1002 let own = self.resolve_auth_path(Some(label))?;
1003 if active == Some(label) && !exists(&own) {
1004 return self.resolve_auth_path(None);
1005 }
1006 Ok(own)
1007 }
1008}
1009
1010impl Default for OpenAiConfig {
1011 fn default() -> Self {
1012 Self {
1013 enabled: true,
1014 codex_auth_path: None,
1015 accounts: Vec::new(),
1016 show_default_account: true,
1017 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
1018 }
1019 }
1020}
1021
1022#[derive(Debug, Clone, Deserialize, Serialize)]
1026#[serde(default)]
1027pub struct CopilotConfig {
1028 pub enabled: bool,
1029 pub gh_binary: Option<PathBuf>,
1032 pub accounts: Vec<CopilotAccount>,
1036 pub show_default_account: bool,
1042}
1043
1044impl Default for CopilotConfig {
1045 fn default() -> Self {
1046 Self {
1047 enabled: false,
1050 gh_binary: None,
1051 accounts: Vec::new(),
1052 show_default_account: true,
1053 }
1054 }
1055}
1056
1057#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1076pub struct CopilotAccount {
1077 pub label: String,
1080 pub user: String,
1083}
1084
1085impl CopilotConfig {
1086 pub fn resolve_user(&self, label: Option<&str>) -> Result<Option<&str>> {
1090 let Some(label) = label else {
1091 return Ok(None);
1092 };
1093 self.accounts
1094 .iter()
1095 .find(|account| account.label == label)
1096 .map(|account| Some(account.user.as_str()))
1097 .ok_or_else(|| {
1098 let known = self
1099 .accounts
1100 .iter()
1101 .map(|account| account.label.as_str())
1102 .collect::<Vec<_>>();
1103 AppError::Other(if known.is_empty() {
1104 format!(
1105 "copilot: no account {label:?} — [copilot] has no accounts array; \
1106 add [[copilot.accounts]] with label and user"
1107 )
1108 } else {
1109 format!(
1110 "copilot: no account {label:?}; known labels: {}",
1111 known.join(", ")
1112 )
1113 })
1114 })
1115 }
1116
1117 pub fn resolve_token(&self, label: Option<&str>) -> Result<String> {
1118 self.resolve_token_with(
1119 |name| std::env::var_os(name),
1120 &crate::copilot::credentials::SystemGhAuthTokenRunner,
1121 label,
1122 )
1123 }
1124
1125 fn resolve_token_with(
1126 &self,
1127 environment: impl Fn(&str) -> Option<std::ffi::OsString>,
1128 runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
1129 label: Option<&str>,
1130 ) -> Result<String> {
1131 let user = self.resolve_user(label)?;
1132 if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
1133 let token = value.into_string().map_err(|_| {
1134 AppError::Credentials(
1135 "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
1136 )
1137 })?;
1138 if !token.is_empty() {
1139 if let Some(label) = label {
1143 return Err(AppError::Credentials(format!(
1144 "GitHub Copilot: GITHUB_COPILOT_TOKEN is set, so it is unclear whether \
1145 --account {label:?} should use that token or {user:?}'s `gh` login. \
1146 Unset the variable to use named accounts."
1147 )));
1148 }
1149 return Ok(token);
1150 }
1151 }
1152 crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref(), user)
1153 }
1154}
1155
1156#[derive(Debug, Clone, Deserialize, Serialize)]
1157#[serde(default)]
1158pub struct NousConfig {
1159 pub enabled: bool,
1160 pub headline: Headline,
1164}
1165
1166impl Default for NousConfig {
1167 fn default() -> Self {
1168 Self {
1169 enabled: false,
1170 headline: Headline::Percent,
1173 }
1174 }
1175}
1176
1177#[derive(Debug, Clone, Deserialize, Serialize)]
1178#[serde(default)]
1179pub struct OpenCodeGoConfig {
1180 pub enabled: bool,
1181 pub api_key_env: String,
1182 pub api_key: Option<String>,
1183 pub auth_paths: Option<Vec<PathBuf>>,
1188}
1189
1190#[derive(Debug, Clone, Deserialize, Serialize)]
1196#[serde(default)]
1197pub struct ClineConfig {
1198 pub enabled: bool,
1199 pub auth_paths: Option<Vec<PathBuf>>,
1200 pub api_key_env: String,
1201 pub api_key: Option<String>,
1202}
1203
1204impl Default for ClineConfig {
1205 fn default() -> Self {
1206 Self {
1207 enabled: false,
1208 auth_paths: None,
1209 api_key_env: crate::cline::creds::API_KEY_ENV.to_string(),
1210 api_key: None,
1211 }
1212 }
1213}
1214
1215#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1220#[serde(default)]
1221pub struct CommandCodeConfig {
1222 pub enabled: bool,
1223 pub auth_paths: Option<Vec<PathBuf>>,
1224}
1225
1226#[derive(Debug, Clone, Deserialize, Serialize)]
1231#[serde(default)]
1232pub struct OllamaConfig {
1233 pub enabled: bool,
1234 pub api_key_env: String,
1235 pub api_key: Option<String>,
1236 pub plan: String,
1239}
1240
1241impl Default for OllamaConfig {
1242 fn default() -> Self {
1243 Self {
1244 enabled: false,
1245 api_key_env: "OLLAMA_API_KEY".to_string(),
1246 api_key: None,
1247 plan: "pro".to_string(),
1248 }
1249 }
1250}
1251
1252#[derive(Debug, Clone, Deserialize, Serialize)]
1255#[serde(default)]
1256pub struct OrcaRouterConfig {
1257 pub enabled: bool,
1258 pub accounts: Vec<ApiKeyAccount>,
1261 pub show_default_account: bool,
1265 pub api_key_env: String,
1266 pub api_key: Option<String>,
1267}
1268
1269impl Default for OrcaRouterConfig {
1270 fn default() -> Self {
1271 Self {
1272 enabled: false,
1273 accounts: Vec::new(),
1274 show_default_account: true,
1275 api_key_env: "ORCAROUTER_API_KEY".to_string(),
1276 api_key: None,
1277 }
1278 }
1279}
1280
1281#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1288#[serde(default)]
1289pub struct ModelStudioConfig {
1290 pub enabled: bool,
1293 pub config_dir: Option<PathBuf>,
1296}
1297
1298#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1300#[serde(default)]
1301pub struct MuseConfig {
1302 pub enabled: bool,
1303 pub credentials_path: Option<PathBuf>,
1304}
1305
1306#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1314pub struct DevinAccount {
1315 pub label: String,
1318 pub credentials_path: PathBuf,
1320}
1321
1322#[derive(Debug, Clone, Deserialize, Serialize)]
1324#[serde(default)]
1325pub struct DevinConfig {
1326 pub enabled: bool,
1327 pub credentials_path: Option<PathBuf>,
1331 #[serde(default)]
1333 pub accounts: Vec<DevinAccount>,
1334 pub show_default_account: bool,
1338}
1339
1340impl Default for DevinConfig {
1341 fn default() -> Self {
1342 Self {
1343 enabled: false,
1344 credentials_path: None,
1345 accounts: Vec::new(),
1346 show_default_account: true,
1347 }
1348 }
1349}
1350
1351impl DevinConfig {
1352 pub fn resolve_credentials_path(&self, label: Option<&str>) -> Result<PathBuf> {
1355 let Some(label) = label else {
1356 return crate::devin::credentials_path(self);
1357 };
1358 self.accounts
1359 .iter()
1360 .find(|account| account.label == label)
1361 .map(|account| account.credentials_path.clone())
1362 .ok_or_else(|| {
1363 AppError::Credentials(format!(
1364 "no Devin account named {label:?}. Add it under \
1365 [[devin.accounts]], or drop --account to use the default login."
1366 ))
1367 })
1368 }
1369}
1370
1371#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1375#[serde(default)]
1376pub struct DroidConfig {
1377 pub enabled: bool,
1378 pub cli_renewal: bool,
1383 pub cli_path: Option<PathBuf>,
1385}
1386
1387impl Default for OpenCodeGoConfig {
1388 fn default() -> Self {
1389 Self {
1390 enabled: false,
1391 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
1392 api_key: None,
1393 auth_paths: None,
1394 }
1395 }
1396}
1397
1398#[derive(Debug, Clone, Deserialize, Serialize)]
1399#[serde(default)]
1400pub struct ZaiConfig {
1401 pub enabled: bool,
1402 pub accounts: Vec<ApiKeyAccount>,
1405 pub show_default_account: bool,
1409 pub api_key_env: String,
1411 pub api_key: Option<String>,
1414 pub plan_tier: Option<String>,
1416}
1417
1418impl Default for ZaiConfig {
1419 fn default() -> Self {
1420 Self {
1421 enabled: true,
1422 accounts: Vec::new(),
1423 show_default_account: true,
1424 api_key_env: "ZAI_API_KEY".to_string(),
1425 api_key: None,
1426 plan_tier: None,
1427 }
1428 }
1429}
1430
1431#[derive(Debug, Clone, Deserialize, Serialize)]
1432#[serde(default)]
1433pub struct OpenRouterConfig {
1434 pub enabled: bool,
1435 pub accounts: Vec<ApiKeyAccount>,
1438 pub show_default_account: bool,
1442 pub api_key_env: String,
1443 pub api_key: Option<String>,
1444 pub management_api_key_env: String,
1449 pub headline: Headline,
1461}
1462
1463impl Default for OpenRouterConfig {
1464 fn default() -> Self {
1465 Self {
1466 enabled: true,
1467 accounts: Vec::new(),
1468 show_default_account: true,
1469 api_key_env: "OPENROUTER_API_KEY".to_string(),
1470 api_key: None,
1471 management_api_key_env: "OPENROUTER_MANAGEMENT_API_KEY".to_string(),
1472 headline: Headline::Percent,
1473 }
1474 }
1475}
1476
1477#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1482pub struct ApiKeyAccount {
1483 pub label: String,
1485 #[serde(default)]
1487 pub api_key_env: Option<String>,
1488 #[serde(default)]
1490 pub api_key: Option<String>,
1491 #[serde(default)]
1495 pub management_api_key_env: Option<String>,
1496}
1497
1498fn api_key_account<'a>(
1502 slug: &str,
1503 accounts: &'a [ApiKeyAccount],
1504 label: &str,
1505) -> Result<&'a ApiKeyAccount> {
1506 validate_account_label_for(slug, label)?;
1507 accounts
1508 .iter()
1509 .find(|account| account.label == label)
1510 .ok_or_else(|| {
1511 let known: Vec<&str> = accounts
1512 .iter()
1513 .map(|account| account.label.as_str())
1514 .collect();
1515 AppError::Credentials(format!(
1516 "{slug} account {label:?} not found in [[{slug}.accounts]]; \
1517 known labels: {known:?}"
1518 ))
1519 })
1520}
1521
1522#[derive(Debug, Clone, Deserialize, Serialize)]
1523#[serde(default)]
1524pub struct DeepseekConfig {
1525 pub enabled: bool,
1526 pub accounts: Vec<ApiKeyAccount>,
1529 pub show_default_account: bool,
1533 pub api_key_env: String,
1534 pub api_key: Option<String>,
1535 pub display_limit: Option<f64>,
1538 pub headline: Headline,
1540}
1541
1542impl Default for DeepseekConfig {
1543 fn default() -> Self {
1544 Self {
1545 enabled: false,
1546 accounts: Vec::new(),
1547 show_default_account: true,
1548 api_key_env: "DEEPSEEK_API_KEY".to_string(),
1549 api_key: None,
1550 display_limit: None,
1551 headline: Headline::Amount,
1552 }
1553 }
1554}
1555
1556#[derive(Debug, Clone, Deserialize, Serialize)]
1557#[serde(default)]
1558pub struct DeepInfraConfig {
1559 pub enabled: bool,
1560 pub accounts: Vec<ApiKeyAccount>,
1561 pub show_default_account: bool,
1562 pub api_key_env: String,
1563 pub api_key: Option<String>,
1564 pub display_limit: Option<f64>,
1566 pub headline: Headline,
1567}
1568
1569impl Default for DeepInfraConfig {
1570 fn default() -> Self {
1571 Self {
1572 enabled: false,
1573 accounts: Vec::new(),
1574 show_default_account: true,
1575 api_key_env: "DEEPINFRA_API_KEY".to_string(),
1576 api_key: None,
1577 display_limit: None,
1578 headline: Headline::Amount,
1579 }
1580 }
1581}
1582
1583#[derive(Debug, Clone, Deserialize, Serialize)]
1584#[serde(default)]
1585pub struct KimiConfig {
1586 pub enabled: bool,
1587 pub api_key_env: String,
1588 pub api_key: Option<String>,
1591 pub credentials_path: Option<PathBuf>,
1595 pub region: String,
1600}
1601
1602impl Default for KimiConfig {
1603 fn default() -> Self {
1604 Self {
1605 enabled: false,
1606 api_key_env: "KIMI_API_KEY".to_string(),
1607 api_key: None,
1608 credentials_path: None,
1609 region: "auto".to_string(),
1610 }
1611 }
1612}
1613
1614#[derive(Debug, Clone, Deserialize, Serialize)]
1615#[serde(default)]
1616pub struct KiloConfig {
1617 pub enabled: bool,
1618 pub accounts: Vec<ApiKeyAccount>,
1621 pub show_default_account: bool,
1625 pub api_key_env: String,
1626 pub api_key: Option<String>,
1627 pub organization_id: Option<String>,
1630 pub display_limit: Option<f64>,
1633 pub headline: Headline,
1635}
1636
1637impl Default for KiloConfig {
1638 fn default() -> Self {
1639 Self {
1642 enabled: false,
1643 accounts: Vec::new(),
1644 show_default_account: true,
1645 api_key_env: "KILO_API_KEY".to_string(),
1646 api_key: None,
1647 organization_id: None,
1648 display_limit: None,
1649 headline: Headline::Amount,
1650 }
1651 }
1652}
1653
1654#[derive(Debug, Clone, Deserialize, Serialize)]
1655#[serde(default)]
1656pub struct NovitaConfig {
1657 pub enabled: bool,
1658 pub accounts: Vec<ApiKeyAccount>,
1661 pub show_default_account: bool,
1665 pub api_key_env: String,
1666 pub api_key: Option<String>,
1667 pub display_limit: Option<f64>,
1671 pub headline: Headline,
1673}
1674
1675impl Default for NovitaConfig {
1676 fn default() -> Self {
1677 Self {
1679 enabled: false,
1680 accounts: Vec::new(),
1681 show_default_account: true,
1682 api_key_env: "NOVITA_API_KEY".to_string(),
1683 api_key: None,
1684 display_limit: None,
1685 headline: Headline::Amount,
1686 }
1687 }
1688}
1689
1690#[derive(Debug, Clone, Deserialize, Serialize)]
1691#[serde(default)]
1692pub struct SyntheticConfig {
1693 pub enabled: bool,
1694 pub api_key_env: String,
1695 pub api_key: Option<String>,
1696}
1697
1698impl Default for SyntheticConfig {
1699 fn default() -> Self {
1700 Self {
1701 enabled: false,
1702 api_key_env: "SYNTHETIC_API_KEY".into(),
1703 api_key: None,
1704 }
1705 }
1706}
1707
1708#[derive(Debug, Clone, Deserialize, Serialize)]
1709#[serde(default)]
1710pub struct LyceumConfig {
1711 pub enabled: bool,
1712 pub accounts: Vec<ApiKeyAccount>,
1713 pub show_default_account: bool,
1714 pub api_key_env: String,
1715 pub api_key: Option<String>,
1716}
1717impl Default for LyceumConfig {
1718 fn default() -> Self {
1719 Self {
1720 enabled: false,
1721 accounts: Vec::new(),
1722 show_default_account: true,
1723 api_key_env: "LYCEUM_API_KEY".into(),
1724 api_key: None,
1725 }
1726 }
1727}
1728
1729#[derive(Debug, Clone, Deserialize, Serialize)]
1730#[serde(default)]
1731pub struct MinimaxConfig {
1732 pub enabled: bool,
1733 pub accounts: Vec<ApiKeyAccount>,
1736 pub show_default_account: bool,
1740 pub api_key_env: String,
1741 pub api_key: Option<String>,
1742 pub region: String,
1748}
1749
1750impl Default for MinimaxConfig {
1751 fn default() -> Self {
1752 Self {
1754 enabled: false,
1755 accounts: Vec::new(),
1756 show_default_account: true,
1757 api_key_env: "MINIMAX_API_KEY".to_string(),
1758 api_key: None,
1759 region: "global".to_string(),
1760 }
1761 }
1762}
1763
1764#[derive(Debug, Clone, Deserialize, Serialize)]
1765#[serde(default)]
1766pub struct MoonshotConfig {
1767 pub enabled: bool,
1768 pub accounts: Vec<ApiKeyAccount>,
1771 pub show_default_account: bool,
1775 pub api_key_env: String,
1776 pub api_key: Option<String>,
1777 pub region: String,
1779 pub display_limit: Option<f64>,
1782 pub headline: Headline,
1784}
1785
1786impl Default for MoonshotConfig {
1787 fn default() -> Self {
1788 Self {
1790 enabled: false,
1791 accounts: Vec::new(),
1792 show_default_account: true,
1793 api_key_env: "MOONSHOT_API_KEY".to_string(),
1794 api_key: None,
1795 region: "global".to_string(),
1796 display_limit: None,
1797 headline: Headline::Amount,
1798 }
1799 }
1800}
1801
1802#[derive(Debug, Clone, Deserialize, Serialize)]
1803#[serde(default)]
1804pub struct GrokConfig {
1805 pub enabled: bool,
1806 pub accounts: Vec<ApiKeyAccount>,
1809 pub show_default_account: bool,
1813 pub api_key_env: String,
1815 pub api_key: Option<String>,
1816 pub team_id: Option<String>,
1819 pub display_limit: Option<f64>,
1821 pub headline: Headline,
1823}
1824
1825impl Default for GrokConfig {
1826 fn default() -> Self {
1827 Self {
1829 enabled: false,
1830 accounts: Vec::new(),
1831 show_default_account: true,
1832 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1833 api_key: None,
1834 team_id: None,
1835 display_limit: None,
1836 headline: Headline::Amount,
1837 }
1838 }
1839}
1840
1841#[derive(Debug, Clone, Deserialize, Serialize)]
1849#[serde(default)]
1850pub struct SuperGrokConfig {
1851 pub enabled: bool,
1852 pub grok_binary: PathBuf,
1856 pub auth_path: Option<PathBuf>,
1859 pub config_path: Option<PathBuf>,
1860}
1861
1862impl Default for SuperGrokConfig {
1863 fn default() -> Self {
1864 Self {
1865 enabled: false,
1866 grok_binary: default_grok_binary(),
1867 auth_path: None,
1868 config_path: None,
1869 }
1870 }
1871}
1872
1873#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1879#[serde(default)]
1880pub struct GrokbotConfig {
1881 pub enabled: bool,
1884 pub secrets_path: Option<PathBuf>,
1889}
1890
1891fn default_grok_binary() -> PathBuf {
1892 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1893 let grok_home = std::env::var_os("GROK_HOME")
1894 .filter(|value| !value.is_empty())
1895 .map(PathBuf::from)
1896 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1897 grok_home
1898 .map(|home| home.join("bin").join(executable))
1899 .unwrap_or_else(|| PathBuf::from(executable))
1900}
1901
1902#[derive(Debug, Clone, Deserialize, Serialize)]
1910#[serde(default)]
1911pub struct AntigravityConfig {
1912 pub enabled: bool,
1913 pub agy_binary: Option<PathBuf>,
1916 pub oauth_client_id: Option<String>,
1918 pub oauth_client_secret: Option<String>,
1922 pub ide_language_server: bool,
1927}
1928
1929impl Default for AntigravityConfig {
1930 fn default() -> Self {
1931 Self {
1932 enabled: false,
1933 agy_binary: None,
1934 oauth_client_id: None,
1935 oauth_client_secret: None,
1936 ide_language_server: true,
1937 }
1938 }
1939}
1940
1941#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1951#[serde(default)]
1952pub struct CursorConfig {
1953 pub enabled: bool,
1954 pub db_path: Option<PathBuf>,
1958 pub agent_auth_path: Option<PathBuf>,
1963}
1964
1965#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1974#[serde(default)]
1975pub struct KiroConfig {
1976 pub enabled: bool,
1977 pub db_path: Option<PathBuf>,
1981}
1982
1983#[derive(Debug, Clone, Deserialize, Serialize)]
1984#[serde(default)]
1985pub struct AnthropicApiConfig {
1986 pub enabled: bool,
1987 pub api_key_env: String,
1990 pub api_key: Option<String>,
1991 pub monthly_limit: Option<f64>,
1994}
1995
1996impl Default for AnthropicApiConfig {
1997 fn default() -> Self {
1998 Self {
2000 enabled: false,
2001 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
2002 api_key: None,
2003 monthly_limit: None,
2004 }
2005 }
2006}
2007
2008#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
2018#[serde(default, remote = "Self")]
2019pub struct CustomProviderConfig {
2020 pub id: String,
2022 pub name: String,
2024 pub short_name: String,
2027 pub brand: Option<String>,
2030 pub enabled: bool,
2031 pub url: String,
2033 pub allow_http: bool,
2034 pub api_key_env: String,
2036 pub api_key: Option<String>,
2037 pub auth_header: String,
2039 pub auth_scheme: String,
2041 pub headers: BTreeMap<String, String>,
2043 pub plan: Option<String>,
2045 pub plan_path: Option<String>,
2047 pub cache_ttl_secs: u64,
2049 pub metrics: Vec<CustomMetricSpec>,
2050 pub texts: Vec<CustomTextSpec>,
2051}
2052
2053impl Default for CustomProviderConfig {
2054 fn default() -> Self {
2055 Self {
2056 id: String::new(),
2057 name: String::new(),
2058 short_name: String::new(),
2059 brand: None,
2060 enabled: false,
2061 url: String::new(),
2062 allow_http: false,
2063 api_key_env: String::new(),
2064 api_key: None,
2065 auth_header: "Authorization".to_string(),
2066 auth_scheme: "Bearer".to_string(),
2067 headers: BTreeMap::new(),
2068 plan: None,
2069 plan_path: None,
2070 cache_ttl_secs: 60,
2071 metrics: Vec::new(),
2072 texts: Vec::new(),
2073 }
2074 }
2075}
2076
2077impl<'de> Deserialize<'de> for CustomProviderConfig {
2082 fn deserialize<D: serde::Deserializer<'de>>(
2083 deserializer: D,
2084 ) -> std::result::Result<Self, D::Error> {
2085 let mut this = Self::deserialize(deserializer)?;
2086 if this.name.is_empty() {
2087 this.name = this.id.clone();
2088 }
2089 Ok(this)
2090 }
2091}
2092
2093impl Serialize for CustomProviderConfig {
2094 fn serialize<S: serde::Serializer>(
2095 &self,
2096 serializer: S,
2097 ) -> std::result::Result<S::Ok, S::Error> {
2098 Self::serialize(self, serializer)
2099 }
2100}
2101
2102#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
2106#[serde(default)]
2107pub struct CustomMetricSpec {
2108 pub label: String,
2109 pub used: Option<String>,
2110 pub limit: Option<String>,
2111 pub percent: Option<String>,
2112 pub resets_at: Option<String>,
2114 pub window_secs: Option<u64>,
2116 pub currency: Option<String>,
2118}
2119
2120#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
2122#[serde(default)]
2123pub struct CustomTextSpec {
2124 pub label: String,
2125 pub value: String,
2126 pub currency: Option<String>,
2128}
2129
2130impl CustomProviderConfig {
2131 pub fn section_label(&self) -> String {
2133 format!("[[custom]] id = {:?}", self.id)
2134 }
2135
2136 pub fn resolve_api_key(&self) -> Result<String> {
2139 if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
2140 return Ok(key);
2141 }
2142 let advice = if self.api_key_env.is_empty() {
2143 "set `api_key`, or name an environment variable in `api_key_env`".to_string()
2144 } else {
2145 format!("export {} or set `api_key`", self.api_key_env)
2146 };
2147 Err(AppError::Credentials(format!(
2148 "custom {}: no API key. Either {advice} under {} in {}.",
2149 self.id,
2150 self.section_label(),
2151 config_path_hint()
2152 )))
2153 }
2154
2155 pub fn cache_ttl(&self) -> std::time::Duration {
2156 std::time::Duration::from_secs(self.cache_ttl_secs)
2157 }
2158
2159 fn validate(&self, index: usize) -> Result<()> {
2163 if !is_valid_custom_id(&self.id) {
2164 return Err(AppError::Other(format!(
2165 "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
2166 index + 1,
2167 self.id
2168 )));
2169 }
2170 let section = self.section_label();
2171 let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
2172
2173 if VendorId::all().iter().any(|v| v.slug() == self.id) {
2174 return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
2175 }
2176 let name_len = self.name.chars().count();
2177 if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
2178 return Err(bad(
2179 "name must be 1 to 48 characters without control characters".into(),
2180 ));
2181 }
2182 if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
2183 return Err(bad(format!(
2184 "short_name {:?} must be exactly 3 lowercase ASCII letters",
2185 self.short_name
2186 )));
2187 }
2188 if let Some(brand) = &self.brand
2189 && !VendorId::all().iter().any(|v| v.slug() == brand)
2190 {
2191 return Err(bad(format!(
2192 "brand {brand:?} must name a built-in vendor (it borrows that \
2193 vendor's mark); leave it unset to keep the short_name tag"
2194 )));
2195 }
2196 let url = reqwest::Url::parse(&self.url)
2197 .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
2198 match url.scheme() {
2199 "https" => {}
2200 "http" if self.allow_http => {}
2201 "http" => {
2202 return Err(bad(
2203 "url must use https:// (set allow_http = true to permit http://)".into(),
2204 ));
2205 }
2206 other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
2207 }
2208 if !url.username().is_empty() || url.password().is_some() {
2209 return Err(bad("url must not carry credentials (user:pass@)".into()));
2210 }
2211 if url.host_str().is_none() {
2212 return Err(bad("url has no host".into()));
2213 }
2214 if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
2215 return Err(bad(
2219 "api_key_env is not a valid environment variable name".into()
2220 ));
2221 }
2222 validate_header_name(§ion, "auth_header", &self.auth_header)?;
2223 if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
2224 return Err(bad(
2225 "auth_scheme contains characters that are not valid in an HTTP header".into(),
2226 ));
2227 }
2228 for (name, value) in &self.headers {
2229 validate_header_name(§ion, "headers", name)?;
2230 if name.eq_ignore_ascii_case(&self.auth_header) {
2231 return Err(bad(format!(
2232 "headers must not repeat auth_header {:?}",
2233 self.auth_header
2234 )));
2235 }
2236 if reqwest::header::HeaderValue::from_str(value).is_err() {
2237 return Err(bad(format!(
2238 "header {name:?} has a value that is not valid in an HTTP header"
2239 )));
2240 }
2241 }
2242 if let Some(plan) = &self.plan {
2243 validate_custom_label(§ion, "plan", plan)?;
2244 }
2245 if let Some(pointer) = &self.plan_path {
2246 validate_pointer(§ion, "plan_path", pointer)?;
2247 }
2248 if !(10..=3600).contains(&self.cache_ttl_secs) {
2249 return Err(bad(format!(
2250 "cache_ttl_secs must be between 10 and 3600, got {}",
2251 self.cache_ttl_secs
2252 )));
2253 }
2254 if self.metrics.is_empty() && self.texts.is_empty() {
2255 return Err(bad(
2256 "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
2257 ));
2258 }
2259 let mut metric_labels = HashSet::new();
2260 for metric in &self.metrics {
2261 validate_custom_label(§ion, "metric label", &metric.label)?;
2262 if !metric_labels.insert(metric.label.as_str()) {
2263 return Err(bad(format!("duplicate metric label {:?}", metric.label)));
2264 }
2265 let pair = (metric.used.is_some(), metric.limit.is_some());
2266 let well_formed = if metric.percent.is_some() {
2267 pair == (false, false)
2268 } else {
2269 pair == (true, true)
2270 };
2271 if !well_formed {
2272 return Err(bad(format!(
2273 "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
2274 metric.label
2275 )));
2276 }
2277 if metric.currency.is_some() && metric.percent.is_some() {
2278 return Err(bad(format!(
2279 "metric {:?} cannot combine `currency` with `percent`",
2280 metric.label
2281 )));
2282 }
2283 if let Some(currency) = &metric.currency {
2284 validate_currency(§ion, &format!("metric {:?}", metric.label), currency)?;
2285 }
2286 for (field, pointer) in [
2287 ("used", &metric.used),
2288 ("limit", &metric.limit),
2289 ("percent", &metric.percent),
2290 ("resets_at", &metric.resets_at),
2291 ] {
2292 if let Some(pointer) = pointer {
2293 validate_pointer(§ion, field, pointer)?;
2294 }
2295 }
2296 if let Some(secs) = metric.window_secs
2297 && secs < 60
2298 {
2299 return Err(bad(format!(
2300 "metric {:?} window_secs must be at least 60, got {secs}",
2301 metric.label
2302 )));
2303 }
2304 }
2305 let mut text_labels = HashSet::new();
2306 for text in &self.texts {
2307 validate_custom_label(§ion, "text label", &text.label)?;
2308 if !text_labels.insert(text.label.as_str()) {
2309 return Err(bad(format!("duplicate text label {:?}", text.label)));
2310 }
2311 validate_pointer(§ion, "value", &text.value)?;
2312 if let Some(currency) = &text.currency {
2313 validate_currency(§ion, &format!("text {:?}", text.label), currency)?;
2314 }
2315 }
2316 Ok(())
2317 }
2318}
2319
2320fn is_valid_custom_id(id: &str) -> bool {
2321 let bytes = id.as_bytes();
2322 let Some(&first) = bytes.first() else {
2323 return false;
2324 };
2325 bytes.len() <= 32
2326 && (first.is_ascii_lowercase() || first.is_ascii_digit())
2327 && bytes
2328 .iter()
2329 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
2330}
2331
2332fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
2333 if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
2334 return Err(AppError::Other(format!(
2335 "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
2336 )));
2337 }
2338 Ok(())
2339}
2340
2341fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
2342 let len = label.chars().count();
2343 if len == 0 || len > 64 || label.chars().any(char::is_control) {
2344 return Err(AppError::Other(format!(
2345 "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
2346 )));
2347 }
2348 Ok(())
2349}
2350
2351fn validate_currency(section: &str, context: &str, currency: &str) -> Result<()> {
2352 if currency.len() != 3 || !currency.bytes().all(|b| b.is_ascii_uppercase()) {
2353 return Err(AppError::Other(format!(
2354 "{section}: {context} currency {currency:?} must be a 3-letter uppercase code (e.g. \"USD\")"
2355 )));
2356 }
2357 Ok(())
2358}
2359
2360fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
2361 if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
2362 return Err(AppError::Other(format!(
2363 "{section}: {field} {name:?} is not a valid HTTP header name"
2364 )));
2365 }
2366 Ok(())
2367}
2368
2369pub fn resolve_api_key(
2372 vendor_label: &str,
2373 env_var_name: &str,
2374 inline: Option<&str>,
2375) -> crate::error::Result<String> {
2376 let section = match vendor_label {
2377 "OpenCode Go" => "[opencode-go]".to_string(),
2378 _ => format!("[{}]", vendor_label.to_lowercase()),
2379 };
2380 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
2381}
2382
2383pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
2387 optional_api_key_with(env_var_name, inline, |name| std::env::var(name).ok())
2388}
2389
2390fn optional_api_key_with(
2391 env_var_name: &str,
2392 inline: Option<&str>,
2393 environment: impl FnOnce(&str) -> Option<String>,
2394) -> Option<String> {
2395 if is_valid_env_var_name(env_var_name)
2396 && let Some(v) = environment(env_var_name)
2397 && !v.is_empty()
2398 {
2399 return Some(v);
2400 }
2401 inline.filter(|v| !v.is_empty()).map(str::to_string)
2402}
2403
2404fn resolve_api_key_in_section(
2405 vendor_label: &str,
2406 section: &str,
2407 env_var_name: &str,
2408 inline: Option<&str>,
2409) -> crate::error::Result<String> {
2410 if let Some(key) = optional_api_key(env_var_name, inline) {
2411 return Ok(key);
2412 }
2413 let valid_env_name = is_valid_env_var_name(env_var_name);
2414 let advice = if valid_env_name {
2415 "set an API key in a valid environment variable or set `api_key`"
2416 } else {
2417 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
2418 };
2419 Err(crate::error::AppError::Credentials(format!(
2420 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
2421 config_path_hint()
2422 )))
2423}
2424
2425pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
2426 let mut chars = name.chars();
2427 let Some(first) = chars.next() else {
2428 return false;
2429 };
2430 (first.is_ascii_alphabetic() || first == '_')
2431 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2432}
2433
2434impl Config {
2435 pub fn load() -> Result<Self> {
2438 let Some(path) = resolved_path() else {
2439 return Ok(Self::default());
2440 };
2441 Self::load_from(&path)
2442 }
2443
2444 pub fn load_from(path: &std::path::Path) -> Result<Self> {
2445 match std::fs::read_to_string(path) {
2446 Ok(s) => {
2447 let mut config: Self = toml::from_str(&s).map_err(|mut e| {
2448 let span = e.span();
2449 e.set_input(None);
2452 let summary = toml_error_summary(&s, span, &e.to_string());
2453 AppError::Other(format!("config.toml: {summary}"))
2454 })?;
2455 config.expand_paths();
2459 config.validate()?;
2460 #[cfg(unix)]
2461 config.protect_inline_secrets(path)?;
2462 crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
2466 crate::vendor::register_secret_env_vars(&config.provider_secret_env_vars());
2467 Ok(config)
2468 }
2469 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
2470 Err(e) => Err(AppError::io_at(path, e)),
2471 }
2472 }
2473
2474 fn expand_paths(&mut self) {
2475 expand_tilde_opt(&mut self.context.projects_path);
2476 expand_tilde_opt(&mut self.anthropic.credentials_path);
2477 expand_tilde_opt(&mut self.anthropic.accounts_dir);
2478 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
2479 expand_tilde_opt(&mut self.anthropic.desktop_prepare_binary);
2480 expand_tilde_opt(&mut self.openai.codex_auth_path);
2481 expand_tilde_opt(&mut self.antigravity.agy_binary);
2482 expand_tilde_opt(&mut self.cursor.db_path);
2483 expand_tilde_opt(&mut self.cursor.agent_auth_path);
2484 expand_tilde_opt(&mut self.kiro.db_path);
2485 expand_tilde_opt(&mut self.kimi.credentials_path);
2486 expand_tilde_opt(&mut self.grokbot.secrets_path);
2487 expand_tilde_opt(&mut self.modelstudio.config_dir);
2488 expand_tilde_opt(&mut self.devin.credentials_path);
2489 expand_tilde_opt(&mut self.muse.credentials_path);
2490 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
2491 expand_tilde_opt(&mut self.supergrok.auth_path);
2492 expand_tilde_opt(&mut self.supergrok.config_path);
2493 expand_tilde_opt(&mut self.copilot.gh_binary);
2494 if let Some(paths) = &mut self.cline.auth_paths {
2495 for path in paths {
2496 *path = expand_tilde(path);
2497 }
2498 }
2499 for account in &mut self.anthropic.accounts {
2500 account.credentials_path = expand_tilde(&account.credentials_path);
2501 }
2502 for account in &mut self.openai.accounts {
2503 account.codex_auth_path = expand_tilde(&account.codex_auth_path);
2504 }
2505 for account in &mut self.devin.accounts {
2506 account.credentials_path = expand_tilde(&account.credentials_path);
2507 }
2508 if let Some(paths) = &mut self.commandcode.auth_paths {
2509 for path in paths {
2510 *path = expand_tilde(path);
2511 }
2512 }
2513 }
2514
2515 #[cfg(unix)]
2519 fn has_inline_secrets(&self) -> bool {
2520 [
2521 self.zai.api_key.as_deref(),
2522 self.openrouter.api_key.as_deref(),
2523 self.deepseek.api_key.as_deref(),
2524 self.deepinfra.api_key.as_deref(),
2525 self.kimi.api_key.as_deref(),
2526 self.kilo.api_key.as_deref(),
2527 self.novita.api_key.as_deref(),
2528 self.synthetic.api_key.as_deref(),
2529 self.minimax.api_key.as_deref(),
2530 self.moonshot.api_key.as_deref(),
2531 self.grok.api_key.as_deref(),
2532 self.anthropic_api.api_key.as_deref(),
2533 self.opencode_go.api_key.as_deref(),
2534 self.ollama.api_key.as_deref(),
2535 self.orcarouter.api_key.as_deref(),
2536 self.lyceum.api_key.as_deref(),
2537 self.antigravity.oauth_client_secret.as_deref(),
2538 self.cline.api_key.as_deref(),
2539 ]
2540 .into_iter()
2541 .chain(
2542 Self::API_KEY_ACCOUNT_VENDORS
2543 .into_iter()
2544 .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2545 .map(|account| account.api_key.as_deref()),
2546 )
2547 .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
2548 .any(|key| key.is_some_and(|key| !key.is_empty()))
2549 }
2550
2551 fn custom_secret_env_vars(&self) -> Vec<String> {
2552 self.custom
2553 .iter()
2554 .filter(|c| !c.api_key_env.is_empty())
2555 .map(|c| c.api_key_env.clone())
2556 .collect()
2557 }
2558
2559 fn provider_secret_env_vars(&self) -> Vec<String> {
2565 let renamed = VendorId::all().iter().map(|&id| self.api_key_env_for(id));
2566 let management = std::iter::once(self.openrouter.management_api_key_env.as_str());
2567 let accounts = Self::API_KEY_ACCOUNT_VENDORS
2568 .into_iter()
2569 .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2570 .flat_map(|account| [&account.api_key_env, &account.management_api_key_env])
2571 .filter_map(|name| name.as_deref());
2572 renamed
2573 .chain(management)
2574 .chain(accounts)
2575 .map(str::to_string)
2576 .collect()
2577 }
2578
2579 pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
2581 self.custom.iter().filter(|c| c.enabled)
2582 }
2583
2584 pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
2586 self.custom.iter().find(|c| c.id == id)
2587 }
2588
2589 #[cfg(unix)]
2590 fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
2591 if !self.has_inline_secrets() {
2592 return Ok(());
2593 }
2594
2595 let metadata = std::fs::metadata(path).map_err(|_| {
2596 AppError::Credentials(format!(
2597 "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
2598 path.display()
2599 ))
2600 })?;
2601 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
2602 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
2603 AppError::Credentials(format!(
2604 "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",
2605 path.display()
2606 ))
2607 })?;
2608 }
2609 Ok(())
2610 }
2611
2612 pub fn is_enabled(&self, id: VendorId) -> bool {
2613 match id {
2614 VendorId::Anthropic => self.anthropic.enabled,
2615 VendorId::AnthropicApi => self.anthropic_api.enabled,
2616 VendorId::Openai => self.openai.enabled,
2617 VendorId::Copilot => self.copilot.enabled,
2618 VendorId::Zai => self.zai.enabled,
2619 VendorId::Openrouter => self.openrouter.enabled,
2620 VendorId::Deepseek => self.deepseek.enabled,
2621 VendorId::Deepinfra => self.deepinfra.enabled,
2622 VendorId::Kimi => self.kimi.enabled,
2623 VendorId::Kilo => self.kilo.enabled,
2624 VendorId::Novita => self.novita.enabled,
2625 VendorId::Synthetic => self.synthetic.enabled,
2626 VendorId::Moonshot => self.moonshot.enabled,
2627 VendorId::Grok => self.grok.enabled,
2628 VendorId::Supergrok => self.supergrok.enabled,
2629 VendorId::Grokbot => self.grokbot.enabled,
2630 VendorId::Antigravity => self.antigravity.enabled,
2631 VendorId::Cursor => self.cursor.enabled,
2632 VendorId::Minimax => self.minimax.enabled,
2633 VendorId::Kiro => self.kiro.enabled,
2634 VendorId::NousResearch => self.nous.enabled,
2635 VendorId::Cline => self.cline.enabled,
2636 VendorId::OpenCodeGo => self.opencode_go.enabled,
2637 VendorId::CommandCode => self.commandcode.enabled,
2638 VendorId::Ollama => self.ollama.enabled,
2639 VendorId::OrcaRouter => self.orcarouter.enabled,
2640 VendorId::ModelStudio => self.modelstudio.enabled,
2641 VendorId::Lyceum => self.lyceum.enabled,
2642 VendorId::Devin => self.devin.enabled,
2643 VendorId::Droid => self.droid.enabled,
2644 VendorId::MuseCode => self.muse.enabled,
2645 }
2646 }
2647
2648 pub fn api_key_env_for(&self, id: VendorId) -> &str {
2655 match id {
2656 VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
2657 VendorId::Zai => &self.zai.api_key_env,
2658 VendorId::Openrouter => &self.openrouter.api_key_env,
2659 VendorId::Deepseek => &self.deepseek.api_key_env,
2660 VendorId::Deepinfra => &self.deepinfra.api_key_env,
2661 VendorId::Kimi => &self.kimi.api_key_env,
2662 VendorId::Kilo => &self.kilo.api_key_env,
2663 VendorId::Novita => &self.novita.api_key_env,
2664 VendorId::Synthetic => &self.synthetic.api_key_env,
2665 VendorId::Moonshot => &self.moonshot.api_key_env,
2666 VendorId::Grok => &self.grok.api_key_env,
2667 VendorId::Minimax => &self.minimax.api_key_env,
2668 VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
2669 VendorId::Ollama => &self.ollama.api_key_env,
2670 VendorId::OrcaRouter => &self.orcarouter.api_key_env,
2671 VendorId::Lyceum => &self.lyceum.api_key_env,
2672 VendorId::Cline => &self.cline.api_key_env,
2673 VendorId::Anthropic
2676 | VendorId::Openai
2677 | VendorId::Copilot
2678 | VendorId::Supergrok
2679 | VendorId::Grokbot
2680 | VendorId::Antigravity
2681 | VendorId::Cursor
2682 | VendorId::Kiro
2683 | VendorId::NousResearch
2684 | VendorId::CommandCode
2685 | VendorId::ModelStudio
2686 | VendorId::Devin
2687 | VendorId::MuseCode
2688 | VendorId::Droid => id.api_key_env(),
2689 }
2690 }
2691
2692 pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
2696 let raw = match id {
2697 VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
2698 VendorId::Zai => self.zai.api_key.as_deref(),
2699 VendorId::Openrouter => self.openrouter.api_key.as_deref(),
2700 VendorId::Deepseek => self.deepseek.api_key.as_deref(),
2701 VendorId::Deepinfra => self.deepinfra.api_key.as_deref(),
2702 VendorId::Kimi => self.kimi.api_key.as_deref(),
2703 VendorId::Kilo => self.kilo.api_key.as_deref(),
2704 VendorId::Novita => self.novita.api_key.as_deref(),
2705 VendorId::Synthetic => self.synthetic.api_key.as_deref(),
2706 VendorId::Moonshot => self.moonshot.api_key.as_deref(),
2707 VendorId::Grok => self.grok.api_key.as_deref(),
2708 VendorId::Minimax => self.minimax.api_key.as_deref(),
2709 VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
2710 VendorId::Ollama => self.ollama.api_key.as_deref(),
2711 VendorId::OrcaRouter => self.orcarouter.api_key.as_deref(),
2712 VendorId::Lyceum => self.lyceum.api_key.as_deref(),
2713 VendorId::Cline => self.cline.api_key.as_deref(),
2714 VendorId::Anthropic
2715 | VendorId::Openai
2716 | VendorId::Copilot
2717 | VendorId::Supergrok
2718 | VendorId::Grokbot
2719 | VendorId::Antigravity
2720 | VendorId::Cursor
2721 | VendorId::Kiro
2722 | VendorId::NousResearch
2723 | VendorId::CommandCode
2724 | VendorId::ModelStudio
2725 | VendorId::Devin
2726 | VendorId::MuseCode
2727 | VendorId::Droid => None,
2728 };
2729 raw.filter(|key| !key.is_empty())
2730 }
2731
2732 pub const API_KEY_ACCOUNT_VENDORS: [VendorId; 11] = [
2736 VendorId::Zai,
2737 VendorId::Openrouter,
2738 VendorId::Deepseek,
2739 VendorId::Deepinfra,
2740 VendorId::Kilo,
2741 VendorId::Novita,
2742 VendorId::Moonshot,
2743 VendorId::Grok,
2744 VendorId::Minimax,
2745 VendorId::OrcaRouter,
2746 VendorId::Lyceum,
2747 ];
2748
2749 pub fn api_key_accounts(&self, id: VendorId) -> Option<&[ApiKeyAccount]> {
2752 match id {
2753 VendorId::Zai => Some(&self.zai.accounts),
2754 VendorId::Openrouter => Some(&self.openrouter.accounts),
2755 VendorId::Deepseek => Some(&self.deepseek.accounts),
2756 VendorId::Deepinfra => Some(&self.deepinfra.accounts),
2757 VendorId::Kilo => Some(&self.kilo.accounts),
2758 VendorId::Novita => Some(&self.novita.accounts),
2759 VendorId::Moonshot => Some(&self.moonshot.accounts),
2760 VendorId::Grok => Some(&self.grok.accounts),
2761 VendorId::Minimax => Some(&self.minimax.accounts),
2762 VendorId::OrcaRouter => Some(&self.orcarouter.accounts),
2763 VendorId::Lyceum => Some(&self.lyceum.accounts),
2764 _ => None,
2765 }
2766 }
2767
2768 pub fn show_default_api_key_account(&self, id: VendorId) -> bool {
2771 match id {
2772 VendorId::Zai => self.zai.show_default_account,
2773 VendorId::Openrouter => self.openrouter.show_default_account,
2774 VendorId::Deepseek => self.deepseek.show_default_account,
2775 VendorId::Deepinfra => self.deepinfra.show_default_account,
2776 VendorId::Kilo => self.kilo.show_default_account,
2777 VendorId::Novita => self.novita.show_default_account,
2778 VendorId::Moonshot => self.moonshot.show_default_account,
2779 VendorId::Grok => self.grok.show_default_account,
2780 VendorId::Minimax => self.minimax.show_default_account,
2781 VendorId::OrcaRouter => self.orcarouter.show_default_account,
2782 VendorId::Lyceum => self.lyceum.show_default_account,
2783 _ => true,
2784 }
2785 }
2786
2787 pub fn resolve_account_api_key_for(&self, id: VendorId, label: Option<&str>) -> Result<String> {
2793 let name = match id {
2796 VendorId::Zai => "Zai",
2797 other => other.display_name(),
2798 };
2799 let Some(label) = label else {
2800 return resolve_api_key(name, self.api_key_env_for(id), self.inline_api_key(id));
2801 };
2802 let slug = id.config_section();
2803 let account = api_key_account(slug, self.api_key_accounts(id).unwrap_or(&[]), label)?;
2804 resolve_api_key_in_section(
2806 &format!("{name} account {label:?}"),
2807 &format!("[[{slug}.accounts]]"),
2808 account.api_key_env.as_deref().unwrap_or(""),
2809 account.api_key.as_deref(),
2810 )
2811 }
2812
2813 pub fn openrouter_management_key(&self, label: Option<&str>) -> Option<String> {
2820 let env_name = match label {
2821 Some(label) => api_key_account("openrouter", &self.openrouter.accounts, label)
2822 .ok()?
2823 .management_api_key_env
2824 .as_deref()?,
2825 None => self.openrouter.management_api_key_env.as_str(),
2826 };
2827 optional_api_key(env_name, None)
2828 }
2829
2830 pub fn display_prefs(&self, vendor: VendorId) -> DisplayPrefs {
2835 match vendor {
2836 VendorId::Deepseek => {
2837 DisplayPrefs::balance(self.deepseek.display_limit, self.deepseek.headline)
2838 }
2839 VendorId::Deepinfra => {
2840 DisplayPrefs::balance(self.deepinfra.display_limit, self.deepinfra.headline)
2841 }
2842 VendorId::Kilo => DisplayPrefs::balance(self.kilo.display_limit, self.kilo.headline),
2843 VendorId::Novita => {
2844 DisplayPrefs::balance(self.novita.display_limit, self.novita.headline)
2845 }
2846 VendorId::Moonshot => {
2847 DisplayPrefs::balance(self.moonshot.display_limit, self.moonshot.headline)
2848 }
2849 VendorId::Grok => DisplayPrefs::balance(self.grok.display_limit, self.grok.headline),
2850 VendorId::Openrouter => DisplayPrefs::balance(None, self.openrouter.headline),
2853 VendorId::NousResearch => DisplayPrefs::balance(None, self.nous.headline),
2856 _ => DisplayPrefs::default(),
2857 }
2858 }
2859
2860 pub fn enabled_vendors(&self) -> Vec<VendorId> {
2861 VendorId::all()
2862 .iter()
2863 .copied()
2864 .filter(|id| self.is_enabled(*id))
2865 .collect()
2866 }
2867
2868 pub fn validate(&self) -> Result<()> {
2872 if let Some(minutes) = self.tray.refresh_minutes
2873 && !TRAY_REFRESH_MINUTES.contains(&minutes)
2874 {
2875 return Err(AppError::Other(format!(
2876 "[tray] refresh_minutes must be one of 1, 5, 10, 15, 30 or 60, got {minutes}"
2877 )));
2878 }
2879 if !(1..=100).contains(&self.notifications.threshold) {
2880 return Err(AppError::Other(format!(
2881 "[notifications] threshold must be between 1 and 100, got {}",
2882 self.notifications.threshold
2883 )));
2884 }
2885 if self.context.context_window_tokens == Some(0) {
2886 return Err(AppError::Other(
2887 "[context] context_window_tokens must be greater than zero".into(),
2888 ));
2889 }
2890 for (model, tokens) in &self.context.model_context_window_tokens {
2891 if model.trim().is_empty() {
2892 return Err(AppError::Other(
2893 "[context] model_context_window_tokens keys must not be empty".into(),
2894 ));
2895 }
2896 if *tokens == 0 {
2897 return Err(AppError::Other(format!(
2898 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
2899 )));
2900 }
2901 }
2902 if let Some(limit) = self.anthropic_api.monthly_limit
2903 && (!limit.is_finite() || limit <= 0.0)
2904 {
2905 return Err(AppError::Other(
2906 "[anthropic_api] monthly_limit must be finite and greater than zero; \
2907 remove it to show spend without a limit"
2908 .into(),
2909 ));
2910 }
2911 for (section, limit) in [
2915 ("deepseek", self.deepseek.display_limit),
2916 ("deepinfra", self.deepinfra.display_limit),
2917 ("kilo", self.kilo.display_limit),
2918 ("novita", self.novita.display_limit),
2919 ("moonshot", self.moonshot.display_limit),
2920 ("grok", self.grok.display_limit),
2921 ] {
2922 if let Some(limit) = limit
2923 && (!limit.is_finite() || limit <= 0.0)
2924 {
2925 return Err(AppError::Other(format!(
2926 "[{section}] display_limit must be finite and greater than zero; \
2927 remove it to show the balance without a limit"
2928 )));
2929 }
2930 }
2931 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
2932 && !self.kimi.region.eq_ignore_ascii_case("auto")
2933 {
2934 return Err(AppError::Other(format!(
2935 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
2936 self.kimi.region
2937 )));
2938 }
2939 if !self.minimax.region.eq_ignore_ascii_case("global")
2940 && !self.minimax.region.eq_ignore_ascii_case("cn")
2941 {
2942 return Err(AppError::Other(format!(
2943 "[minimax] region must be \"global\" or \"cn\", got {:?}",
2944 self.minimax.region
2945 )));
2946 }
2947 if self.supergrok.grok_binary.as_os_str().is_empty() {
2948 return Err(AppError::Other(
2949 "[supergrok] grok_binary must not be empty".into(),
2950 ));
2951 }
2952 if self
2953 .antigravity
2954 .agy_binary
2955 .as_ref()
2956 .is_some_and(|path| path.as_os_str().is_empty())
2957 {
2958 return Err(AppError::Other(
2959 "[antigravity] agy_binary must not be empty".into(),
2960 ));
2961 }
2962 let mut labels = HashSet::new();
2963 for account in &self.anthropic.accounts {
2964 validate_account_label(&account.label)?;
2965 if !labels.insert(&account.label) {
2966 return Err(AppError::Credentials(format!(
2967 "duplicate anthropic account label {:?}",
2968 account.label
2969 )));
2970 }
2971 }
2972 let mut openai_labels = HashSet::new();
2973 for account in &self.openai.accounts {
2974 validate_account_label_for("openai", &account.label)?;
2975 if !openai_labels.insert(&account.label) {
2976 return Err(AppError::Credentials(format!(
2977 "duplicate openai account label {:?}",
2978 account.label
2979 )));
2980 }
2981 }
2982 let mut devin_labels = HashSet::new();
2983 for account in &self.devin.accounts {
2984 validate_account_label_for("devin", &account.label)?;
2985 if !devin_labels.insert(&account.label) {
2986 return Err(AppError::Credentials(format!(
2987 "duplicate devin account label {:?}",
2988 account.label
2989 )));
2990 }
2991 }
2992 for id in Self::API_KEY_ACCOUNT_VENDORS {
2996 let slug = id.config_section();
2997 let mut labels = HashSet::new();
2998 for account in self.api_key_accounts(id).unwrap_or(&[]) {
2999 validate_account_label_for(slug, &account.label)?;
3000 if !labels.insert(&account.label) {
3001 return Err(AppError::Credentials(format!(
3002 "duplicate {slug} account label {:?}",
3003 account.label
3004 )));
3005 }
3006 let has_env = account
3007 .api_key_env
3008 .as_deref()
3009 .is_some_and(|name| !name.is_empty());
3010 let has_inline = account
3011 .api_key
3012 .as_deref()
3013 .is_some_and(|key| !key.is_empty());
3014 if !has_env && !has_inline {
3015 return Err(AppError::Credentials(format!(
3016 "{slug} account {:?} must set api_key_env or api_key",
3017 account.label
3018 )));
3019 }
3020 }
3021 }
3022 self.validate_custom()
3023 }
3024
3025 fn validate_custom(&self) -> Result<()> {
3029 let mut ids = HashSet::new();
3030 let mut short_names: HashSet<&str> =
3031 VendorId::all().iter().map(|v| v.short_name()).collect();
3032 for (index, custom) in self.custom.iter().enumerate() {
3033 custom.validate(index)?;
3034 if !ids.insert(custom.id.as_str()) {
3035 return Err(AppError::Other(format!(
3036 "{}: duplicate id",
3037 custom.section_label()
3038 )));
3039 }
3040 if !short_names.insert(custom.short_name.as_str()) {
3041 return Err(AppError::Other(format!(
3042 "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
3043 custom.section_label(),
3044 custom.short_name
3045 )));
3046 }
3047 }
3048 Ok(())
3049 }
3050}
3051
3052#[cfg(unix)]
3053#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3054enum InlineKeyPermissionDecision {
3055 Ok,
3056 Tighten,
3057}
3058
3059#[cfg(unix)]
3060fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
3061 if mode & 0o077 == 0 {
3062 InlineKeyPermissionDecision::Ok
3063 } else {
3064 InlineKeyPermissionDecision::Tighten
3065 }
3066}
3067
3068pub fn default_path() -> Option<PathBuf> {
3069 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
3070 Some(proj.config_dir().join("config.toml"))
3071}
3072
3073fn legacy_xdg_path() -> Option<PathBuf> {
3078 let home = crate::cache::home_dir().ok()?;
3079 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
3080}
3081
3082pub fn resolved_path() -> Option<PathBuf> {
3093 if let Some(path) = override_path() {
3094 return Some(path);
3095 }
3096 let canonical = default_path();
3097 if let Some(p) = &canonical
3098 && p.exists()
3099 {
3100 return canonical;
3101 }
3102 if let Some(legacy) = legacy_xdg_path()
3103 && legacy.exists()
3104 {
3105 return Some(legacy);
3106 }
3107 canonical
3108}
3109
3110static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
3111
3112pub fn set_override_path(path: &std::path::Path) {
3118 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
3119 *slot = Some(path.to_path_buf());
3120 }
3121}
3122
3123#[doc(hidden)]
3126pub fn clear_override_path() {
3127 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
3128 *slot = None;
3129 }
3130}
3131
3132fn override_path() -> Option<PathBuf> {
3133 PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
3134}
3135
3136#[doc(hidden)]
3142pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
3143 #[cfg(unix)]
3144 {
3145 use std::os::unix::ffi::{OsStrExt, OsStringExt};
3146 let rest = arg.as_bytes().strip_prefix(b"--config=")?;
3147 Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
3148 }
3149 #[cfg(windows)]
3150 {
3151 use std::os::windows::ffi::{OsStrExt, OsStringExt};
3152 const PREFIX: &[u16] = &[
3153 b'-' as u16,
3154 b'-' as u16,
3155 b'c' as u16,
3156 b'o' as u16,
3157 b'n' as u16,
3158 b'f' as u16,
3159 b'i' as u16,
3160 b'g' as u16,
3161 b'=' as u16,
3162 ];
3163 let wide: Vec<u16> = arg.encode_wide().collect();
3164 let rest = wide.strip_prefix(PREFIX)?;
3165 Some(std::ffi::OsString::from_wide(rest).into())
3166 }
3167 #[cfg(not(any(unix, windows)))]
3168 {
3169 Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
3170 }
3171}
3172
3173fn expand_tilde(p: &std::path::Path) -> PathBuf {
3176 let Some(s) = p.to_str() else {
3177 return p.to_path_buf();
3178 };
3179 let rest = if s == "~" {
3180 ""
3181 } else if let Some(r) = s.strip_prefix("~/") {
3182 r
3183 } else {
3184 return p.to_path_buf();
3185 };
3186 match crate::cache::home_dir() {
3187 Ok(home) if rest.is_empty() => home,
3188 Ok(home) => home.join(rest),
3189 Err(_) => p.to_path_buf(),
3190 }
3191}
3192
3193fn expand_tilde_opt(p: &mut Option<PathBuf>) {
3194 if let Some(inner) = p.as_ref() {
3195 *p = Some(expand_tilde(inner));
3196 }
3197}
3198
3199pub fn config_path_hint() -> String {
3204 resolved_path()
3205 .map(|p| p.display().to_string())
3206 .unwrap_or_else(|| "config.toml".to_string())
3207}
3208
3209#[cfg(test)]
3210mod tests {
3211 use super::*;
3212 use std::io::Write;
3213 use tempfile::NamedTempFile;
3214
3215 #[cfg(unix)]
3216 use std::os::unix::fs::{MetadataExt, PermissionsExt};
3217
3218 fn write_toml(s: &str) -> NamedTempFile {
3219 let mut f = NamedTempFile::new().unwrap();
3220 f.write_all(s.as_bytes()).unwrap();
3221 f.flush().unwrap();
3222 f
3223 }
3224
3225 #[test]
3229 fn openai_without_accounts_resolves_the_singular_path() {
3230 let explicit = OpenAiConfig {
3231 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
3232 ..OpenAiConfig::default()
3233 };
3234 assert_eq!(
3235 explicit.resolve_auth_path(None).unwrap(),
3236 PathBuf::from("/tmp/codex/auth.json")
3237 );
3238
3239 let bare = OpenAiConfig::default();
3240 assert_eq!(
3241 bare.resolve_auth_path(None).unwrap(),
3242 crate::openai::creds::default_path().unwrap(),
3243 "no codex_auth_path must still mean ~/.codex/auth.json"
3244 );
3245 }
3246
3247 #[test]
3250 fn openai_named_accounts_resolve_their_own_auth_file() {
3251 let config: Config = toml::from_str(
3252 r#"
3253 [openai]
3254 codex_auth_path = "/tmp/personal/auth.json"
3255 [[openai.accounts]]
3256 label = "work"
3257 codex_auth_path = "/tmp/work/auth.json"
3258 "#,
3259 )
3260 .unwrap();
3261
3262 assert_eq!(
3263 config.openai.resolve_auth_path(Some("work")).unwrap(),
3264 PathBuf::from("/tmp/work/auth.json")
3265 );
3266 assert_eq!(
3267 config.openai.resolve_auth_path(None).unwrap(),
3268 PathBuf::from("/tmp/personal/auth.json")
3269 );
3270 }
3271
3272 #[test]
3275 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
3276 let config = OpenAiConfig {
3277 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
3278 accounts: vec![OpenAiAccount {
3279 label: "work".into(),
3280 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
3281 }],
3282 ..OpenAiConfig::default()
3283 };
3284 let err = config
3285 .resolve_auth_path(Some("nope"))
3286 .unwrap_err()
3287 .to_string();
3288 assert!(err.contains("nope"), "{err}");
3289 assert!(err.contains("[[openai.accounts]]"), "{err}");
3290 }
3291
3292 fn two_codex_accounts() -> OpenAiConfig {
3293 OpenAiConfig {
3294 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
3295 accounts: vec![
3296 OpenAiAccount {
3297 label: "main".into(),
3298 codex_auth_path: PathBuf::from("/tmp/codex-main/auth.json"),
3299 },
3300 OpenAiAccount {
3301 label: "work".into(),
3302 codex_auth_path: PathBuf::from("/tmp/codex-work/auth.json"),
3303 },
3304 ],
3305 ..OpenAiConfig::default()
3306 }
3307 }
3308
3309 #[test]
3310 fn the_active_codex_account_is_read_from_the_default_slot() {
3311 let config = two_codex_accounts();
3312 assert_eq!(
3313 config
3314 .fetch_auth_path_probing("work", Some("work"), |_| false)
3315 .unwrap(),
3316 PathBuf::from("/tmp/codex/auth.json")
3317 );
3318 assert_eq!(
3319 config
3320 .fetch_auth_path_probing("main", Some("work"), |_| false)
3321 .unwrap(),
3322 PathBuf::from("/tmp/codex-main/auth.json")
3323 );
3324 }
3325
3326 #[test]
3327 fn an_active_codex_account_with_its_own_file_keeps_reading_it() {
3328 let config = two_codex_accounts();
3329 assert_eq!(
3330 config
3331 .fetch_auth_path_probing("work", Some("work"), |_| true)
3332 .unwrap(),
3333 PathBuf::from("/tmp/codex-work/auth.json")
3334 );
3335 }
3336
3337 #[test]
3338 fn adding_an_openai_account_keeps_the_rest_of_the_file() {
3339 let mut doc: toml_edit::DocumentMut = "# mine\n[zai]\nenabled = true\n".parse().unwrap();
3340 add_openai_account_to_doc(&mut doc, "work", "~/.codex-work/auth.json").unwrap();
3341 let text = doc.to_string();
3342 assert!(
3343 text.starts_with("# mine\n[zai]\nenabled = true\n"),
3344 "{text}"
3345 );
3346 let parsed: Config = toml::from_str(&text).unwrap();
3347 assert_eq!(parsed.openai.accounts[0].label, "work");
3348 assert!(add_openai_account_to_doc(&mut doc, "work", "x").is_err());
3349 assert!(add_openai_account_to_doc(&mut doc, "../x", "x").is_err());
3350 }
3351
3352 #[test]
3353 fn defaults_enable_only_the_four_core_vendors() {
3354 let c = Config::default();
3355 assert!(c.is_enabled(VendorId::Anthropic));
3356 assert!(c.is_enabled(VendorId::Openai));
3357 assert!(c.is_enabled(VendorId::Zai));
3358 assert!(c.is_enabled(VendorId::Openrouter));
3359 for opt_in in [
3360 VendorId::AnthropicApi,
3361 VendorId::Copilot,
3362 VendorId::Deepseek,
3363 VendorId::Kimi,
3364 VendorId::Kilo,
3365 VendorId::Novita,
3366 VendorId::Moonshot,
3367 VendorId::Grok,
3368 VendorId::Supergrok,
3369 VendorId::Grokbot,
3370 VendorId::Cursor,
3371 VendorId::Minimax,
3372 VendorId::Kiro,
3373 VendorId::CommandCode,
3374 VendorId::OrcaRouter,
3375 VendorId::ModelStudio,
3376 VendorId::Devin,
3377 VendorId::Droid,
3378 ] {
3379 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
3380 }
3381 assert_eq!(c.enabled_vendors().len(), 4);
3382 }
3383
3384 #[test]
3385 fn commandcode_is_opt_in_when_loading_existing_configs() {
3386 let absent: Config = toml::from_str("[openai]\nenabled = true\n").unwrap();
3387 assert!(!absent.is_enabled(VendorId::CommandCode));
3388
3389 let opted_in: Config = toml::from_str("[commandcode]\nenabled = true\n").unwrap();
3390 assert!(opted_in.is_enabled(VendorId::CommandCode));
3391
3392 let opted_out: Config = toml::from_str("[commandcode]\nenabled = false\n").unwrap();
3393 assert!(!opted_out.is_enabled(VendorId::CommandCode));
3394 }
3395
3396 #[test]
3397 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
3398 let config = Config::default();
3399 assert!(!config.is_enabled(VendorId::NousResearch));
3400 assert!(!config.is_enabled(VendorId::OpenCodeGo));
3401 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
3402 assert!(config.opencode_go.api_key.is_none());
3403 assert!(!config.is_enabled(VendorId::Copilot));
3404 }
3405
3406 #[cfg(unix)]
3407 #[test]
3408 fn inline_credentials_are_protected() {
3409 let mut config = Config::default();
3410 config.opencode_go.api_key = Some("<redacted>".to_string());
3411 assert!(config.has_inline_secrets());
3412 }
3413
3414 #[test]
3415 fn antigravity_oauth_client_overrides_parse() {
3416 let config: Config = toml::from_str(
3417 "[antigravity]
3418enabled = true
3419oauth_client_id = \"test-client\"
3420oauth_client_secret = \"test-client-secret\"
3421",
3422 )
3423 .unwrap();
3424 assert!(config.antigravity.enabled);
3425 assert_eq!(
3426 config.antigravity.oauth_client_id.as_deref(),
3427 Some("test-client")
3428 );
3429 assert_eq!(
3430 config.antigravity.oauth_client_secret.as_deref(),
3431 Some("test-client-secret")
3432 );
3433 let bare: Config = toml::from_str(
3434 "[antigravity]
3435enabled = true
3436",
3437 )
3438 .unwrap();
3439 assert!(bare.antigravity.oauth_client_id.is_none());
3440 assert!(bare.antigravity.oauth_client_secret.is_none());
3441 }
3442
3443 #[test]
3446 fn antigravity_ide_language_server_defaults_to_probing() {
3447 assert!(Config::default().antigravity.ide_language_server);
3448 let bare: Config = toml::from_str(
3449 "[antigravity]
3450enabled = true
3451",
3452 )
3453 .unwrap();
3454 assert!(bare.antigravity.ide_language_server);
3455 let opted_out: Config = toml::from_str(
3456 "[antigravity]
3457enabled = true
3458ide_language_server = false
3459",
3460 )
3461 .unwrap();
3462 assert!(!opted_out.antigravity.ide_language_server);
3463 }
3464
3465 #[test]
3466 fn antigravity_agy_binary_is_optional_and_tilde_expanded() {
3467 assert!(Config::default().antigravity.agy_binary.is_none());
3468 let file = write_toml(
3469 r#"
3470 [antigravity]
3471 enabled = true
3472 agy_binary = "~/bin/agy"
3473 "#,
3474 );
3475 let config = Config::load_from(file.path()).unwrap();
3476 assert_eq!(
3477 config.antigravity.agy_binary,
3478 Some(crate::cache::home_dir().unwrap().join("bin/agy"))
3479 );
3480 }
3481
3482 #[test]
3483 fn antigravity_agy_binary_must_not_be_empty() {
3484 let file = write_toml(
3485 r#"
3486 [antigravity]
3487 agy_binary = ""
3488 "#,
3489 );
3490 let error = Config::load_from(file.path()).unwrap_err().to_string();
3491 assert!(error.contains("[antigravity] agy_binary must not be empty"));
3492 }
3493
3494 #[cfg(unix)]
3495 #[test]
3496 fn antigravity_inline_oauth_secret_receives_config_file_protection() {
3497 let mut config = Config::default();
3498 config.antigravity.oauth_client_id = Some("test-client".into());
3499 assert!(!config.has_inline_secrets());
3500 config.antigravity.oauth_client_secret = Some("<redacted>".into());
3501 assert!(config.has_inline_secrets());
3502 }
3503
3504 #[cfg(unix)]
3505 #[test]
3506 fn openrouter_named_inline_keys_receive_config_file_protection() {
3507 let mut config = Config::default();
3508 config.openrouter.accounts.push(ApiKeyAccount {
3509 label: "work".into(),
3510 api_key_env: None,
3511 api_key: Some("<redacted>".into()),
3512 management_api_key_env: None,
3513 });
3514 assert!(config.has_inline_secrets());
3515 }
3516
3517 #[cfg(unix)]
3518 #[test]
3519 fn every_named_inline_api_key_receives_config_file_protection() {
3520 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
3521 let section = vendor.config_section();
3522 let config: Config = toml::from_str(&format!(
3523 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"<redacted>\"\n"
3524 ))
3525 .unwrap();
3526 assert!(config.has_inline_secrets(), "{vendor:?}");
3527 }
3528 }
3529
3530 #[cfg(unix)]
3534 #[test]
3535 fn every_inline_api_key_receives_config_file_protection() {
3536 for &vendor in VendorId::all() {
3537 let section = vendor.config_section();
3538 let config: Config =
3539 toml::from_str(&format!("[{section}]\napi_key = \"<redacted>\"\n")).unwrap();
3540 if config.inline_api_key(vendor).is_some() {
3541 assert!(config.has_inline_secrets(), "{vendor:?}");
3542 }
3543 }
3544 }
3545
3546 #[test]
3547 fn missing_file_uses_defaults() {
3548 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
3549 let c = Config::load_from(path).unwrap();
3550 assert!(c.is_enabled(VendorId::Anthropic));
3551 }
3552
3553 #[test]
3554 fn parses_full_config() {
3555 let f = write_toml(
3556 r#"
3557 [anthropic]
3558 enabled = true
3559
3560 [openai]
3561 enabled = false
3562 admin_key_env = "MY_ADMIN_KEY"
3563
3564 [zai]
3565 enabled = true
3566 api_key_env = "MY_ZAI"
3567 plan_tier = "pro"
3568
3569 [openrouter]
3570 enabled = false
3571 "#,
3572 );
3573 let c = Config::load_from(f.path()).unwrap();
3574 assert!(c.is_enabled(VendorId::Anthropic));
3575 assert!(!c.is_enabled(VendorId::Openai));
3576 assert!(c.is_enabled(VendorId::Zai));
3577 assert!(!c.is_enabled(VendorId::Openrouter));
3578 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
3579 assert_eq!(c.zai.api_key_env, "MY_ZAI");
3580 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
3581 assert!(c.openrouter.accounts.is_empty());
3582 assert!(c.openrouter.show_default_account);
3583 }
3584
3585 #[test]
3586 fn partial_config_falls_back_to_defaults() {
3587 let f = write_toml(
3588 r#"[openai]
3589enabled = false
3590"#,
3591 );
3592 let c = Config::load_from(f.path()).unwrap();
3593 assert!(!c.is_enabled(VendorId::Openai));
3594 assert!(c.is_enabled(VendorId::Anthropic));
3596 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
3597 }
3598
3599 #[test]
3600 fn malformed_toml_returns_error() {
3601 let f = write_toml("this is not = = valid");
3602 assert!(Config::load_from(f.path()).is_err());
3603 }
3604
3605 #[cfg(unix)]
3606 #[test]
3607 fn load_from_tightens_world_readable_config_with_inline_api_key() {
3608 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
3609 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3610
3611 Config::load_from(file.path()).unwrap();
3612
3613 assert_eq!(
3614 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3615 0o600
3616 );
3617 }
3618
3619 #[cfg(unix)]
3620 #[test]
3621 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
3622 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
3623 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3624
3625 Config::load_from(file.path()).unwrap();
3626
3627 assert_eq!(
3628 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3629 0o644
3630 );
3631 }
3632
3633 #[cfg(unix)]
3634 #[test]
3635 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
3636 assert_eq!(
3637 inline_key_permission_decision(0o600),
3638 InlineKeyPermissionDecision::Ok
3639 );
3640 assert_eq!(
3641 inline_key_permission_decision(0o640),
3642 InlineKeyPermissionDecision::Tighten
3643 );
3644 assert_eq!(
3645 inline_key_permission_decision(0o604),
3646 InlineKeyPermissionDecision::Tighten
3647 );
3648 }
3649
3650 #[test]
3651 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
3652 for value in ["0", "-1", "inf", "nan"] {
3653 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
3654 let error = Config::load_from(file.path()).unwrap_err().to_string();
3655 assert!(error.contains("monthly_limit"), "value {value}: {error}");
3656 }
3657
3658 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
3659 assert_eq!(
3660 Config::load_from(file.path())
3661 .unwrap()
3662 .anthropic_api
3663 .monthly_limit,
3664 Some(1000.0)
3665 );
3666 }
3667
3668 #[test]
3669 fn display_limit_must_be_positive_and_finite_on_every_balance_vendor() {
3670 for section in ["deepseek", "kilo", "novita", "moonshot", "grok"] {
3672 for value in ["0", "-1", "inf", "nan"] {
3673 let file = write_toml(&format!("[{section}]\ndisplay_limit = {value}\n"));
3674 let error = Config::load_from(file.path()).unwrap_err().to_string();
3675 assert!(
3676 error.contains(&format!("[{section}] display_limit")),
3677 "{section} = {value}: {error}"
3678 );
3679 }
3680 let file = write_toml(&format!("[{section}]\ndisplay_limit = 200\n"));
3681 let config = Config::load_from(file.path()).unwrap();
3682 assert_eq!(
3683 config.display_prefs(vendor_of(section)).display_limit,
3684 Some(200.0),
3685 "{section}"
3686 );
3687 }
3688 }
3689
3690 #[test]
3692 fn display_limit_is_absent_until_the_user_states_one() {
3693 let config = Config::default();
3694 for vendor in VendorId::all() {
3695 assert_eq!(
3696 config.display_prefs(*vendor).display_limit,
3697 None,
3698 "{vendor:?}"
3699 );
3700 }
3701 }
3702
3703 #[test]
3706 fn the_default_headline_follows_the_kind_of_vendor() {
3707 let config = Config::default();
3708 for vendor in [
3709 VendorId::Deepseek,
3710 VendorId::Kilo,
3711 VendorId::Novita,
3712 VendorId::Moonshot,
3713 VendorId::Grok,
3714 ] {
3715 assert_eq!(
3716 config.display_prefs(vendor).headline,
3717 Headline::Amount,
3718 "{vendor:?}"
3719 );
3720 }
3721 assert_eq!(
3722 config.display_prefs(VendorId::Openrouter).headline,
3723 Headline::Percent
3724 );
3725 assert_eq!(
3726 config.display_prefs(VendorId::Anthropic),
3727 DisplayPrefs::default()
3728 );
3729 }
3730
3731 #[test]
3732 fn the_headline_is_configurable_per_vendor_and_a_typo_is_loud() {
3733 let file = write_toml("[deepseek]\nheadline = \"percent\"\n");
3734 assert_eq!(
3735 Config::load_from(file.path())
3736 .unwrap()
3737 .display_prefs(VendorId::Deepseek)
3738 .headline,
3739 Headline::Percent
3740 );
3741
3742 let file = write_toml("[openrouter]\nheadline = \"amount\"\n");
3743 assert_eq!(
3744 Config::load_from(file.path())
3745 .unwrap()
3746 .display_prefs(VendorId::Openrouter)
3747 .headline,
3748 Headline::Amount
3749 );
3750
3751 let file = write_toml("[deepseek]\nheadline = \"dollars\"\n");
3752 let error = Config::load_from(file.path()).unwrap_err().to_string();
3753 assert!(error.contains("headline"), "{error}");
3754 }
3755
3756 #[test]
3762 fn openrouter_has_no_display_limit_to_be_ignored() {
3763 let file = write_toml("[openrouter]\ndisplay_limit = 200\nheadline = \"percent\"\n");
3764 let config = Config::load_from(file.path()).unwrap();
3765 let prefs = config.display_prefs(VendorId::Openrouter);
3766 assert_eq!(prefs.display_limit, None);
3767 assert_eq!(prefs.headline, Headline::Percent);
3768 }
3769
3770 #[test]
3773 fn a_display_limit_alone_leaves_the_headline_where_it_was() {
3774 let file = write_toml("[deepseek]\ndisplay_limit = 200\n");
3775 let prefs = Config::load_from(file.path())
3776 .unwrap()
3777 .display_prefs(VendorId::Deepseek);
3778 assert_eq!(prefs.display_limit, Some(200.0));
3779 assert_eq!(prefs.headline, Headline::Amount);
3780 }
3781
3782 fn vendor_of(section: &str) -> VendorId {
3783 VendorId::all()
3784 .iter()
3785 .copied()
3786 .find(|vendor| vendor.config_section() == section)
3787 .unwrap_or_else(|| panic!("no vendor for [{section}]"))
3788 }
3789
3790 #[test]
3791 fn minimax_region_accepts_only_known_instances() {
3792 for region in ["global", "GLOBAL", "cn", "CN"] {
3793 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3794 assert_eq!(
3795 Config::load_from(file.path()).unwrap().minimax.region,
3796 region
3797 );
3798 }
3799
3800 for region in ["", "china", "us"] {
3801 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3802 let error = Config::load_from(file.path()).unwrap_err().to_string();
3803 assert!(error.contains("[minimax] region"), "{error}");
3804 }
3805 }
3806
3807 #[test]
3808 fn kimi_region_accepts_auto_and_both_deployments() {
3809 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
3810 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3811 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
3812 }
3813
3814 for region in ["", "us", "oversea"] {
3815 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3816 let error = Config::load_from(file.path()).unwrap_err().to_string();
3817 assert!(error.contains("[kimi] region"), "{error}");
3818 }
3819 }
3820
3821 #[test]
3822 fn kimi_defaults_to_auto_region_and_no_credential_override() {
3823 let defaults = KimiConfig::default();
3824 assert_eq!(defaults.region, "auto");
3825 assert_eq!(defaults.credentials_path, None);
3826 assert!(!defaults.enabled);
3827 }
3828
3829 #[test]
3830 fn kimi_credentials_path_expands_a_tilde() {
3831 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
3832 let path = Config::load_from(file.path())
3833 .unwrap()
3834 .kimi
3835 .credentials_path
3836 .unwrap();
3837 assert!(!path.starts_with("~"), "{}", path.display());
3838 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
3839 }
3840
3841 #[test]
3842 fn grokbot_is_opt_in_and_takes_no_api_key() {
3843 let defaults = GrokbotConfig::default();
3844 assert!(!defaults.enabled);
3845 assert_eq!(defaults.secrets_path, None);
3846 let config = Config::default();
3848 assert_eq!(config.api_key_env_for(VendorId::Grokbot), "");
3849 assert_eq!(config.inline_api_key(VendorId::Grokbot), None);
3850
3851 let file = write_toml("[grokbot]\nenabled = true\n");
3852 let config = Config::load_from(file.path()).unwrap();
3853 assert!(config.is_enabled(VendorId::Grokbot));
3854 assert!(config.enabled_vendors().contains(&VendorId::Grokbot));
3855 }
3856
3857 #[test]
3858 fn grokbot_secrets_path_expands_a_tilde() {
3859 let file = write_toml("[grokbot]\nsecrets_path = \"~/gb/secrets.json\"\n");
3860 let path = Config::load_from(file.path())
3861 .unwrap()
3862 .grokbot
3863 .secrets_path
3864 .unwrap();
3865 assert!(!path.starts_with("~"), "{}", path.display());
3866 assert!(path.ends_with("gb/secrets.json"), "{}", path.display());
3867 }
3868
3869 #[test]
3870 fn modelstudio_is_opt_in_and_takes_no_api_key() {
3871 let defaults = ModelStudioConfig::default();
3872 assert!(!defaults.enabled);
3873 assert_eq!(defaults.config_dir, None);
3874 let config = Config::default();
3876 assert_eq!(config.api_key_env_for(VendorId::ModelStudio), "");
3877 assert_eq!(config.inline_api_key(VendorId::ModelStudio), None);
3878
3879 let file = write_toml("[modelstudio]\nenabled = true\n");
3880 let config = Config::load_from(file.path()).unwrap();
3881 assert!(config.is_enabled(VendorId::ModelStudio));
3882 assert!(config.enabled_vendors().contains(&VendorId::ModelStudio));
3883 }
3884
3885 #[test]
3886 fn modelstudio_config_dir_expands_a_tilde() {
3887 let file = write_toml("[modelstudio]\nconfig_dir = \"~/bl\"\n");
3888 let path = Config::load_from(file.path())
3889 .unwrap()
3890 .modelstudio
3891 .config_dir
3892 .unwrap();
3893 assert!(!path.starts_with("~"), "{}", path.display());
3894 assert!(path.ends_with("bl"), "{}", path.display());
3895 }
3896
3897 #[test]
3898 fn droid_is_opt_in_and_takes_no_api_key() {
3899 let defaults = DroidConfig::default();
3900 assert!(!defaults.enabled);
3901 assert!(!defaults.cli_renewal);
3902 assert_eq!(defaults.cli_path, None);
3903
3904 let config = Config::default();
3905 assert_eq!(config.api_key_env_for(VendorId::Droid), "");
3906 assert_eq!(config.inline_api_key(VendorId::Droid), None);
3907
3908 let file = write_toml("[droid]\nenabled = true\n");
3909 let config = Config::load_from(file.path()).unwrap();
3910 assert!(config.is_enabled(VendorId::Droid));
3911 assert!(config.enabled_vendors().contains(&VendorId::Droid));
3912 }
3913
3914 #[test]
3915 fn devin_is_opt_in_and_takes_no_api_key() {
3916 let defaults = DevinConfig::default();
3917 assert!(!defaults.enabled);
3918 assert_eq!(defaults.credentials_path, None);
3919
3920 let config = Config::default();
3921 assert_eq!(config.api_key_env_for(VendorId::Devin), "");
3922 assert_eq!(config.inline_api_key(VendorId::Devin), None);
3923
3924 let file = write_toml("[devin]\nenabled = true\n");
3925 let config = Config::load_from(file.path()).unwrap();
3926 assert!(config.is_enabled(VendorId::Devin));
3927 assert!(config.enabled_vendors().contains(&VendorId::Devin));
3928 }
3929
3930 #[test]
3931 fn devin_credentials_path_expands_a_tilde() {
3932 let file = write_toml("[devin]\ncredentials_path = \"~/devin/credentials.toml\"\n");
3933 let path = Config::load_from(file.path())
3934 .unwrap()
3935 .devin
3936 .credentials_path
3937 .unwrap();
3938 assert!(!path.starts_with("~"), "{}", path.display());
3939 assert!(
3940 path.ends_with("devin/credentials.toml"),
3941 "{}",
3942 path.display()
3943 );
3944 }
3945
3946 #[test]
3947 fn devin_without_accounts_resolves_the_singular_path() {
3948 let explicit = DevinConfig {
3949 credentials_path: Some(PathBuf::from("/tmp/devin/credentials.toml")),
3950 ..DevinConfig::default()
3951 };
3952 assert_eq!(
3953 explicit.resolve_credentials_path(None).unwrap(),
3954 PathBuf::from("/tmp/devin/credentials.toml")
3955 );
3956 }
3957
3958 #[test]
3959 fn devin_named_accounts_resolve_their_own_credentials_file() {
3960 let config: Config = toml::from_str(
3961 r#"
3962 [devin]
3963 credentials_path = "/tmp/personal/credentials.toml"
3964 [[devin.accounts]]
3965 label = "work"
3966 credentials_path = "/tmp/work/credentials.toml"
3967 "#,
3968 )
3969 .unwrap();
3970
3971 assert_eq!(
3972 config.devin.resolve_credentials_path(Some("work")).unwrap(),
3973 PathBuf::from("/tmp/work/credentials.toml")
3974 );
3975 assert_eq!(
3976 config.devin.resolve_credentials_path(None).unwrap(),
3977 PathBuf::from("/tmp/personal/credentials.toml")
3978 );
3979 }
3980
3981 #[test]
3982 fn devin_named_accounts_reject_an_unregistered_label() {
3983 let config = DevinConfig {
3984 accounts: vec![crate::config::DevinAccount {
3985 label: "work".into(),
3986 credentials_path: PathBuf::from("/tmp/work/credentials.toml"),
3987 }],
3988 ..DevinConfig::default()
3989 };
3990 let err = config
3991 .resolve_credentials_path(Some("nope"))
3992 .unwrap_err()
3993 .to_string();
3994 assert!(err.contains("nope"), "{err}");
3995 assert!(err.contains("[[devin.accounts]]"), "{err}");
3996 }
3997
3998 #[test]
3999 fn devin_account_credentials_paths_are_tilde_expanded_on_load() {
4000 let f = write_toml(
4001 r#"
4002 [[devin.accounts]]
4003 label = "work"
4004 credentials_path = "~/.devin-work/credentials.toml"
4005 "#,
4006 );
4007 let c = Config::load_from(f.path()).unwrap();
4008 let home = crate::cache::home_dir().unwrap();
4009 assert_eq!(
4010 c.devin.accounts[0].credentials_path,
4011 home.join(".devin-work/credentials.toml")
4012 );
4013 }
4014
4015 #[test]
4016 fn devin_rejects_duplicate_account_labels() {
4017 let f = write_toml(
4018 r#"
4019 [[devin.accounts]]
4020 label = "work"
4021 credentials_path = "/tmp/work1/credentials.toml"
4022 [[devin.accounts]]
4023 label = "work"
4024 credentials_path = "/tmp/work2/credentials.toml"
4025 "#,
4026 );
4027 let err = Config::load_from(f.path()).unwrap_err().to_string();
4028 assert!(
4029 err.contains("duplicate devin account label \"work\""),
4030 "{err}"
4031 );
4032 }
4033
4034 #[test]
4035 fn devin_rejects_invalid_account_labels() {
4036 let f = write_toml(
4037 r#"
4038 [[devin.accounts]]
4039 label = "../bad"
4040 credentials_path = "/tmp/bad/credentials.toml"
4041 "#,
4042 );
4043 assert!(Config::load_from(f.path()).is_err());
4044 }
4045
4046 #[test]
4047 fn lyceum_is_opt_in_and_uses_shared_api_key_account_resolution() {
4048 let defaults = LyceumConfig::default();
4049 assert!(!defaults.enabled);
4050 assert_eq!(defaults.api_key_env, "LYCEUM_API_KEY");
4051 let override_file = write_toml("[lyceum]\napi_key_env = \"LYCEUM_CUSTOM_TEST_KEY\"\n");
4052 let override_config = Config::load_from(override_file.path()).unwrap();
4053 assert_eq!(
4054 override_config.api_key_env_for(VendorId::Lyceum),
4055 "LYCEUM_CUSTOM_TEST_KEY"
4056 );
4057 let file = write_toml(
4058 r#"[lyceum]
4059enabled = true
4060api_key_env = ""
4061api_key = "synthetic-inline"
4062[[lyceum.accounts]]
4063label = "work"
4064api_key = "synthetic-account"
4065"#,
4066 );
4067 let config = Config::load_from(file.path()).unwrap();
4068 assert!(config.is_enabled(VendorId::Lyceum));
4069 assert_eq!(config.api_key_env_for(VendorId::Lyceum), "");
4070 assert_eq!(
4071 config
4072 .resolve_account_api_key_for(VendorId::Lyceum, None)
4073 .unwrap(),
4074 "synthetic-inline"
4075 );
4076 assert_eq!(
4077 config
4078 .resolve_account_api_key_for(VendorId::Lyceum, Some("work"))
4079 .unwrap(),
4080 "synthetic-account"
4081 );
4082 }
4083
4084 #[test]
4085 fn optional_api_key_reports_absence_instead_of_failing() {
4086 assert_eq!(
4087 optional_api_key("9INVALID", Some("inline")),
4088 Some("inline".to_string())
4089 );
4090 assert_eq!(optional_api_key("9INVALID", None), None);
4091 assert_eq!(optional_api_key("9INVALID", Some("")), None);
4092 }
4093
4094 #[test]
4095 fn optional_api_key_prefers_env_and_falls_through_to_inline() {
4096 let from_env = |name: &str| (name == "TEST_API_KEY").then(|| "from-env".to_string());
4097 assert_eq!(
4098 optional_api_key_with("TEST_API_KEY", Some("from-config"), from_env),
4099 Some("from-env".to_string())
4100 );
4101 assert_eq!(
4102 optional_api_key_with("TEST_API_KEY", Some("from-config"), |_| None),
4103 Some("from-config".to_string())
4104 );
4105 assert_eq!(
4106 optional_api_key_with("TEST_API_KEY", Some("from-config"), |_| {
4107 Some(String::new())
4108 }),
4109 Some("from-config".to_string())
4110 );
4111 assert_eq!(optional_api_key_with("TEST_API_KEY", None, |_| None), None);
4112 }
4113
4114 #[test]
4115 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
4116 let defaults = Config::default();
4117 assert!(!defaults.context.enabled);
4118 assert_eq!(
4119 defaults.context.window_tokens_for(Some("claude-test")),
4120 None
4121 );
4122
4123 let file = write_toml(
4124 r#"
4125 [context]
4126 enabled = true
4127 context_window_tokens = 200000
4128
4129 [context.model_context_window_tokens]
4130 claude-opus-1m = 1000000
4131 "claude exact id" = 300000
4132 "#,
4133 );
4134 let config = Config::load_from(file.path()).unwrap();
4135 assert!(config.context.enabled);
4136 assert_eq!(
4137 config.context.window_tokens_for(Some("claude-opus-1m")),
4138 Some(1_000_000)
4139 );
4140 assert_eq!(
4141 config.context.window_tokens_for(Some("claude exact id")),
4142 Some(300_000)
4143 );
4144 assert_eq!(
4145 config.context.window_tokens_for(Some("another-model")),
4146 Some(200_000)
4147 );
4148 }
4149
4150 #[test]
4151 fn context_layout_defaults_to_full_and_parses_each_variant() {
4152 assert_eq!(Config::default().context.layout, ContextLayout::Full);
4153 for (text, want) in [
4154 ("full", ContextLayout::Full),
4155 ("split", ContextLayout::Split),
4156 ("bottom", ContextLayout::Bottom),
4157 ] {
4158 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
4159 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
4160 }
4161 let file = write_toml("[context]\nlayout = \"floating\"\n");
4162 assert!(
4163 Config::load_from(file.path()).is_err(),
4164 "an unknown layout must be rejected, not silently defaulted"
4165 );
4166 }
4167
4168 #[test]
4169 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
4170 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
4171 for (text, want) in [
4172 ("sidebar", VendorBoxStyle::Sidebar),
4173 ("navbar", VendorBoxStyle::Navbar),
4174 ("none", VendorBoxStyle::None),
4175 ] {
4176 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
4177 assert_eq!(
4178 Config::load_from(file.path()).unwrap().ui.vendor_box(),
4179 want
4180 );
4181 }
4182 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
4183 assert!(
4184 Config::load_from(file.path()).is_err(),
4185 "an unknown vendor_box style must be rejected, not silently defaulted"
4186 );
4187 }
4188
4189 #[test]
4190 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
4191 for source in [
4192 "[context]\ncontext_window_tokens = 0\n",
4193 "[context.model_context_window_tokens]\nclaude = 0\n",
4194 "[context.model_context_window_tokens]\n\" \" = 200000\n",
4195 ] {
4196 let file = write_toml(source);
4197 let error = Config::load_from(file.path()).unwrap_err().to_string();
4198 assert!(error.contains("context"), "{error}");
4199 }
4200 }
4201
4202 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
4204 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
4205 M.lock().unwrap_or_else(|p| p.into_inner())
4206 }
4207
4208 #[test]
4209 fn resolve_api_key_prefers_env_over_inline() {
4210 let _g = env_guard();
4211 let var = "AI_USAGEBAR_TEST_ENV_WINS";
4213 unsafe { std::env::set_var(var, "from-env") };
4215 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
4216 unsafe { std::env::remove_var(var) };
4217 assert_eq!(got, "from-env");
4218 }
4219
4220 #[test]
4221 fn resolve_api_key_falls_back_to_inline() {
4222 let _g = env_guard();
4223 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
4224 unsafe { std::env::remove_var(var) };
4225 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
4226 assert_eq!(got, "inline-key");
4227 }
4228
4229 #[test]
4230 fn copilot_token_prefers_explicit_environment_over_gh_cli() {
4231 struct NeverRun;
4232 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
4233 fn run(
4234 &self,
4235 _: &crate::copilot::credentials::GhAuthTokenCommand,
4236 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
4237 panic!("environment override must not invoke gh")
4238 }
4239 }
4240
4241 let token = CopilotConfig::default()
4242 .resolve_token_with(
4243 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
4244 &NeverRun,
4245 None,
4246 )
4247 .unwrap();
4248 assert_eq!(token, "from-environment");
4249 }
4250
4251 #[test]
4255 fn copilot_rejects_an_environment_override_combined_with_a_named_account() {
4256 struct NeverRun;
4257 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
4258 fn run(
4259 &self,
4260 _: &crate::copilot::credentials::GhAuthTokenCommand,
4261 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
4262 panic!("the conflict must be refused before gh runs")
4263 }
4264 }
4265 let config = CopilotConfig {
4266 accounts: vec![CopilotAccount {
4267 label: "work".into(),
4268 user: "octocat".into(),
4269 }],
4270 ..CopilotConfig::default()
4271 };
4272 let error = config
4273 .resolve_token_with(
4274 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
4275 &NeverRun,
4276 Some("work"),
4277 )
4278 .unwrap_err()
4279 .to_string();
4280 assert!(error.contains("GITHUB_COPILOT_TOKEN is set"), "{error}");
4281 assert!(error.contains("work"), "{error}");
4282 }
4283
4284 #[test]
4287 fn copilot_unknown_account_label_errors_listing_the_known_ones() {
4288 let config = CopilotConfig {
4289 accounts: vec![CopilotAccount {
4290 label: "work".into(),
4291 user: "octocat".into(),
4292 }],
4293 ..CopilotConfig::default()
4294 };
4295 assert_eq!(config.resolve_user(None).unwrap(), None);
4296 assert_eq!(config.resolve_user(Some("work")).unwrap(), Some("octocat"));
4297 let error = config
4298 .resolve_user(Some("personal"))
4299 .unwrap_err()
4300 .to_string();
4301 assert!(error.contains("personal"), "{error}");
4302 assert!(error.contains("work"), "{error}");
4303
4304 let error = CopilotConfig::default()
4306 .resolve_user(Some("work"))
4307 .unwrap_err()
4308 .to_string();
4309 assert!(error.contains("no accounts array"), "{error}");
4310 }
4311
4312 #[test]
4315 fn copilot_defaults_keep_the_unnamed_account_visible() {
4316 let config = CopilotConfig::default();
4317 assert!(config.show_default_account);
4318 assert!(config.accounts.is_empty());
4319 assert!(!config.enabled, "Copilot stays opt-in");
4320 }
4321
4322 #[test]
4323 fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
4324 struct FailedGh;
4325 impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
4326 fn run(
4327 &self,
4328 _: &crate::copilot::credentials::GhAuthTokenCommand,
4329 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
4330 Ok(crate::copilot::credentials::GhAuthTokenOutput {
4331 success: false,
4332 stdout: b"never-echo-gh-output".to_vec(),
4333 })
4334 }
4335 }
4336 let error = CopilotConfig::default()
4337 .resolve_token_with(|_| None, &FailedGh, None)
4338 .unwrap_err()
4339 .to_string();
4340 assert!(error.contains("gh auth login --web"));
4341 assert!(!error.contains("never-echo-gh-output"));
4342 }
4343
4344 #[test]
4345 fn resolve_api_key_errors_when_both_missing() {
4346 let _g = env_guard();
4347 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
4348 unsafe { std::env::remove_var(var) };
4349 let err = resolve_api_key("Zai", var, None).unwrap_err();
4350 match err {
4351 crate::error::AppError::Credentials(msg) => {
4352 assert!(
4353 msg.contains("api_key"),
4354 "error should suggest config field: {msg}"
4355 );
4356 }
4357 other => panic!("expected Credentials error, got {other:?}"),
4358 }
4359 }
4360
4361 #[test]
4362 fn resolve_api_key_uses_exact_opencode_go_section_name() {
4363 let _g = env_guard();
4364 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
4365 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
4366 let message = err.to_string();
4367 assert!(
4368 message.contains("[opencode-go]"),
4369 "wrong section hint: {message}"
4370 );
4371 assert!(
4372 !message.contains("[opencode go]"),
4373 "wrong section hint: {message}"
4374 );
4375 }
4376
4377 fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
4378 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
4379 M.lock().unwrap_or_else(|p| p.into_inner())
4380 }
4381
4382 struct ScopedPathOverride {
4388 _serial: std::sync::MutexGuard<'static, ()>,
4389 }
4390
4391 impl Drop for ScopedPathOverride {
4392 fn drop(&mut self) {
4393 clear_override_path();
4394 }
4395 }
4396
4397 fn scoped_path_override() -> ScopedPathOverride {
4398 ScopedPathOverride {
4399 _serial: path_override_guard(),
4400 }
4401 }
4402
4403 #[test]
4404 fn override_path_wins_over_canonical_and_legacy() {
4405 let _scoped = scoped_path_override();
4406 let file = NamedTempFile::new().unwrap();
4407 set_override_path(file.path());
4408 assert_eq!(resolved_path().as_deref(), Some(file.path()));
4409 assert_eq!(config_path_hint(), file.path().display().to_string());
4410 clear_override_path();
4411 let p = resolved_path().expect("a config path must resolve");
4413 assert!(p.ends_with("config.toml"));
4414 }
4415
4416 #[test]
4417 fn scoped_override_guard_clears_the_override_on_panic() {
4418 let hook = std::panic::take_hook();
4421 std::panic::set_hook(Box::new(|_| {}));
4422 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4423 let _scoped = scoped_path_override();
4424 set_override_path(std::path::Path::new("panicked-override.toml"));
4425 panic!("simulated mid-test failure");
4426 }))
4427 .is_err();
4428 std::panic::set_hook(hook);
4429 assert!(panicked, "the simulated failure must run");
4430 let _serial = path_override_guard();
4431 assert!(
4432 override_path().is_none(),
4433 "a panicking test must not leak the override into siblings"
4434 );
4435 }
4436
4437 #[test]
4438 fn config_path_hint_ends_with_config_toml() {
4439 let _g = path_override_guard();
4440 assert!(config_path_hint().ends_with("config.toml"));
4443 }
4444
4445 #[test]
4446 fn config_flag_value_splits_the_equals_form() {
4447 use std::ffi::OsStr;
4448 assert_eq!(
4449 config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
4450 Some(std::path::Path::new("work.toml"))
4451 );
4452 assert_eq!(
4453 config_flag_value(OsStr::new("--config=")).as_deref(),
4454 Some(std::path::Path::new(""))
4455 );
4456 assert_eq!(config_flag_value(OsStr::new("--config")), None);
4457 assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
4458 assert_eq!(config_flag_value(OsStr::new("account")), None);
4459 }
4460
4461 #[cfg(unix)]
4465 #[test]
4466 fn config_flag_value_keeps_undecodable_bytes_intact() {
4467 use std::ffi::OsString;
4468 use std::os::unix::ffi::{OsStrExt, OsStringExt};
4469 let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
4470 let value = config_flag_value(&raw).expect("prefix matches");
4471 assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
4472 }
4473
4474 #[cfg(windows)]
4475 #[test]
4476 fn config_flag_value_keeps_lone_surrogates_intact() {
4477 use std::ffi::OsString;
4478 use std::os::windows::ffi::{OsStrExt, OsStringExt};
4479 let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
4480 wide.push(0xDC00); wide.extend("x.toml".encode_utf16());
4482 let raw = OsString::from_wide(&wide);
4483 let value = config_flag_value(&raw).expect("prefix matches");
4484 let mut expected = vec![0xDC00u16];
4485 expected.extend("x.toml".encode_utf16());
4486 assert_eq!(
4487 value.as_os_str().encode_wide().collect::<Vec<_>>(),
4488 expected
4489 );
4490 }
4491
4492 #[test]
4493 fn resolve_api_key_treats_empty_env_as_unset() {
4494 let _g = env_guard();
4495 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
4496 unsafe { std::env::set_var(var, "") };
4497 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
4498 unsafe { std::env::remove_var(var) };
4499 assert_eq!(got, "inline");
4500 }
4501
4502 #[test]
4503 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
4504 let _g = env_guard();
4505 let bad = "sk-kimi-very-real-looking-pasted-secret";
4507 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
4508 let msg = err.to_string();
4509 assert!(
4510 msg.contains("invalid") && msg.contains("api_key_env"),
4511 "error should explain misconfiguration: {msg}"
4512 );
4513 assert!(
4514 !msg.contains(bad),
4515 "error must not echo the misconfigured value: {msg}"
4516 );
4517 assert!(msg.contains("valid environment variable name"));
4518 assert!(
4519 msg.contains("[kimi]"),
4520 "error should point at the lowercase TOML section: {msg}"
4521 );
4522 }
4523
4524 #[test]
4525 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
4526 let _g = env_guard();
4527 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
4528 assert_eq!(got, "inline-key");
4529 }
4530
4531 #[test]
4532 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
4533 let _g = env_guard();
4534 let pasted_secret = "sk_pasted_secret";
4537 unsafe { std::env::remove_var(pasted_secret) };
4538 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
4539 assert!(
4540 !err.to_string().contains(pasted_secret),
4541 "error must not echo configured api_key_env values"
4542 );
4543 }
4544
4545 #[test]
4546 fn is_valid_env_var_name_rules() {
4547 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
4549 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
4550 }
4551 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
4553 assert!(
4554 !is_valid_env_var_name(invalid),
4555 "{invalid} should be invalid"
4556 );
4557 }
4558 }
4559
4560 #[test]
4561 fn config_parses_with_inline_api_key_and_primary() {
4562 let f = write_toml(
4563 r#"
4564 [ui]
4565 primary = "openrouter"
4566
4567 [zai]
4568 enabled = true
4569 api_key_env = "MY_ZAI"
4570 api_key = "sk-zai-inline"
4571
4572 [openrouter]
4573 enabled = true
4574 api_key = "sk-or-inline"
4575 "#,
4576 );
4577 let c = Config::load_from(f.path()).unwrap();
4578 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
4579 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
4580 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
4581 }
4582
4583 #[test]
4584 fn openrouter_named_accounts_preserve_the_default_contract() {
4585 let f = write_toml(
4586 r#"
4587 [openrouter]
4588 enabled = true
4589 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
4590 api_key = "default-inline"
4591 show_default_account = false
4592
4593 [[openrouter.accounts]]
4594 label = "work"
4595 api_key_env = "OPENROUTER_WORK_API_KEY"
4596
4597 [[openrouter.accounts]]
4598 label = "personal"
4599 api_key = "personal-inline"
4600 "#,
4601 );
4602 let _g = env_guard();
4603 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
4604 let config = Config::load_from(f.path()).unwrap();
4605 assert!(!config.openrouter.show_default_account);
4606 assert_eq!(config.openrouter.accounts.len(), 2);
4607 assert_eq!(
4608 config
4609 .resolve_account_api_key_for(VendorId::Openrouter, None)
4610 .unwrap(),
4611 "default-inline"
4612 );
4613 assert_eq!(
4614 config
4615 .resolve_account_api_key_for(VendorId::Openrouter, Some("personal"))
4616 .unwrap(),
4617 "personal-inline"
4618 );
4619 }
4620
4621 #[test]
4622 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
4623 for source in [
4624 r#"
4625 [[openrouter.accounts]]
4626 label = "work"
4627 api_key = "one"
4628 [[openrouter.accounts]]
4629 label = "work"
4630 api_key = "two"
4631 "#,
4632 r#"
4633 [[openrouter.accounts]]
4634 label = "../work"
4635 api_key = "one"
4636 "#,
4637 r#"
4638 [[openrouter.accounts]]
4639 label = "work"
4640 "#,
4641 ] {
4642 let f = write_toml(source);
4643 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
4644 }
4645 }
4646
4647 #[test]
4648 fn openrouter_unknown_account_never_falls_back_to_default_key() {
4649 let mut config = Config::default();
4650 config.openrouter.api_key = Some("default-secret".into());
4651 config.openrouter.accounts.push(ApiKeyAccount {
4652 label: "work".into(),
4653 api_key_env: None,
4654 api_key: Some("work-secret".into()),
4655 management_api_key_env: None,
4656 });
4657 let message = config
4658 .resolve_account_api_key_for(VendorId::Openrouter, Some("missing"))
4659 .unwrap_err()
4660 .to_string();
4661 assert!(message.contains("missing") && message.contains("work"));
4662 assert!(!message.contains("default-secret"));
4663 assert!(!message.contains("work-secret"));
4664 }
4665
4666 #[test]
4667 fn openrouter_account_key_errors_do_not_echo_configured_values() {
4668 let mut config = Config::default();
4669 config.openrouter.accounts.push(ApiKeyAccount {
4670 label: "work".into(),
4671 api_key_env: Some("sk_pasted_secret".into()),
4672 api_key: None,
4673 management_api_key_env: None,
4674 });
4675 let _g = env_guard();
4676 unsafe { std::env::remove_var("sk_pasted_secret") };
4677 let message = config
4678 .resolve_account_api_key_for(VendorId::Openrouter, Some("work"))
4679 .unwrap_err()
4680 .to_string();
4681 assert!(message.contains("[[openrouter.accounts]]"));
4682 assert!(!message.contains("sk_pasted_secret"));
4683 }
4684
4685 #[test]
4686 fn openrouter_management_key_resolves_per_account_and_never_inherits() {
4687 let mut config = Config::default();
4688 config.openrouter.accounts = vec![
4689 ApiKeyAccount {
4690 label: "work".into(),
4691 api_key_env: None,
4692 api_key: Some("work-key".into()),
4693 management_api_key_env: Some("AI_USAGEBAR_TEST_OR_MGMT_WORK".into()),
4694 },
4695 ApiKeyAccount {
4696 label: "plain".into(),
4697 api_key_env: None,
4698 api_key: Some("plain-key".into()),
4699 management_api_key_env: None,
4700 },
4701 ];
4702 let _g = env_guard();
4703 unsafe { std::env::remove_var("OPENROUTER_MANAGEMENT_API_KEY") };
4704 unsafe { std::env::set_var("AI_USAGEBAR_TEST_OR_MGMT_WORK", "mgmt-work") };
4705
4706 assert_eq!(config.openrouter_management_key(None), None);
4708 assert_eq!(
4710 config.openrouter_management_key(Some("work")).as_deref(),
4711 Some("mgmt-work")
4712 );
4713 unsafe { std::env::set_var("OPENROUTER_MANAGEMENT_API_KEY", "mgmt-default") };
4716 assert_eq!(
4717 config.openrouter_management_key(None).as_deref(),
4718 Some("mgmt-default")
4719 );
4720 assert_eq!(config.openrouter_management_key(Some("plain")), None);
4721 assert_eq!(config.openrouter_management_key(Some("typo")), None);
4723
4724 config.openrouter.management_api_key_env = "AI_USAGEBAR_TEST_OR_MGMT_DEFAULT".into();
4726 unsafe { std::env::set_var("AI_USAGEBAR_TEST_OR_MGMT_DEFAULT", "mgmt-renamed") };
4727 assert_eq!(
4728 config.openrouter_management_key(None).as_deref(),
4729 Some("mgmt-renamed")
4730 );
4731 unsafe { std::env::remove_var("OPENROUTER_MANAGEMENT_API_KEY") };
4732 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_MGMT_WORK") };
4733 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_MGMT_DEFAULT") };
4734 }
4735
4736 #[test]
4737 fn every_api_key_account_vendor_validates_its_array_like_openrouter() {
4738 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
4739 let section = vendor.config_section();
4740 let parse = |accounts: &str| -> Result<Config> {
4741 let config: Config =
4742 toml::from_str(&format!("[{section}]\nenabled = true\n{accounts}")).unwrap();
4743 config.validate().map(|()| config)
4744 };
4745 let config = parse(&format!(
4746 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k\"\n"
4747 ))
4748 .unwrap();
4749 assert_eq!(
4750 config.api_key_accounts(vendor).unwrap().len(),
4751 1,
4752 "{vendor:?}"
4753 );
4754 assert!(config.show_default_api_key_account(vendor), "{vendor:?}");
4755
4756 let duplicate = parse(&format!(
4757 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"a\"\n\
4758 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"b\"\n"
4759 ))
4760 .unwrap_err()
4761 .to_string();
4762 assert!(
4763 duplicate.contains(&format!("duplicate {section} account label")),
4764 "{duplicate}"
4765 );
4766
4767 let keyless = parse(&format!("[[{section}.accounts]]\nlabel = \"work\"\n"))
4768 .unwrap_err()
4769 .to_string();
4770 assert!(
4771 keyless.contains("must set api_key_env or api_key"),
4772 "{keyless}"
4773 );
4774
4775 assert!(
4777 parse(&format!(
4778 "[[{section}.accounts]]\nlabel = \"../x\"\napi_key = \"k\"\n"
4779 ))
4780 .is_err(),
4781 "{vendor:?}"
4782 );
4783 }
4784 }
4785
4786 #[test]
4787 fn named_api_key_accounts_resolve_their_own_key_and_never_the_default() {
4788 let mut config = Config::default();
4789 config.deepseek.api_key_env.clear();
4790 config.deepseek.api_key = Some("default-key".into());
4791 config.deepseek.accounts.push(ApiKeyAccount {
4792 label: "work".into(),
4793 api_key_env: Some("AI_USAGEBAR_TEST_DEEPSEEK_WORK".into()),
4794 api_key: Some("work-inline".into()),
4795 management_api_key_env: None,
4796 });
4797 let _g = env_guard();
4798 unsafe { std::env::set_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK", "work-env") };
4799 let resolve = |label| config.resolve_account_api_key_for(VendorId::Deepseek, label);
4800 assert_eq!(resolve(None).unwrap(), "default-key");
4801 assert_eq!(resolve(Some("work")).unwrap(), "work-env", "env wins");
4802 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK") };
4803 assert_eq!(resolve(Some("work")).unwrap(), "work-inline");
4804
4805 let unknown = resolve(Some("typo")).unwrap_err().to_string();
4806 assert!(unknown.contains("[[deepseek.accounts]]"), "{unknown}");
4807 assert!(
4808 unknown.contains("\"work\""),
4809 "lists known labels: {unknown}"
4810 );
4811 assert!(!unknown.contains("default-key"), "{unknown}");
4812 }
4813
4814 #[test]
4815 fn the_default_key_error_keeps_each_vendors_historic_wording() {
4816 let mut config = Config::default();
4817 config.zai.api_key_env.clear();
4818 config.minimax.api_key_env.clear();
4819 let zai = config
4820 .resolve_account_api_key_for(VendorId::Zai, None)
4821 .unwrap_err()
4822 .to_string();
4823 let expected = resolve_api_key("Zai", "", None).unwrap_err().to_string();
4824 assert_eq!(zai, expected);
4825 assert!(zai.contains("[zai]"), "{zai}");
4826 let minimax = config
4827 .resolve_account_api_key_for(VendorId::Minimax, None)
4828 .unwrap_err()
4829 .to_string();
4830 assert_eq!(
4831 minimax,
4832 resolve_api_key("MiniMax", "", None)
4833 .unwrap_err()
4834 .to_string()
4835 );
4836 }
4837
4838 #[test]
4839 fn enabled_vendors_preserves_canonical_order() {
4840 let c = Config::default();
4843 assert_eq!(
4844 c.enabled_vendors(),
4845 vec![
4846 VendorId::Anthropic,
4847 VendorId::Openai,
4848 VendorId::Zai,
4849 VendorId::Openrouter,
4850 ]
4851 );
4852 }
4853
4854 #[test]
4855 fn deepseek_appears_when_enabled() {
4856 let f = write_toml(
4857 r#"
4858 [deepseek]
4859 enabled = true
4860 api_key = "sk-test"
4861 "#,
4862 );
4863 let c = Config::load_from(f.path()).unwrap();
4864 assert!(c.is_enabled(VendorId::Deepseek));
4865 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
4866 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
4867 }
4868
4869 #[test]
4870 fn tilde_paths_are_expanded_on_load() {
4871 let f = write_toml(
4875 r#"
4876 [context]
4877 projects_path = "~/.claude/projects"
4878
4879 [anthropic]
4880 credentials_path = "~/.claude/.credentials.json"
4881
4882 [[anthropic.accounts]]
4883 label = "work"
4884 credentials_path = "~/work.json"
4885 "#,
4886 );
4887 let c = Config::load_from(f.path()).unwrap();
4888 let home = crate::cache::home_dir().unwrap();
4889
4890 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
4891 let got = c.anthropic.credentials_path.unwrap();
4892 assert_eq!(got, home.join(".claude/.credentials.json"));
4893 assert!(!got.to_string_lossy().contains('~'));
4894 assert_eq!(
4895 c.anthropic.accounts[0].credentials_path,
4896 home.join("work.json")
4897 );
4898 }
4899
4900 #[test]
4901 fn absolute_and_relative_paths_are_left_alone() {
4902 let f = write_toml(
4903 r#"
4904 [anthropic]
4905 credentials_path = "/etc/creds.json"
4906 "#,
4907 );
4908 let c = Config::load_from(f.path()).unwrap();
4909 assert_eq!(
4910 c.anthropic.credentials_path.unwrap(),
4911 std::path::Path::new("/etc/creds.json")
4912 );
4913
4914 let f2 = write_toml(
4916 r#"
4917 [anthropic]
4918 credentials_path = "~someone/creds.json"
4919 "#,
4920 );
4921 let c2 = Config::load_from(f2.path()).unwrap();
4922 assert_eq!(
4923 c2.anthropic.credentials_path.unwrap(),
4924 std::path::Path::new("~someone/creds.json")
4925 );
4926 }
4927
4928 #[test]
4929 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
4930 let _g = path_override_guard();
4931 let p = resolved_path().expect("a config path must resolve");
4934 assert!(p.ends_with("config.toml"));
4935 let canonical = default_path().unwrap();
4936 let legacy = legacy_xdg_path().unwrap();
4937 assert!(
4938 p == canonical || p == legacy,
4939 "resolved to an unexpected location: {}",
4940 p.display()
4941 );
4942 }
4943
4944 #[test]
4945 fn misspelled_section_is_rejected_not_ignored() {
4946 let f = write_toml(
4949 r#"
4950 [openrouer]
4951 enabled = true
4952 api_key = "sk-or-v1-typo"
4953 "#,
4954 );
4955 let err = Config::load_from(f.path()).unwrap_err().to_string();
4956 assert!(
4957 err.contains("openrouer"),
4958 "error should name the typo: {err}"
4959 );
4960 }
4961
4962 #[test]
4963 fn invalid_toml_is_an_error_not_silent_defaults() {
4964 let f = write_toml("[zai\nenabled = true\n");
4965 assert!(Config::load_from(f.path()).is_err());
4966 }
4967
4968 #[test]
4972 fn a_parse_error_names_the_line_without_quoting_it() {
4973 let f = write_toml("[openrouter]\nenabled = true\napi_key = sk-or-v1-unquoted\n");
4974 let err = Config::load_from(f.path()).unwrap_err().to_string();
4975 assert!(err.contains("line 3,"), "{err}");
4976 assert!(!err.contains("sk-or-v1-unquoted"), "{err}");
4977 }
4978
4979 #[test]
4982 fn a_writer_parse_error_names_the_line_without_quoting_it() {
4983 let (_dir, path) =
4984 crate::cache::closed_temp_file("config.toml", Some("[zai]\napi_key = zk-unquoted\n"));
4985 let err = read_config_document(&path).unwrap_err().to_string();
4986 assert!(err.contains("line 2,"), "{err}");
4987 assert!(!err.contains("zk-unquoted"), "{err}");
4988 }
4989
4990 #[test]
4991 fn toml_error_summary_keeps_the_position_and_the_message() {
4992 let input = "a = 1\nkey = sk-secret\n";
4993 assert_eq!(
4994 toml_error_summary(input, Some(12..21), "string values must be quoted\n"),
4995 "line 2, column 7: string values must be quoted"
4996 );
4997 assert_eq!(toml_error_summary(input, None, "bad"), "bad");
4998 }
4999
5000 #[test]
5001 fn a_missing_file_is_still_just_defaults() {
5002 let dir = tempfile::tempdir().unwrap();
5005 let missing = dir.path().join("nope").join("config.toml");
5006 let c = Config::load_from(&missing).unwrap();
5007 assert!(c.is_enabled(VendorId::Anthropic));
5008 }
5009
5010 #[test]
5011 fn kimi_appears_when_enabled() {
5012 let f = write_toml(
5013 r#"
5014 [kimi]
5015 enabled = true
5016 api_key = "sk-test"
5017 "#,
5018 );
5019 let c = Config::load_from(f.path()).unwrap();
5020 assert!(c.is_enabled(VendorId::Kimi));
5021 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
5022 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
5023 }
5024
5025 #[test]
5026 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
5027 let f = write_toml(
5028 r#"
5029 [deepseek]
5030 enabled = true
5031 api_key = "sk-ds"
5032
5033 [kimi]
5034 enabled = true
5035 api_key = "sk-kimi"
5036 "#,
5037 );
5038 let c = Config::load_from(f.path()).unwrap();
5039 assert_eq!(
5040 c.enabled_vendors(),
5041 vec![
5042 VendorId::Anthropic,
5043 VendorId::Openai,
5044 VendorId::Zai,
5045 VendorId::Openrouter,
5046 VendorId::Deepseek,
5047 VendorId::Kimi,
5048 ]
5049 );
5050 }
5051
5052 #[test]
5053 fn parses_anthropic_accounts_and_looks_them_up() {
5054 let f = write_toml(
5055 r#"
5056 [anthropic]
5057 enabled = true
5058
5059 [[anthropic.accounts]]
5060 label = "personal"
5061 credentials_path = "/creds/personal.json"
5062
5063 [[anthropic.accounts]]
5064 label = "work"
5065 credentials_path = "/creds/work.json"
5066 "#,
5067 );
5068 let c = Config::load_from(f.path()).unwrap();
5069 assert_eq!(c.anthropic.accounts.len(), 2);
5070 let work = c.anthropic.account("work").unwrap();
5071 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
5072 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
5074 assert!(err.contains("missing") && err.contains("work"), "{err}");
5075 }
5076
5077 #[test]
5078 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
5079 let f = write_toml(
5080 r#"
5081 [[anthropic.accounts]]
5082 label = "work"
5083 credentials_path = "/creds/work-one.json"
5084
5085 [[anthropic.accounts]]
5086 label = "work"
5087 credentials_path = "/creds/work-two.json"
5088 "#,
5089 );
5090 let err = Config::load_from(f.path()).unwrap_err().to_string();
5091 assert!(
5092 err.contains("duplicate anthropic account label \"work\""),
5093 "{err}"
5094 );
5095 }
5096
5097 #[test]
5098 fn account_label_rejects_path_like_names() {
5099 let cfg = AnthropicConfig::default();
5100 for bad in [
5101 "",
5102 ".",
5103 "..",
5104 "a/b",
5105 r"a\b",
5106 "C:work",
5107 "line\nbreak",
5108 "tab\tname",
5109 "usage.json",
5110 ".stale",
5111 ".last_error",
5112 ".fetch.lock",
5113 ] {
5114 let err = cfg.account(bad).unwrap_err();
5115 assert!(
5116 format!("{err:?}").contains("invalid anthropic account label"),
5117 "{bad:?} should be rejected as a label"
5118 );
5119 }
5120 }
5121
5122 #[test]
5123 fn anthropic_accounts_default_to_empty() {
5124 assert!(Config::default().anthropic.accounts.is_empty());
5127 assert!(Config::default().anthropic.accounts_dir.is_none());
5128 }
5129
5130 fn seed_account_dir(root: &std::path::Path, label: &str) {
5136 let dir = root.join(label);
5137 std::fs::create_dir_all(&dir).unwrap();
5138 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
5139 }
5140
5141 #[test]
5142 fn discovers_account_dirs_in_claude_config_dir_layout() {
5143 let td = tempfile::tempdir().unwrap();
5144 seed_account_dir(td.path(), "work");
5145 seed_account_dir(td.path(), "personal");
5146 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
5149 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
5151
5152 let cfg = AnthropicConfig {
5153 accounts_dir: Some(td.path().to_path_buf()),
5154 ..Default::default()
5155 };
5156 let all = cfg.all_accounts();
5157 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
5158 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
5159 assert_eq!(
5160 all[2].credentials_path,
5161 td.path().join("work").join(".credentials.json")
5162 );
5163 }
5164
5165 #[test]
5166 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
5167 let td = tempfile::tempdir().unwrap();
5168 seed_account_dir(td.path(), "work");
5169 let cfg = AnthropicConfig {
5170 accounts: vec![AnthropicAccount {
5171 label: "work".into(),
5172 credentials_path: "/explicit/work.json".into(),
5173 }],
5174 accounts_dir: Some(td.path().to_path_buf()),
5175 ..Default::default()
5176 };
5177 let all = cfg.all_accounts();
5178 assert_eq!(all.len(), 1, "no duplicate label");
5179 assert_eq!(
5180 all[0].credentials_path,
5181 std::path::Path::new("/explicit/work.json"),
5182 "explicit entry wins"
5183 );
5184 seed_account_dir(td.path(), "other");
5186 assert_eq!(cfg.account("other").unwrap().label, "other");
5187 }
5188
5189 #[test]
5190 fn missing_accounts_dir_is_silently_empty_not_an_error() {
5191 let cfg = AnthropicConfig {
5192 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
5193 ..Default::default()
5194 };
5195 assert!(cfg.all_accounts().is_empty());
5196 }
5197
5198 #[test]
5199 fn openai_account_auth_paths_are_tilde_expanded_on_load() {
5200 let f = write_toml(
5201 r#"
5202 [[openai.accounts]]
5203 label = "work"
5204 codex_auth_path = "~/.codex-work/auth.json"
5205 "#,
5206 );
5207 let c = Config::load_from(f.path()).unwrap();
5208 let home = crate::cache::home_dir().unwrap();
5209 assert_eq!(
5210 c.openai.accounts[0].codex_auth_path,
5211 home.join(".codex-work/auth.json")
5212 );
5213 }
5214
5215 #[test]
5216 fn accounts_dir_is_tilde_expanded_on_load() {
5217 let f = write_toml(
5218 r#"
5219 [anthropic]
5220 accounts_dir = "~/.config/ai-usagebar/accounts"
5221 "#,
5222 );
5223 let c = Config::load_from(f.path()).unwrap();
5224 let home = crate::cache::home_dir().unwrap();
5225 assert_eq!(
5226 c.anthropic.accounts_dir,
5227 Some(home.join(".config/ai-usagebar/accounts"))
5228 );
5229 }
5230
5231 #[test]
5232 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
5233 let f = write_toml(
5234 r#"
5235 [anthropic]
5236 desktop_profiles_dir = "~/.claude-acc/profiles"
5237 "#,
5238 );
5239 let c = Config::load_from(f.path()).unwrap();
5240 let home = crate::cache::home_dir().unwrap();
5241 assert_eq!(
5242 c.anthropic.desktop_profiles_dir,
5243 Some(home.join(".claude-acc/profiles"))
5244 );
5245 }
5246
5247 #[test]
5248 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
5249 let cfg = AnthropicConfig {
5250 accounts: vec![
5251 AnthropicAccount {
5252 label: "work".into(),
5253 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
5254 },
5255 AnthropicAccount {
5256 label: "personal".into(),
5257 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
5258 },
5259 ],
5260 ..Default::default()
5261 };
5262
5263 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
5264 assert!(
5265 matches!(&idle, CredsTarget::Named { config_dir, .. }
5266 if config_dir == std::path::Path::new("/tmp/accounts/work")),
5267 "{idle:?}"
5268 );
5269
5270 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
5272 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
5273
5274 assert_eq!(idle_cache.dir(), live_cache.dir());
5277 }
5278
5279 #[test]
5280 fn the_live_cli_account_keeps_its_own_slot_while_that_file_is_there() {
5281 let cfg = AnthropicConfig {
5286 accounts: vec![AnthropicAccount {
5287 label: "personal".into(),
5288 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
5289 }],
5290 ..Default::default()
5291 };
5292
5293 let (present, _) = cfg
5294 .account_target_probing("personal", Some("personal"), |_| true)
5295 .unwrap();
5296 assert!(
5297 matches!(&present, CredsTarget::Named { path, .. }
5298 if path == Path::new("/tmp/accounts/personal/.credentials.json")),
5299 "{present:?}"
5300 );
5301
5302 let (moved, _) = cfg
5304 .account_target_probing("personal", Some("personal"), |_| false)
5305 .unwrap();
5306 assert!(matches!(moved, CredsTarget::Default(_)), "{moved:?}");
5307 }
5308
5309 #[test]
5310 fn the_default_entry_reads_config_credentials_path_strictly() {
5311 let cfg = AnthropicConfig {
5312 credentials_path: Some("/tmp/claude-home/.credentials.json".into()),
5313 ..Default::default()
5314 };
5315
5316 assert!(
5317 matches!(&cfg.default_creds_target(), CredsTarget::Explicit(path)
5318 if path == Path::new("/tmp/claude-home/.credentials.json")),
5319 );
5320 assert!(matches!(
5322 AnthropicConfig::default().default_creds_target(),
5323 CredsTarget::Default(_)
5324 ));
5325 }
5326
5327 #[test]
5328 fn the_live_cli_accounts_own_file_is_probed_on_disk() {
5329 let creds = NamedTempFile::new().unwrap();
5333 let cfg = AnthropicConfig {
5334 accounts: vec![AnthropicAccount {
5335 label: "personal".into(),
5336 credentials_path: creds.path().to_path_buf(),
5337 }],
5338 ..Default::default()
5339 };
5340 let (target, _) = cfg
5341 .account_target_with("personal", Some("personal"))
5342 .unwrap();
5343 assert!(
5344 matches!(&target, CredsTarget::Named { path, .. } if path == creds.path()),
5345 "read {target:?} instead of the account's own file"
5346 );
5347 }
5348
5349 #[test]
5350 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
5351 let cfg = AnthropicConfig {
5352 accounts: vec![AnthropicAccount {
5353 label: "work".into(),
5354 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
5355 }],
5356 ..Default::default()
5357 };
5358 let (target, _) = cfg.account_target_with("work", None).unwrap();
5359 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
5360 }
5361
5362 fn config_example() -> PathBuf {
5366 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
5367 }
5368
5369 #[test]
5370 fn shipped_example_parses_as_a_real_config() {
5371 let c = Config::load_from(&config_example()).unwrap();
5376 assert!(!c.context.enabled);
5377 assert!(c.is_enabled(VendorId::Anthropic));
5378 assert!(c.is_enabled(VendorId::Openai));
5379 assert!(!c.is_enabled(VendorId::AnthropicApi));
5380 assert!(!c.is_enabled(VendorId::Deepseek));
5381 assert!(!c.is_enabled(VendorId::Kimi));
5382 assert!(!c.is_enabled(VendorId::Kilo));
5383 assert!(!c.is_enabled(VendorId::Novita));
5384 assert!(!c.is_enabled(VendorId::Moonshot));
5385 assert!(!c.is_enabled(VendorId::Grok));
5386 assert!(!c.is_enabled(VendorId::Cursor));
5387 assert!(!c.is_enabled(VendorId::Minimax));
5388 }
5389
5390 #[test]
5391 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
5392 let text = std::fs::read_to_string(config_example()).unwrap();
5397 let live: Vec<&str> = text
5398 .lines()
5399 .map(str::trim)
5400 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
5401 .collect();
5402 assert!(
5403 live.is_empty(),
5404 "admin_key_env must stay commented out while it is inert: {live:?}"
5405 );
5406 assert!(
5409 text.contains("admin_key_env") && text.contains("RESERVED"),
5410 "the example should keep describing admin_key_env as reserved"
5411 );
5412 }
5413
5414 #[test]
5415 fn admin_key_env_is_accepted_but_changes_nothing() {
5416 let f = write_toml(
5420 r#"
5421 [openai]
5422 admin_key_env = "SOME_ADMIN_KEY"
5423 "#,
5424 );
5425 let c = Config::load_from(f.path()).unwrap();
5426 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
5427 let default = OpenAiConfig::default();
5429 assert_eq!(c.openai.enabled, default.enabled);
5430 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
5431 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
5432 }
5433
5434 #[test]
5435 fn config_example_documents_every_vendor_without_secrets() {
5436 let raw = std::fs::read_to_string(config_example()).unwrap();
5437 let cfg = Config::load_from(&config_example()).unwrap();
5438 for id in VendorId::all() {
5441 let section = id.slug();
5442 assert!(
5443 raw.contains(&format!("[{section}]")),
5444 "config.example.toml has no [{section}] section"
5445 );
5446 }
5447
5448 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
5451 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
5452 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
5453 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
5454 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
5455 assert!(!cfg.supergrok.enabled);
5456 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
5457 assert_eq!(
5458 cfg.supergrok
5459 .grok_binary
5460 .file_name()
5461 .and_then(|p| p.to_str()),
5462 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
5463 );
5464 assert!(cfg.supergrok.auth_path.is_none());
5465 assert!(cfg.supergrok.config_path.is_none());
5466 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
5467 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
5468 }
5469
5470 #[test]
5471 fn supergrok_binary_must_not_be_empty() {
5472 let file = write_toml(
5473 r#"
5474 [supergrok]
5475 enabled = true
5476 grok_binary = ""
5477 "#,
5478 );
5479 let error = Config::load_from(file.path()).unwrap_err().to_string();
5480 assert!(error.contains("grok_binary must not be empty"));
5481 }
5482
5483 #[test]
5484 fn supergrok_paths_are_tilde_expanded() {
5485 let file = write_toml(
5486 r#"
5487 [supergrok]
5488 grok_binary = "~/bin/grok"
5489 auth_path = "~/.grok/auth.json"
5490 config_path = "~/.grok/config.toml"
5491 "#,
5492 );
5493 let config = Config::load_from(file.path()).unwrap();
5494 let home = crate::cache::home_dir().unwrap();
5495 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
5496 assert_eq!(
5497 config.supergrok.auth_path,
5498 Some(home.join(".grok/auth.json"))
5499 );
5500 assert_eq!(
5501 config.supergrok.config_path,
5502 Some(home.join(".grok/config.toml"))
5503 );
5504 }
5505
5506 #[test]
5509 fn commandcode_and_copilot_paths_are_tilde_expanded() {
5510 let file = write_toml(
5511 r#"
5512 [commandcode]
5513 auth_paths = ["~/.commandcode/auth.json", "/etc/commandcode/auth.json"]
5514
5515 [copilot]
5516 gh_binary = "~/bin/gh"
5517 "#,
5518 );
5519 let config = Config::load_from(file.path()).unwrap();
5520 let home = crate::cache::home_dir().unwrap();
5521 assert_eq!(
5522 config.commandcode.auth_paths,
5523 Some(vec![
5524 home.join(".commandcode/auth.json"),
5525 PathBuf::from("/etc/commandcode/auth.json"),
5526 ])
5527 );
5528 assert_eq!(config.copilot.gh_binary, Some(home.join("bin/gh")));
5529 }
5530
5531 #[test]
5532 fn kiro_db_path_is_tilde_expanded() {
5533 let f = write_toml(
5534 r#"
5535 [kiro]
5536 db_path = "~/kiro-data.sqlite3"
5537 "#,
5538 );
5539 let c = Config::load_from(f.path()).unwrap();
5540 let home = crate::cache::home_dir().unwrap();
5541 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
5542 }
5543
5544 #[test]
5545 fn kiro_appears_when_enabled() {
5546 let f = write_toml(
5547 r#"
5548 [kiro]
5549 enabled = true
5550 "#,
5551 );
5552 let c = Config::load_from(f.path()).unwrap();
5553 assert!(c.is_enabled(VendorId::Kiro));
5554 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
5555 }
5556
5557 #[test]
5558 fn cursor_db_path_is_tilde_expanded() {
5559 let f = write_toml(
5560 r#"
5561 [cursor]
5562 db_path = "~/cursor-state.vscdb"
5563 "#,
5564 );
5565 let c = Config::load_from(f.path()).unwrap();
5566 let home = crate::cache::home_dir().unwrap();
5567 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
5568 }
5569
5570 #[test]
5571 fn cursor_agent_auth_path_is_tilde_expanded() {
5572 let f = write_toml(
5573 r#"
5574 [cursor]
5575 agent_auth_path = "~/cursor-agent-auth.json"
5576 "#,
5577 );
5578 let c = Config::load_from(f.path()).unwrap();
5579 let home = crate::cache::home_dir().unwrap();
5580 assert_eq!(
5581 c.cursor.agent_auth_path,
5582 Some(home.join("cursor-agent-auth.json"))
5583 );
5584 }
5585
5586 #[test]
5587 fn cursor_appears_when_enabled() {
5588 let f = write_toml(
5589 r#"
5590 [cursor]
5591 enabled = true
5592 "#,
5593 );
5594 let c = Config::load_from(f.path()).unwrap();
5595 assert!(c.is_enabled(VendorId::Cursor));
5596 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
5597 }
5598
5599 #[test]
5600 fn add_account_appends_and_preserves_existing() {
5601 let mut doc: toml_edit::DocumentMut = r#"
5602# keep me
5603[anthropic]
5604enabled = true
5605
5606[[anthropic.accounts]]
5607label = "personal"
5608credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
5609"#
5610 .parse()
5611 .unwrap();
5612 add_anthropic_account_to_doc(
5613 &mut doc,
5614 "work",
5615 "~/.config/ai-usagebar/accounts/work/.credentials.json",
5616 )
5617 .unwrap();
5618 let rendered = doc.to_string();
5619 assert!(rendered.contains("# keep me"), "comment must survive");
5620 let f = write_toml(&rendered);
5622 let c = Config::load_from(f.path()).unwrap();
5623 let labels: Vec<&str> = c
5624 .anthropic
5625 .accounts
5626 .iter()
5627 .map(|a| a.label.as_str())
5628 .collect();
5629 assert_eq!(labels, vec!["personal", "work"]);
5630 }
5631
5632 #[test]
5633 fn add_account_to_empty_doc_is_loadable() {
5634 let mut doc = toml_edit::DocumentMut::new();
5635 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
5636 let f = write_toml(&doc.to_string());
5637 let c = Config::load_from(f.path()).unwrap();
5638 assert_eq!(c.anthropic.accounts.len(), 1);
5639 assert_eq!(c.anthropic.accounts[0].label, "solo");
5640 }
5641
5642 #[test]
5643 fn add_account_rejects_duplicate_label() {
5644 let mut doc: toml_edit::DocumentMut = r#"
5645[[anthropic.accounts]]
5646label = "work"
5647credentials_path = "~/w/.credentials.json"
5648"#
5649 .parse()
5650 .unwrap();
5651 assert!(
5652 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
5653 "a duplicate label must be rejected, not appended"
5654 );
5655 }
5656
5657 #[test]
5658 fn add_account_rejects_bad_label() {
5659 let mut doc = toml_edit::DocumentMut::new();
5660 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
5661 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
5662 }
5663
5664 #[test]
5665 fn tildify_collapses_home_only() {
5666 let home = Path::new("/Users/me");
5667 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
5668 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
5669 }
5670
5671 #[test]
5672 fn default_account_credentials_path_nests_under_config_dir() {
5673 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
5674 assert_eq!(
5675 default_account_credentials_path(cfg, "work"),
5676 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
5677 );
5678 }
5679 const CUSTOM_BLOCK: &str = r#"
5682[[custom]]
5683id = "mytool"
5684name = "My Tool"
5685short_name = "myt"
5686enabled = true
5687url = "https://api.example.test/v1/usage"
5688api_key_env = "MYTOOL_API_KEY"
5689auth_header = "Authorization"
5690auth_scheme = "Bearer"
5691plan = "Pro"
5692cache_ttl_secs = 120
5693[custom.headers]
5694X-Org = "org_1"
5695[[custom.metrics]]
5696label = "Requests"
5697used = "/requests/used"
5698limit = "/requests/limit"
5699resets_at = "/requests/reset"
5700window_secs = 3600
5701[[custom.texts]]
5702label = "Tier"
5703value = "/tier"
5704"#;
5705
5706 fn custom_with(from: &str, to: &str) -> String {
5707 assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
5708 CUSTOM_BLOCK.replace(from, to)
5709 }
5710
5711 fn custom_error(toml: &str) -> String {
5712 Config::load_from(write_toml(toml).path())
5713 .unwrap_err()
5714 .to_string()
5715 }
5716
5717 fn assert_custom_rejected(toml: &str, needle: &str) {
5718 let msg = custom_error(toml);
5719 assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
5720 assert!(
5721 msg.contains("[[custom]]"),
5722 "the error must locate the section: {msg}"
5723 );
5724 }
5725
5726 #[test]
5727 fn custom_block_parses_every_field() {
5728 let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
5729 assert_eq!(config.custom.len(), 1);
5730 let c = &config.custom[0];
5731 assert_eq!(c.id, "mytool");
5732 assert_eq!(c.name, "My Tool");
5733 assert_eq!(c.short_name, "myt");
5734 assert_eq!(
5735 c.brand, None,
5736 "a custom provider has no mark unless it asks"
5737 );
5738 assert!(c.enabled);
5739 assert_eq!(c.url, "https://api.example.test/v1/usage");
5740 assert!(!c.allow_http);
5741 assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
5742 assert_eq!(c.api_key, None);
5743 assert_eq!(c.auth_header, "Authorization");
5744 assert_eq!(c.auth_scheme, "Bearer");
5745 assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
5746 assert_eq!(c.plan.as_deref(), Some("Pro"));
5747 assert_eq!(c.plan_path, None);
5748 assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
5749 assert_eq!(c.metrics.len(), 1);
5750 assert_eq!(c.metrics[0].label, "Requests");
5751 assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
5752 assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
5753 assert_eq!(c.metrics[0].percent, None);
5754 assert_eq!(c.metrics[0].currency, None);
5755 assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
5756 assert_eq!(c.metrics[0].window_secs, Some(3600));
5757 assert_eq!(c.texts.len(), 1);
5758 assert_eq!(c.texts[0].label, "Tier");
5759 assert_eq!(c.texts[0].value, "/tier");
5760 assert_eq!(c.texts[0].currency, None);
5761 assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
5762 }
5763
5764 #[test]
5765 fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
5766 let config: Config = toml::from_str(
5767 r#"
5768 [[custom]]
5769 id = "bare"
5770 short_name = "bre"
5771 url = "https://example.test/u"
5772 [[custom.metrics]]
5773 label = "Q"
5774 percent = "/pct"
5775 "#,
5776 )
5777 .unwrap();
5778 let c = &config.custom[0];
5779 assert_eq!(c.name, "bare", "name must default to id on a plain parse");
5780 assert!(!c.enabled);
5781 assert!(!c.allow_http);
5782 assert_eq!(c.api_key_env, "");
5783 assert_eq!(c.auth_header, "Authorization");
5784 assert_eq!(c.auth_scheme, "Bearer");
5785 assert_eq!(c.cache_ttl_secs, 60);
5786 assert!(config.validate().is_ok());
5787 assert!(Config::default().custom.is_empty());
5788 }
5789
5790 #[test]
5791 fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
5792 let config = Config::load_from(
5793 write_toml(&custom_with(
5794 r#"short_name = "myt""#,
5795 "short_name = \"myt\"\nbrand = \"opencode-go\"",
5796 ))
5797 .path(),
5798 )
5799 .unwrap();
5800 assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
5801
5802 for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
5806 assert_custom_rejected(
5807 &custom_with(
5808 r#"short_name = "myt""#,
5809 &format!("short_name = \"myt\"\nbrand = {brand:?}"),
5810 ),
5811 "must name a built-in vendor",
5812 );
5813 }
5814 }
5815
5816 #[test]
5817 fn custom_rejects_a_malformed_id() {
5818 let long = "a".repeat(33);
5819 for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
5820 let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
5821 assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
5822 assert!(msg.contains("must match"), "{id:?}: {msg}");
5823 }
5824 }
5825
5826 #[test]
5827 fn custom_rejects_a_builtin_slug_as_id() {
5828 assert_custom_rejected(
5829 &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
5830 "is a built-in vendor",
5831 );
5832 assert_custom_rejected(
5833 &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
5834 "is a built-in vendor",
5835 );
5836 }
5837
5838 #[test]
5839 fn custom_rejects_duplicate_ids() {
5840 let twice = format!(
5841 "{}{}",
5842 CUSTOM_BLOCK,
5843 custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
5844 );
5845 assert_custom_rejected(&twice, "duplicate id");
5846 }
5847
5848 #[test]
5849 fn custom_rejects_a_name_over_48_chars() {
5850 let long = "n".repeat(49);
5851 assert_custom_rejected(
5852 &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
5853 "name must be 1 to 48 characters",
5854 );
5855 }
5856
5857 #[test]
5858 fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
5859 for short in ["my", "myto", "MYT", "m1t"] {
5860 assert_custom_rejected(
5861 &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
5862 "exactly 3 lowercase ASCII letters",
5863 );
5864 }
5865 }
5866
5867 #[test]
5868 fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
5869 assert_custom_rejected(
5870 &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
5871 "already used by a built-in vendor",
5872 );
5873 let twice = format!(
5874 "{}{}",
5875 CUSTOM_BLOCK,
5876 custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
5877 );
5878 assert_custom_rejected(&twice, "already used by a built-in vendor");
5879 }
5880
5881 #[test]
5882 fn custom_rejects_http_unless_allowed() {
5883 let plain = custom_with(
5884 r#"url = "https://api.example.test/v1/usage""#,
5885 r#"url = "http://localhost:8080/usage""#,
5886 );
5887 assert_custom_rejected(&plain, "url must use https://");
5888 let allowed = plain.replace(
5889 r#"url = "http://localhost:8080/usage""#,
5890 "url = \"http://localhost:8080/usage\"\nallow_http = true",
5891 );
5892 assert!(
5893 Config::load_from(write_toml(&allowed).path()).is_ok(),
5894 "allow_http must permit http://"
5895 );
5896 }
5897
5898 #[test]
5899 fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
5900 assert_custom_rejected(
5901 &custom_with(
5902 r#"url = "https://api.example.test/v1/usage""#,
5903 r#"url = "https://user:pw@api.example.test/v1/usage""#,
5904 ),
5905 "must not carry credentials",
5906 );
5907 assert_custom_rejected(
5908 &custom_with(
5909 r#"url = "https://api.example.test/v1/usage""#,
5910 r#"url = "not a url""#,
5911 ),
5912 "is not a valid URL",
5913 );
5914 assert_custom_rejected(
5915 &custom_with(
5916 r#"url = "https://api.example.test/v1/usage""#,
5917 r#"url = "ftp://api.example.test/v1/usage""#,
5918 ),
5919 "is not http or https",
5920 );
5921 }
5922
5923 #[test]
5924 fn custom_rejects_an_invalid_api_key_env() {
5925 assert_custom_rejected(
5926 &custom_with(
5927 r#"api_key_env = "MYTOOL_API_KEY""#,
5928 r#"api_key_env = "1BAD-NAME""#,
5929 ),
5930 "is not a valid environment variable name",
5931 );
5932 let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
5933 assert!(
5934 Config::load_from(write_toml(&none).path()).is_ok(),
5935 "an empty api_key_env means inline-only and is valid"
5936 );
5937 }
5938
5939 #[test]
5943 fn custom_invalid_api_key_env_is_not_repeated() {
5944 let msg = custom_error(&custom_with(
5945 r#"api_key_env = "MYTOOL_API_KEY""#,
5946 r#"api_key_env = "sk-live-pasted-secret""#,
5947 ));
5948 assert!(msg.contains("api_key_env"), "{msg}");
5949 assert!(!msg.contains("sk-live-pasted-secret"), "{msg}");
5950 }
5951
5952 #[test]
5953 fn custom_rejects_an_invalid_auth_header_name() {
5954 assert_custom_rejected(
5955 &custom_with(
5956 r#"auth_header = "Authorization""#,
5957 r#"auth_header = "X Api Key""#,
5958 ),
5959 "auth_header \"X Api Key\" is not a valid HTTP header name",
5960 );
5961 }
5962
5963 #[test]
5964 fn custom_rejects_a_control_char_in_auth_scheme() {
5965 assert_custom_rejected(
5966 &custom_with(
5967 r#"auth_scheme = "Bearer""#,
5968 "auth_scheme = \"Bearer\\u0007\"",
5969 ),
5970 "auth_scheme contains characters that are not valid",
5971 );
5972 let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
5973 assert!(
5974 Config::load_from(write_toml(&bare).path()).is_ok(),
5975 "an empty scheme (bare key) is valid"
5976 );
5977 }
5978
5979 #[test]
5980 fn custom_rejects_a_bad_extra_header() {
5981 assert_custom_rejected(
5982 &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
5983 "headers must not repeat auth_header",
5984 );
5985 assert_custom_rejected(
5986 &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
5987 "is not a valid HTTP header name",
5988 );
5989 assert_custom_rejected(
5990 &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
5991 "has a value that is not valid in an HTTP header",
5992 );
5993 }
5994
5995 #[test]
5996 fn custom_rejects_cache_ttl_outside_10_to_3600() {
5997 for ttl in ["9", "3601"] {
5998 assert_custom_rejected(
5999 &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
6000 "cache_ttl_secs must be between 10 and 3600",
6001 );
6002 }
6003 }
6004
6005 #[test]
6006 fn custom_rejects_an_entry_with_no_metrics_or_texts() {
6007 let toml = r#"
6008[[custom]]
6009id = "empty"
6010short_name = "emp"
6011url = "https://example.test/u"
6012"#;
6013 assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
6014 }
6015
6016 #[test]
6017 fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
6018 assert_custom_rejected(
6019 &custom_with(
6020 r#"limit = "/requests/limit""#,
6021 "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
6022 ),
6023 "must set `percent`, or both `used` and `limit`",
6024 );
6025 assert_custom_rejected(
6026 &custom_with("limit = \"/requests/limit\"\n", ""),
6027 "must set `percent`, or both `used` and `limit`",
6028 );
6029 }
6030
6031 #[test]
6032 fn custom_rejects_a_pointer_without_a_leading_slash() {
6033 assert_custom_rejected(
6034 &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
6035 "used \"requests.used\" must be an RFC 6901 JSON Pointer",
6036 );
6037 assert_custom_rejected(
6038 &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
6039 "value \"tier\" must be an RFC 6901 JSON Pointer",
6040 );
6041 assert_custom_rejected(
6042 &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
6043 "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
6044 );
6045 assert_custom_rejected(
6046 &custom_with(
6047 r#"resets_at = "/requests/reset""#,
6048 "resets_at = \"/re\\u001bset\"",
6049 ),
6050 "resets_at",
6051 );
6052 }
6053
6054 #[test]
6055 fn custom_rejects_a_label_outside_1_to_64_chars() {
6056 let long = "l".repeat(65);
6057 assert_custom_rejected(
6058 &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
6059 "metric label",
6060 );
6061 assert_custom_rejected(
6062 &custom_with(r#"label = "Tier""#, r#"label = """#),
6063 "text label \"\" must be 1 to 64 characters",
6064 );
6065 }
6066
6067 #[test]
6068 fn custom_rejects_window_secs_under_60() {
6069 assert_custom_rejected(
6070 &custom_with("window_secs = 3600", "window_secs = 59"),
6071 "window_secs must be at least 60",
6072 );
6073 }
6074
6075 #[test]
6076 fn custom_rejects_duplicate_metric_and_text_labels() {
6077 let metric_twice = custom_with(
6078 "window_secs = 3600\n",
6079 "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
6080 );
6081 assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
6082 let text_twice =
6083 format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
6084 assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
6085 }
6086
6087 #[test]
6088 fn custom_metric_and_text_parse_currency() {
6089 let toml = custom_with(
6090 "window_secs = 3600\n",
6091 "window_secs = 3600\ncurrency = \"USD\"\n",
6092 )
6093 .replace(
6094 "value = \"/tier\"\n",
6095 "value = \"/tier\"\ncurrency = \"EUR\"\n",
6096 );
6097 let config = Config::load_from(write_toml(&toml).path()).unwrap();
6098 assert_eq!(config.custom[0].metrics[0].currency.as_deref(), Some("USD"));
6099 assert_eq!(config.custom[0].texts[0].currency.as_deref(), Some("EUR"));
6100 }
6101
6102 #[test]
6103 fn custom_rejects_an_invalid_currency_code() {
6104 let toml = custom_with(
6105 "window_secs = 3600\n",
6106 "window_secs = 3600\ncurrency = \"usd\"\n",
6107 );
6108 assert_custom_rejected(
6109 &toml,
6110 "metric \"Requests\" currency \"usd\" must be a 3-letter uppercase code",
6111 );
6112
6113 let toml = custom_with(
6114 "value = \"/tier\"\n",
6115 "value = \"/tier\"\ncurrency = \"USDT\"\n",
6116 );
6117 assert_custom_rejected(
6118 &toml,
6119 "text \"Tier\" currency \"USDT\" must be a 3-letter uppercase code",
6120 );
6121 }
6122
6123 #[test]
6124 fn custom_rejects_a_metric_mixing_currency_with_percent() {
6125 let toml = r#"
6126 [[custom]]
6127 id = "bare"
6128 short_name = "bre"
6129 url = "https://example.test/u"
6130 [[custom.metrics]]
6131 label = "Quota"
6132 percent = "/pct"
6133 currency = "USD"
6134 "#;
6135 assert_custom_rejected(
6136 toml,
6137 "metric \"Quota\" cannot combine `currency` with `percent`",
6138 );
6139 }
6140
6141 #[test]
6142 fn enabled_custom_and_custom_by_id_select_entries() {
6143 let two = format!(
6144 "{}{}",
6145 CUSTOM_BLOCK,
6146 custom_with(r#"id = "mytool""#, r#"id = "off""#)
6147 .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
6148 .replace("enabled = true", "enabled = false")
6149 );
6150 let config = Config::load_from(write_toml(&two).path()).unwrap();
6151 let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
6152 assert_eq!(enabled, ["mytool"]);
6153 assert_eq!(
6154 config.custom_by_id("off").map(|c| c.name.as_str()),
6155 Some("My Tool")
6156 );
6157 assert!(config.custom_by_id("nope").is_none());
6158 }
6159
6160 #[cfg(unix)]
6161 #[test]
6162 fn has_inline_secrets_sees_a_custom_inline_key() {
6163 let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
6164 assert!(!without.has_inline_secrets());
6165 let with: Config = toml::from_str(&custom_with(
6166 r#"api_key_env = "MYTOOL_API_KEY""#,
6167 "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
6168 ))
6169 .unwrap();
6170 assert!(with.has_inline_secrets());
6171 }
6172
6173 #[test]
6174 fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
6175 let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
6176 let mut spec = CustomProviderConfig {
6177 id: "mytool".into(),
6178 api_key_env: var.into(),
6179 api_key: Some("sk-inline-secret".into()),
6180 ..CustomProviderConfig::default()
6181 };
6182 unsafe { std::env::set_var(var, "sk-env-secret") };
6183 let from_env = spec.resolve_api_key();
6184 unsafe { std::env::remove_var(var) };
6185 assert_eq!(from_env.unwrap(), "sk-env-secret");
6186
6187 assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
6188
6189 spec.api_key = Some(String::new());
6190 let err = spec.resolve_api_key().unwrap_err();
6191 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
6192 let msg = err.to_string();
6193 assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
6194 assert!(msg.contains(var), "{msg}");
6195 assert!(!msg.contains("secret"), "{msg}");
6196
6197 spec.api_key_env = String::new();
6198 let msg = spec.resolve_api_key().unwrap_err().to_string();
6199 assert!(msg.contains("set `api_key`"), "{msg}");
6200 }
6201
6202 #[test]
6203 fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
6204 let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
6205 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
6206 let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
6207 Config::load_from(file.path()).unwrap();
6208 assert!(
6209 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
6210 "a custom provider's env var must be scrubbed from subprocesses"
6211 );
6212 }
6213
6214 #[test]
6215 fn loading_a_config_registers_account_and_renamed_env_vars_for_scrubbing() {
6216 let account = "AI_USAGEBAR_ACCOUNT_SCRUB_TEST_4C2E";
6217 let renamed = "AI_USAGEBAR_RENAMED_SCRUB_TEST_7A3F";
6218 let before = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6219 assert!(!before.contains(&account));
6220 assert!(!before.contains(&renamed));
6221 let file = write_toml(&format!(
6222 "[zai]\napi_key_env = \"{renamed}\"\n\
6223 [[deepseek.accounts]]\nlabel = \"work\"\napi_key_env = \"{account}\"\n"
6224 ));
6225 Config::load_from(file.path()).unwrap();
6226 let after = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6227 assert!(after.contains(&account), "a named account's key variable");
6228 assert!(after.contains(&renamed), "a renamed api_key_env");
6229 }
6230
6231 #[test]
6232 fn loading_a_config_registers_openrouter_management_env_vars_for_scrubbing() {
6233 let renamed = "AI_USAGEBAR_MGMT_RENAMED_SCRUB_TEST_5D1B";
6234 let account = "AI_USAGEBAR_MGMT_ACCOUNT_SCRUB_TEST_8E6C";
6235 let before = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6236 assert!(!before.contains(&renamed));
6237 assert!(!before.contains(&account));
6238 let file = write_toml(&format!(
6239 "[openrouter]\nmanagement_api_key_env = \"{renamed}\"\n\
6240 [[openrouter.accounts]]\nlabel = \"work\"\napi_key_env = \"OR_WORK_KEY\"\n\
6241 management_api_key_env = \"{account}\"\n"
6242 ));
6243 Config::load_from(file.path()).unwrap();
6244 let after = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6245 assert!(after.contains(&renamed), "a renamed management_api_key_env");
6246 assert!(
6247 after.contains(&account),
6248 "an account's management_api_key_env"
6249 );
6250 }
6251
6252 #[test]
6257 fn loading_a_config_resolves_and_scrubs_a_renamed_cline_env_var() {
6258 let renamed = "AI_USAGEBAR_CLINE_RENAMED_SCRUB_TEST_2F8A";
6259 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&renamed));
6260 let file = write_toml(&format!("[cline]\napi_key_env = \"{renamed}\"\n"));
6261 let config = Config::load_from(file.path()).unwrap();
6262 assert_eq!(config.api_key_env_for(VendorId::Cline), renamed);
6263 assert!(
6264 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&renamed),
6265 "a renamed Cline key variable must be scrubbed from subprocesses"
6266 );
6267 }
6268
6269 #[test]
6274 fn every_config_section_parses_to_its_vendors_enabled_switch() {
6275 for vendor in VendorId::all() {
6276 let text = format!(
6277 "[{}]
6278enabled = true
6279",
6280 vendor.config_section()
6281 );
6282 let config: Config = toml::from_str(&text)
6283 .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
6284 assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
6285 let others = VendorId::all()
6286 .iter()
6287 .filter(|other| *other != vendor && config.is_enabled(**other))
6288 .count();
6289 assert_eq!(
6290 others,
6291 Config::default().enabled_vendors().len()
6292 - usize::from(Config::default().is_enabled(*vendor)),
6293 "[{}] enabled a different vendor",
6294 vendor.config_section()
6295 );
6296 }
6297 }
6298
6299 #[test]
6300 fn tray_section_parses_and_defaults_to_notify() {
6301 let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
6302 let config = Config::load_from(file.path()).unwrap();
6303 assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
6304 assert_eq!(config.tray.updates(), UpdateMode::Auto);
6305
6306 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
6307 assert_eq!(empty.tray, TrayConfig::default());
6308 assert_eq!(empty.tray.updates(), UpdateMode::Notify);
6309 assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
6310 assert_eq!(UpdateMode::parse("weekly"), None);
6311 assert_eq!(UpdateMode::Auto.as_str(), "auto");
6312 }
6313
6314 #[test]
6315 fn tray_ignores_removed_menu_bar_keys_for_back_compatibility() {
6316 let legacy = write_toml(
6317 "[tray]\nmenu_bar_show_all = false\nmenu_bar_hide_value = true\nmenu_bar_names = \"short\"\nmenu_bar_provider = \"anthropic\"\nmenu_bar_window = \"weekly\"\n",
6318 );
6319 let config = Config::load_from(legacy.path()).unwrap();
6320 assert_eq!(config.tray, TrayConfig::default());
6321 }
6322
6323 #[test]
6324 fn tray_section_rejects_a_misspelled_mode() {
6325 let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
6326 assert!(Config::load_from(file.path()).is_err());
6327 }
6328
6329 #[test]
6330 fn tray_refresh_minutes_defaults_to_five_and_parses() {
6331 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
6332 assert_eq!(empty.tray.refresh_minutes, None);
6333 assert_eq!(empty.tray.refresh_minutes(), 5);
6334
6335 let file = write_toml("[tray]\nrefresh_minutes = 10\n");
6336 let config = Config::load_from(file.path()).unwrap();
6337 assert_eq!(config.tray.refresh_minutes(), 10);
6338 }
6339
6340 #[test]
6341 fn tray_refresh_minutes_rejects_values_outside_the_menu() {
6342 for minutes in ["3", "0", "120"] {
6343 let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
6344 let error = Config::load_from(file.path()).unwrap_err().to_string();
6345 assert!(error.contains("[tray] refresh_minutes"), "{error}");
6346 assert!(error.contains("1, 5, 10, 15, 30 or 60"), "{error}");
6347 }
6348 }
6349
6350 #[test]
6351 fn notifications_default_on_at_97_and_parse_overrides() {
6352 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
6353 assert!(empty.notifications.enabled);
6354 assert_eq!(empty.notifications.threshold, 97);
6355
6356 let file = write_toml("[notifications]\nenabled = false\nthreshold = 100\n");
6357 let config = Config::load_from(file.path()).unwrap();
6358 assert!(!config.notifications.enabled);
6359 assert_eq!(config.notifications.threshold, 100);
6360 }
6361
6362 #[test]
6363 fn notifications_threshold_rejects_values_outside_1_to_100() {
6364 for threshold in ["0", "101", "255"] {
6365 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
6366 let error = Config::load_from(file.path()).unwrap_err().to_string();
6367 assert!(
6368 error.contains("[notifications] threshold must be between 1 and 100"),
6369 "threshold {threshold}: {error}"
6370 );
6371 }
6372 for threshold in ["1", "50", "100"] {
6374 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
6375 let config = Config::load_from(file.path()).unwrap();
6376 assert_eq!(config.notifications.threshold.to_string(), threshold);
6377 }
6378 }
6379
6380 #[test]
6381 fn notification_preferences_round_trip_without_changing_other_sections() {
6382 let dir = tempfile::tempdir().unwrap();
6383 let path = dir.path().join("config.toml");
6384 std::fs::write(&path, "# keep this\n[tray]\nrefresh_minutes = 10\n").unwrap();
6385 set_notification_value(&path, "enabled", false.into()).unwrap();
6386 set_notification_value(&path, "threshold", 85i64.into()).unwrap();
6387 let text = std::fs::read_to_string(&path).unwrap();
6388 assert!(text.contains("# keep this\n[tray]\nrefresh_minutes = 10"));
6389 let config = Config::load_from(&path).unwrap();
6390 assert!(!config.notifications.enabled);
6391 assert_eq!(config.notifications.threshold, 85);
6392 assert!(set_notification_value(&path, "threshold", 101i64.into()).is_err());
6393 assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
6394 }
6395
6396 #[test]
6401 fn vendor_enabled_round_trips_and_rejects_unknown_slugs() {
6402 let dir = tempfile::tempdir().unwrap();
6403 let path = dir.path().join("config.toml");
6404 std::fs::write(&path, "# keep\n[zai]\nenabled = true # mine\n").unwrap();
6405
6406 set_vendor_enabled(&path, "zai", false).unwrap();
6407 let text = std::fs::read_to_string(&path).unwrap();
6408 assert!(text.starts_with("# keep\n"), "{text}");
6409 assert!(text.contains("enabled = false # mine"), "{text}");
6410 assert!(!config_enabled(&path, VendorId::Zai));
6411
6412 set_vendor_enabled(&path, "grok", true).unwrap();
6413 let config = Config::load_from(&path).unwrap();
6414 assert!(config.is_enabled(VendorId::Grok));
6415 assert!(
6416 !config.is_enabled(VendorId::Deepseek),
6417 "only the named vendor moves; untouched opt-in vendors stay off"
6418 );
6419 assert!(
6420 config.is_enabled(VendorId::Openai),
6421 "an untouched default-on vendor is not switched off either"
6422 );
6423
6424 for bad in ["", "custom", "not-a-vendor", "anthropic "] {
6425 let error = set_vendor_enabled(&path, bad, true)
6426 .unwrap_err()
6427 .to_string();
6428 assert!(error.contains("unknown provider"), "{bad}: {error}");
6429 }
6430 assert!(
6432 std::fs::read_to_string(&path)
6433 .unwrap()
6434 .contains("enabled = false # mine")
6435 );
6436 }
6437
6438 #[test]
6441 fn set_vendor_enabled_in_doc_targets_the_section_and_stays_idempotent() {
6442 let dir = tempfile::tempdir().unwrap();
6443 let path = dir.path().join("config.toml");
6444 std::fs::write(&path, "[grok]\nenabled = true\napi_key = \"k\"\n").unwrap();
6445 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
6446
6447 set_vendor_enabled(&path, "grok", true).unwrap();
6448
6449 assert_eq!(
6450 std::fs::read_to_string(&path).unwrap(),
6451 "[grok]\nenabled = true\napi_key = \"k\"\n"
6452 );
6453 assert_eq!(
6454 std::fs::metadata(&path).unwrap().modified().unwrap(),
6455 before
6456 );
6457
6458 let mut doc = toml_edit::DocumentMut::new();
6459 set_vendor_enabled_in_doc(&mut doc, "opencode-go", true).unwrap();
6460 assert_eq!(doc.to_string(), "[opencode-go]\nenabled = true\n");
6461 assert!(set_vendor_enabled_in_doc(&mut doc, "mytool", true).is_err());
6462 }
6463
6464 fn config_enabled(path: &std::path::Path, vendor: VendorId) -> bool {
6465 Config::load_from(path).unwrap().is_enabled(vendor)
6466 }
6467
6468 #[test]
6469 fn menu_bar_short_name_defaults_on_and_persists_as_a_bool() {
6470 let dir = tempfile::tempdir().unwrap();
6471 let path = dir.path().join("config.toml");
6472 std::fs::write(&path, "[tray]\n").unwrap();
6473 assert!(Config::load_from(&path).unwrap().tray.menu_bar_short_name());
6474
6475 set_tray_value(&path, "menu_bar_short_name", Some(false.into())).unwrap();
6476 let text = std::fs::read_to_string(&path).unwrap();
6477 assert_eq!(text, "[tray]\nmenu_bar_short_name = false\n");
6478 assert!(!Config::load_from(&path).unwrap().tray.menu_bar_short_name());
6479 }
6480
6481 #[test]
6482 fn set_tray_value_writes_refresh_minutes_as_an_integer() {
6483 let dir = tempfile::tempdir().unwrap();
6484 let path = dir.path().join("config.toml");
6485 std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
6486
6487 set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
6488 let text = std::fs::read_to_string(&path).unwrap();
6489 assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
6490 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
6491
6492 set_tray_value(&path, "refresh_minutes", None).unwrap();
6493 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
6494 }
6495
6496 #[test]
6497 fn set_tray_value_creates_replaces_and_removes_keys() {
6498 let dir = tempfile::tempdir().unwrap();
6499 let path = dir.path().join("config.toml");
6500 std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
6501
6502 set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
6503 let text = std::fs::read_to_string(&path).unwrap();
6504 assert!(text.contains("# primary = \"anthropic\""), "{text}");
6505 assert!(
6506 text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
6507 "{text}"
6508 );
6509
6510 set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
6511 set_tray_value(&path, "updates", Some("off".into())).unwrap();
6512 let config = Config::load_from(&path).unwrap();
6513 assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
6514 assert_eq!(config.tray.updates(), UpdateMode::Off);
6515
6516 set_tray_value(&path, "shortcut", None).unwrap();
6517 let text = std::fs::read_to_string(&path).unwrap();
6518 assert!(!text.contains("shortcut"), "{text}");
6519 assert!(text.contains("updates = \"off\""), "{text}");
6520
6521 let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
6523 set_tray_value(&path, "shortcut", None).unwrap();
6524 assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
6525 }
6526
6527 #[test]
6528 fn set_value_keeps_the_trailing_comment_when_replacing() {
6529 let mut doc: toml_edit::DocumentMut =
6530 "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
6531 set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
6532 assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
6533 }
6534
6535 #[test]
6536 fn enable_vendors_in_creates_a_missing_config() {
6537 let dir = tempfile::TempDir::new().unwrap();
6538 let path = dir.path().join("sub").join("config.toml");
6539
6540 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6541
6542 assert_eq!(
6543 std::fs::read_to_string(&path).unwrap(),
6544 "[grok]
6545enabled = true
6546"
6547 );
6548 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
6549 }
6550
6551 #[test]
6552 fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
6553 let dir = tempfile::TempDir::new().unwrap();
6554 let path = dir.path().join("config.toml");
6555 let original = "# my settings
6556[zai]
6557api_key = \"x\" # keep
6558enabled = false
6559";
6560 std::fs::write(&path, original).unwrap();
6561
6562 enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
6563
6564 let text = std::fs::read_to_string(&path).unwrap();
6565 assert!(
6566 text.starts_with(
6567 "# my settings
6568"
6569 ),
6570 "{text}"
6571 );
6572 assert!(
6573 text.contains(
6574 "api_key = \"x\" # keep
6575"
6576 ),
6577 "{text}"
6578 );
6579 assert!(
6580 text.contains(
6581 "[grok]
6582enabled = true
6583"
6584 ),
6585 "{text}"
6586 );
6587 assert!(
6588 text.contains(
6589 "[opencode-go]
6590enabled = true
6591"
6592 ),
6593 "{text}"
6594 );
6595 let config = Config::load_from(&path).unwrap();
6596 assert!(
6597 !config.is_enabled(VendorId::Zai),
6598 "never widens to false, never flips others"
6599 );
6600 assert!(config.is_enabled(VendorId::Grok));
6601 assert!(config.is_enabled(VendorId::OpenCodeGo));
6602 }
6603
6604 #[test]
6605 fn enable_vendors_in_leaves_an_explicit_false_alone() {
6606 let dir = tempfile::TempDir::new().unwrap();
6607 let path = dir.path().join("config.toml");
6608 let original = "[grok]
6609enabled = false # off
6610api_key = \"k\"
6611";
6612 std::fs::write(&path, original).unwrap();
6613
6614 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6618
6619 assert!(written.is_empty(), "{written:?}");
6620 assert_eq!(
6621 std::fs::read_to_string(&path).unwrap(),
6622 original,
6623 "the file must not be rewritten at all"
6624 );
6625 }
6626
6627 #[test]
6628 fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
6629 let dir = tempfile::TempDir::new().unwrap();
6630 let path = dir.path().join("config.toml");
6631 std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
6632
6633 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6634
6635 assert_eq!(written, vec![VendorId::Grok]);
6636 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
6637 }
6638
6639 #[test]
6640 fn enable_vendors_in_is_textually_idempotent() {
6641 let dir = tempfile::TempDir::new().unwrap();
6642 let path = dir.path().join("config.toml");
6643 let original = "[grok]
6644enabled = true
6645
6646# trailing
6647";
6648 std::fs::write(&path, original).unwrap();
6649 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
6650
6651 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6652
6653 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
6654 assert_eq!(
6655 std::fs::metadata(&path).unwrap().modified().unwrap(),
6656 before,
6657 "an unchanged document must not be rewritten"
6658 );
6659 }
6660
6661 #[test]
6662 fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
6663 let dir = tempfile::TempDir::new().unwrap();
6664 let path = dir.path().join("config.toml");
6665
6666 enable_vendors_in(&path, &[]).unwrap();
6667
6668 assert!(!path.exists());
6669 }
6670}