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_like_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_like_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 pub plan_tier: Option<String>,
1699}
1700
1701impl Default for SyntheticConfig {
1702 fn default() -> Self {
1703 Self {
1704 enabled: false,
1705 api_key_env: "SYNTHETIC_API_KEY".into(),
1706 api_key: None,
1707 plan_tier: None,
1708 }
1709 }
1710}
1711
1712#[derive(Debug, Clone, Deserialize, Serialize)]
1713#[serde(default)]
1714pub struct LyceumConfig {
1715 pub enabled: bool,
1716 pub accounts: Vec<ApiKeyAccount>,
1717 pub show_default_account: bool,
1718 pub api_key_env: String,
1719 pub api_key: Option<String>,
1720}
1721impl Default for LyceumConfig {
1722 fn default() -> Self {
1723 Self {
1724 enabled: false,
1725 accounts: Vec::new(),
1726 show_default_account: true,
1727 api_key_env: "LYCEUM_API_KEY".into(),
1728 api_key: None,
1729 }
1730 }
1731}
1732
1733#[derive(Debug, Clone, Deserialize, Serialize)]
1734#[serde(default)]
1735pub struct MinimaxConfig {
1736 pub enabled: bool,
1737 pub accounts: Vec<ApiKeyAccount>,
1740 pub show_default_account: bool,
1744 pub api_key_env: String,
1745 pub api_key: Option<String>,
1746 pub region: String,
1752}
1753
1754impl Default for MinimaxConfig {
1755 fn default() -> Self {
1756 Self {
1758 enabled: false,
1759 accounts: Vec::new(),
1760 show_default_account: true,
1761 api_key_env: "MINIMAX_API_KEY".to_string(),
1762 api_key: None,
1763 region: "global".to_string(),
1764 }
1765 }
1766}
1767
1768#[derive(Debug, Clone, Deserialize, Serialize)]
1769#[serde(default)]
1770pub struct MoonshotConfig {
1771 pub enabled: bool,
1772 pub accounts: Vec<ApiKeyAccount>,
1775 pub show_default_account: bool,
1779 pub api_key_env: String,
1780 pub api_key: Option<String>,
1781 pub region: String,
1783 pub display_limit: Option<f64>,
1786 pub headline: Headline,
1788}
1789
1790impl Default for MoonshotConfig {
1791 fn default() -> Self {
1792 Self {
1794 enabled: false,
1795 accounts: Vec::new(),
1796 show_default_account: true,
1797 api_key_env: "MOONSHOT_API_KEY".to_string(),
1798 api_key: None,
1799 region: "global".to_string(),
1800 display_limit: None,
1801 headline: Headline::Amount,
1802 }
1803 }
1804}
1805
1806#[derive(Debug, Clone, Deserialize, Serialize)]
1807#[serde(default)]
1808pub struct GrokConfig {
1809 pub enabled: bool,
1810 pub accounts: Vec<ApiKeyAccount>,
1813 pub show_default_account: bool,
1817 pub api_key_env: String,
1819 pub api_key: Option<String>,
1820 pub team_id: Option<String>,
1823 pub display_limit: Option<f64>,
1825 pub headline: Headline,
1827}
1828
1829impl Default for GrokConfig {
1830 fn default() -> Self {
1831 Self {
1833 enabled: false,
1834 accounts: Vec::new(),
1835 show_default_account: true,
1836 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1837 api_key: None,
1838 team_id: None,
1839 display_limit: None,
1840 headline: Headline::Amount,
1841 }
1842 }
1843}
1844
1845#[derive(Debug, Clone, Deserialize, Serialize)]
1853#[serde(default)]
1854pub struct SuperGrokConfig {
1855 pub enabled: bool,
1856 pub grok_binary: PathBuf,
1860 pub auth_path: Option<PathBuf>,
1863 pub config_path: Option<PathBuf>,
1864}
1865
1866impl Default for SuperGrokConfig {
1867 fn default() -> Self {
1868 Self {
1869 enabled: false,
1870 grok_binary: default_grok_binary(),
1871 auth_path: None,
1872 config_path: None,
1873 }
1874 }
1875}
1876
1877#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1883#[serde(default)]
1884pub struct GrokbotConfig {
1885 pub enabled: bool,
1888 pub secrets_path: Option<PathBuf>,
1893}
1894
1895fn default_grok_binary() -> PathBuf {
1896 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1897 let grok_home = std::env::var_os("GROK_HOME")
1898 .filter(|value| !value.is_empty())
1899 .map(PathBuf::from)
1900 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1901 grok_home
1902 .map(|home| home.join("bin").join(executable))
1903 .unwrap_or_else(|| PathBuf::from(executable))
1904}
1905
1906#[derive(Debug, Clone, Deserialize, Serialize)]
1914#[serde(default)]
1915pub struct AntigravityConfig {
1916 pub enabled: bool,
1917 pub agy_binary: Option<PathBuf>,
1920 pub oauth_client_id: Option<String>,
1922 pub oauth_client_secret: Option<String>,
1926 pub ide_language_server: bool,
1931}
1932
1933impl Default for AntigravityConfig {
1934 fn default() -> Self {
1935 Self {
1936 enabled: false,
1937 agy_binary: None,
1938 oauth_client_id: None,
1939 oauth_client_secret: None,
1940 ide_language_server: true,
1941 }
1942 }
1943}
1944
1945#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1955#[serde(default)]
1956pub struct CursorConfig {
1957 pub enabled: bool,
1958 pub db_path: Option<PathBuf>,
1962 pub agent_auth_path: Option<PathBuf>,
1967}
1968
1969#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1978#[serde(default)]
1979pub struct KiroConfig {
1980 pub enabled: bool,
1981 pub db_path: Option<PathBuf>,
1985}
1986
1987#[derive(Debug, Clone, Deserialize, Serialize)]
1988#[serde(default)]
1989pub struct AnthropicApiConfig {
1990 pub enabled: bool,
1991 pub api_key_env: String,
1994 pub api_key: Option<String>,
1995 pub monthly_limit: Option<f64>,
1998}
1999
2000impl Default for AnthropicApiConfig {
2001 fn default() -> Self {
2002 Self {
2004 enabled: false,
2005 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
2006 api_key: None,
2007 monthly_limit: None,
2008 }
2009 }
2010}
2011
2012#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
2022#[serde(default, remote = "Self")]
2023pub struct CustomProviderConfig {
2024 pub id: String,
2026 pub name: String,
2028 pub short_name: String,
2031 pub brand: Option<String>,
2034 pub enabled: bool,
2035 pub url: String,
2037 pub allow_http: bool,
2038 pub api_key_env: String,
2040 pub api_key: Option<String>,
2041 pub auth_header: String,
2043 pub auth_scheme: String,
2045 pub headers: BTreeMap<String, String>,
2047 pub plan: Option<String>,
2049 pub plan_path: Option<String>,
2051 pub cache_ttl_secs: u64,
2053 pub metrics: Vec<CustomMetricSpec>,
2054 pub texts: Vec<CustomTextSpec>,
2055}
2056
2057impl Default for CustomProviderConfig {
2058 fn default() -> Self {
2059 Self {
2060 id: String::new(),
2061 name: String::new(),
2062 short_name: String::new(),
2063 brand: None,
2064 enabled: false,
2065 url: String::new(),
2066 allow_http: false,
2067 api_key_env: String::new(),
2068 api_key: None,
2069 auth_header: "Authorization".to_string(),
2070 auth_scheme: "Bearer".to_string(),
2071 headers: BTreeMap::new(),
2072 plan: None,
2073 plan_path: None,
2074 cache_ttl_secs: 60,
2075 metrics: Vec::new(),
2076 texts: Vec::new(),
2077 }
2078 }
2079}
2080
2081impl<'de> Deserialize<'de> for CustomProviderConfig {
2086 fn deserialize<D: serde::Deserializer<'de>>(
2087 deserializer: D,
2088 ) -> std::result::Result<Self, D::Error> {
2089 let mut this = Self::deserialize(deserializer)?;
2090 if this.name.is_empty() {
2091 this.name = this.id.clone();
2092 }
2093 Ok(this)
2094 }
2095}
2096
2097impl Serialize for CustomProviderConfig {
2098 fn serialize<S: serde::Serializer>(
2099 &self,
2100 serializer: S,
2101 ) -> std::result::Result<S::Ok, S::Error> {
2102 Self::serialize(self, serializer)
2103 }
2104}
2105
2106#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
2110#[serde(default)]
2111pub struct CustomMetricSpec {
2112 pub label: String,
2113 pub used: Option<String>,
2114 pub limit: Option<String>,
2115 pub percent: Option<String>,
2116 pub resets_at: Option<String>,
2118 pub window_secs: Option<u64>,
2120 pub currency: Option<String>,
2122}
2123
2124#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
2126#[serde(default)]
2127pub struct CustomTextSpec {
2128 pub label: String,
2129 pub value: String,
2130 pub currency: Option<String>,
2132}
2133
2134impl CustomProviderConfig {
2135 pub fn section_label(&self) -> String {
2137 format!("[[custom]] id = {:?}", self.id)
2138 }
2139
2140 pub fn resolve_api_key(&self) -> Result<String> {
2143 if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
2144 return Ok(key);
2145 }
2146 let advice = if self.api_key_env.is_empty() {
2147 "set `api_key`, or name an environment variable in `api_key_env`".to_string()
2148 } else {
2149 format!("export {} or set `api_key`", self.api_key_env)
2150 };
2151 Err(AppError::Credentials(format!(
2152 "custom {}: no API key. Either {advice} under {} in {}.",
2153 self.id,
2154 self.section_label(),
2155 config_path_hint()
2156 )))
2157 }
2158
2159 pub fn cache_ttl(&self) -> std::time::Duration {
2160 std::time::Duration::from_secs(self.cache_ttl_secs)
2161 }
2162
2163 fn validate(&self, index: usize) -> Result<()> {
2167 if !is_valid_custom_id(&self.id) {
2168 return Err(AppError::Other(format!(
2169 "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
2170 index + 1,
2171 self.id
2172 )));
2173 }
2174 let section = self.section_label();
2175 let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
2176
2177 if VendorId::all().iter().any(|v| v.slug() == self.id) {
2178 return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
2179 }
2180 let name_len = self.name.chars().count();
2181 if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
2182 return Err(bad(
2183 "name must be 1 to 48 characters without control characters".into(),
2184 ));
2185 }
2186 if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
2187 return Err(bad(format!(
2188 "short_name {:?} must be exactly 3 lowercase ASCII letters",
2189 self.short_name
2190 )));
2191 }
2192 if let Some(brand) = &self.brand
2193 && !VendorId::all().iter().any(|v| v.slug() == brand)
2194 {
2195 return Err(bad(format!(
2196 "brand {brand:?} must name a built-in vendor (it borrows that \
2197 vendor's mark); leave it unset to keep the short_name tag"
2198 )));
2199 }
2200 let url = reqwest::Url::parse(&self.url)
2201 .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
2202 match url.scheme() {
2203 "https" => {}
2204 "http" if self.allow_http => {}
2205 "http" => {
2206 return Err(bad(
2207 "url must use https:// (set allow_http = true to permit http://)".into(),
2208 ));
2209 }
2210 other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
2211 }
2212 if !url.username().is_empty() || url.password().is_some() {
2213 return Err(bad("url must not carry credentials (user:pass@)".into()));
2214 }
2215 if url.host_str().is_none() {
2216 return Err(bad("url has no host".into()));
2217 }
2218 if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
2219 return Err(bad(
2223 "api_key_env is not a valid environment variable name".into()
2224 ));
2225 }
2226 validate_header_name(§ion, "auth_header", &self.auth_header)?;
2227 if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
2228 return Err(bad(
2229 "auth_scheme contains characters that are not valid in an HTTP header".into(),
2230 ));
2231 }
2232 for (name, value) in &self.headers {
2233 validate_header_name(§ion, "headers", name)?;
2234 if name.eq_ignore_ascii_case(&self.auth_header) {
2235 return Err(bad(format!(
2236 "headers must not repeat auth_header {:?}",
2237 self.auth_header
2238 )));
2239 }
2240 if reqwest::header::HeaderValue::from_str(value).is_err() {
2241 return Err(bad(format!(
2242 "header {name:?} has a value that is not valid in an HTTP header"
2243 )));
2244 }
2245 }
2246 if let Some(plan) = &self.plan {
2247 validate_custom_label(§ion, "plan", plan)?;
2248 }
2249 if let Some(pointer) = &self.plan_path {
2250 validate_pointer(§ion, "plan_path", pointer)?;
2251 }
2252 if !(10..=3600).contains(&self.cache_ttl_secs) {
2253 return Err(bad(format!(
2254 "cache_ttl_secs must be between 10 and 3600, got {}",
2255 self.cache_ttl_secs
2256 )));
2257 }
2258 if self.metrics.is_empty() && self.texts.is_empty() {
2259 return Err(bad(
2260 "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
2261 ));
2262 }
2263 let mut metric_labels = HashSet::new();
2264 for metric in &self.metrics {
2265 validate_custom_label(§ion, "metric label", &metric.label)?;
2266 if !metric_labels.insert(metric.label.as_str()) {
2267 return Err(bad(format!("duplicate metric label {:?}", metric.label)));
2268 }
2269 let pair = (metric.used.is_some(), metric.limit.is_some());
2270 let well_formed = if metric.percent.is_some() {
2271 pair == (false, false)
2272 } else {
2273 pair == (true, true)
2274 };
2275 if !well_formed {
2276 return Err(bad(format!(
2277 "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
2278 metric.label
2279 )));
2280 }
2281 if metric.currency.is_some() && metric.percent.is_some() {
2282 return Err(bad(format!(
2283 "metric {:?} cannot combine `currency` with `percent`",
2284 metric.label
2285 )));
2286 }
2287 if let Some(currency) = &metric.currency {
2288 validate_currency(§ion, &format!("metric {:?}", metric.label), currency)?;
2289 }
2290 for (field, pointer) in [
2291 ("used", &metric.used),
2292 ("limit", &metric.limit),
2293 ("percent", &metric.percent),
2294 ("resets_at", &metric.resets_at),
2295 ] {
2296 if let Some(pointer) = pointer {
2297 validate_pointer(§ion, field, pointer)?;
2298 }
2299 }
2300 if let Some(secs) = metric.window_secs
2301 && secs < 60
2302 {
2303 return Err(bad(format!(
2304 "metric {:?} window_secs must be at least 60, got {secs}",
2305 metric.label
2306 )));
2307 }
2308 }
2309 let mut text_labels = HashSet::new();
2310 for text in &self.texts {
2311 validate_custom_label(§ion, "text label", &text.label)?;
2312 if !text_labels.insert(text.label.as_str()) {
2313 return Err(bad(format!("duplicate text label {:?}", text.label)));
2314 }
2315 validate_pointer(§ion, "value", &text.value)?;
2316 if let Some(currency) = &text.currency {
2317 validate_currency(§ion, &format!("text {:?}", text.label), currency)?;
2318 }
2319 }
2320 Ok(())
2321 }
2322}
2323
2324fn is_valid_custom_id(id: &str) -> bool {
2325 let bytes = id.as_bytes();
2326 let Some(&first) = bytes.first() else {
2327 return false;
2328 };
2329 bytes.len() <= 32
2330 && (first.is_ascii_lowercase() || first.is_ascii_digit())
2331 && bytes
2332 .iter()
2333 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
2334}
2335
2336fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
2337 if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
2338 return Err(AppError::Other(format!(
2339 "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
2340 )));
2341 }
2342 Ok(())
2343}
2344
2345fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
2346 let len = label.chars().count();
2347 if len == 0 || len > 64 || label.chars().any(char::is_control) {
2348 return Err(AppError::Other(format!(
2349 "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
2350 )));
2351 }
2352 Ok(())
2353}
2354
2355fn validate_currency(section: &str, context: &str, currency: &str) -> Result<()> {
2356 if currency.len() != 3 || !currency.bytes().all(|b| b.is_ascii_uppercase()) {
2357 return Err(AppError::Other(format!(
2358 "{section}: {context} currency {currency:?} must be a 3-letter uppercase code (e.g. \"USD\")"
2359 )));
2360 }
2361 Ok(())
2362}
2363
2364fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
2365 if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
2366 return Err(AppError::Other(format!(
2367 "{section}: {field} {name:?} is not a valid HTTP header name"
2368 )));
2369 }
2370 Ok(())
2371}
2372
2373pub fn resolve_api_key(
2376 vendor_label: &str,
2377 env_var_name: &str,
2378 inline: Option<&str>,
2379) -> crate::error::Result<String> {
2380 let section = match vendor_label {
2381 "OpenCode Go" => "[opencode-go]".to_string(),
2382 _ => format!("[{}]", vendor_label.to_lowercase()),
2383 };
2384 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
2385}
2386
2387pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
2391 optional_api_key_with(env_var_name, inline, |name| std::env::var(name).ok())
2392}
2393
2394fn optional_api_key_with(
2395 env_var_name: &str,
2396 inline: Option<&str>,
2397 environment: impl FnOnce(&str) -> Option<String>,
2398) -> Option<String> {
2399 if is_valid_env_var_name(env_var_name)
2400 && let Some(v) = environment(env_var_name)
2401 && !v.is_empty()
2402 {
2403 return Some(v);
2404 }
2405 inline.filter(|v| !v.is_empty()).map(str::to_string)
2406}
2407
2408fn resolve_api_key_in_section(
2409 vendor_label: &str,
2410 section: &str,
2411 env_var_name: &str,
2412 inline: Option<&str>,
2413) -> crate::error::Result<String> {
2414 if let Some(key) = optional_api_key(env_var_name, inline) {
2415 return Ok(key);
2416 }
2417 let valid_env_name = is_valid_env_var_name(env_var_name);
2418 let advice = if valid_env_name {
2419 "set an API key in a valid environment variable or set `api_key`"
2420 } else {
2421 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
2422 };
2423 Err(crate::error::AppError::Credentials(format!(
2424 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
2425 config_path_hint()
2426 )))
2427}
2428
2429pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
2430 let mut chars = name.chars();
2431 let Some(first) = chars.next() else {
2432 return false;
2433 };
2434 (first.is_ascii_alphabetic() || first == '_')
2435 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2436}
2437
2438impl Config {
2439 pub fn load() -> Result<Self> {
2442 let Some(path) = resolved_path() else {
2443 return Ok(Self::default());
2444 };
2445 Self::load_from(&path)
2446 }
2447
2448 pub fn load_from(path: &std::path::Path) -> Result<Self> {
2449 match std::fs::read_to_string(path) {
2450 Ok(s) => {
2451 let mut config: Self = toml::from_str(&s).map_err(|mut e| {
2452 let span = e.span();
2453 e.set_input(None);
2456 let summary = toml_error_summary(&s, span, &e.to_string());
2457 AppError::Other(format!("config.toml: {summary}"))
2458 })?;
2459 config.expand_paths();
2463 config.validate()?;
2464 #[cfg(unix)]
2465 config.protect_inline_secrets(path)?;
2466 crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
2470 crate::vendor::register_secret_env_vars(&config.provider_secret_env_vars());
2471 Ok(config)
2472 }
2473 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
2474 Err(e) => Err(AppError::io_at(path, e)),
2475 }
2476 }
2477
2478 fn expand_paths(&mut self) {
2479 expand_tilde_opt(&mut self.context.projects_path);
2480 expand_tilde_opt(&mut self.anthropic.credentials_path);
2481 expand_tilde_opt(&mut self.anthropic.accounts_dir);
2482 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
2483 expand_tilde_opt(&mut self.anthropic.desktop_prepare_binary);
2484 expand_tilde_opt(&mut self.openai.codex_auth_path);
2485 expand_tilde_opt(&mut self.antigravity.agy_binary);
2486 expand_tilde_opt(&mut self.cursor.db_path);
2487 expand_tilde_opt(&mut self.cursor.agent_auth_path);
2488 expand_tilde_opt(&mut self.kiro.db_path);
2489 expand_tilde_opt(&mut self.kimi.credentials_path);
2490 expand_tilde_opt(&mut self.grokbot.secrets_path);
2491 expand_tilde_opt(&mut self.modelstudio.config_dir);
2492 expand_tilde_opt(&mut self.devin.credentials_path);
2493 expand_tilde_opt(&mut self.muse.credentials_path);
2494 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
2495 expand_tilde_opt(&mut self.supergrok.auth_path);
2496 expand_tilde_opt(&mut self.supergrok.config_path);
2497 expand_tilde_opt(&mut self.copilot.gh_binary);
2498 if let Some(paths) = &mut self.cline.auth_paths {
2499 for path in paths {
2500 *path = expand_tilde(path);
2501 }
2502 }
2503 for account in &mut self.anthropic.accounts {
2504 account.credentials_path = expand_tilde(&account.credentials_path);
2505 }
2506 for account in &mut self.openai.accounts {
2507 account.codex_auth_path = expand_tilde(&account.codex_auth_path);
2508 }
2509 for account in &mut self.devin.accounts {
2510 account.credentials_path = expand_tilde(&account.credentials_path);
2511 }
2512 if let Some(paths) = &mut self.commandcode.auth_paths {
2513 for path in paths {
2514 *path = expand_tilde(path);
2515 }
2516 }
2517 if let Some(paths) = &mut self.opencode_go.auth_paths {
2518 for path in paths {
2519 *path = expand_tilde(path);
2520 }
2521 }
2522 expand_tilde_opt(&mut self.droid.cli_path);
2523 }
2524
2525 #[cfg(unix)]
2529 fn has_inline_secrets(&self) -> bool {
2530 [
2531 self.zai.api_key.as_deref(),
2532 self.openrouter.api_key.as_deref(),
2533 self.deepseek.api_key.as_deref(),
2534 self.deepinfra.api_key.as_deref(),
2535 self.kimi.api_key.as_deref(),
2536 self.kilo.api_key.as_deref(),
2537 self.novita.api_key.as_deref(),
2538 self.synthetic.api_key.as_deref(),
2539 self.minimax.api_key.as_deref(),
2540 self.moonshot.api_key.as_deref(),
2541 self.grok.api_key.as_deref(),
2542 self.anthropic_api.api_key.as_deref(),
2543 self.opencode_go.api_key.as_deref(),
2544 self.ollama.api_key.as_deref(),
2545 self.orcarouter.api_key.as_deref(),
2546 self.lyceum.api_key.as_deref(),
2547 self.antigravity.oauth_client_secret.as_deref(),
2548 self.cline.api_key.as_deref(),
2549 ]
2550 .into_iter()
2551 .chain(
2552 Self::API_KEY_ACCOUNT_VENDORS
2553 .into_iter()
2554 .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2555 .map(|account| account.api_key.as_deref()),
2556 )
2557 .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
2558 .any(|key| key.is_some_and(|key| !key.is_empty()))
2559 }
2560
2561 fn custom_secret_env_vars(&self) -> Vec<String> {
2562 self.custom
2563 .iter()
2564 .filter(|c| !c.api_key_env.is_empty())
2565 .map(|c| c.api_key_env.clone())
2566 .collect()
2567 }
2568
2569 fn provider_secret_env_vars(&self) -> Vec<String> {
2575 let renamed = VendorId::all().iter().map(|&id| self.api_key_env_for(id));
2576 let management = std::iter::once(self.openrouter.management_api_key_env.as_str());
2577 let accounts = Self::API_KEY_ACCOUNT_VENDORS
2578 .into_iter()
2579 .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2580 .flat_map(|account| [&account.api_key_env, &account.management_api_key_env])
2581 .filter_map(|name| name.as_deref());
2582 renamed
2583 .chain(management)
2584 .chain(accounts)
2585 .map(str::to_string)
2586 .collect()
2587 }
2588
2589 pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
2591 self.custom.iter().filter(|c| c.enabled)
2592 }
2593
2594 pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
2596 self.custom.iter().find(|c| c.id == id)
2597 }
2598
2599 #[cfg(unix)]
2600 fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
2601 if !self.has_inline_secrets() {
2602 return Ok(());
2603 }
2604
2605 let metadata = std::fs::metadata(path).map_err(|_| {
2606 AppError::Credentials(format!(
2607 "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
2608 path.display()
2609 ))
2610 })?;
2611 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
2612 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
2613 AppError::Credentials(format!(
2614 "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",
2615 path.display()
2616 ))
2617 })?;
2618 }
2619 Ok(())
2620 }
2621
2622 pub fn is_enabled(&self, id: VendorId) -> bool {
2623 match id {
2624 VendorId::Anthropic => self.anthropic.enabled,
2625 VendorId::AnthropicApi => self.anthropic_api.enabled,
2626 VendorId::Openai => self.openai.enabled,
2627 VendorId::Copilot => self.copilot.enabled,
2628 VendorId::Zai => self.zai.enabled,
2629 VendorId::Openrouter => self.openrouter.enabled,
2630 VendorId::Deepseek => self.deepseek.enabled,
2631 VendorId::Deepinfra => self.deepinfra.enabled,
2632 VendorId::Kimi => self.kimi.enabled,
2633 VendorId::Kilo => self.kilo.enabled,
2634 VendorId::Novita => self.novita.enabled,
2635 VendorId::Synthetic => self.synthetic.enabled,
2636 VendorId::Moonshot => self.moonshot.enabled,
2637 VendorId::Grok => self.grok.enabled,
2638 VendorId::Supergrok => self.supergrok.enabled,
2639 VendorId::Grokbot => self.grokbot.enabled,
2640 VendorId::Antigravity => self.antigravity.enabled,
2641 VendorId::Cursor => self.cursor.enabled,
2642 VendorId::Minimax => self.minimax.enabled,
2643 VendorId::Kiro => self.kiro.enabled,
2644 VendorId::NousResearch => self.nous.enabled,
2645 VendorId::Cline => self.cline.enabled,
2646 VendorId::OpenCodeGo => self.opencode_go.enabled,
2647 VendorId::CommandCode => self.commandcode.enabled,
2648 VendorId::Ollama => self.ollama.enabled,
2649 VendorId::OrcaRouter => self.orcarouter.enabled,
2650 VendorId::ModelStudio => self.modelstudio.enabled,
2651 VendorId::Lyceum => self.lyceum.enabled,
2652 VendorId::Devin => self.devin.enabled,
2653 VendorId::Droid => self.droid.enabled,
2654 VendorId::MuseCode => self.muse.enabled,
2655 }
2656 }
2657
2658 pub fn api_key_env_for(&self, id: VendorId) -> &str {
2665 match id {
2666 VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
2667 VendorId::Zai => &self.zai.api_key_env,
2668 VendorId::Openrouter => &self.openrouter.api_key_env,
2669 VendorId::Deepseek => &self.deepseek.api_key_env,
2670 VendorId::Deepinfra => &self.deepinfra.api_key_env,
2671 VendorId::Kimi => &self.kimi.api_key_env,
2672 VendorId::Kilo => &self.kilo.api_key_env,
2673 VendorId::Novita => &self.novita.api_key_env,
2674 VendorId::Synthetic => &self.synthetic.api_key_env,
2675 VendorId::Moonshot => &self.moonshot.api_key_env,
2676 VendorId::Grok => &self.grok.api_key_env,
2677 VendorId::Minimax => &self.minimax.api_key_env,
2678 VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
2679 VendorId::Ollama => &self.ollama.api_key_env,
2680 VendorId::OrcaRouter => &self.orcarouter.api_key_env,
2681 VendorId::Lyceum => &self.lyceum.api_key_env,
2682 VendorId::Cline => &self.cline.api_key_env,
2683 VendorId::Anthropic
2686 | VendorId::Openai
2687 | VendorId::Copilot
2688 | VendorId::Supergrok
2689 | VendorId::Grokbot
2690 | VendorId::Antigravity
2691 | VendorId::Cursor
2692 | VendorId::Kiro
2693 | VendorId::NousResearch
2694 | VendorId::CommandCode
2695 | VendorId::ModelStudio
2696 | VendorId::Devin
2697 | VendorId::MuseCode
2698 | VendorId::Droid => id.api_key_env(),
2699 }
2700 }
2701
2702 pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
2706 let raw = match id {
2707 VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
2708 VendorId::Zai => self.zai.api_key.as_deref(),
2709 VendorId::Openrouter => self.openrouter.api_key.as_deref(),
2710 VendorId::Deepseek => self.deepseek.api_key.as_deref(),
2711 VendorId::Deepinfra => self.deepinfra.api_key.as_deref(),
2712 VendorId::Kimi => self.kimi.api_key.as_deref(),
2713 VendorId::Kilo => self.kilo.api_key.as_deref(),
2714 VendorId::Novita => self.novita.api_key.as_deref(),
2715 VendorId::Synthetic => self.synthetic.api_key.as_deref(),
2716 VendorId::Moonshot => self.moonshot.api_key.as_deref(),
2717 VendorId::Grok => self.grok.api_key.as_deref(),
2718 VendorId::Minimax => self.minimax.api_key.as_deref(),
2719 VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
2720 VendorId::Ollama => self.ollama.api_key.as_deref(),
2721 VendorId::OrcaRouter => self.orcarouter.api_key.as_deref(),
2722 VendorId::Lyceum => self.lyceum.api_key.as_deref(),
2723 VendorId::Cline => self.cline.api_key.as_deref(),
2724 VendorId::Anthropic
2725 | VendorId::Openai
2726 | VendorId::Copilot
2727 | VendorId::Supergrok
2728 | VendorId::Grokbot
2729 | VendorId::Antigravity
2730 | VendorId::Cursor
2731 | VendorId::Kiro
2732 | VendorId::NousResearch
2733 | VendorId::CommandCode
2734 | VendorId::ModelStudio
2735 | VendorId::Devin
2736 | VendorId::MuseCode
2737 | VendorId::Droid => None,
2738 };
2739 raw.filter(|key| !key.is_empty())
2740 }
2741
2742 pub const API_KEY_ACCOUNT_VENDORS: [VendorId; 11] = [
2746 VendorId::Zai,
2747 VendorId::Openrouter,
2748 VendorId::Deepseek,
2749 VendorId::Deepinfra,
2750 VendorId::Kilo,
2751 VendorId::Novita,
2752 VendorId::Moonshot,
2753 VendorId::Grok,
2754 VendorId::Minimax,
2755 VendorId::OrcaRouter,
2756 VendorId::Lyceum,
2757 ];
2758
2759 pub fn api_key_accounts(&self, id: VendorId) -> Option<&[ApiKeyAccount]> {
2762 match id {
2763 VendorId::Zai => Some(&self.zai.accounts),
2764 VendorId::Openrouter => Some(&self.openrouter.accounts),
2765 VendorId::Deepseek => Some(&self.deepseek.accounts),
2766 VendorId::Deepinfra => Some(&self.deepinfra.accounts),
2767 VendorId::Kilo => Some(&self.kilo.accounts),
2768 VendorId::Novita => Some(&self.novita.accounts),
2769 VendorId::Moonshot => Some(&self.moonshot.accounts),
2770 VendorId::Grok => Some(&self.grok.accounts),
2771 VendorId::Minimax => Some(&self.minimax.accounts),
2772 VendorId::OrcaRouter => Some(&self.orcarouter.accounts),
2773 VendorId::Lyceum => Some(&self.lyceum.accounts),
2774 _ => None,
2775 }
2776 }
2777
2778 pub fn show_default_api_key_account(&self, id: VendorId) -> bool {
2781 match id {
2782 VendorId::Zai => self.zai.show_default_account,
2783 VendorId::Openrouter => self.openrouter.show_default_account,
2784 VendorId::Deepseek => self.deepseek.show_default_account,
2785 VendorId::Deepinfra => self.deepinfra.show_default_account,
2786 VendorId::Kilo => self.kilo.show_default_account,
2787 VendorId::Novita => self.novita.show_default_account,
2788 VendorId::Moonshot => self.moonshot.show_default_account,
2789 VendorId::Grok => self.grok.show_default_account,
2790 VendorId::Minimax => self.minimax.show_default_account,
2791 VendorId::OrcaRouter => self.orcarouter.show_default_account,
2792 VendorId::Lyceum => self.lyceum.show_default_account,
2793 _ => true,
2794 }
2795 }
2796
2797 pub fn resolve_account_api_key_for(&self, id: VendorId, label: Option<&str>) -> Result<String> {
2803 let name = match id {
2806 VendorId::Zai => "Zai",
2807 other => other.display_name(),
2808 };
2809 let Some(label) = label else {
2810 return resolve_api_key(name, self.api_key_env_for(id), self.inline_api_key(id));
2811 };
2812 let slug = id.config_section();
2813 let account = api_key_account(slug, self.api_key_accounts(id).unwrap_or(&[]), label)?;
2814 resolve_api_key_in_section(
2816 &format!("{name} account {label:?}"),
2817 &format!("[[{slug}.accounts]]"),
2818 account.api_key_env.as_deref().unwrap_or(""),
2819 account.api_key.as_deref(),
2820 )
2821 }
2822
2823 pub fn openrouter_management_key(&self, label: Option<&str>) -> Option<String> {
2830 let env_name = match label {
2831 Some(label) => api_key_account("openrouter", &self.openrouter.accounts, label)
2832 .ok()?
2833 .management_api_key_env
2834 .as_deref()?,
2835 None => self.openrouter.management_api_key_env.as_str(),
2836 };
2837 optional_api_key(env_name, None)
2838 }
2839
2840 pub fn display_prefs(&self, vendor: VendorId) -> DisplayPrefs {
2845 match vendor {
2846 VendorId::Deepseek => {
2847 DisplayPrefs::balance(self.deepseek.display_limit, self.deepseek.headline)
2848 }
2849 VendorId::Deepinfra => {
2850 DisplayPrefs::balance(self.deepinfra.display_limit, self.deepinfra.headline)
2851 }
2852 VendorId::Kilo => DisplayPrefs::balance(self.kilo.display_limit, self.kilo.headline),
2853 VendorId::Novita => {
2854 DisplayPrefs::balance(self.novita.display_limit, self.novita.headline)
2855 }
2856 VendorId::Moonshot => {
2857 DisplayPrefs::balance(self.moonshot.display_limit, self.moonshot.headline)
2858 }
2859 VendorId::Grok => DisplayPrefs::balance(self.grok.display_limit, self.grok.headline),
2860 VendorId::Openrouter => DisplayPrefs::balance(None, self.openrouter.headline),
2863 VendorId::NousResearch => DisplayPrefs::balance(None, self.nous.headline),
2866 _ => DisplayPrefs::default(),
2867 }
2868 }
2869
2870 pub fn enabled_vendors(&self) -> Vec<VendorId> {
2871 VendorId::all()
2872 .iter()
2873 .copied()
2874 .filter(|id| self.is_enabled(*id))
2875 .collect()
2876 }
2877
2878 pub fn validate(&self) -> Result<()> {
2882 if let Some(minutes) = self.tray.refresh_minutes
2883 && !TRAY_REFRESH_MINUTES.contains(&minutes)
2884 {
2885 return Err(AppError::Other(format!(
2886 "[tray] refresh_minutes must be one of 1, 5, 10, 15, 30 or 60, got {minutes}"
2887 )));
2888 }
2889 if !(1..=100).contains(&self.notifications.threshold) {
2890 return Err(AppError::Other(format!(
2891 "[notifications] threshold must be between 1 and 100, got {}",
2892 self.notifications.threshold
2893 )));
2894 }
2895 if self.context.context_window_tokens == Some(0) {
2896 return Err(AppError::Other(
2897 "[context] context_window_tokens must be greater than zero".into(),
2898 ));
2899 }
2900 for (model, tokens) in &self.context.model_context_window_tokens {
2901 if model.trim().is_empty() {
2902 return Err(AppError::Other(
2903 "[context] model_context_window_tokens keys must not be empty".into(),
2904 ));
2905 }
2906 if *tokens == 0 {
2907 return Err(AppError::Other(format!(
2908 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
2909 )));
2910 }
2911 }
2912 if let Some(limit) = self.anthropic_api.monthly_limit
2913 && (!limit.is_finite() || limit <= 0.0)
2914 {
2915 return Err(AppError::Other(
2916 "[anthropic_api] monthly_limit must be finite and greater than zero; \
2917 remove it to show spend without a limit"
2918 .into(),
2919 ));
2920 }
2921 for (section, limit) in [
2925 ("deepseek", self.deepseek.display_limit),
2926 ("deepinfra", self.deepinfra.display_limit),
2927 ("kilo", self.kilo.display_limit),
2928 ("novita", self.novita.display_limit),
2929 ("moonshot", self.moonshot.display_limit),
2930 ("grok", self.grok.display_limit),
2931 ] {
2932 if let Some(limit) = limit
2933 && (!limit.is_finite() || limit <= 0.0)
2934 {
2935 return Err(AppError::Other(format!(
2936 "[{section}] display_limit must be finite and greater than zero; \
2937 remove it to show the balance without a limit"
2938 )));
2939 }
2940 }
2941 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
2942 && !self.kimi.region.eq_ignore_ascii_case("auto")
2943 {
2944 return Err(AppError::Other(format!(
2945 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
2946 self.kimi.region
2947 )));
2948 }
2949 if !self.minimax.region.eq_ignore_ascii_case("global")
2950 && !self.minimax.region.eq_ignore_ascii_case("cn")
2951 {
2952 return Err(AppError::Other(format!(
2953 "[minimax] region must be \"global\" or \"cn\", got {:?}",
2954 self.minimax.region
2955 )));
2956 }
2957 if self.supergrok.grok_binary.as_os_str().is_empty() {
2958 return Err(AppError::Other(
2959 "[supergrok] grok_binary must not be empty".into(),
2960 ));
2961 }
2962 if self
2963 .antigravity
2964 .agy_binary
2965 .as_ref()
2966 .is_some_and(|path| path.as_os_str().is_empty())
2967 {
2968 return Err(AppError::Other(
2969 "[antigravity] agy_binary must not be empty".into(),
2970 ));
2971 }
2972 let mut labels = HashSet::new();
2973 for account in &self.anthropic.accounts {
2974 validate_account_label(&account.label)?;
2975 if !labels.insert(&account.label) {
2976 return Err(AppError::Credentials(format!(
2977 "duplicate anthropic account label {:?}",
2978 account.label
2979 )));
2980 }
2981 }
2982 let mut openai_labels = HashSet::new();
2983 for account in &self.openai.accounts {
2984 validate_account_label_for("openai", &account.label)?;
2985 if !openai_labels.insert(&account.label) {
2986 return Err(AppError::Credentials(format!(
2987 "duplicate openai account label {:?}",
2988 account.label
2989 )));
2990 }
2991 }
2992 let mut devin_labels = HashSet::new();
2993 for account in &self.devin.accounts {
2994 validate_account_label_for("devin", &account.label)?;
2995 if !devin_labels.insert(&account.label) {
2996 return Err(AppError::Credentials(format!(
2997 "duplicate devin account label {:?}",
2998 account.label
2999 )));
3000 }
3001 }
3002 for id in Self::API_KEY_ACCOUNT_VENDORS {
3006 let slug = id.config_section();
3007 let mut labels = HashSet::new();
3008 for account in self.api_key_accounts(id).unwrap_or(&[]) {
3009 validate_account_label_for(slug, &account.label)?;
3010 if !labels.insert(&account.label) {
3011 return Err(AppError::Credentials(format!(
3012 "duplicate {slug} account label {:?}",
3013 account.label
3014 )));
3015 }
3016 let has_env = account
3017 .api_key_env
3018 .as_deref()
3019 .is_some_and(|name| !name.is_empty());
3020 let has_inline = account
3021 .api_key
3022 .as_deref()
3023 .is_some_and(|key| !key.is_empty());
3024 if !has_env && !has_inline {
3025 return Err(AppError::Credentials(format!(
3026 "{slug} account {:?} must set api_key_env or api_key",
3027 account.label
3028 )));
3029 }
3030 }
3031 }
3032 self.validate_custom()
3033 }
3034
3035 fn validate_custom(&self) -> Result<()> {
3039 let mut ids = HashSet::new();
3040 let mut short_names: HashSet<&str> =
3041 VendorId::all().iter().map(|v| v.short_name()).collect();
3042 for (index, custom) in self.custom.iter().enumerate() {
3043 custom.validate(index)?;
3044 if !ids.insert(custom.id.as_str()) {
3045 return Err(AppError::Other(format!(
3046 "{}: duplicate id",
3047 custom.section_label()
3048 )));
3049 }
3050 if !short_names.insert(custom.short_name.as_str()) {
3051 return Err(AppError::Other(format!(
3052 "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
3053 custom.section_label(),
3054 custom.short_name
3055 )));
3056 }
3057 }
3058 Ok(())
3059 }
3060}
3061
3062#[cfg(unix)]
3063#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3064enum InlineKeyPermissionDecision {
3065 Ok,
3066 Tighten,
3067}
3068
3069#[cfg(unix)]
3070fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
3071 if mode & 0o077 == 0 {
3072 InlineKeyPermissionDecision::Ok
3073 } else {
3074 InlineKeyPermissionDecision::Tighten
3075 }
3076}
3077
3078pub fn default_path() -> Option<PathBuf> {
3079 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
3080 Some(proj.config_dir().join("config.toml"))
3081}
3082
3083fn legacy_xdg_path() -> Option<PathBuf> {
3088 let home = crate::cache::home_dir().ok()?;
3089 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
3090}
3091
3092pub fn resolved_path() -> Option<PathBuf> {
3103 if let Some(path) = override_path() {
3104 return Some(path);
3105 }
3106 let canonical = default_path();
3107 if let Some(p) = &canonical
3108 && p.exists()
3109 {
3110 return canonical;
3111 }
3112 if let Some(legacy) = legacy_xdg_path()
3113 && legacy.exists()
3114 {
3115 return Some(legacy);
3116 }
3117 canonical
3118}
3119
3120static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
3121
3122pub fn set_override_path(path: &std::path::Path) {
3128 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
3129 *slot = Some(path.to_path_buf());
3130 }
3131}
3132
3133#[doc(hidden)]
3136pub fn clear_override_path() {
3137 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
3138 *slot = None;
3139 }
3140}
3141
3142fn override_path() -> Option<PathBuf> {
3143 PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
3144}
3145
3146#[doc(hidden)]
3152pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
3153 #[cfg(unix)]
3154 {
3155 use std::os::unix::ffi::{OsStrExt, OsStringExt};
3156 let rest = arg.as_bytes().strip_prefix(b"--config=")?;
3157 Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
3158 }
3159 #[cfg(windows)]
3160 {
3161 use std::os::windows::ffi::{OsStrExt, OsStringExt};
3162 const PREFIX: &[u16] = &[
3163 b'-' as u16,
3164 b'-' as u16,
3165 b'c' as u16,
3166 b'o' as u16,
3167 b'n' as u16,
3168 b'f' as u16,
3169 b'i' as u16,
3170 b'g' as u16,
3171 b'=' as u16,
3172 ];
3173 let wide: Vec<u16> = arg.encode_wide().collect();
3174 let rest = wide.strip_prefix(PREFIX)?;
3175 Some(std::ffi::OsString::from_wide(rest).into())
3176 }
3177 #[cfg(not(any(unix, windows)))]
3178 {
3179 Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
3180 }
3181}
3182
3183fn expand_tilde(p: &std::path::Path) -> PathBuf {
3186 let Some(s) = p.to_str() else {
3187 return p.to_path_buf();
3188 };
3189 let rest = if s == "~" {
3190 ""
3191 } else if let Some(r) = s.strip_prefix("~/") {
3192 r
3193 } else {
3194 return p.to_path_buf();
3195 };
3196 match crate::cache::home_dir() {
3197 Ok(home) if rest.is_empty() => home,
3198 Ok(home) => home.join(rest),
3199 Err(_) => p.to_path_buf(),
3200 }
3201}
3202
3203fn expand_tilde_opt(p: &mut Option<PathBuf>) {
3204 if let Some(inner) = p.as_ref() {
3205 *p = Some(expand_tilde(inner));
3206 }
3207}
3208
3209pub fn config_path_hint() -> String {
3214 resolved_path()
3215 .map(|p| p.display().to_string())
3216 .unwrap_or_else(|| "config.toml".to_string())
3217}
3218
3219#[cfg(test)]
3220mod tests {
3221 use super::*;
3222 use std::io::Write;
3223 use tempfile::NamedTempFile;
3224
3225 #[cfg(unix)]
3226 use std::os::unix::fs::{MetadataExt, PermissionsExt};
3227
3228 fn write_toml(s: &str) -> NamedTempFile {
3229 let mut f = NamedTempFile::new().unwrap();
3230 f.write_all(s.as_bytes()).unwrap();
3231 f.flush().unwrap();
3232 f
3233 }
3234
3235 #[test]
3239 fn openai_without_accounts_resolves_the_singular_path() {
3240 let explicit = OpenAiConfig {
3241 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
3242 ..OpenAiConfig::default()
3243 };
3244 assert_eq!(
3245 explicit.resolve_auth_path(None).unwrap(),
3246 PathBuf::from("/tmp/codex/auth.json")
3247 );
3248
3249 let bare = OpenAiConfig::default();
3250 assert_eq!(
3251 bare.resolve_auth_path(None).unwrap(),
3252 crate::openai::creds::default_path().unwrap(),
3253 "no codex_auth_path must still mean ~/.codex/auth.json"
3254 );
3255 }
3256
3257 #[test]
3260 fn openai_named_accounts_resolve_their_own_auth_file() {
3261 let config: Config = toml::from_str(
3262 r#"
3263 [openai]
3264 codex_auth_path = "/tmp/personal/auth.json"
3265 [[openai.accounts]]
3266 label = "work"
3267 codex_auth_path = "/tmp/work/auth.json"
3268 "#,
3269 )
3270 .unwrap();
3271
3272 assert_eq!(
3273 config.openai.resolve_auth_path(Some("work")).unwrap(),
3274 PathBuf::from("/tmp/work/auth.json")
3275 );
3276 assert_eq!(
3277 config.openai.resolve_auth_path(None).unwrap(),
3278 PathBuf::from("/tmp/personal/auth.json")
3279 );
3280 }
3281
3282 #[test]
3285 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
3286 let config = OpenAiConfig {
3287 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
3288 accounts: vec![OpenAiAccount {
3289 label: "work".into(),
3290 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
3291 }],
3292 ..OpenAiConfig::default()
3293 };
3294 let err = config
3295 .resolve_auth_path(Some("nope"))
3296 .unwrap_err()
3297 .to_string();
3298 assert!(err.contains("nope"), "{err}");
3299 assert!(err.contains("[[openai.accounts]]"), "{err}");
3300 }
3301
3302 fn two_codex_accounts() -> OpenAiConfig {
3303 OpenAiConfig {
3304 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
3305 accounts: vec![
3306 OpenAiAccount {
3307 label: "main".into(),
3308 codex_auth_path: PathBuf::from("/tmp/codex-main/auth.json"),
3309 },
3310 OpenAiAccount {
3311 label: "work".into(),
3312 codex_auth_path: PathBuf::from("/tmp/codex-work/auth.json"),
3313 },
3314 ],
3315 ..OpenAiConfig::default()
3316 }
3317 }
3318
3319 #[test]
3320 fn the_active_codex_account_is_read_from_the_default_slot() {
3321 let config = two_codex_accounts();
3322 assert_eq!(
3323 config
3324 .fetch_auth_path_probing("work", Some("work"), |_| false)
3325 .unwrap(),
3326 PathBuf::from("/tmp/codex/auth.json")
3327 );
3328 assert_eq!(
3329 config
3330 .fetch_auth_path_probing("main", Some("work"), |_| false)
3331 .unwrap(),
3332 PathBuf::from("/tmp/codex-main/auth.json")
3333 );
3334 }
3335
3336 #[test]
3337 fn an_active_codex_account_with_its_own_file_keeps_reading_it() {
3338 let config = two_codex_accounts();
3339 assert_eq!(
3340 config
3341 .fetch_auth_path_probing("work", Some("work"), |_| true)
3342 .unwrap(),
3343 PathBuf::from("/tmp/codex-work/auth.json")
3344 );
3345 }
3346
3347 #[test]
3348 fn adding_an_openai_account_keeps_the_rest_of_the_file() {
3349 let mut doc: toml_edit::DocumentMut = "# mine\n[zai]\nenabled = true\n".parse().unwrap();
3350 add_openai_account_to_doc(&mut doc, "work", "~/.codex-work/auth.json").unwrap();
3351 let text = doc.to_string();
3352 assert!(
3353 text.starts_with("# mine\n[zai]\nenabled = true\n"),
3354 "{text}"
3355 );
3356 let parsed: Config = toml::from_str(&text).unwrap();
3357 assert_eq!(parsed.openai.accounts[0].label, "work");
3358 assert!(add_openai_account_to_doc(&mut doc, "work", "x").is_err());
3359 assert!(add_openai_account_to_doc(&mut doc, "../x", "x").is_err());
3360 }
3361
3362 #[test]
3363 fn defaults_enable_only_the_four_core_vendors() {
3364 let c = Config::default();
3365 assert!(c.is_enabled(VendorId::Anthropic));
3366 assert!(c.is_enabled(VendorId::Openai));
3367 assert!(c.is_enabled(VendorId::Zai));
3368 assert!(c.is_enabled(VendorId::Openrouter));
3369 for opt_in in [
3370 VendorId::AnthropicApi,
3371 VendorId::Copilot,
3372 VendorId::Deepseek,
3373 VendorId::Kimi,
3374 VendorId::Kilo,
3375 VendorId::Novita,
3376 VendorId::Moonshot,
3377 VendorId::Grok,
3378 VendorId::Supergrok,
3379 VendorId::Grokbot,
3380 VendorId::Cursor,
3381 VendorId::Minimax,
3382 VendorId::Kiro,
3383 VendorId::CommandCode,
3384 VendorId::OrcaRouter,
3385 VendorId::ModelStudio,
3386 VendorId::Devin,
3387 VendorId::Droid,
3388 ] {
3389 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
3390 }
3391 assert_eq!(c.enabled_vendors().len(), 4);
3392 }
3393
3394 #[test]
3395 fn commandcode_is_opt_in_when_loading_existing_configs() {
3396 let absent: Config = toml::from_str("[openai]\nenabled = true\n").unwrap();
3397 assert!(!absent.is_enabled(VendorId::CommandCode));
3398
3399 let opted_in: Config = toml::from_str("[commandcode]\nenabled = true\n").unwrap();
3400 assert!(opted_in.is_enabled(VendorId::CommandCode));
3401
3402 let opted_out: Config = toml::from_str("[commandcode]\nenabled = false\n").unwrap();
3403 assert!(!opted_out.is_enabled(VendorId::CommandCode));
3404 }
3405
3406 #[test]
3407 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
3408 let config = Config::default();
3409 assert!(!config.is_enabled(VendorId::NousResearch));
3410 assert!(!config.is_enabled(VendorId::OpenCodeGo));
3411 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
3412 assert!(config.opencode_go.api_key.is_none());
3413 assert!(!config.is_enabled(VendorId::Copilot));
3414 }
3415
3416 #[cfg(unix)]
3417 #[test]
3418 fn inline_credentials_are_protected() {
3419 let mut config = Config::default();
3420 config.opencode_go.api_key = Some("<redacted>".to_string());
3421 assert!(config.has_inline_secrets());
3422 }
3423
3424 #[test]
3425 fn antigravity_oauth_client_overrides_parse() {
3426 let config: Config = toml::from_str(
3427 "[antigravity]
3428enabled = true
3429oauth_client_id = \"test-client\"
3430oauth_client_secret = \"test-client-secret\"
3431",
3432 )
3433 .unwrap();
3434 assert!(config.antigravity.enabled);
3435 assert_eq!(
3436 config.antigravity.oauth_client_id.as_deref(),
3437 Some("test-client")
3438 );
3439 assert_eq!(
3440 config.antigravity.oauth_client_secret.as_deref(),
3441 Some("test-client-secret")
3442 );
3443 let bare: Config = toml::from_str(
3444 "[antigravity]
3445enabled = true
3446",
3447 )
3448 .unwrap();
3449 assert!(bare.antigravity.oauth_client_id.is_none());
3450 assert!(bare.antigravity.oauth_client_secret.is_none());
3451 }
3452
3453 #[test]
3456 fn antigravity_ide_language_server_defaults_to_probing() {
3457 assert!(Config::default().antigravity.ide_language_server);
3458 let bare: Config = toml::from_str(
3459 "[antigravity]
3460enabled = true
3461",
3462 )
3463 .unwrap();
3464 assert!(bare.antigravity.ide_language_server);
3465 let opted_out: Config = toml::from_str(
3466 "[antigravity]
3467enabled = true
3468ide_language_server = false
3469",
3470 )
3471 .unwrap();
3472 assert!(!opted_out.antigravity.ide_language_server);
3473 }
3474
3475 #[test]
3476 fn antigravity_agy_binary_is_optional_and_tilde_expanded() {
3477 assert!(Config::default().antigravity.agy_binary.is_none());
3478 let file = write_toml(
3479 r#"
3480 [antigravity]
3481 enabled = true
3482 agy_binary = "~/bin/agy"
3483 "#,
3484 );
3485 let config = Config::load_from(file.path()).unwrap();
3486 assert_eq!(
3487 config.antigravity.agy_binary,
3488 Some(crate::cache::home_dir().unwrap().join("bin/agy"))
3489 );
3490 }
3491
3492 #[test]
3493 fn antigravity_agy_binary_must_not_be_empty() {
3494 let file = write_toml(
3495 r#"
3496 [antigravity]
3497 agy_binary = ""
3498 "#,
3499 );
3500 let error = Config::load_from(file.path()).unwrap_err().to_string();
3501 assert!(error.contains("[antigravity] agy_binary must not be empty"));
3502 }
3503
3504 #[cfg(unix)]
3505 #[test]
3506 fn antigravity_inline_oauth_secret_receives_config_file_protection() {
3507 let mut config = Config::default();
3508 config.antigravity.oauth_client_id = Some("test-client".into());
3509 assert!(!config.has_inline_secrets());
3510 config.antigravity.oauth_client_secret = Some("<redacted>".into());
3511 assert!(config.has_inline_secrets());
3512 }
3513
3514 #[cfg(unix)]
3515 #[test]
3516 fn openrouter_named_inline_keys_receive_config_file_protection() {
3517 let mut config = Config::default();
3518 config.openrouter.accounts.push(ApiKeyAccount {
3519 label: "work".into(),
3520 api_key_env: None,
3521 api_key: Some("<redacted>".into()),
3522 management_api_key_env: None,
3523 });
3524 assert!(config.has_inline_secrets());
3525 }
3526
3527 #[cfg(unix)]
3528 #[test]
3529 fn every_named_inline_api_key_receives_config_file_protection() {
3530 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
3531 let section = vendor.config_section();
3532 let config: Config = toml::from_str(&format!(
3533 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"<redacted>\"\n"
3534 ))
3535 .unwrap();
3536 assert!(config.has_inline_secrets(), "{vendor:?}");
3537 }
3538 }
3539
3540 #[cfg(unix)]
3544 #[test]
3545 fn every_inline_api_key_receives_config_file_protection() {
3546 for &vendor in VendorId::all() {
3547 let section = vendor.config_section();
3548 let config: Config =
3549 toml::from_str(&format!("[{section}]\napi_key = \"<redacted>\"\n")).unwrap();
3550 if config.inline_api_key(vendor).is_some() {
3551 assert!(config.has_inline_secrets(), "{vendor:?}");
3552 }
3553 }
3554 }
3555
3556 #[test]
3557 fn missing_file_uses_defaults() {
3558 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
3559 let c = Config::load_from(path).unwrap();
3560 assert!(c.is_enabled(VendorId::Anthropic));
3561 }
3562
3563 #[test]
3564 fn parses_full_config() {
3565 let f = write_toml(
3566 r#"
3567 [anthropic]
3568 enabled = true
3569
3570 [openai]
3571 enabled = false
3572 admin_key_env = "MY_ADMIN_KEY"
3573
3574 [zai]
3575 enabled = true
3576 api_key_env = "MY_ZAI"
3577 plan_tier = "pro"
3578
3579 [openrouter]
3580 enabled = false
3581 "#,
3582 );
3583 let c = Config::load_from(f.path()).unwrap();
3584 assert!(c.is_enabled(VendorId::Anthropic));
3585 assert!(!c.is_enabled(VendorId::Openai));
3586 assert!(c.is_enabled(VendorId::Zai));
3587 assert!(!c.is_enabled(VendorId::Openrouter));
3588 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
3589 assert_eq!(c.zai.api_key_env, "MY_ZAI");
3590 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
3591 assert!(c.openrouter.accounts.is_empty());
3592 assert!(c.openrouter.show_default_account);
3593 }
3594
3595 #[test]
3596 fn partial_config_falls_back_to_defaults() {
3597 let f = write_toml(
3598 r#"[openai]
3599enabled = false
3600"#,
3601 );
3602 let c = Config::load_from(f.path()).unwrap();
3603 assert!(!c.is_enabled(VendorId::Openai));
3604 assert!(c.is_enabled(VendorId::Anthropic));
3606 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
3607 }
3608
3609 #[test]
3610 fn malformed_toml_returns_error() {
3611 let f = write_toml("this is not = = valid");
3612 assert!(Config::load_from(f.path()).is_err());
3613 }
3614
3615 #[cfg(unix)]
3616 #[test]
3617 fn load_from_tightens_world_readable_config_with_inline_api_key() {
3618 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
3619 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3620
3621 Config::load_from(file.path()).unwrap();
3622
3623 assert_eq!(
3624 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3625 0o600
3626 );
3627 }
3628
3629 #[cfg(unix)]
3630 #[test]
3631 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
3632 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
3633 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3634
3635 Config::load_from(file.path()).unwrap();
3636
3637 assert_eq!(
3638 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3639 0o644
3640 );
3641 }
3642
3643 #[cfg(unix)]
3644 #[test]
3645 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
3646 assert_eq!(
3647 inline_key_permission_decision(0o600),
3648 InlineKeyPermissionDecision::Ok
3649 );
3650 assert_eq!(
3651 inline_key_permission_decision(0o640),
3652 InlineKeyPermissionDecision::Tighten
3653 );
3654 assert_eq!(
3655 inline_key_permission_decision(0o604),
3656 InlineKeyPermissionDecision::Tighten
3657 );
3658 }
3659
3660 #[test]
3661 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
3662 for value in ["0", "-1", "inf", "nan"] {
3663 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
3664 let error = Config::load_from(file.path()).unwrap_err().to_string();
3665 assert!(error.contains("monthly_limit"), "value {value}: {error}");
3666 }
3667
3668 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
3669 assert_eq!(
3670 Config::load_from(file.path())
3671 .unwrap()
3672 .anthropic_api
3673 .monthly_limit,
3674 Some(1000.0)
3675 );
3676 }
3677
3678 #[test]
3679 fn display_limit_must_be_positive_and_finite_on_every_balance_vendor() {
3680 for section in ["deepseek", "kilo", "novita", "moonshot", "grok"] {
3682 for value in ["0", "-1", "inf", "nan"] {
3683 let file = write_toml(&format!("[{section}]\ndisplay_limit = {value}\n"));
3684 let error = Config::load_from(file.path()).unwrap_err().to_string();
3685 assert!(
3686 error.contains(&format!("[{section}] display_limit")),
3687 "{section} = {value}: {error}"
3688 );
3689 }
3690 let file = write_toml(&format!("[{section}]\ndisplay_limit = 200\n"));
3691 let config = Config::load_from(file.path()).unwrap();
3692 assert_eq!(
3693 config.display_prefs(vendor_of(section)).display_limit,
3694 Some(200.0),
3695 "{section}"
3696 );
3697 }
3698 }
3699
3700 #[test]
3702 fn display_limit_is_absent_until_the_user_states_one() {
3703 let config = Config::default();
3704 for vendor in VendorId::all() {
3705 assert_eq!(
3706 config.display_prefs(*vendor).display_limit,
3707 None,
3708 "{vendor:?}"
3709 );
3710 }
3711 }
3712
3713 #[test]
3716 fn the_default_headline_follows_the_kind_of_vendor() {
3717 let config = Config::default();
3718 for vendor in [
3719 VendorId::Deepseek,
3720 VendorId::Kilo,
3721 VendorId::Novita,
3722 VendorId::Moonshot,
3723 VendorId::Grok,
3724 ] {
3725 assert_eq!(
3726 config.display_prefs(vendor).headline,
3727 Headline::Amount,
3728 "{vendor:?}"
3729 );
3730 }
3731 assert_eq!(
3732 config.display_prefs(VendorId::Openrouter).headline,
3733 Headline::Percent
3734 );
3735 assert_eq!(
3736 config.display_prefs(VendorId::Anthropic),
3737 DisplayPrefs::default()
3738 );
3739 }
3740
3741 #[test]
3742 fn the_headline_is_configurable_per_vendor_and_a_typo_is_loud() {
3743 let file = write_toml("[deepseek]\nheadline = \"percent\"\n");
3744 assert_eq!(
3745 Config::load_from(file.path())
3746 .unwrap()
3747 .display_prefs(VendorId::Deepseek)
3748 .headline,
3749 Headline::Percent
3750 );
3751
3752 let file = write_toml("[openrouter]\nheadline = \"amount\"\n");
3753 assert_eq!(
3754 Config::load_from(file.path())
3755 .unwrap()
3756 .display_prefs(VendorId::Openrouter)
3757 .headline,
3758 Headline::Amount
3759 );
3760
3761 let file = write_toml("[deepseek]\nheadline = \"dollars\"\n");
3762 let error = Config::load_from(file.path()).unwrap_err().to_string();
3763 assert!(error.contains("headline"), "{error}");
3764 }
3765
3766 #[test]
3772 fn openrouter_has_no_display_limit_to_be_ignored() {
3773 let file = write_toml("[openrouter]\ndisplay_limit = 200\nheadline = \"percent\"\n");
3774 let config = Config::load_from(file.path()).unwrap();
3775 let prefs = config.display_prefs(VendorId::Openrouter);
3776 assert_eq!(prefs.display_limit, None);
3777 assert_eq!(prefs.headline, Headline::Percent);
3778 }
3779
3780 #[test]
3783 fn a_display_limit_alone_leaves_the_headline_where_it_was() {
3784 let file = write_toml("[deepseek]\ndisplay_limit = 200\n");
3785 let prefs = Config::load_from(file.path())
3786 .unwrap()
3787 .display_prefs(VendorId::Deepseek);
3788 assert_eq!(prefs.display_limit, Some(200.0));
3789 assert_eq!(prefs.headline, Headline::Amount);
3790 }
3791
3792 fn vendor_of(section: &str) -> VendorId {
3793 VendorId::all()
3794 .iter()
3795 .copied()
3796 .find(|vendor| vendor.config_section() == section)
3797 .unwrap_or_else(|| panic!("no vendor for [{section}]"))
3798 }
3799
3800 #[test]
3801 fn minimax_region_accepts_only_known_instances() {
3802 for region in ["global", "GLOBAL", "cn", "CN"] {
3803 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3804 assert_eq!(
3805 Config::load_from(file.path()).unwrap().minimax.region,
3806 region
3807 );
3808 }
3809
3810 for region in ["", "china", "us"] {
3811 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3812 let error = Config::load_from(file.path()).unwrap_err().to_string();
3813 assert!(error.contains("[minimax] region"), "{error}");
3814 }
3815 }
3816
3817 #[test]
3818 fn kimi_region_accepts_auto_and_both_deployments() {
3819 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
3820 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3821 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
3822 }
3823
3824 for region in ["", "us", "oversea"] {
3825 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3826 let error = Config::load_from(file.path()).unwrap_err().to_string();
3827 assert!(error.contains("[kimi] region"), "{error}");
3828 }
3829 }
3830
3831 #[test]
3832 fn kimi_defaults_to_auto_region_and_no_credential_override() {
3833 let defaults = KimiConfig::default();
3834 assert_eq!(defaults.region, "auto");
3835 assert_eq!(defaults.credentials_path, None);
3836 assert!(!defaults.enabled);
3837 }
3838
3839 #[test]
3840 fn kimi_credentials_path_expands_a_tilde() {
3841 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
3842 let path = Config::load_from(file.path())
3843 .unwrap()
3844 .kimi
3845 .credentials_path
3846 .unwrap();
3847 assert!(!path.starts_with("~"), "{}", path.display());
3848 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
3849 }
3850
3851 #[test]
3852 fn grokbot_is_opt_in_and_takes_no_api_key() {
3853 let defaults = GrokbotConfig::default();
3854 assert!(!defaults.enabled);
3855 assert_eq!(defaults.secrets_path, None);
3856 let config = Config::default();
3858 assert_eq!(config.api_key_env_for(VendorId::Grokbot), "");
3859 assert_eq!(config.inline_api_key(VendorId::Grokbot), None);
3860
3861 let file = write_toml("[grokbot]\nenabled = true\n");
3862 let config = Config::load_from(file.path()).unwrap();
3863 assert!(config.is_enabled(VendorId::Grokbot));
3864 assert!(config.enabled_vendors().contains(&VendorId::Grokbot));
3865 }
3866
3867 #[test]
3868 fn grokbot_secrets_path_expands_a_tilde() {
3869 let file = write_toml("[grokbot]\nsecrets_path = \"~/gb/secrets.json\"\n");
3870 let path = Config::load_from(file.path())
3871 .unwrap()
3872 .grokbot
3873 .secrets_path
3874 .unwrap();
3875 assert!(!path.starts_with("~"), "{}", path.display());
3876 assert!(path.ends_with("gb/secrets.json"), "{}", path.display());
3877 }
3878
3879 #[test]
3880 fn modelstudio_is_opt_in_and_takes_no_api_key() {
3881 let defaults = ModelStudioConfig::default();
3882 assert!(!defaults.enabled);
3883 assert_eq!(defaults.config_dir, None);
3884 let config = Config::default();
3886 assert_eq!(config.api_key_env_for(VendorId::ModelStudio), "");
3887 assert_eq!(config.inline_api_key(VendorId::ModelStudio), None);
3888
3889 let file = write_toml("[modelstudio]\nenabled = true\n");
3890 let config = Config::load_from(file.path()).unwrap();
3891 assert!(config.is_enabled(VendorId::ModelStudio));
3892 assert!(config.enabled_vendors().contains(&VendorId::ModelStudio));
3893 }
3894
3895 #[test]
3896 fn modelstudio_config_dir_expands_a_tilde() {
3897 let file = write_toml("[modelstudio]\nconfig_dir = \"~/bl\"\n");
3898 let path = Config::load_from(file.path())
3899 .unwrap()
3900 .modelstudio
3901 .config_dir
3902 .unwrap();
3903 assert!(!path.starts_with("~"), "{}", path.display());
3904 assert!(path.ends_with("bl"), "{}", path.display());
3905 }
3906
3907 #[test]
3908 fn droid_is_opt_in_and_takes_no_api_key() {
3909 let defaults = DroidConfig::default();
3910 assert!(!defaults.enabled);
3911 assert!(!defaults.cli_renewal);
3912 assert_eq!(defaults.cli_path, None);
3913
3914 let config = Config::default();
3915 assert_eq!(config.api_key_env_for(VendorId::Droid), "");
3916 assert_eq!(config.inline_api_key(VendorId::Droid), None);
3917
3918 let file = write_toml("[droid]\nenabled = true\n");
3919 let config = Config::load_from(file.path()).unwrap();
3920 assert!(config.is_enabled(VendorId::Droid));
3921 assert!(config.enabled_vendors().contains(&VendorId::Droid));
3922 }
3923
3924 #[test]
3925 fn devin_is_opt_in_and_takes_no_api_key() {
3926 let defaults = DevinConfig::default();
3927 assert!(!defaults.enabled);
3928 assert_eq!(defaults.credentials_path, None);
3929
3930 let config = Config::default();
3931 assert_eq!(config.api_key_env_for(VendorId::Devin), "");
3932 assert_eq!(config.inline_api_key(VendorId::Devin), None);
3933
3934 let file = write_toml("[devin]\nenabled = true\n");
3935 let config = Config::load_from(file.path()).unwrap();
3936 assert!(config.is_enabled(VendorId::Devin));
3937 assert!(config.enabled_vendors().contains(&VendorId::Devin));
3938 }
3939
3940 #[test]
3941 fn devin_credentials_path_expands_a_tilde() {
3942 let file = write_toml("[devin]\ncredentials_path = \"~/devin/credentials.toml\"\n");
3943 let path = Config::load_from(file.path())
3944 .unwrap()
3945 .devin
3946 .credentials_path
3947 .unwrap();
3948 assert!(!path.starts_with("~"), "{}", path.display());
3949 assert!(
3950 path.ends_with("devin/credentials.toml"),
3951 "{}",
3952 path.display()
3953 );
3954 }
3955
3956 #[test]
3957 fn devin_without_accounts_resolves_the_singular_path() {
3958 let explicit = DevinConfig {
3959 credentials_path: Some(PathBuf::from("/tmp/devin/credentials.toml")),
3960 ..DevinConfig::default()
3961 };
3962 assert_eq!(
3963 explicit.resolve_credentials_path(None).unwrap(),
3964 PathBuf::from("/tmp/devin/credentials.toml")
3965 );
3966 }
3967
3968 #[test]
3969 fn devin_named_accounts_resolve_their_own_credentials_file() {
3970 let config: Config = toml::from_str(
3971 r#"
3972 [devin]
3973 credentials_path = "/tmp/personal/credentials.toml"
3974 [[devin.accounts]]
3975 label = "work"
3976 credentials_path = "/tmp/work/credentials.toml"
3977 "#,
3978 )
3979 .unwrap();
3980
3981 assert_eq!(
3982 config.devin.resolve_credentials_path(Some("work")).unwrap(),
3983 PathBuf::from("/tmp/work/credentials.toml")
3984 );
3985 assert_eq!(
3986 config.devin.resolve_credentials_path(None).unwrap(),
3987 PathBuf::from("/tmp/personal/credentials.toml")
3988 );
3989 }
3990
3991 #[test]
3992 fn devin_named_accounts_reject_an_unregistered_label() {
3993 let config = DevinConfig {
3994 accounts: vec![crate::config::DevinAccount {
3995 label: "work".into(),
3996 credentials_path: PathBuf::from("/tmp/work/credentials.toml"),
3997 }],
3998 ..DevinConfig::default()
3999 };
4000 let err = config
4001 .resolve_credentials_path(Some("nope"))
4002 .unwrap_err()
4003 .to_string();
4004 assert!(err.contains("nope"), "{err}");
4005 assert!(err.contains("[[devin.accounts]]"), "{err}");
4006 }
4007
4008 #[test]
4009 fn devin_account_credentials_paths_are_tilde_expanded_on_load() {
4010 let f = write_toml(
4011 r#"
4012 [[devin.accounts]]
4013 label = "work"
4014 credentials_path = "~/.devin-work/credentials.toml"
4015 "#,
4016 );
4017 let c = Config::load_from(f.path()).unwrap();
4018 let home = crate::cache::home_dir().unwrap();
4019 assert_eq!(
4020 c.devin.accounts[0].credentials_path,
4021 home.join(".devin-work/credentials.toml")
4022 );
4023 }
4024
4025 #[test]
4026 fn devin_rejects_duplicate_account_labels() {
4027 let f = write_toml(
4028 r#"
4029 [[devin.accounts]]
4030 label = "work"
4031 credentials_path = "/tmp/work1/credentials.toml"
4032 [[devin.accounts]]
4033 label = "work"
4034 credentials_path = "/tmp/work2/credentials.toml"
4035 "#,
4036 );
4037 let err = Config::load_from(f.path()).unwrap_err().to_string();
4038 assert!(
4039 err.contains("duplicate devin account label \"work\""),
4040 "{err}"
4041 );
4042 }
4043
4044 #[test]
4045 fn devin_rejects_invalid_account_labels() {
4046 let f = write_toml(
4047 r#"
4048 [[devin.accounts]]
4049 label = "../bad"
4050 credentials_path = "/tmp/bad/credentials.toml"
4051 "#,
4052 );
4053 assert!(Config::load_from(f.path()).is_err());
4054 }
4055
4056 #[test]
4057 fn lyceum_is_opt_in_and_uses_shared_api_key_account_resolution() {
4058 let defaults = LyceumConfig::default();
4059 assert!(!defaults.enabled);
4060 assert_eq!(defaults.api_key_env, "LYCEUM_API_KEY");
4061 let override_file = write_toml("[lyceum]\napi_key_env = \"LYCEUM_CUSTOM_TEST_KEY\"\n");
4062 let override_config = Config::load_from(override_file.path()).unwrap();
4063 assert_eq!(
4064 override_config.api_key_env_for(VendorId::Lyceum),
4065 "LYCEUM_CUSTOM_TEST_KEY"
4066 );
4067 let file = write_toml(
4068 r#"[lyceum]
4069enabled = true
4070api_key_env = ""
4071api_key = "synthetic-inline"
4072[[lyceum.accounts]]
4073label = "work"
4074api_key = "synthetic-account"
4075"#,
4076 );
4077 let config = Config::load_from(file.path()).unwrap();
4078 assert!(config.is_enabled(VendorId::Lyceum));
4079 assert_eq!(config.api_key_env_for(VendorId::Lyceum), "");
4080 assert_eq!(
4081 config
4082 .resolve_account_api_key_for(VendorId::Lyceum, None)
4083 .unwrap(),
4084 "synthetic-inline"
4085 );
4086 assert_eq!(
4087 config
4088 .resolve_account_api_key_for(VendorId::Lyceum, Some("work"))
4089 .unwrap(),
4090 "synthetic-account"
4091 );
4092 }
4093
4094 #[test]
4095 fn optional_api_key_reports_absence_instead_of_failing() {
4096 assert_eq!(
4097 optional_api_key("9INVALID", Some("inline")),
4098 Some("inline".to_string())
4099 );
4100 assert_eq!(optional_api_key("9INVALID", None), None);
4101 assert_eq!(optional_api_key("9INVALID", Some("")), None);
4102 }
4103
4104 #[test]
4105 fn optional_api_key_prefers_env_and_falls_through_to_inline() {
4106 let from_env = |name: &str| (name == "TEST_API_KEY").then(|| "from-env".to_string());
4107 assert_eq!(
4108 optional_api_key_with("TEST_API_KEY", Some("from-config"), from_env),
4109 Some("from-env".to_string())
4110 );
4111 assert_eq!(
4112 optional_api_key_with("TEST_API_KEY", Some("from-config"), |_| None),
4113 Some("from-config".to_string())
4114 );
4115 assert_eq!(
4116 optional_api_key_with("TEST_API_KEY", Some("from-config"), |_| {
4117 Some(String::new())
4118 }),
4119 Some("from-config".to_string())
4120 );
4121 assert_eq!(optional_api_key_with("TEST_API_KEY", None, |_| None), None);
4122 }
4123
4124 #[test]
4125 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
4126 let defaults = Config::default();
4127 assert!(!defaults.context.enabled);
4128 assert_eq!(
4129 defaults.context.window_tokens_for(Some("claude-test")),
4130 None
4131 );
4132
4133 let file = write_toml(
4134 r#"
4135 [context]
4136 enabled = true
4137 context_window_tokens = 200000
4138
4139 [context.model_context_window_tokens]
4140 claude-opus-1m = 1000000
4141 "claude exact id" = 300000
4142 "#,
4143 );
4144 let config = Config::load_from(file.path()).unwrap();
4145 assert!(config.context.enabled);
4146 assert_eq!(
4147 config.context.window_tokens_for(Some("claude-opus-1m")),
4148 Some(1_000_000)
4149 );
4150 assert_eq!(
4151 config.context.window_tokens_for(Some("claude exact id")),
4152 Some(300_000)
4153 );
4154 assert_eq!(
4155 config.context.window_tokens_for(Some("another-model")),
4156 Some(200_000)
4157 );
4158 }
4159
4160 #[test]
4161 fn context_layout_defaults_to_full_and_parses_each_variant() {
4162 assert_eq!(Config::default().context.layout, ContextLayout::Full);
4163 for (text, want) in [
4164 ("full", ContextLayout::Full),
4165 ("split", ContextLayout::Split),
4166 ("bottom", ContextLayout::Bottom),
4167 ] {
4168 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
4169 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
4170 }
4171 let file = write_toml("[context]\nlayout = \"floating\"\n");
4172 assert!(
4173 Config::load_from(file.path()).is_err(),
4174 "an unknown layout must be rejected, not silently defaulted"
4175 );
4176 }
4177
4178 #[test]
4179 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
4180 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
4181 for (text, want) in [
4182 ("sidebar", VendorBoxStyle::Sidebar),
4183 ("navbar", VendorBoxStyle::Navbar),
4184 ("none", VendorBoxStyle::None),
4185 ] {
4186 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
4187 assert_eq!(
4188 Config::load_from(file.path()).unwrap().ui.vendor_box(),
4189 want
4190 );
4191 }
4192 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
4193 assert!(
4194 Config::load_from(file.path()).is_err(),
4195 "an unknown vendor_box style must be rejected, not silently defaulted"
4196 );
4197 }
4198
4199 #[test]
4200 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
4201 for source in [
4202 "[context]\ncontext_window_tokens = 0\n",
4203 "[context.model_context_window_tokens]\nclaude = 0\n",
4204 "[context.model_context_window_tokens]\n\" \" = 200000\n",
4205 ] {
4206 let file = write_toml(source);
4207 let error = Config::load_from(file.path()).unwrap_err().to_string();
4208 assert!(error.contains("context"), "{error}");
4209 }
4210 }
4211
4212 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
4214 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
4215 M.lock().unwrap_or_else(|p| p.into_inner())
4216 }
4217
4218 #[test]
4219 fn resolve_api_key_prefers_env_over_inline() {
4220 let _g = env_guard();
4221 let var = "AI_USAGEBAR_TEST_ENV_WINS";
4223 unsafe { std::env::set_var(var, "from-env") };
4225 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
4226 unsafe { std::env::remove_var(var) };
4227 assert_eq!(got, "from-env");
4228 }
4229
4230 #[test]
4231 fn resolve_api_key_falls_back_to_inline() {
4232 let _g = env_guard();
4233 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
4234 unsafe { std::env::remove_var(var) };
4235 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
4236 assert_eq!(got, "inline-key");
4237 }
4238
4239 #[test]
4240 fn copilot_token_prefers_explicit_environment_over_gh_cli() {
4241 struct NeverRun;
4242 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
4243 fn run(
4244 &self,
4245 _: &crate::copilot::credentials::GhAuthTokenCommand,
4246 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
4247 panic!("environment override must not invoke gh")
4248 }
4249 }
4250
4251 let token = CopilotConfig::default()
4252 .resolve_token_with(
4253 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
4254 &NeverRun,
4255 None,
4256 )
4257 .unwrap();
4258 assert_eq!(token, "from-environment");
4259 }
4260
4261 #[test]
4265 fn copilot_rejects_an_environment_override_combined_with_a_named_account() {
4266 struct NeverRun;
4267 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
4268 fn run(
4269 &self,
4270 _: &crate::copilot::credentials::GhAuthTokenCommand,
4271 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
4272 panic!("the conflict must be refused before gh runs")
4273 }
4274 }
4275 let config = CopilotConfig {
4276 accounts: vec![CopilotAccount {
4277 label: "work".into(),
4278 user: "octocat".into(),
4279 }],
4280 ..CopilotConfig::default()
4281 };
4282 let error = config
4283 .resolve_token_with(
4284 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
4285 &NeverRun,
4286 Some("work"),
4287 )
4288 .unwrap_err()
4289 .to_string();
4290 assert!(error.contains("GITHUB_COPILOT_TOKEN is set"), "{error}");
4291 assert!(error.contains("work"), "{error}");
4292 }
4293
4294 #[test]
4297 fn copilot_unknown_account_label_errors_listing_the_known_ones() {
4298 let config = CopilotConfig {
4299 accounts: vec![CopilotAccount {
4300 label: "work".into(),
4301 user: "octocat".into(),
4302 }],
4303 ..CopilotConfig::default()
4304 };
4305 assert_eq!(config.resolve_user(None).unwrap(), None);
4306 assert_eq!(config.resolve_user(Some("work")).unwrap(), Some("octocat"));
4307 let error = config
4308 .resolve_user(Some("personal"))
4309 .unwrap_err()
4310 .to_string();
4311 assert!(error.contains("personal"), "{error}");
4312 assert!(error.contains("work"), "{error}");
4313
4314 let error = CopilotConfig::default()
4316 .resolve_user(Some("work"))
4317 .unwrap_err()
4318 .to_string();
4319 assert!(error.contains("no accounts array"), "{error}");
4320 }
4321
4322 #[test]
4325 fn copilot_defaults_keep_the_unnamed_account_visible() {
4326 let config = CopilotConfig::default();
4327 assert!(config.show_default_account);
4328 assert!(config.accounts.is_empty());
4329 assert!(!config.enabled, "Copilot stays opt-in");
4330 }
4331
4332 #[test]
4333 fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
4334 struct FailedGh;
4335 impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
4336 fn run(
4337 &self,
4338 _: &crate::copilot::credentials::GhAuthTokenCommand,
4339 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
4340 Ok(crate::copilot::credentials::GhAuthTokenOutput {
4341 success: false,
4342 stdout: b"never-echo-gh-output".to_vec(),
4343 })
4344 }
4345 }
4346 let error = CopilotConfig::default()
4347 .resolve_token_with(|_| None, &FailedGh, None)
4348 .unwrap_err()
4349 .to_string();
4350 assert!(error.contains("gh auth login --web"));
4351 assert!(!error.contains("never-echo-gh-output"));
4352 }
4353
4354 #[test]
4355 fn resolve_api_key_errors_when_both_missing() {
4356 let _g = env_guard();
4357 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
4358 unsafe { std::env::remove_var(var) };
4359 let err = resolve_api_key("Zai", var, None).unwrap_err();
4360 match err {
4361 crate::error::AppError::Credentials(msg) => {
4362 assert!(
4363 msg.contains("api_key"),
4364 "error should suggest config field: {msg}"
4365 );
4366 }
4367 other => panic!("expected Credentials error, got {other:?}"),
4368 }
4369 }
4370
4371 #[test]
4372 fn resolve_api_key_uses_exact_opencode_go_section_name() {
4373 let _g = env_guard();
4374 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
4375 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
4376 let message = err.to_string();
4377 assert!(
4378 message.contains("[opencode-go]"),
4379 "wrong section hint: {message}"
4380 );
4381 assert!(
4382 !message.contains("[opencode go]"),
4383 "wrong section hint: {message}"
4384 );
4385 }
4386
4387 fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
4388 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
4389 M.lock().unwrap_or_else(|p| p.into_inner())
4390 }
4391
4392 struct ScopedPathOverride {
4398 _serial: std::sync::MutexGuard<'static, ()>,
4399 }
4400
4401 impl Drop for ScopedPathOverride {
4402 fn drop(&mut self) {
4403 clear_override_path();
4404 }
4405 }
4406
4407 fn scoped_path_override() -> ScopedPathOverride {
4408 ScopedPathOverride {
4409 _serial: path_override_guard(),
4410 }
4411 }
4412
4413 #[test]
4414 fn override_path_wins_over_canonical_and_legacy() {
4415 let _scoped = scoped_path_override();
4416 let file = NamedTempFile::new().unwrap();
4417 set_override_path(file.path());
4418 assert_eq!(resolved_path().as_deref(), Some(file.path()));
4419 assert_eq!(config_path_hint(), file.path().display().to_string());
4420 clear_override_path();
4421 let p = resolved_path().expect("a config path must resolve");
4423 assert!(p.ends_with("config.toml"));
4424 }
4425
4426 #[test]
4427 fn scoped_override_guard_clears_the_override_on_panic() {
4428 let hook = std::panic::take_hook();
4431 std::panic::set_hook(Box::new(|_| {}));
4432 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4433 let _scoped = scoped_path_override();
4434 set_override_path(std::path::Path::new("panicked-override.toml"));
4435 panic!("simulated mid-test failure");
4436 }))
4437 .is_err();
4438 std::panic::set_hook(hook);
4439 assert!(panicked, "the simulated failure must run");
4440 let _serial = path_override_guard();
4441 assert!(
4442 override_path().is_none(),
4443 "a panicking test must not leak the override into siblings"
4444 );
4445 }
4446
4447 #[test]
4448 fn config_path_hint_ends_with_config_toml() {
4449 let _g = path_override_guard();
4450 assert!(config_path_hint().ends_with("config.toml"));
4453 }
4454
4455 #[test]
4456 fn config_flag_value_splits_the_equals_form() {
4457 use std::ffi::OsStr;
4458 assert_eq!(
4459 config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
4460 Some(std::path::Path::new("work.toml"))
4461 );
4462 assert_eq!(
4463 config_flag_value(OsStr::new("--config=")).as_deref(),
4464 Some(std::path::Path::new(""))
4465 );
4466 assert_eq!(config_flag_value(OsStr::new("--config")), None);
4467 assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
4468 assert_eq!(config_flag_value(OsStr::new("account")), None);
4469 }
4470
4471 #[cfg(unix)]
4475 #[test]
4476 fn config_flag_value_keeps_undecodable_bytes_intact() {
4477 use std::ffi::OsString;
4478 use std::os::unix::ffi::{OsStrExt, OsStringExt};
4479 let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
4480 let value = config_flag_value(&raw).expect("prefix matches");
4481 assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
4482 }
4483
4484 #[cfg(windows)]
4485 #[test]
4486 fn config_flag_value_keeps_lone_surrogates_intact() {
4487 use std::ffi::OsString;
4488 use std::os::windows::ffi::{OsStrExt, OsStringExt};
4489 let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
4490 wide.push(0xDC00); wide.extend("x.toml".encode_utf16());
4492 let raw = OsString::from_wide(&wide);
4493 let value = config_flag_value(&raw).expect("prefix matches");
4494 let mut expected = vec![0xDC00u16];
4495 expected.extend("x.toml".encode_utf16());
4496 assert_eq!(
4497 value.as_os_str().encode_wide().collect::<Vec<_>>(),
4498 expected
4499 );
4500 }
4501
4502 #[test]
4503 fn resolve_api_key_treats_empty_env_as_unset() {
4504 let _g = env_guard();
4505 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
4506 unsafe { std::env::set_var(var, "") };
4507 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
4508 unsafe { std::env::remove_var(var) };
4509 assert_eq!(got, "inline");
4510 }
4511
4512 #[test]
4513 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
4514 let _g = env_guard();
4515 let bad = "sk-kimi-very-real-looking-pasted-secret";
4517 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
4518 let msg = err.to_string();
4519 assert!(
4520 msg.contains("invalid") && msg.contains("api_key_env"),
4521 "error should explain misconfiguration: {msg}"
4522 );
4523 assert!(
4524 !msg.contains(bad),
4525 "error must not echo the misconfigured value: {msg}"
4526 );
4527 assert!(msg.contains("valid environment variable name"));
4528 assert!(
4529 msg.contains("[kimi]"),
4530 "error should point at the lowercase TOML section: {msg}"
4531 );
4532 }
4533
4534 #[test]
4535 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
4536 let _g = env_guard();
4537 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
4538 assert_eq!(got, "inline-key");
4539 }
4540
4541 #[test]
4542 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
4543 let _g = env_guard();
4544 let pasted_secret = "sk_pasted_secret";
4547 unsafe { std::env::remove_var(pasted_secret) };
4548 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
4549 assert!(
4550 !err.to_string().contains(pasted_secret),
4551 "error must not echo configured api_key_env values"
4552 );
4553 }
4554
4555 #[test]
4556 fn is_valid_env_var_name_rules() {
4557 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
4559 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
4560 }
4561 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
4563 assert!(
4564 !is_valid_env_var_name(invalid),
4565 "{invalid} should be invalid"
4566 );
4567 }
4568 }
4569
4570 #[test]
4571 fn config_parses_with_inline_api_key_and_primary() {
4572 let f = write_toml(
4573 r#"
4574 [ui]
4575 primary = "openrouter"
4576
4577 [zai]
4578 enabled = true
4579 api_key_env = "MY_ZAI"
4580 api_key = "sk-zai-inline"
4581
4582 [openrouter]
4583 enabled = true
4584 api_key = "sk-or-inline"
4585 "#,
4586 );
4587 let c = Config::load_from(f.path()).unwrap();
4588 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
4589 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
4590 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
4591 }
4592
4593 #[test]
4594 fn openrouter_named_accounts_preserve_the_default_contract() {
4595 let f = write_toml(
4596 r#"
4597 [openrouter]
4598 enabled = true
4599 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
4600 api_key = "default-inline"
4601 show_default_account = false
4602
4603 [[openrouter.accounts]]
4604 label = "work"
4605 api_key_env = "OPENROUTER_WORK_API_KEY"
4606
4607 [[openrouter.accounts]]
4608 label = "personal"
4609 api_key = "personal-inline"
4610 "#,
4611 );
4612 let _g = env_guard();
4613 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
4614 let config = Config::load_from(f.path()).unwrap();
4615 assert!(!config.openrouter.show_default_account);
4616 assert_eq!(config.openrouter.accounts.len(), 2);
4617 assert_eq!(
4618 config
4619 .resolve_account_api_key_for(VendorId::Openrouter, None)
4620 .unwrap(),
4621 "default-inline"
4622 );
4623 assert_eq!(
4624 config
4625 .resolve_account_api_key_for(VendorId::Openrouter, Some("personal"))
4626 .unwrap(),
4627 "personal-inline"
4628 );
4629 }
4630
4631 #[test]
4632 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
4633 for source in [
4634 r#"
4635 [[openrouter.accounts]]
4636 label = "work"
4637 api_key = "one"
4638 [[openrouter.accounts]]
4639 label = "work"
4640 api_key = "two"
4641 "#,
4642 r#"
4643 [[openrouter.accounts]]
4644 label = "../work"
4645 api_key = "one"
4646 "#,
4647 r#"
4648 [[openrouter.accounts]]
4649 label = "work"
4650 "#,
4651 ] {
4652 let f = write_toml(source);
4653 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
4654 }
4655 }
4656
4657 #[test]
4658 fn openrouter_unknown_account_never_falls_back_to_default_key() {
4659 let mut config = Config::default();
4660 config.openrouter.api_key = Some("default-secret".into());
4661 config.openrouter.accounts.push(ApiKeyAccount {
4662 label: "work".into(),
4663 api_key_env: None,
4664 api_key: Some("work-secret".into()),
4665 management_api_key_env: None,
4666 });
4667 let message = config
4668 .resolve_account_api_key_for(VendorId::Openrouter, Some("missing"))
4669 .unwrap_err()
4670 .to_string();
4671 assert!(message.contains("missing") && message.contains("work"));
4672 assert!(!message.contains("default-secret"));
4673 assert!(!message.contains("work-secret"));
4674 }
4675
4676 #[test]
4677 fn openrouter_account_key_errors_do_not_echo_configured_values() {
4678 let mut config = Config::default();
4679 config.openrouter.accounts.push(ApiKeyAccount {
4680 label: "work".into(),
4681 api_key_env: Some("sk_pasted_secret".into()),
4682 api_key: None,
4683 management_api_key_env: None,
4684 });
4685 let _g = env_guard();
4686 unsafe { std::env::remove_var("sk_pasted_secret") };
4687 let message = config
4688 .resolve_account_api_key_for(VendorId::Openrouter, Some("work"))
4689 .unwrap_err()
4690 .to_string();
4691 assert!(message.contains("[[openrouter.accounts]]"));
4692 assert!(!message.contains("sk_pasted_secret"));
4693 }
4694
4695 #[test]
4696 fn openrouter_management_key_resolves_per_account_and_never_inherits() {
4697 let mut config = Config::default();
4698 config.openrouter.accounts = vec![
4699 ApiKeyAccount {
4700 label: "work".into(),
4701 api_key_env: None,
4702 api_key: Some("work-key".into()),
4703 management_api_key_env: Some("AI_USAGEBAR_TEST_OR_MGMT_WORK".into()),
4704 },
4705 ApiKeyAccount {
4706 label: "plain".into(),
4707 api_key_env: None,
4708 api_key: Some("plain-key".into()),
4709 management_api_key_env: None,
4710 },
4711 ];
4712 let _g = env_guard();
4713 unsafe { std::env::remove_var("OPENROUTER_MANAGEMENT_API_KEY") };
4714 unsafe { std::env::set_var("AI_USAGEBAR_TEST_OR_MGMT_WORK", "mgmt-work") };
4715
4716 assert_eq!(config.openrouter_management_key(None), None);
4718 assert_eq!(
4720 config.openrouter_management_key(Some("work")).as_deref(),
4721 Some("mgmt-work")
4722 );
4723 unsafe { std::env::set_var("OPENROUTER_MANAGEMENT_API_KEY", "mgmt-default") };
4726 assert_eq!(
4727 config.openrouter_management_key(None).as_deref(),
4728 Some("mgmt-default")
4729 );
4730 assert_eq!(config.openrouter_management_key(Some("plain")), None);
4731 assert_eq!(config.openrouter_management_key(Some("typo")), None);
4733
4734 config.openrouter.management_api_key_env = "AI_USAGEBAR_TEST_OR_MGMT_DEFAULT".into();
4736 unsafe { std::env::set_var("AI_USAGEBAR_TEST_OR_MGMT_DEFAULT", "mgmt-renamed") };
4737 assert_eq!(
4738 config.openrouter_management_key(None).as_deref(),
4739 Some("mgmt-renamed")
4740 );
4741 unsafe { std::env::remove_var("OPENROUTER_MANAGEMENT_API_KEY") };
4742 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_MGMT_WORK") };
4743 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_MGMT_DEFAULT") };
4744 }
4745
4746 #[test]
4747 fn every_api_key_account_vendor_validates_its_array_like_openrouter() {
4748 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
4749 let section = vendor.config_section();
4750 let parse = |accounts: &str| -> Result<Config> {
4751 let config: Config =
4752 toml::from_str(&format!("[{section}]\nenabled = true\n{accounts}")).unwrap();
4753 config.validate().map(|()| config)
4754 };
4755 let config = parse(&format!(
4756 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k\"\n"
4757 ))
4758 .unwrap();
4759 assert_eq!(
4760 config.api_key_accounts(vendor).unwrap().len(),
4761 1,
4762 "{vendor:?}"
4763 );
4764 assert!(config.show_default_api_key_account(vendor), "{vendor:?}");
4765
4766 let duplicate = parse(&format!(
4767 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"a\"\n\
4768 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"b\"\n"
4769 ))
4770 .unwrap_err()
4771 .to_string();
4772 assert!(
4773 duplicate.contains(&format!("duplicate {section} account label")),
4774 "{duplicate}"
4775 );
4776
4777 let keyless = parse(&format!("[[{section}.accounts]]\nlabel = \"work\"\n"))
4778 .unwrap_err()
4779 .to_string();
4780 assert!(
4781 keyless.contains("must set api_key_env or api_key"),
4782 "{keyless}"
4783 );
4784
4785 assert!(
4787 parse(&format!(
4788 "[[{section}.accounts]]\nlabel = \"../x\"\napi_key = \"k\"\n"
4789 ))
4790 .is_err(),
4791 "{vendor:?}"
4792 );
4793 }
4794 }
4795
4796 #[test]
4797 fn named_api_key_accounts_resolve_their_own_key_and_never_the_default() {
4798 let mut config = Config::default();
4799 config.deepseek.api_key_env.clear();
4800 config.deepseek.api_key = Some("default-key".into());
4801 config.deepseek.accounts.push(ApiKeyAccount {
4802 label: "work".into(),
4803 api_key_env: Some("AI_USAGEBAR_TEST_DEEPSEEK_WORK".into()),
4804 api_key: Some("work-inline".into()),
4805 management_api_key_env: None,
4806 });
4807 let _g = env_guard();
4808 unsafe { std::env::set_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK", "work-env") };
4809 let resolve = |label| config.resolve_account_api_key_for(VendorId::Deepseek, label);
4810 assert_eq!(resolve(None).unwrap(), "default-key");
4811 assert_eq!(resolve(Some("work")).unwrap(), "work-env", "env wins");
4812 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK") };
4813 assert_eq!(resolve(Some("work")).unwrap(), "work-inline");
4814
4815 let unknown = resolve(Some("typo")).unwrap_err().to_string();
4816 assert!(unknown.contains("[[deepseek.accounts]]"), "{unknown}");
4817 assert!(
4818 unknown.contains("\"work\""),
4819 "lists known labels: {unknown}"
4820 );
4821 assert!(!unknown.contains("default-key"), "{unknown}");
4822 }
4823
4824 #[test]
4825 fn the_default_key_error_keeps_each_vendors_historic_wording() {
4826 let mut config = Config::default();
4827 config.zai.api_key_env.clear();
4828 config.minimax.api_key_env.clear();
4829 let zai = config
4830 .resolve_account_api_key_for(VendorId::Zai, None)
4831 .unwrap_err()
4832 .to_string();
4833 let expected = resolve_api_key("Zai", "", None).unwrap_err().to_string();
4834 assert_eq!(zai, expected);
4835 assert!(zai.contains("[zai]"), "{zai}");
4836 let minimax = config
4837 .resolve_account_api_key_for(VendorId::Minimax, None)
4838 .unwrap_err()
4839 .to_string();
4840 assert_eq!(
4841 minimax,
4842 resolve_api_key("MiniMax", "", None)
4843 .unwrap_err()
4844 .to_string()
4845 );
4846 }
4847
4848 #[test]
4849 fn enabled_vendors_preserves_canonical_order() {
4850 let c = Config::default();
4853 assert_eq!(
4854 c.enabled_vendors(),
4855 vec![
4856 VendorId::Anthropic,
4857 VendorId::Openai,
4858 VendorId::Zai,
4859 VendorId::Openrouter,
4860 ]
4861 );
4862 }
4863
4864 #[test]
4865 fn deepseek_appears_when_enabled() {
4866 let f = write_toml(
4867 r#"
4868 [deepseek]
4869 enabled = true
4870 api_key = "sk-test"
4871 "#,
4872 );
4873 let c = Config::load_from(f.path()).unwrap();
4874 assert!(c.is_enabled(VendorId::Deepseek));
4875 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
4876 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
4877 }
4878
4879 #[test]
4880 fn tilde_paths_are_expanded_on_load() {
4881 let f = write_toml(
4885 r#"
4886 [context]
4887 projects_path = "~/.claude/projects"
4888
4889 [anthropic]
4890 credentials_path = "~/.claude/.credentials.json"
4891
4892 [[anthropic.accounts]]
4893 label = "work"
4894 credentials_path = "~/work.json"
4895 "#,
4896 );
4897 let c = Config::load_from(f.path()).unwrap();
4898 let home = crate::cache::home_dir().unwrap();
4899
4900 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
4901 let got = c.anthropic.credentials_path.unwrap();
4902 assert_eq!(got, home.join(".claude/.credentials.json"));
4903 assert!(!got.to_string_lossy().contains('~'));
4904 assert_eq!(
4905 c.anthropic.accounts[0].credentials_path,
4906 home.join("work.json")
4907 );
4908 }
4909
4910 #[test]
4911 fn absolute_and_relative_paths_are_left_alone() {
4912 let f = write_toml(
4913 r#"
4914 [anthropic]
4915 credentials_path = "/etc/creds.json"
4916 "#,
4917 );
4918 let c = Config::load_from(f.path()).unwrap();
4919 assert_eq!(
4920 c.anthropic.credentials_path.unwrap(),
4921 std::path::Path::new("/etc/creds.json")
4922 );
4923
4924 let f2 = write_toml(
4926 r#"
4927 [anthropic]
4928 credentials_path = "~someone/creds.json"
4929 "#,
4930 );
4931 let c2 = Config::load_from(f2.path()).unwrap();
4932 assert_eq!(
4933 c2.anthropic.credentials_path.unwrap(),
4934 std::path::Path::new("~someone/creds.json")
4935 );
4936 }
4937
4938 #[test]
4939 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
4940 let _g = path_override_guard();
4941 let p = resolved_path().expect("a config path must resolve");
4944 assert!(p.ends_with("config.toml"));
4945 let canonical = default_path().unwrap();
4946 let legacy = legacy_xdg_path().unwrap();
4947 assert!(
4948 p == canonical || p == legacy,
4949 "resolved to an unexpected location: {}",
4950 p.display()
4951 );
4952 }
4953
4954 #[test]
4955 fn misspelled_section_is_rejected_not_ignored() {
4956 let f = write_toml(
4959 r#"
4960 [openrouer]
4961 enabled = true
4962 api_key = "sk-or-v1-typo"
4963 "#,
4964 );
4965 let err = Config::load_from(f.path()).unwrap_err().to_string();
4966 assert!(
4967 err.contains("openrouer"),
4968 "error should name the typo: {err}"
4969 );
4970 }
4971
4972 #[test]
4973 fn invalid_toml_is_an_error_not_silent_defaults() {
4974 let f = write_toml("[zai\nenabled = true\n");
4975 assert!(Config::load_from(f.path()).is_err());
4976 }
4977
4978 #[test]
4982 fn a_parse_error_names_the_line_without_quoting_it() {
4983 let f = write_toml("[openrouter]\nenabled = true\napi_key = sk-or-v1-unquoted\n");
4984 let err = Config::load_from(f.path()).unwrap_err().to_string();
4985 assert!(err.contains("line 3,"), "{err}");
4986 assert!(!err.contains("sk-or-v1-unquoted"), "{err}");
4987 }
4988
4989 #[test]
4992 fn a_writer_parse_error_names_the_line_without_quoting_it() {
4993 let (_dir, path) =
4994 crate::cache::closed_temp_file("config.toml", Some("[zai]\napi_key = zk-unquoted\n"));
4995 let err = read_config_document(&path).unwrap_err().to_string();
4996 assert!(err.contains("line 2,"), "{err}");
4997 assert!(!err.contains("zk-unquoted"), "{err}");
4998 }
4999
5000 #[test]
5001 fn toml_error_summary_keeps_the_position_and_the_message() {
5002 let input = "a = 1\nkey = sk-secret\n";
5003 assert_eq!(
5004 toml_error_summary(input, Some(12..21), "string values must be quoted\n"),
5005 "line 2, column 7: string values must be quoted"
5006 );
5007 assert_eq!(toml_error_summary(input, None, "bad"), "bad");
5008 }
5009
5010 #[test]
5011 fn a_missing_file_is_still_just_defaults() {
5012 let dir = tempfile::tempdir().unwrap();
5015 let missing = dir.path().join("nope").join("config.toml");
5016 let c = Config::load_from(&missing).unwrap();
5017 assert!(c.is_enabled(VendorId::Anthropic));
5018 }
5019
5020 #[test]
5021 fn kimi_appears_when_enabled() {
5022 let f = write_toml(
5023 r#"
5024 [kimi]
5025 enabled = true
5026 api_key = "sk-test"
5027 "#,
5028 );
5029 let c = Config::load_from(f.path()).unwrap();
5030 assert!(c.is_enabled(VendorId::Kimi));
5031 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
5032 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
5033 }
5034
5035 #[test]
5036 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
5037 let f = write_toml(
5038 r#"
5039 [deepseek]
5040 enabled = true
5041 api_key = "sk-ds"
5042
5043 [kimi]
5044 enabled = true
5045 api_key = "sk-kimi"
5046 "#,
5047 );
5048 let c = Config::load_from(f.path()).unwrap();
5049 assert_eq!(
5050 c.enabled_vendors(),
5051 vec![
5052 VendorId::Anthropic,
5053 VendorId::Openai,
5054 VendorId::Zai,
5055 VendorId::Openrouter,
5056 VendorId::Deepseek,
5057 VendorId::Kimi,
5058 ]
5059 );
5060 }
5061
5062 #[test]
5063 fn parses_anthropic_accounts_and_looks_them_up() {
5064 let f = write_toml(
5065 r#"
5066 [anthropic]
5067 enabled = true
5068
5069 [[anthropic.accounts]]
5070 label = "personal"
5071 credentials_path = "/creds/personal.json"
5072
5073 [[anthropic.accounts]]
5074 label = "work"
5075 credentials_path = "/creds/work.json"
5076 "#,
5077 );
5078 let c = Config::load_from(f.path()).unwrap();
5079 assert_eq!(c.anthropic.accounts.len(), 2);
5080 let work = c.anthropic.account("work").unwrap();
5081 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
5082 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
5084 assert!(err.contains("missing") && err.contains("work"), "{err}");
5085 }
5086
5087 #[test]
5088 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
5089 let f = write_toml(
5090 r#"
5091 [[anthropic.accounts]]
5092 label = "work"
5093 credentials_path = "/creds/work-one.json"
5094
5095 [[anthropic.accounts]]
5096 label = "work"
5097 credentials_path = "/creds/work-two.json"
5098 "#,
5099 );
5100 let err = Config::load_from(f.path()).unwrap_err().to_string();
5101 assert!(
5102 err.contains("duplicate anthropic account label \"work\""),
5103 "{err}"
5104 );
5105 }
5106
5107 #[test]
5108 fn account_label_rejects_path_like_names() {
5109 let cfg = AnthropicConfig::default();
5110 for bad in [
5111 "",
5112 ".",
5113 "..",
5114 "a/b",
5115 r"a\b",
5116 "C:work",
5117 "line\nbreak",
5118 "tab\tname",
5119 "usage.json",
5120 ".stale",
5121 ".last_error",
5122 ".fetch.lock",
5123 ] {
5124 let err = cfg.account(bad).unwrap_err();
5125 assert!(
5126 format!("{err:?}").contains("invalid anthropic account label"),
5127 "{bad:?} should be rejected as a label"
5128 );
5129 }
5130 }
5131
5132 #[test]
5133 fn anthropic_accounts_default_to_empty() {
5134 assert!(Config::default().anthropic.accounts.is_empty());
5137 assert!(Config::default().anthropic.accounts_dir.is_none());
5138 }
5139
5140 fn seed_account_dir(root: &std::path::Path, label: &str) {
5146 let dir = root.join(label);
5147 std::fs::create_dir_all(&dir).unwrap();
5148 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
5149 }
5150
5151 #[test]
5152 fn discovers_account_dirs_in_claude_config_dir_layout() {
5153 let td = tempfile::tempdir().unwrap();
5154 seed_account_dir(td.path(), "work");
5155 seed_account_dir(td.path(), "personal");
5156 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
5159 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
5161
5162 let cfg = AnthropicConfig {
5163 accounts_dir: Some(td.path().to_path_buf()),
5164 ..Default::default()
5165 };
5166 let all = cfg.all_accounts();
5167 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
5168 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
5169 assert_eq!(
5170 all[2].credentials_path,
5171 td.path().join("work").join(".credentials.json")
5172 );
5173 }
5174
5175 #[test]
5176 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
5177 let td = tempfile::tempdir().unwrap();
5178 seed_account_dir(td.path(), "work");
5179 let cfg = AnthropicConfig {
5180 accounts: vec![AnthropicAccount {
5181 label: "work".into(),
5182 credentials_path: "/explicit/work.json".into(),
5183 }],
5184 accounts_dir: Some(td.path().to_path_buf()),
5185 ..Default::default()
5186 };
5187 let all = cfg.all_accounts();
5188 assert_eq!(all.len(), 1, "no duplicate label");
5189 assert_eq!(
5190 all[0].credentials_path,
5191 std::path::Path::new("/explicit/work.json"),
5192 "explicit entry wins"
5193 );
5194 seed_account_dir(td.path(), "other");
5196 assert_eq!(cfg.account("other").unwrap().label, "other");
5197 }
5198
5199 #[test]
5200 fn missing_accounts_dir_is_silently_empty_not_an_error() {
5201 let cfg = AnthropicConfig {
5202 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
5203 ..Default::default()
5204 };
5205 assert!(cfg.all_accounts().is_empty());
5206 }
5207
5208 #[test]
5209 fn openai_account_auth_paths_are_tilde_expanded_on_load() {
5210 let f = write_toml(
5211 r#"
5212 [[openai.accounts]]
5213 label = "work"
5214 codex_auth_path = "~/.codex-work/auth.json"
5215 "#,
5216 );
5217 let c = Config::load_from(f.path()).unwrap();
5218 let home = crate::cache::home_dir().unwrap();
5219 assert_eq!(
5220 c.openai.accounts[0].codex_auth_path,
5221 home.join(".codex-work/auth.json")
5222 );
5223 }
5224
5225 #[test]
5226 fn accounts_dir_is_tilde_expanded_on_load() {
5227 let f = write_toml(
5228 r#"
5229 [anthropic]
5230 accounts_dir = "~/.config/ai-usagebar/accounts"
5231 "#,
5232 );
5233 let c = Config::load_from(f.path()).unwrap();
5234 let home = crate::cache::home_dir().unwrap();
5235 assert_eq!(
5236 c.anthropic.accounts_dir,
5237 Some(home.join(".config/ai-usagebar/accounts"))
5238 );
5239 }
5240
5241 #[test]
5242 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
5243 let f = write_toml(
5244 r#"
5245 [anthropic]
5246 desktop_profiles_dir = "~/.claude-acc/profiles"
5247 "#,
5248 );
5249 let c = Config::load_from(f.path()).unwrap();
5250 let home = crate::cache::home_dir().unwrap();
5251 assert_eq!(
5252 c.anthropic.desktop_profiles_dir,
5253 Some(home.join(".claude-acc/profiles"))
5254 );
5255 }
5256
5257 #[test]
5258 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
5259 let cfg = AnthropicConfig {
5260 accounts: vec![
5261 AnthropicAccount {
5262 label: "work".into(),
5263 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
5264 },
5265 AnthropicAccount {
5266 label: "personal".into(),
5267 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
5268 },
5269 ],
5270 ..Default::default()
5271 };
5272
5273 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
5274 assert!(
5275 matches!(&idle, CredsTarget::Named { config_dir, .. }
5276 if config_dir == std::path::Path::new("/tmp/accounts/work")),
5277 "{idle:?}"
5278 );
5279
5280 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
5282 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
5283
5284 assert_eq!(idle_cache.dir(), live_cache.dir());
5287 }
5288
5289 #[test]
5290 fn the_live_cli_account_keeps_its_own_slot_while_that_file_is_there() {
5291 let cfg = AnthropicConfig {
5296 accounts: vec![AnthropicAccount {
5297 label: "personal".into(),
5298 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
5299 }],
5300 ..Default::default()
5301 };
5302
5303 let (present, _) = cfg
5304 .account_target_probing("personal", Some("personal"), |_| true)
5305 .unwrap();
5306 assert!(
5307 matches!(&present, CredsTarget::Named { path, .. }
5308 if path == Path::new("/tmp/accounts/personal/.credentials.json")),
5309 "{present:?}"
5310 );
5311
5312 let (moved, _) = cfg
5314 .account_target_probing("personal", Some("personal"), |_| false)
5315 .unwrap();
5316 assert!(matches!(moved, CredsTarget::Default(_)), "{moved:?}");
5317 }
5318
5319 #[test]
5320 fn the_default_entry_reads_config_credentials_path_strictly() {
5321 let cfg = AnthropicConfig {
5322 credentials_path: Some("/tmp/claude-home/.credentials.json".into()),
5323 ..Default::default()
5324 };
5325
5326 assert!(
5327 matches!(&cfg.default_creds_target(), CredsTarget::Explicit(path)
5328 if path == Path::new("/tmp/claude-home/.credentials.json")),
5329 );
5330 assert!(matches!(
5332 AnthropicConfig::default().default_creds_target(),
5333 CredsTarget::Default(_)
5334 ));
5335 }
5336
5337 #[test]
5338 fn the_live_cli_accounts_own_file_is_probed_on_disk() {
5339 let creds = NamedTempFile::new().unwrap();
5343 let cfg = AnthropicConfig {
5344 accounts: vec![AnthropicAccount {
5345 label: "personal".into(),
5346 credentials_path: creds.path().to_path_buf(),
5347 }],
5348 ..Default::default()
5349 };
5350 let (target, _) = cfg
5351 .account_target_with("personal", Some("personal"))
5352 .unwrap();
5353 assert!(
5354 matches!(&target, CredsTarget::Named { path, .. } if path == creds.path()),
5355 "read {target:?} instead of the account's own file"
5356 );
5357 }
5358
5359 #[test]
5360 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
5361 let cfg = AnthropicConfig {
5362 accounts: vec![AnthropicAccount {
5363 label: "work".into(),
5364 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
5365 }],
5366 ..Default::default()
5367 };
5368 let (target, _) = cfg.account_target_with("work", None).unwrap();
5369 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
5370 }
5371
5372 fn config_example() -> PathBuf {
5376 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
5377 }
5378
5379 #[test]
5380 fn shipped_example_parses_as_a_real_config() {
5381 let c = Config::load_from(&config_example()).unwrap();
5386 assert!(!c.context.enabled);
5387 assert!(c.is_enabled(VendorId::Anthropic));
5388 assert!(c.is_enabled(VendorId::Openai));
5389 assert!(!c.is_enabled(VendorId::AnthropicApi));
5390 assert!(!c.is_enabled(VendorId::Deepseek));
5391 assert!(!c.is_enabled(VendorId::Kimi));
5392 assert!(!c.is_enabled(VendorId::Kilo));
5393 assert!(!c.is_enabled(VendorId::Novita));
5394 assert!(!c.is_enabled(VendorId::Moonshot));
5395 assert!(!c.is_enabled(VendorId::Grok));
5396 assert!(!c.is_enabled(VendorId::Cursor));
5397 assert!(!c.is_enabled(VendorId::Minimax));
5398 }
5399
5400 #[test]
5401 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
5402 let text = std::fs::read_to_string(config_example()).unwrap();
5407 let live: Vec<&str> = text
5408 .lines()
5409 .map(str::trim)
5410 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
5411 .collect();
5412 assert!(
5413 live.is_empty(),
5414 "admin_key_env must stay commented out while it is inert: {live:?}"
5415 );
5416 assert!(
5419 text.contains("admin_key_env") && text.contains("RESERVED"),
5420 "the example should keep describing admin_key_env as reserved"
5421 );
5422 }
5423
5424 #[test]
5425 fn admin_key_env_is_accepted_but_changes_nothing() {
5426 let f = write_toml(
5430 r#"
5431 [openai]
5432 admin_key_env = "SOME_ADMIN_KEY"
5433 "#,
5434 );
5435 let c = Config::load_from(f.path()).unwrap();
5436 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
5437 let default = OpenAiConfig::default();
5439 assert_eq!(c.openai.enabled, default.enabled);
5440 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
5441 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
5442 }
5443
5444 #[test]
5445 fn config_example_documents_every_vendor_without_secrets() {
5446 let raw = std::fs::read_to_string(config_example()).unwrap();
5447 let cfg = Config::load_from(&config_example()).unwrap();
5448 for id in VendorId::all() {
5451 let section = id.slug();
5452 assert!(
5453 raw.contains(&format!("[{section}]")),
5454 "config.example.toml has no [{section}] section"
5455 );
5456 }
5457
5458 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
5461 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
5462 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
5463 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
5464 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
5465 assert!(!cfg.supergrok.enabled);
5466 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
5467 assert_eq!(
5468 cfg.supergrok
5469 .grok_binary
5470 .file_name()
5471 .and_then(|p| p.to_str()),
5472 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
5473 );
5474 assert!(cfg.supergrok.auth_path.is_none());
5475 assert!(cfg.supergrok.config_path.is_none());
5476 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
5477 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
5478 }
5479
5480 #[test]
5481 fn supergrok_binary_must_not_be_empty() {
5482 let file = write_toml(
5483 r#"
5484 [supergrok]
5485 enabled = true
5486 grok_binary = ""
5487 "#,
5488 );
5489 let error = Config::load_from(file.path()).unwrap_err().to_string();
5490 assert!(error.contains("grok_binary must not be empty"));
5491 }
5492
5493 #[test]
5494 fn supergrok_paths_are_tilde_expanded() {
5495 let file = write_toml(
5496 r#"
5497 [supergrok]
5498 grok_binary = "~/bin/grok"
5499 auth_path = "~/.grok/auth.json"
5500 config_path = "~/.grok/config.toml"
5501 "#,
5502 );
5503 let config = Config::load_from(file.path()).unwrap();
5504 let home = crate::cache::home_dir().unwrap();
5505 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
5506 assert_eq!(
5507 config.supergrok.auth_path,
5508 Some(home.join(".grok/auth.json"))
5509 );
5510 assert_eq!(
5511 config.supergrok.config_path,
5512 Some(home.join(".grok/config.toml"))
5513 );
5514 }
5515
5516 #[test]
5519 fn commandcode_and_copilot_paths_are_tilde_expanded() {
5520 let file = write_toml(
5521 r#"
5522 [commandcode]
5523 auth_paths = ["~/.commandcode/auth.json", "/etc/commandcode/auth.json"]
5524
5525 [copilot]
5526 gh_binary = "~/bin/gh"
5527 "#,
5528 );
5529 let config = Config::load_from(file.path()).unwrap();
5530 let home = crate::cache::home_dir().unwrap();
5531 assert_eq!(
5532 config.commandcode.auth_paths,
5533 Some(vec![
5534 home.join(".commandcode/auth.json"),
5535 PathBuf::from("/etc/commandcode/auth.json"),
5536 ])
5537 );
5538 assert_eq!(config.copilot.gh_binary, Some(home.join("bin/gh")));
5539 }
5540
5541 #[test]
5542 fn opencode_go_and_droid_paths_are_tilde_expanded() {
5543 let file = write_toml(
5544 r#"
5545 [opencode-go]
5546 auth_paths = ["~/.local/share/opencode/opencode.db", "/etc/opencode/auth.json"]
5547
5548 [droid]
5549 cli_path = "~/.local/bin/droid"
5550 "#,
5551 );
5552 let config = Config::load_from(file.path()).unwrap();
5553 let home = crate::cache::home_dir().unwrap();
5554 assert_eq!(
5555 config.opencode_go.auth_paths,
5556 Some(vec![
5557 home.join(".local/share/opencode/opencode.db"),
5558 PathBuf::from("/etc/opencode/auth.json"),
5559 ])
5560 );
5561 assert_eq!(config.droid.cli_path, Some(home.join(".local/bin/droid")));
5562 }
5563
5564 #[test]
5565 fn kiro_db_path_is_tilde_expanded() {
5566 let f = write_toml(
5567 r#"
5568 [kiro]
5569 db_path = "~/kiro-data.sqlite3"
5570 "#,
5571 );
5572 let c = Config::load_from(f.path()).unwrap();
5573 let home = crate::cache::home_dir().unwrap();
5574 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
5575 }
5576
5577 #[test]
5578 fn kiro_appears_when_enabled() {
5579 let f = write_toml(
5580 r#"
5581 [kiro]
5582 enabled = true
5583 "#,
5584 );
5585 let c = Config::load_from(f.path()).unwrap();
5586 assert!(c.is_enabled(VendorId::Kiro));
5587 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
5588 }
5589
5590 #[test]
5591 fn cursor_db_path_is_tilde_expanded() {
5592 let f = write_toml(
5593 r#"
5594 [cursor]
5595 db_path = "~/cursor-state.vscdb"
5596 "#,
5597 );
5598 let c = Config::load_from(f.path()).unwrap();
5599 let home = crate::cache::home_dir().unwrap();
5600 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
5601 }
5602
5603 #[test]
5604 fn cursor_agent_auth_path_is_tilde_expanded() {
5605 let f = write_toml(
5606 r#"
5607 [cursor]
5608 agent_auth_path = "~/cursor-agent-auth.json"
5609 "#,
5610 );
5611 let c = Config::load_from(f.path()).unwrap();
5612 let home = crate::cache::home_dir().unwrap();
5613 assert_eq!(
5614 c.cursor.agent_auth_path,
5615 Some(home.join("cursor-agent-auth.json"))
5616 );
5617 }
5618
5619 #[test]
5620 fn cursor_appears_when_enabled() {
5621 let f = write_toml(
5622 r#"
5623 [cursor]
5624 enabled = true
5625 "#,
5626 );
5627 let c = Config::load_from(f.path()).unwrap();
5628 assert!(c.is_enabled(VendorId::Cursor));
5629 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
5630 }
5631
5632 #[test]
5633 fn add_account_appends_and_preserves_existing() {
5634 let mut doc: toml_edit::DocumentMut = r#"
5635# keep me
5636[anthropic]
5637enabled = true
5638
5639[[anthropic.accounts]]
5640label = "personal"
5641credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
5642"#
5643 .parse()
5644 .unwrap();
5645 add_anthropic_account_to_doc(
5646 &mut doc,
5647 "work",
5648 "~/.config/ai-usagebar/accounts/work/.credentials.json",
5649 )
5650 .unwrap();
5651 let rendered = doc.to_string();
5652 assert!(rendered.contains("# keep me"), "comment must survive");
5653 let f = write_toml(&rendered);
5655 let c = Config::load_from(f.path()).unwrap();
5656 let labels: Vec<&str> = c
5657 .anthropic
5658 .accounts
5659 .iter()
5660 .map(|a| a.label.as_str())
5661 .collect();
5662 assert_eq!(labels, vec!["personal", "work"]);
5663 }
5664
5665 #[test]
5666 fn add_account_to_empty_doc_is_loadable() {
5667 let mut doc = toml_edit::DocumentMut::new();
5668 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
5669 let f = write_toml(&doc.to_string());
5670 let c = Config::load_from(f.path()).unwrap();
5671 assert_eq!(c.anthropic.accounts.len(), 1);
5672 assert_eq!(c.anthropic.accounts[0].label, "solo");
5673 }
5674
5675 #[test]
5676 fn add_account_rejects_duplicate_label() {
5677 let mut doc: toml_edit::DocumentMut = r#"
5678[[anthropic.accounts]]
5679label = "work"
5680credentials_path = "~/w/.credentials.json"
5681"#
5682 .parse()
5683 .unwrap();
5684 assert!(
5685 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
5686 "a duplicate label must be rejected, not appended"
5687 );
5688 }
5689
5690 #[test]
5691 fn add_account_rejects_bad_label() {
5692 let mut doc = toml_edit::DocumentMut::new();
5693 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
5694 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
5695 }
5696
5697 #[test]
5698 fn tildify_collapses_home_only() {
5699 let home = Path::new("/Users/me");
5700 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
5701 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
5702 }
5703
5704 #[test]
5705 fn default_account_credentials_path_nests_under_config_dir() {
5706 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
5707 assert_eq!(
5708 default_account_credentials_path(cfg, "work"),
5709 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
5710 );
5711 }
5712 const CUSTOM_BLOCK: &str = r#"
5715[[custom]]
5716id = "mytool"
5717name = "My Tool"
5718short_name = "myt"
5719enabled = true
5720url = "https://api.example.test/v1/usage"
5721api_key_env = "MYTOOL_API_KEY"
5722auth_header = "Authorization"
5723auth_scheme = "Bearer"
5724plan = "Pro"
5725cache_ttl_secs = 120
5726[custom.headers]
5727X-Org = "org_1"
5728[[custom.metrics]]
5729label = "Requests"
5730used = "/requests/used"
5731limit = "/requests/limit"
5732resets_at = "/requests/reset"
5733window_secs = 3600
5734[[custom.texts]]
5735label = "Tier"
5736value = "/tier"
5737"#;
5738
5739 fn custom_with(from: &str, to: &str) -> String {
5740 assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
5741 CUSTOM_BLOCK.replace(from, to)
5742 }
5743
5744 fn custom_error(toml: &str) -> String {
5745 Config::load_from(write_toml(toml).path())
5746 .unwrap_err()
5747 .to_string()
5748 }
5749
5750 fn assert_custom_rejected(toml: &str, needle: &str) {
5751 let msg = custom_error(toml);
5752 assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
5753 assert!(
5754 msg.contains("[[custom]]"),
5755 "the error must locate the section: {msg}"
5756 );
5757 }
5758
5759 #[test]
5760 fn custom_block_parses_every_field() {
5761 let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
5762 assert_eq!(config.custom.len(), 1);
5763 let c = &config.custom[0];
5764 assert_eq!(c.id, "mytool");
5765 assert_eq!(c.name, "My Tool");
5766 assert_eq!(c.short_name, "myt");
5767 assert_eq!(
5768 c.brand, None,
5769 "a custom provider has no mark unless it asks"
5770 );
5771 assert!(c.enabled);
5772 assert_eq!(c.url, "https://api.example.test/v1/usage");
5773 assert!(!c.allow_http);
5774 assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
5775 assert_eq!(c.api_key, None);
5776 assert_eq!(c.auth_header, "Authorization");
5777 assert_eq!(c.auth_scheme, "Bearer");
5778 assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
5779 assert_eq!(c.plan.as_deref(), Some("Pro"));
5780 assert_eq!(c.plan_path, None);
5781 assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
5782 assert_eq!(c.metrics.len(), 1);
5783 assert_eq!(c.metrics[0].label, "Requests");
5784 assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
5785 assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
5786 assert_eq!(c.metrics[0].percent, None);
5787 assert_eq!(c.metrics[0].currency, None);
5788 assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
5789 assert_eq!(c.metrics[0].window_secs, Some(3600));
5790 assert_eq!(c.texts.len(), 1);
5791 assert_eq!(c.texts[0].label, "Tier");
5792 assert_eq!(c.texts[0].value, "/tier");
5793 assert_eq!(c.texts[0].currency, None);
5794 assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
5795 }
5796
5797 #[test]
5798 fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
5799 let config: Config = toml::from_str(
5800 r#"
5801 [[custom]]
5802 id = "bare"
5803 short_name = "bre"
5804 url = "https://example.test/u"
5805 [[custom.metrics]]
5806 label = "Q"
5807 percent = "/pct"
5808 "#,
5809 )
5810 .unwrap();
5811 let c = &config.custom[0];
5812 assert_eq!(c.name, "bare", "name must default to id on a plain parse");
5813 assert!(!c.enabled);
5814 assert!(!c.allow_http);
5815 assert_eq!(c.api_key_env, "");
5816 assert_eq!(c.auth_header, "Authorization");
5817 assert_eq!(c.auth_scheme, "Bearer");
5818 assert_eq!(c.cache_ttl_secs, 60);
5819 assert!(config.validate().is_ok());
5820 assert!(Config::default().custom.is_empty());
5821 }
5822
5823 #[test]
5824 fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
5825 let config = Config::load_from(
5826 write_toml(&custom_with(
5827 r#"short_name = "myt""#,
5828 "short_name = \"myt\"\nbrand = \"opencode-go\"",
5829 ))
5830 .path(),
5831 )
5832 .unwrap();
5833 assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
5834
5835 for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
5839 assert_custom_rejected(
5840 &custom_with(
5841 r#"short_name = "myt""#,
5842 &format!("short_name = \"myt\"\nbrand = {brand:?}"),
5843 ),
5844 "must name a built-in vendor",
5845 );
5846 }
5847 }
5848
5849 #[test]
5850 fn custom_rejects_a_malformed_id() {
5851 let long = "a".repeat(33);
5852 for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
5853 let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
5854 assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
5855 assert!(msg.contains("must match"), "{id:?}: {msg}");
5856 }
5857 }
5858
5859 #[test]
5860 fn custom_rejects_a_builtin_slug_as_id() {
5861 assert_custom_rejected(
5862 &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
5863 "is a built-in vendor",
5864 );
5865 assert_custom_rejected(
5866 &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
5867 "is a built-in vendor",
5868 );
5869 }
5870
5871 #[test]
5872 fn custom_rejects_duplicate_ids() {
5873 let twice = format!(
5874 "{}{}",
5875 CUSTOM_BLOCK,
5876 custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
5877 );
5878 assert_custom_rejected(&twice, "duplicate id");
5879 }
5880
5881 #[test]
5882 fn custom_rejects_a_name_over_48_chars() {
5883 let long = "n".repeat(49);
5884 assert_custom_rejected(
5885 &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
5886 "name must be 1 to 48 characters",
5887 );
5888 }
5889
5890 #[test]
5891 fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
5892 for short in ["my", "myto", "MYT", "m1t"] {
5893 assert_custom_rejected(
5894 &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
5895 "exactly 3 lowercase ASCII letters",
5896 );
5897 }
5898 }
5899
5900 #[test]
5901 fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
5902 assert_custom_rejected(
5903 &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
5904 "already used by a built-in vendor",
5905 );
5906 let twice = format!(
5907 "{}{}",
5908 CUSTOM_BLOCK,
5909 custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
5910 );
5911 assert_custom_rejected(&twice, "already used by a built-in vendor");
5912 }
5913
5914 #[test]
5915 fn custom_rejects_http_unless_allowed() {
5916 let plain = custom_with(
5917 r#"url = "https://api.example.test/v1/usage""#,
5918 r#"url = "http://localhost:8080/usage""#,
5919 );
5920 assert_custom_rejected(&plain, "url must use https://");
5921 let allowed = plain.replace(
5922 r#"url = "http://localhost:8080/usage""#,
5923 "url = \"http://localhost:8080/usage\"\nallow_http = true",
5924 );
5925 assert!(
5926 Config::load_from(write_toml(&allowed).path()).is_ok(),
5927 "allow_http must permit http://"
5928 );
5929 }
5930
5931 #[test]
5932 fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
5933 assert_custom_rejected(
5934 &custom_with(
5935 r#"url = "https://api.example.test/v1/usage""#,
5936 r#"url = "https://user:pw@api.example.test/v1/usage""#,
5937 ),
5938 "must not carry credentials",
5939 );
5940 assert_custom_rejected(
5941 &custom_with(
5942 r#"url = "https://api.example.test/v1/usage""#,
5943 r#"url = "not a url""#,
5944 ),
5945 "is not a valid URL",
5946 );
5947 assert_custom_rejected(
5948 &custom_with(
5949 r#"url = "https://api.example.test/v1/usage""#,
5950 r#"url = "ftp://api.example.test/v1/usage""#,
5951 ),
5952 "is not http or https",
5953 );
5954 }
5955
5956 #[test]
5957 fn custom_rejects_an_invalid_api_key_env() {
5958 assert_custom_rejected(
5959 &custom_with(
5960 r#"api_key_env = "MYTOOL_API_KEY""#,
5961 r#"api_key_env = "1BAD-NAME""#,
5962 ),
5963 "is not a valid environment variable name",
5964 );
5965 let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
5966 assert!(
5967 Config::load_from(write_toml(&none).path()).is_ok(),
5968 "an empty api_key_env means inline-only and is valid"
5969 );
5970 }
5971
5972 #[test]
5976 fn custom_invalid_api_key_env_is_not_repeated() {
5977 let msg = custom_error(&custom_with(
5978 r#"api_key_env = "MYTOOL_API_KEY""#,
5979 r#"api_key_env = "sk-live-pasted-secret""#,
5980 ));
5981 assert!(msg.contains("api_key_env"), "{msg}");
5982 assert!(!msg.contains("sk-live-pasted-secret"), "{msg}");
5983 }
5984
5985 #[test]
5986 fn custom_rejects_an_invalid_auth_header_name() {
5987 assert_custom_rejected(
5988 &custom_with(
5989 r#"auth_header = "Authorization""#,
5990 r#"auth_header = "X Api Key""#,
5991 ),
5992 "auth_header \"X Api Key\" is not a valid HTTP header name",
5993 );
5994 }
5995
5996 #[test]
5997 fn custom_rejects_a_control_char_in_auth_scheme() {
5998 assert_custom_rejected(
5999 &custom_with(
6000 r#"auth_scheme = "Bearer""#,
6001 "auth_scheme = \"Bearer\\u0007\"",
6002 ),
6003 "auth_scheme contains characters that are not valid",
6004 );
6005 let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
6006 assert!(
6007 Config::load_from(write_toml(&bare).path()).is_ok(),
6008 "an empty scheme (bare key) is valid"
6009 );
6010 }
6011
6012 #[test]
6013 fn custom_rejects_a_bad_extra_header() {
6014 assert_custom_rejected(
6015 &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
6016 "headers must not repeat auth_header",
6017 );
6018 assert_custom_rejected(
6019 &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
6020 "is not a valid HTTP header name",
6021 );
6022 assert_custom_rejected(
6023 &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
6024 "has a value that is not valid in an HTTP header",
6025 );
6026 }
6027
6028 #[test]
6029 fn custom_rejects_cache_ttl_outside_10_to_3600() {
6030 for ttl in ["9", "3601"] {
6031 assert_custom_rejected(
6032 &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
6033 "cache_ttl_secs must be between 10 and 3600",
6034 );
6035 }
6036 }
6037
6038 #[test]
6039 fn custom_rejects_an_entry_with_no_metrics_or_texts() {
6040 let toml = r#"
6041[[custom]]
6042id = "empty"
6043short_name = "emp"
6044url = "https://example.test/u"
6045"#;
6046 assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
6047 }
6048
6049 #[test]
6050 fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
6051 assert_custom_rejected(
6052 &custom_with(
6053 r#"limit = "/requests/limit""#,
6054 "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
6055 ),
6056 "must set `percent`, or both `used` and `limit`",
6057 );
6058 assert_custom_rejected(
6059 &custom_with("limit = \"/requests/limit\"\n", ""),
6060 "must set `percent`, or both `used` and `limit`",
6061 );
6062 }
6063
6064 #[test]
6065 fn custom_rejects_a_pointer_without_a_leading_slash() {
6066 assert_custom_rejected(
6067 &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
6068 "used \"requests.used\" must be an RFC 6901 JSON Pointer",
6069 );
6070 assert_custom_rejected(
6071 &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
6072 "value \"tier\" must be an RFC 6901 JSON Pointer",
6073 );
6074 assert_custom_rejected(
6075 &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
6076 "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
6077 );
6078 assert_custom_rejected(
6079 &custom_with(
6080 r#"resets_at = "/requests/reset""#,
6081 "resets_at = \"/re\\u001bset\"",
6082 ),
6083 "resets_at",
6084 );
6085 }
6086
6087 #[test]
6088 fn custom_rejects_a_label_outside_1_to_64_chars() {
6089 let long = "l".repeat(65);
6090 assert_custom_rejected(
6091 &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
6092 "metric label",
6093 );
6094 assert_custom_rejected(
6095 &custom_with(r#"label = "Tier""#, r#"label = """#),
6096 "text label \"\" must be 1 to 64 characters",
6097 );
6098 }
6099
6100 #[test]
6101 fn custom_rejects_window_secs_under_60() {
6102 assert_custom_rejected(
6103 &custom_with("window_secs = 3600", "window_secs = 59"),
6104 "window_secs must be at least 60",
6105 );
6106 }
6107
6108 #[test]
6109 fn custom_rejects_duplicate_metric_and_text_labels() {
6110 let metric_twice = custom_with(
6111 "window_secs = 3600\n",
6112 "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
6113 );
6114 assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
6115 let text_twice =
6116 format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
6117 assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
6118 }
6119
6120 #[test]
6121 fn custom_metric_and_text_parse_currency() {
6122 let toml = custom_with(
6123 "window_secs = 3600\n",
6124 "window_secs = 3600\ncurrency = \"USD\"\n",
6125 )
6126 .replace(
6127 "value = \"/tier\"\n",
6128 "value = \"/tier\"\ncurrency = \"EUR\"\n",
6129 );
6130 let config = Config::load_from(write_toml(&toml).path()).unwrap();
6131 assert_eq!(config.custom[0].metrics[0].currency.as_deref(), Some("USD"));
6132 assert_eq!(config.custom[0].texts[0].currency.as_deref(), Some("EUR"));
6133 }
6134
6135 #[test]
6136 fn custom_rejects_an_invalid_currency_code() {
6137 let toml = custom_with(
6138 "window_secs = 3600\n",
6139 "window_secs = 3600\ncurrency = \"usd\"\n",
6140 );
6141 assert_custom_rejected(
6142 &toml,
6143 "metric \"Requests\" currency \"usd\" must be a 3-letter uppercase code",
6144 );
6145
6146 let toml = custom_with(
6147 "value = \"/tier\"\n",
6148 "value = \"/tier\"\ncurrency = \"USDT\"\n",
6149 );
6150 assert_custom_rejected(
6151 &toml,
6152 "text \"Tier\" currency \"USDT\" must be a 3-letter uppercase code",
6153 );
6154 }
6155
6156 #[test]
6157 fn custom_rejects_a_metric_mixing_currency_with_percent() {
6158 let toml = r#"
6159 [[custom]]
6160 id = "bare"
6161 short_name = "bre"
6162 url = "https://example.test/u"
6163 [[custom.metrics]]
6164 label = "Quota"
6165 percent = "/pct"
6166 currency = "USD"
6167 "#;
6168 assert_custom_rejected(
6169 toml,
6170 "metric \"Quota\" cannot combine `currency` with `percent`",
6171 );
6172 }
6173
6174 #[test]
6175 fn enabled_custom_and_custom_by_id_select_entries() {
6176 let two = format!(
6177 "{}{}",
6178 CUSTOM_BLOCK,
6179 custom_with(r#"id = "mytool""#, r#"id = "off""#)
6180 .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
6181 .replace("enabled = true", "enabled = false")
6182 );
6183 let config = Config::load_from(write_toml(&two).path()).unwrap();
6184 let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
6185 assert_eq!(enabled, ["mytool"]);
6186 assert_eq!(
6187 config.custom_by_id("off").map(|c| c.name.as_str()),
6188 Some("My Tool")
6189 );
6190 assert!(config.custom_by_id("nope").is_none());
6191 }
6192
6193 #[cfg(unix)]
6194 #[test]
6195 fn has_inline_secrets_sees_a_custom_inline_key() {
6196 let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
6197 assert!(!without.has_inline_secrets());
6198 let with: Config = toml::from_str(&custom_with(
6199 r#"api_key_env = "MYTOOL_API_KEY""#,
6200 "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
6201 ))
6202 .unwrap();
6203 assert!(with.has_inline_secrets());
6204 }
6205
6206 #[test]
6207 fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
6208 let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
6209 let mut spec = CustomProviderConfig {
6210 id: "mytool".into(),
6211 api_key_env: var.into(),
6212 api_key: Some("sk-inline-secret".into()),
6213 ..CustomProviderConfig::default()
6214 };
6215 unsafe { std::env::set_var(var, "sk-env-secret") };
6216 let from_env = spec.resolve_api_key();
6217 unsafe { std::env::remove_var(var) };
6218 assert_eq!(from_env.unwrap(), "sk-env-secret");
6219
6220 assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
6221
6222 spec.api_key = Some(String::new());
6223 let err = spec.resolve_api_key().unwrap_err();
6224 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
6225 let msg = err.to_string();
6226 assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
6227 assert!(msg.contains(var), "{msg}");
6228 assert!(!msg.contains("secret"), "{msg}");
6229
6230 spec.api_key_env = String::new();
6231 let msg = spec.resolve_api_key().unwrap_err().to_string();
6232 assert!(msg.contains("set `api_key`"), "{msg}");
6233 }
6234
6235 #[test]
6236 fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
6237 let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
6238 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
6239 let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
6240 Config::load_from(file.path()).unwrap();
6241 assert!(
6242 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
6243 "a custom provider's env var must be scrubbed from subprocesses"
6244 );
6245 }
6246
6247 #[test]
6248 fn loading_a_config_registers_account_and_renamed_env_vars_for_scrubbing() {
6249 let account = "AI_USAGEBAR_ACCOUNT_SCRUB_TEST_4C2E";
6250 let renamed = "AI_USAGEBAR_RENAMED_SCRUB_TEST_7A3F";
6251 let before = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6252 assert!(!before.contains(&account));
6253 assert!(!before.contains(&renamed));
6254 let file = write_toml(&format!(
6255 "[zai]\napi_key_env = \"{renamed}\"\n\
6256 [[deepseek.accounts]]\nlabel = \"work\"\napi_key_env = \"{account}\"\n"
6257 ));
6258 Config::load_from(file.path()).unwrap();
6259 let after = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6260 assert!(after.contains(&account), "a named account's key variable");
6261 assert!(after.contains(&renamed), "a renamed api_key_env");
6262 }
6263
6264 #[test]
6265 fn loading_a_config_registers_openrouter_management_env_vars_for_scrubbing() {
6266 let renamed = "AI_USAGEBAR_MGMT_RENAMED_SCRUB_TEST_5D1B";
6267 let account = "AI_USAGEBAR_MGMT_ACCOUNT_SCRUB_TEST_8E6C";
6268 let before = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6269 assert!(!before.contains(&renamed));
6270 assert!(!before.contains(&account));
6271 let file = write_toml(&format!(
6272 "[openrouter]\nmanagement_api_key_env = \"{renamed}\"\n\
6273 [[openrouter.accounts]]\nlabel = \"work\"\napi_key_env = \"OR_WORK_KEY\"\n\
6274 management_api_key_env = \"{account}\"\n"
6275 ));
6276 Config::load_from(file.path()).unwrap();
6277 let after = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6278 assert!(after.contains(&renamed), "a renamed management_api_key_env");
6279 assert!(
6280 after.contains(&account),
6281 "an account's management_api_key_env"
6282 );
6283 }
6284
6285 #[test]
6290 fn loading_a_config_resolves_and_scrubs_a_renamed_cline_env_var() {
6291 let renamed = "AI_USAGEBAR_CLINE_RENAMED_SCRUB_TEST_2F8A";
6292 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&renamed));
6293 let file = write_toml(&format!("[cline]\napi_key_env = \"{renamed}\"\n"));
6294 let config = Config::load_from(file.path()).unwrap();
6295 assert_eq!(config.api_key_env_for(VendorId::Cline), renamed);
6296 assert!(
6297 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&renamed),
6298 "a renamed Cline key variable must be scrubbed from subprocesses"
6299 );
6300 }
6301
6302 #[test]
6307 fn every_config_section_parses_to_its_vendors_enabled_switch() {
6308 for vendor in VendorId::all() {
6309 let text = format!(
6310 "[{}]
6311enabled = true
6312",
6313 vendor.config_section()
6314 );
6315 let config: Config = toml::from_str(&text)
6316 .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
6317 assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
6318 let others = VendorId::all()
6319 .iter()
6320 .filter(|other| *other != vendor && config.is_enabled(**other))
6321 .count();
6322 assert_eq!(
6323 others,
6324 Config::default().enabled_vendors().len()
6325 - usize::from(Config::default().is_enabled(*vendor)),
6326 "[{}] enabled a different vendor",
6327 vendor.config_section()
6328 );
6329 }
6330 }
6331
6332 #[test]
6333 fn tray_section_parses_and_defaults_to_notify() {
6334 let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
6335 let config = Config::load_from(file.path()).unwrap();
6336 assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
6337 assert_eq!(config.tray.updates(), UpdateMode::Auto);
6338
6339 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
6340 assert_eq!(empty.tray, TrayConfig::default());
6341 assert_eq!(empty.tray.updates(), UpdateMode::Notify);
6342 assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
6343 assert_eq!(UpdateMode::parse("weekly"), None);
6344 assert_eq!(UpdateMode::Auto.as_str(), "auto");
6345 }
6346
6347 #[test]
6348 fn tray_ignores_removed_menu_bar_keys_for_back_compatibility() {
6349 let legacy = write_toml(
6350 "[tray]\nmenu_bar_show_all = false\nmenu_bar_hide_value = true\nmenu_bar_names = \"short\"\nmenu_bar_provider = \"anthropic\"\nmenu_bar_window = \"weekly\"\n",
6351 );
6352 let config = Config::load_from(legacy.path()).unwrap();
6353 assert_eq!(config.tray, TrayConfig::default());
6354 }
6355
6356 #[test]
6357 fn tray_section_rejects_a_misspelled_mode() {
6358 let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
6359 assert!(Config::load_from(file.path()).is_err());
6360 }
6361
6362 #[test]
6363 fn tray_refresh_minutes_defaults_to_five_and_parses() {
6364 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
6365 assert_eq!(empty.tray.refresh_minutes, None);
6366 assert_eq!(empty.tray.refresh_minutes(), 5);
6367
6368 let file = write_toml("[tray]\nrefresh_minutes = 10\n");
6369 let config = Config::load_from(file.path()).unwrap();
6370 assert_eq!(config.tray.refresh_minutes(), 10);
6371 }
6372
6373 #[test]
6374 fn tray_refresh_minutes_rejects_values_outside_the_menu() {
6375 for minutes in ["3", "0", "120"] {
6376 let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
6377 let error = Config::load_from(file.path()).unwrap_err().to_string();
6378 assert!(error.contains("[tray] refresh_minutes"), "{error}");
6379 assert!(error.contains("1, 5, 10, 15, 30 or 60"), "{error}");
6380 }
6381 }
6382
6383 #[test]
6384 fn notifications_default_on_at_97_and_parse_overrides() {
6385 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
6386 assert!(empty.notifications.enabled);
6387 assert_eq!(empty.notifications.threshold, 97);
6388
6389 let file = write_toml("[notifications]\nenabled = false\nthreshold = 100\n");
6390 let config = Config::load_from(file.path()).unwrap();
6391 assert!(!config.notifications.enabled);
6392 assert_eq!(config.notifications.threshold, 100);
6393 }
6394
6395 #[test]
6396 fn notifications_threshold_rejects_values_outside_1_to_100() {
6397 for threshold in ["0", "101", "255"] {
6398 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
6399 let error = Config::load_from(file.path()).unwrap_err().to_string();
6400 assert!(
6401 error.contains("[notifications] threshold must be between 1 and 100"),
6402 "threshold {threshold}: {error}"
6403 );
6404 }
6405 for threshold in ["1", "50", "100"] {
6407 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
6408 let config = Config::load_from(file.path()).unwrap();
6409 assert_eq!(config.notifications.threshold.to_string(), threshold);
6410 }
6411 }
6412
6413 #[test]
6414 fn notification_preferences_round_trip_without_changing_other_sections() {
6415 let dir = tempfile::tempdir().unwrap();
6416 let path = dir.path().join("config.toml");
6417 std::fs::write(&path, "# keep this\n[tray]\nrefresh_minutes = 10\n").unwrap();
6418 set_notification_value(&path, "enabled", false.into()).unwrap();
6419 set_notification_value(&path, "threshold", 85i64.into()).unwrap();
6420 let text = std::fs::read_to_string(&path).unwrap();
6421 assert!(text.contains("# keep this\n[tray]\nrefresh_minutes = 10"));
6422 let config = Config::load_from(&path).unwrap();
6423 assert!(!config.notifications.enabled);
6424 assert_eq!(config.notifications.threshold, 85);
6425 assert!(set_notification_value(&path, "threshold", 101i64.into()).is_err());
6426 assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
6427 }
6428
6429 #[test]
6434 fn vendor_enabled_round_trips_and_rejects_unknown_slugs() {
6435 let dir = tempfile::tempdir().unwrap();
6436 let path = dir.path().join("config.toml");
6437 std::fs::write(&path, "# keep\n[zai]\nenabled = true # mine\n").unwrap();
6438
6439 set_vendor_enabled(&path, "zai", false).unwrap();
6440 let text = std::fs::read_to_string(&path).unwrap();
6441 assert!(text.starts_with("# keep\n"), "{text}");
6442 assert!(text.contains("enabled = false # mine"), "{text}");
6443 assert!(!config_enabled(&path, VendorId::Zai));
6444
6445 set_vendor_enabled(&path, "grok", true).unwrap();
6446 let config = Config::load_from(&path).unwrap();
6447 assert!(config.is_enabled(VendorId::Grok));
6448 assert!(
6449 !config.is_enabled(VendorId::Deepseek),
6450 "only the named vendor moves; untouched opt-in vendors stay off"
6451 );
6452 assert!(
6453 config.is_enabled(VendorId::Openai),
6454 "an untouched default-on vendor is not switched off either"
6455 );
6456
6457 for bad in ["", "custom", "not-a-vendor", "anthropic "] {
6458 let error = set_vendor_enabled(&path, bad, true)
6459 .unwrap_err()
6460 .to_string();
6461 assert!(error.contains("unknown provider"), "{bad}: {error}");
6462 }
6463 assert!(
6465 std::fs::read_to_string(&path)
6466 .unwrap()
6467 .contains("enabled = false # mine")
6468 );
6469 }
6470
6471 #[test]
6474 fn set_vendor_enabled_in_doc_targets_the_section_and_stays_idempotent() {
6475 let dir = tempfile::tempdir().unwrap();
6476 let path = dir.path().join("config.toml");
6477 std::fs::write(&path, "[grok]\nenabled = true\napi_key = \"k\"\n").unwrap();
6478 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
6479
6480 set_vendor_enabled(&path, "grok", true).unwrap();
6481
6482 assert_eq!(
6483 std::fs::read_to_string(&path).unwrap(),
6484 "[grok]\nenabled = true\napi_key = \"k\"\n"
6485 );
6486 assert_eq!(
6487 std::fs::metadata(&path).unwrap().modified().unwrap(),
6488 before
6489 );
6490
6491 let mut doc = toml_edit::DocumentMut::new();
6492 set_vendor_enabled_in_doc(&mut doc, "opencode-go", true).unwrap();
6493 assert_eq!(doc.to_string(), "[opencode-go]\nenabled = true\n");
6494 assert!(set_vendor_enabled_in_doc(&mut doc, "mytool", true).is_err());
6495 }
6496
6497 fn config_enabled(path: &std::path::Path, vendor: VendorId) -> bool {
6498 Config::load_from(path).unwrap().is_enabled(vendor)
6499 }
6500
6501 #[test]
6502 fn menu_bar_short_name_defaults_on_and_persists_as_a_bool() {
6503 let dir = tempfile::tempdir().unwrap();
6504 let path = dir.path().join("config.toml");
6505 std::fs::write(&path, "[tray]\n").unwrap();
6506 assert!(Config::load_from(&path).unwrap().tray.menu_bar_short_name());
6507
6508 set_tray_value(&path, "menu_bar_short_name", Some(false.into())).unwrap();
6509 let text = std::fs::read_to_string(&path).unwrap();
6510 assert_eq!(text, "[tray]\nmenu_bar_short_name = false\n");
6511 assert!(!Config::load_from(&path).unwrap().tray.menu_bar_short_name());
6512 }
6513
6514 #[test]
6515 fn set_tray_value_writes_refresh_minutes_as_an_integer() {
6516 let dir = tempfile::tempdir().unwrap();
6517 let path = dir.path().join("config.toml");
6518 std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
6519
6520 set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
6521 let text = std::fs::read_to_string(&path).unwrap();
6522 assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
6523 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
6524
6525 set_tray_value(&path, "refresh_minutes", None).unwrap();
6526 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
6527 }
6528
6529 #[test]
6530 fn set_tray_value_creates_replaces_and_removes_keys() {
6531 let dir = tempfile::tempdir().unwrap();
6532 let path = dir.path().join("config.toml");
6533 std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
6534
6535 set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
6536 let text = std::fs::read_to_string(&path).unwrap();
6537 assert!(text.contains("# primary = \"anthropic\""), "{text}");
6538 assert!(
6539 text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
6540 "{text}"
6541 );
6542
6543 set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
6544 set_tray_value(&path, "updates", Some("off".into())).unwrap();
6545 let config = Config::load_from(&path).unwrap();
6546 assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
6547 assert_eq!(config.tray.updates(), UpdateMode::Off);
6548
6549 set_tray_value(&path, "shortcut", None).unwrap();
6550 let text = std::fs::read_to_string(&path).unwrap();
6551 assert!(!text.contains("shortcut"), "{text}");
6552 assert!(text.contains("updates = \"off\""), "{text}");
6553
6554 let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
6556 set_tray_value(&path, "shortcut", None).unwrap();
6557 assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
6558 }
6559
6560 #[test]
6561 fn set_value_keeps_the_trailing_comment_when_replacing() {
6562 let mut doc: toml_edit::DocumentMut =
6563 "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
6564 set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
6565 assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
6566 }
6567
6568 #[test]
6569 fn set_value_updates_fields_in_a_toml_inline_table() {
6570 let mut doc: toml_edit::DocumentMut =
6571 "tray = { shortcut = \"Ctrl+U\" } # mine\n".parse().unwrap();
6572 set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
6573 assert_eq!(doc.to_string(), "tray = { shortcut = \"Alt+U\" } # mine\n");
6574 }
6575
6576 #[test]
6577 fn enable_vendors_in_creates_a_missing_config() {
6578 let dir = tempfile::TempDir::new().unwrap();
6579 let path = dir.path().join("sub").join("config.toml");
6580
6581 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6582
6583 assert_eq!(
6584 std::fs::read_to_string(&path).unwrap(),
6585 "[grok]
6586enabled = true
6587"
6588 );
6589 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
6590 }
6591
6592 #[test]
6593 fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
6594 let dir = tempfile::TempDir::new().unwrap();
6595 let path = dir.path().join("config.toml");
6596 let original = "# my settings
6597[zai]
6598api_key = \"x\" # keep
6599enabled = false
6600";
6601 std::fs::write(&path, original).unwrap();
6602
6603 enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
6604
6605 let text = std::fs::read_to_string(&path).unwrap();
6606 assert!(
6607 text.starts_with(
6608 "# my settings
6609"
6610 ),
6611 "{text}"
6612 );
6613 assert!(
6614 text.contains(
6615 "api_key = \"x\" # keep
6616"
6617 ),
6618 "{text}"
6619 );
6620 assert!(
6621 text.contains(
6622 "[grok]
6623enabled = true
6624"
6625 ),
6626 "{text}"
6627 );
6628 assert!(
6629 text.contains(
6630 "[opencode-go]
6631enabled = true
6632"
6633 ),
6634 "{text}"
6635 );
6636 let config = Config::load_from(&path).unwrap();
6637 assert!(
6638 !config.is_enabled(VendorId::Zai),
6639 "never widens to false, never flips others"
6640 );
6641 assert!(config.is_enabled(VendorId::Grok));
6642 assert!(config.is_enabled(VendorId::OpenCodeGo));
6643 }
6644
6645 #[test]
6646 fn enable_vendors_in_leaves_an_explicit_false_alone() {
6647 let dir = tempfile::TempDir::new().unwrap();
6648 let path = dir.path().join("config.toml");
6649 let original = "[grok]
6650enabled = false # off
6651api_key = \"k\"
6652";
6653 std::fs::write(&path, original).unwrap();
6654
6655 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6659
6660 assert!(written.is_empty(), "{written:?}");
6661 assert_eq!(
6662 std::fs::read_to_string(&path).unwrap(),
6663 original,
6664 "the file must not be rewritten at all"
6665 );
6666 }
6667
6668 #[test]
6669 fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
6670 let dir = tempfile::TempDir::new().unwrap();
6671 let path = dir.path().join("config.toml");
6672 std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
6673
6674 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6675
6676 assert_eq!(written, vec![VendorId::Grok]);
6677 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
6678 }
6679
6680 #[test]
6681 fn enable_vendors_in_enables_a_toml_inline_table() {
6682 let dir = tempfile::TempDir::new().unwrap();
6683 let path = dir.path().join("config.toml");
6684 std::fs::write(&path, "grok = { api_key = \"k\" }\n").unwrap();
6685
6686 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6687
6688 assert_eq!(written, vec![VendorId::Grok]);
6689 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
6690 let raw = std::fs::read_to_string(&path).unwrap();
6691 assert!(raw.contains("grok = {"));
6692 }
6693
6694 #[test]
6695 fn enable_vendors_in_is_textually_idempotent() {
6696 let dir = tempfile::TempDir::new().unwrap();
6697 let path = dir.path().join("config.toml");
6698 let original = "[grok]
6699enabled = true
6700
6701# trailing
6702";
6703 std::fs::write(&path, original).unwrap();
6704 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
6705
6706 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6707
6708 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
6709 assert_eq!(
6710 std::fs::metadata(&path).unwrap().modified().unwrap(),
6711 before,
6712 "an unchanged document must not be rewritten"
6713 );
6714 }
6715
6716 #[test]
6717 fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
6718 let dir = tempfile::TempDir::new().unwrap();
6719 let path = dir.path().join("config.toml");
6720
6721 enable_vendors_in(&path, &[]).unwrap();
6722
6723 assert!(!path.exists());
6724 }
6725}