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