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