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