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