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