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 notifications: NotificationsConfig,
76 pub custom: Vec<CustomProviderConfig>,
78}
79
80#[derive(Debug, Clone, Default, Deserialize, Serialize)]
84#[serde(default)]
85pub struct UiConfig {
86 pub primary: Option<VendorId>,
88 pub overview_vendors: Option<Vec<VendorId>>,
92 pub vendor_box: Option<VendorBoxStyle>,
94}
95
96impl UiConfig {
97 pub fn vendor_box(&self) -> VendorBoxStyle {
98 self.vendor_box.unwrap_or_default()
99 }
100}
101
102#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
106#[serde(default)]
107pub struct TrayConfig {
108 pub shortcut: Option<String>,
111 pub refresh_minutes: Option<u64>,
114 pub updates: Option<UpdateMode>,
116 pub menu_bar_style: Option<String>,
118}
119
120pub const TRAY_REFRESH_MINUTES: [u64; 3] = [1, 5, 10];
123const DEFAULT_TRAY_REFRESH_MINUTES: u64 = 5;
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
129#[serde(default)]
130pub struct NotificationsConfig {
131 pub enabled: bool,
132 pub threshold: u8,
135}
136
137impl Default for NotificationsConfig {
138 fn default() -> Self {
139 Self {
140 enabled: true,
141 threshold: 97,
142 }
143 }
144}
145
146impl TrayConfig {
147 pub fn refresh_minutes(&self) -> u64 {
148 self.refresh_minutes.unwrap_or(DEFAULT_TRAY_REFRESH_MINUTES)
149 }
150
151 pub fn updates(&self) -> UpdateMode {
152 self.updates.unwrap_or_default()
153 }
154}
155
156#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
159#[serde(rename_all = "lowercase")]
160pub enum UpdateMode {
161 Auto,
162 #[default]
163 Notify,
164 Off,
165}
166
167impl UpdateMode {
168 pub fn as_str(self) -> &'static str {
169 match self {
170 Self::Auto => "auto",
171 Self::Notify => "notify",
172 Self::Off => "off",
173 }
174 }
175
176 pub fn parse(text: &str) -> Option<Self> {
177 match text.trim().to_ascii_lowercase().as_str() {
178 "auto" => Some(Self::Auto),
179 "notify" => Some(Self::Notify),
180 "off" => Some(Self::Off),
181 _ => None,
182 }
183 }
184}
185
186#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
188#[serde(rename_all = "lowercase")]
189pub enum VendorBoxStyle {
190 #[default]
192 Sidebar,
193 Navbar,
195 None,
197}
198
199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
202#[serde(rename_all = "lowercase")]
203pub enum ContextLayout {
204 #[default]
206 Full,
207 Split,
209 Bottom,
211}
212
213impl ContextLayout {
214 pub fn next(self) -> Self {
215 match self {
216 ContextLayout::Full => ContextLayout::Split,
217 ContextLayout::Split => ContextLayout::Bottom,
218 ContextLayout::Bottom => ContextLayout::Full,
219 }
220 }
221
222 pub fn label(self) -> &'static str {
223 match self {
224 ContextLayout::Full => "full",
225 ContextLayout::Split => "split",
226 ContextLayout::Bottom => "bottom",
227 }
228 }
229}
230
231#[derive(Debug, Clone, Default, Deserialize, Serialize)]
236#[serde(default)]
237pub struct ContextConfig {
238 pub enabled: bool,
241 pub projects_path: Option<PathBuf>,
243 pub context_window_tokens: Option<u64>,
246 pub model_context_window_tokens: BTreeMap<String, u64>,
249 pub layout: ContextLayout,
251}
252
253impl ContextConfig {
254 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
255 model
256 .and_then(|model| self.model_context_window_tokens.get(model).copied())
257 .filter(|tokens| *tokens > 0)
258 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
259 }
260}
261
262#[derive(Debug, Clone, Deserialize, Serialize)]
263#[serde(default)]
264pub struct AnthropicConfig {
265 pub enabled: bool,
266 pub credentials_path: Option<PathBuf>,
269 pub accounts: Vec<AnthropicAccount>,
273 pub accounts_dir: Option<PathBuf>,
281 pub show_default_account: bool,
287 pub desktop_profiles_dir: Option<PathBuf>,
293}
294
295impl Default for AnthropicConfig {
296 fn default() -> Self {
297 Self {
298 enabled: true,
299 credentials_path: None,
300 accounts: Vec::new(),
301 accounts_dir: None,
302 show_default_account: true,
303 desktop_profiles_dir: None,
304 }
305 }
306}
307
308#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
318pub struct AnthropicAccount {
319 pub label: String,
322 pub credentials_path: PathBuf,
326}
327
328impl AnthropicAccount {
329 pub fn config_dir(&self) -> PathBuf {
334 self.credentials_path
335 .parent()
336 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
337 }
338}
339
340impl AnthropicConfig {
341 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
347 let mut out = self.accounts.clone();
348 if let Some(dir) = &self.accounts_dir {
349 for acct in discover_accounts(dir) {
350 if !out.iter().any(|a| a.label == acct.label) {
351 out.push(acct);
352 }
353 }
354 }
355 out
356 }
357
358 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
363 validate_account_label(label)?;
364 let all = self.all_accounts();
365 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
366 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
367 AppError::Credentials(format!(
368 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
369 known labels: {known:?}"
370 ))
371 })
372 }
373
374 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
385 let active = crate::anthropic::cli_account::home_claude_json()
386 .ok()
387 .and_then(|path| {
388 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
389 });
390 self.account_target_with(label, active.as_deref())
391 }
392
393 pub fn account_target_with(
401 &self,
402 label: &str,
403 cli_active: Option<&str>,
404 ) -> Result<(CredsTarget, Cache)> {
405 self.account_target_probing(label, cli_active, |path| path.exists())
406 }
407
408 pub fn account_target_probing(
428 &self,
429 label: &str,
430 cli_active: Option<&str>,
431 exists: impl Fn(&Path) -> bool,
432 ) -> Result<(CredsTarget, Cache)> {
433 let account = self.account(label)?;
434 let cache = Cache::for_vendor_account("anthropic", label)?;
435 if cli_active == Some(label) && !exists(&account.credentials_path) {
436 return Ok((
437 CredsTarget::Default(crate::anthropic::creds::default_path()?),
438 cache,
439 ));
440 }
441 Ok((
442 CredsTarget::Named {
443 config_dir: account.config_dir(),
444 path: account.credentials_path,
445 },
446 cache,
447 ))
448 }
449}
450
451pub fn validate_account_label(label: &str) -> Result<()> {
457 validate_account_label_for("anthropic", label)
458}
459
460fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
461 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
462 let bad = label.is_empty()
463 || label == "."
464 || label == ".."
465 || label.contains(['/', '\\'])
466 || label.contains(':')
467 || label.chars().any(char::is_control)
468 || RESERVED.contains(&label);
469 if bad {
470 return Err(AppError::Credentials(format!(
471 "invalid {vendor} account label {label:?}: must be a non-empty name \
472 without path separators, drive prefixes, control characters, or reserved cache names"
473 )));
474 }
475 Ok(())
476}
477
478fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
486 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
487 return Vec::new();
488 };
489 let mut found: Vec<AnthropicAccount> = entries
490 .flatten()
491 .filter_map(|entry| {
492 let path = entry.path();
493 if !path.is_dir() {
494 return None;
495 }
496 let label = path.file_name()?.to_str()?.to_string();
497 validate_account_label(&label).ok()?;
498 Some(AnthropicAccount {
499 label,
500 credentials_path: path.join(".credentials.json"),
501 })
502 })
503 .collect();
504 found.sort_by(|a, b| a.label.cmp(&b.label));
505 found
506}
507
508pub fn tildify(path: &Path, home: &Path) -> String {
512 path.strip_prefix(home)
513 .map(|rest| {
514 let rendered = rest.display().to_string();
515 #[cfg(windows)]
518 let rendered = rendered.replace('\\', "/");
519 format!("~/{rendered}")
520 })
521 .unwrap_or_else(|_| path.display().to_string())
522}
523
524pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
529 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
530 base.join("accounts").join(label).join(".credentials.json")
531}
532
533pub fn add_anthropic_account_to_doc(
539 doc: &mut toml_edit::DocumentMut,
540 label: &str,
541 credentials_path: &str,
542) -> Result<()> {
543 use toml_edit::{Item, Table, value};
544
545 validate_account_label(label)?;
546
547 let anthropic = doc
548 .entry("anthropic")
549 .or_insert_with(|| Item::Table(Table::new()));
550 let anthropic = anthropic
551 .as_table_mut()
552 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
553
554 let accounts = anthropic
555 .entry("accounts")
556 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
557 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
558 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
559 })?;
560
561 let exists = accounts
562 .iter()
563 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
564 if exists {
565 return Err(AppError::Credentials(format!(
566 "anthropic account {label:?} already exists in config.toml"
567 )));
568 }
569
570 let mut table = Table::new();
571 table["label"] = value(label);
572 table["credentials_path"] = value(credentials_path);
573 accounts.push(table);
574 Ok(())
575}
576
577pub fn default_codex_auth_path(home: &Path, label: &str) -> PathBuf {
581 home.join(format!(".codex-{label}")).join("auth.json")
582}
583
584pub fn add_openai_account_to_doc(
589 doc: &mut toml_edit::DocumentMut,
590 label: &str,
591 codex_auth_path: &str,
592) -> Result<()> {
593 use toml_edit::{Item, Table, value};
594
595 validate_account_label_for("openai", label)?;
596
597 let openai = doc
598 .entry("openai")
599 .or_insert_with(|| Item::Table(Table::new()));
600 let openai = openai
601 .as_table_mut()
602 .ok_or_else(|| AppError::Other("[openai] in config.toml is not a table".into()))?;
603
604 let accounts = openai
605 .entry("accounts")
606 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
607 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
608 AppError::Other("[[openai.accounts]] in config.toml is not an array of tables".into())
609 })?;
610
611 if accounts
612 .iter()
613 .any(|t| t.get("label").and_then(Item::as_str) == Some(label))
614 {
615 return Err(AppError::Credentials(format!(
616 "openai account {label:?} already exists in config.toml"
617 )));
618 }
619
620 let mut table = Table::new();
621 table["label"] = value(label);
622 table["codex_auth_path"] = value(codex_auth_path);
623 accounts.push(table);
624 Ok(())
625}
626
627pub(crate) fn set_bool(
631 doc: &mut toml_edit::DocumentMut,
632 section: &str,
633 key: &str,
634 new_value: bool,
635) -> Result<()> {
636 let table = doc
637 .entry(section)
638 .or_insert_with(toml_edit::table)
639 .as_table_mut()
640 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
641
642 if let Some(item) = table.get_mut(key)
643 && let Some(v) = item.as_value_mut()
644 {
645 let suffix = v.decor().suffix().cloned();
649 *v = toml_edit::Value::from(new_value);
650 v.decor_mut().set_prefix(" ");
651 if let Some(suffix) = suffix {
652 v.decor_mut().set_suffix(suffix);
653 }
654 return Ok(());
655 }
656 table.insert(key, toml_edit::value(new_value));
657 Ok(())
658}
659
660pub(crate) fn set_value(
666 doc: &mut toml_edit::DocumentMut,
667 section: &str,
668 key: &str,
669 new_value: Option<toml_edit::Value>,
670) -> Result<()> {
671 let table = doc
672 .entry(section)
673 .or_insert_with(toml_edit::table)
674 .as_table_mut()
675 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
676
677 let Some(mut new_value) = new_value else {
678 table.remove(key);
679 return Ok(());
680 };
681 if let Some(item) = table.get_mut(key)
682 && let Some(v) = item.as_value_mut()
683 {
684 let suffix = v.decor().suffix().cloned();
685 new_value.decor_mut().set_prefix(" ");
686 if let Some(suffix) = suffix {
687 new_value.decor_mut().set_suffix(suffix);
688 }
689 *v = new_value;
690 return Ok(());
691 }
692 table.insert(key, toml_edit::Item::Value(new_value));
693 Ok(())
694}
695
696pub fn set_tray_value(path: &Path, key: &str, value: Option<toml_edit::Value>) -> Result<()> {
702 let mut doc = read_config_document(path)?;
703 let before = doc.to_string();
704 set_value(&mut doc, "tray", key, value)?;
705 if doc.to_string() == before {
706 return Ok(());
707 }
708 write_config_document(path, &doc)
709}
710
711pub fn set_notification_value(path: &Path, key: &str, value: toml_edit::Value) -> Result<()> {
714 match key {
715 "enabled" if value.as_bool().is_some() => {}
716 "threshold" if value.as_integer().is_some_and(|n| (1..=100).contains(&n)) => {}
717 _ => {
718 return Err(AppError::Other(format!(
719 "invalid notification preference: {key}"
720 )));
721 }
722 }
723 let mut doc = read_config_document(path)?;
724 let before = doc.to_string();
725 set_value(&mut doc, "notifications", key, Some(value))?;
726 if doc.to_string() == before {
727 return Ok(());
728 }
729 write_config_document(path, &doc)
730}
731
732pub fn set_vendor_enabled(path: &Path, slug: &str, enabled: bool) -> Result<()> {
740 let mut doc = read_config_document(path)?;
741 let before = doc.to_string();
742 set_vendor_enabled_in_doc(&mut doc, slug, enabled)?;
743 if doc.to_string() == before {
744 return Ok(());
745 }
746 write_config_document(path, &doc)
747}
748
749pub fn set_vendor_enabled_in_doc(
754 doc: &mut toml_edit::DocumentMut,
755 slug: &str,
756 enabled: bool,
757) -> Result<()> {
758 let Some(vendor) = VendorId::from_slug(slug) else {
759 return Err(AppError::Other(format!(
760 "unknown provider {slug:?}: not a built-in vendor"
761 )));
762 };
763 set_bool(doc, vendor.config_section(), "enabled", enabled)
764}
765
766pub(crate) fn read_config_document(path: &Path) -> Result<toml_edit::DocumentMut> {
770 let original = match std::fs::read_to_string(path) {
771 Ok(contents) => contents,
772 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
773 Err(error) => return Err(AppError::io_at(path, error)),
774 };
775 if original.trim().is_empty() {
776 return Ok(toml_edit::DocumentMut::new());
777 }
778 original.parse().map_err(|e: toml_edit::TomlError| {
779 AppError::Other(format!("config.toml not parseable: {e}"))
780 })
781}
782
783pub(crate) fn write_config_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> {
787 let bytes = doc.to_string();
788 crate::cache::atomic_write(path, bytes.as_bytes())?;
789
790 #[cfg(unix)]
791 {
792 if let Ok(meta) = std::fs::metadata(path) {
793 let mut perms = meta.permissions();
794 perms.set_mode(0o600);
795 let _ = std::fs::set_permissions(path, perms);
796 }
797 }
798 Ok(())
799}
800
801pub fn enable_vendors_in(path: &Path, vendors: &[VendorId]) -> Result<Vec<VendorId>> {
809 let mut doc = read_config_document(path)?;
810 let before = doc.to_string();
811 let written: Vec<VendorId> = vendors
812 .iter()
813 .copied()
814 .filter(|vendor| !is_explicitly_disabled(&doc, *vendor))
815 .collect();
816 for vendor in &written {
817 set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
818 }
819 if doc.to_string() == before {
820 return Ok(written);
821 }
822 write_config_document(path, &doc)?;
823 Ok(written)
824}
825
826fn is_explicitly_disabled(doc: &toml_edit::DocumentMut, vendor: VendorId) -> bool {
837 doc.get(vendor.config_section())
838 .and_then(|section| section.get("enabled"))
839 .and_then(|enabled| enabled.as_bool())
840 == Some(false)
841}
842
843#[derive(Debug, Clone, Deserialize, Serialize)]
844#[serde(default)]
845pub struct OpenAiConfig {
846 pub enabled: bool,
847 pub codex_auth_path: Option<PathBuf>,
849 #[serde(default)]
854 pub accounts: Vec<OpenAiAccount>,
855 pub show_default_account: bool,
861 pub admin_key_env: String,
869}
870
871#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
882pub struct OpenAiAccount {
883 pub label: String,
886 pub codex_auth_path: PathBuf,
889}
890
891impl OpenAiConfig {
892 pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
896 let Some(label) = label else {
897 return match &self.codex_auth_path {
898 Some(path) => Ok(path.clone()),
899 None => crate::openai::creds::default_path(),
900 };
901 };
902 self.accounts
903 .iter()
904 .find(|account| account.label == label)
905 .map(|account| account.codex_auth_path.clone())
906 .ok_or_else(|| {
907 AppError::Credentials(format!(
908 "no OpenAI account named {label:?}. Add it under \
909 [[openai.accounts]], or drop --account to use the default login."
910 ))
911 })
912 }
913
914 pub fn fetch_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
921 let Some(label) = label else {
922 return self.resolve_auth_path(None);
923 };
924 let default = self.resolve_auth_path(None)?;
925 let active = crate::openai::account::resolve_active_label(&default, &self.accounts);
926 self.fetch_auth_path_probing(label, active.as_deref(), Path::exists)
927 }
928
929 pub fn fetch_auth_path_probing(
934 &self,
935 label: &str,
936 active: Option<&str>,
937 exists: impl Fn(&Path) -> bool,
938 ) -> Result<PathBuf> {
939 let own = self.resolve_auth_path(Some(label))?;
940 if active == Some(label) && !exists(&own) {
941 return self.resolve_auth_path(None);
942 }
943 Ok(own)
944 }
945}
946
947impl Default for OpenAiConfig {
948 fn default() -> Self {
949 Self {
950 enabled: true,
951 codex_auth_path: None,
952 accounts: Vec::new(),
953 show_default_account: true,
954 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
955 }
956 }
957}
958
959#[derive(Debug, Clone, Default, Deserialize, Serialize)]
963#[serde(default)]
964pub struct CopilotConfig {
965 pub enabled: bool,
966 pub gh_binary: Option<PathBuf>,
969}
970
971impl CopilotConfig {
972 pub fn resolve_token(&self) -> Result<String> {
973 self.resolve_token_with(
974 |name| std::env::var_os(name),
975 &crate::copilot::credentials::SystemGhAuthTokenRunner,
976 )
977 }
978
979 fn resolve_token_with(
980 &self,
981 environment: impl Fn(&str) -> Option<std::ffi::OsString>,
982 runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
983 ) -> Result<String> {
984 if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
985 let token = value.into_string().map_err(|_| {
986 AppError::Credentials(
987 "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
988 )
989 })?;
990 if !token.is_empty() {
991 return Ok(token);
992 }
993 }
994 crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref())
995 }
996}
997
998#[derive(Debug, Clone, Default, Deserialize, Serialize)]
999#[serde(default)]
1000pub struct NousConfig {
1001 pub enabled: bool,
1002}
1003
1004#[derive(Debug, Clone, Deserialize, Serialize)]
1005#[serde(default)]
1006pub struct OpenCodeGoConfig {
1007 pub enabled: bool,
1008 pub api_key_env: String,
1009 pub api_key: Option<String>,
1010}
1011
1012#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1017#[serde(default)]
1018pub struct CommandCodeConfig {
1019 pub enabled: bool,
1020 pub auth_paths: Option<Vec<PathBuf>>,
1021}
1022
1023#[derive(Debug, Clone, Deserialize, Serialize)]
1028#[serde(default)]
1029pub struct OllamaConfig {
1030 pub enabled: bool,
1031 pub api_key_env: String,
1032 pub api_key: Option<String>,
1033 pub plan: String,
1036}
1037
1038impl Default for OllamaConfig {
1039 fn default() -> Self {
1040 Self {
1041 enabled: false,
1042 api_key_env: "OLLAMA_API_KEY".to_string(),
1043 api_key: None,
1044 plan: "pro".to_string(),
1045 }
1046 }
1047}
1048
1049#[derive(Debug, Clone, Deserialize, Serialize)]
1052#[serde(default)]
1053pub struct OrcaRouterConfig {
1054 pub enabled: bool,
1055 pub accounts: Vec<ApiKeyAccount>,
1058 pub show_default_account: bool,
1062 pub api_key_env: String,
1063 pub api_key: Option<String>,
1064}
1065
1066impl Default for OrcaRouterConfig {
1067 fn default() -> Self {
1068 Self {
1069 enabled: false,
1070 accounts: Vec::new(),
1071 show_default_account: true,
1072 api_key_env: "ORCAROUTER_API_KEY".to_string(),
1073 api_key: None,
1074 }
1075 }
1076}
1077
1078#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1085#[serde(default)]
1086pub struct ModelStudioConfig {
1087 pub enabled: bool,
1090 pub config_dir: Option<PathBuf>,
1093}
1094
1095impl Default for OpenCodeGoConfig {
1096 fn default() -> Self {
1097 Self {
1098 enabled: false,
1099 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
1100 api_key: None,
1101 }
1102 }
1103}
1104
1105#[derive(Debug, Clone, Deserialize, Serialize)]
1106#[serde(default)]
1107pub struct ZaiConfig {
1108 pub enabled: bool,
1109 pub accounts: Vec<ApiKeyAccount>,
1112 pub show_default_account: bool,
1116 pub api_key_env: String,
1118 pub api_key: Option<String>,
1121 pub plan_tier: Option<String>,
1123}
1124
1125impl Default for ZaiConfig {
1126 fn default() -> Self {
1127 Self {
1128 enabled: true,
1129 accounts: Vec::new(),
1130 show_default_account: true,
1131 api_key_env: "ZAI_API_KEY".to_string(),
1132 api_key: None,
1133 plan_tier: None,
1134 }
1135 }
1136}
1137
1138#[derive(Debug, Clone, Deserialize, Serialize)]
1139#[serde(default)]
1140pub struct OpenRouterConfig {
1141 pub enabled: bool,
1142 pub accounts: Vec<ApiKeyAccount>,
1145 pub show_default_account: bool,
1149 pub api_key_env: String,
1150 pub api_key: Option<String>,
1151 pub headline: Headline,
1163}
1164
1165impl Default for OpenRouterConfig {
1166 fn default() -> Self {
1167 Self {
1168 enabled: true,
1169 accounts: Vec::new(),
1170 show_default_account: true,
1171 api_key_env: "OPENROUTER_API_KEY".to_string(),
1172 api_key: None,
1173 headline: Headline::Percent,
1174 }
1175 }
1176}
1177
1178#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1183pub struct ApiKeyAccount {
1184 pub label: String,
1186 #[serde(default)]
1188 pub api_key_env: Option<String>,
1189 #[serde(default)]
1191 pub api_key: Option<String>,
1192}
1193
1194fn api_key_account<'a>(
1198 slug: &str,
1199 accounts: &'a [ApiKeyAccount],
1200 label: &str,
1201) -> Result<&'a ApiKeyAccount> {
1202 validate_account_label_for(slug, label)?;
1203 accounts
1204 .iter()
1205 .find(|account| account.label == label)
1206 .ok_or_else(|| {
1207 let known: Vec<&str> = accounts
1208 .iter()
1209 .map(|account| account.label.as_str())
1210 .collect();
1211 AppError::Credentials(format!(
1212 "{slug} account {label:?} not found in [[{slug}.accounts]]; \
1213 known labels: {known:?}"
1214 ))
1215 })
1216}
1217
1218#[derive(Debug, Clone, Deserialize, Serialize)]
1219#[serde(default)]
1220pub struct DeepseekConfig {
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 pub display_limit: Option<f64>,
1234 pub headline: Headline,
1236}
1237
1238impl Default for DeepseekConfig {
1239 fn default() -> Self {
1240 Self {
1241 enabled: false,
1242 accounts: Vec::new(),
1243 show_default_account: true,
1244 api_key_env: "DEEPSEEK_API_KEY".to_string(),
1245 api_key: None,
1246 display_limit: None,
1247 headline: Headline::Amount,
1248 }
1249 }
1250}
1251
1252#[derive(Debug, Clone, Deserialize, Serialize)]
1253#[serde(default)]
1254pub struct DeepInfraConfig {
1255 pub enabled: bool,
1256 pub accounts: Vec<ApiKeyAccount>,
1257 pub show_default_account: bool,
1258 pub api_key_env: String,
1259 pub api_key: Option<String>,
1260 pub display_limit: Option<f64>,
1262 pub headline: Headline,
1263}
1264
1265impl Default for DeepInfraConfig {
1266 fn default() -> Self {
1267 Self {
1268 enabled: false,
1269 accounts: Vec::new(),
1270 show_default_account: true,
1271 api_key_env: "DEEPINFRA_API_KEY".to_string(),
1272 api_key: None,
1273 display_limit: None,
1274 headline: Headline::Amount,
1275 }
1276 }
1277}
1278
1279#[derive(Debug, Clone, Deserialize, Serialize)]
1280#[serde(default)]
1281pub struct KimiConfig {
1282 pub enabled: bool,
1283 pub api_key_env: String,
1284 pub api_key: Option<String>,
1287 pub credentials_path: Option<PathBuf>,
1291 pub region: String,
1296}
1297
1298impl Default for KimiConfig {
1299 fn default() -> Self {
1300 Self {
1301 enabled: false,
1302 api_key_env: "KIMI_API_KEY".to_string(),
1303 api_key: None,
1304 credentials_path: None,
1305 region: "auto".to_string(),
1306 }
1307 }
1308}
1309
1310#[derive(Debug, Clone, Deserialize, Serialize)]
1311#[serde(default)]
1312pub struct KiloConfig {
1313 pub enabled: bool,
1314 pub accounts: Vec<ApiKeyAccount>,
1317 pub show_default_account: bool,
1321 pub api_key_env: String,
1322 pub api_key: Option<String>,
1323 pub organization_id: Option<String>,
1326 pub display_limit: Option<f64>,
1329 pub headline: Headline,
1331}
1332
1333impl Default for KiloConfig {
1334 fn default() -> Self {
1335 Self {
1338 enabled: false,
1339 accounts: Vec::new(),
1340 show_default_account: true,
1341 api_key_env: "KILO_API_KEY".to_string(),
1342 api_key: None,
1343 organization_id: None,
1344 display_limit: None,
1345 headline: Headline::Amount,
1346 }
1347 }
1348}
1349
1350#[derive(Debug, Clone, Deserialize, Serialize)]
1351#[serde(default)]
1352pub struct NovitaConfig {
1353 pub enabled: bool,
1354 pub accounts: Vec<ApiKeyAccount>,
1357 pub show_default_account: bool,
1361 pub api_key_env: String,
1362 pub api_key: Option<String>,
1363 pub display_limit: Option<f64>,
1367 pub headline: Headline,
1369}
1370
1371impl Default for NovitaConfig {
1372 fn default() -> Self {
1373 Self {
1375 enabled: false,
1376 accounts: Vec::new(),
1377 show_default_account: true,
1378 api_key_env: "NOVITA_API_KEY".to_string(),
1379 api_key: None,
1380 display_limit: None,
1381 headline: Headline::Amount,
1382 }
1383 }
1384}
1385
1386#[derive(Debug, Clone, Deserialize, Serialize)]
1387#[serde(default)]
1388pub struct MinimaxConfig {
1389 pub enabled: bool,
1390 pub accounts: Vec<ApiKeyAccount>,
1393 pub show_default_account: bool,
1397 pub api_key_env: String,
1398 pub api_key: Option<String>,
1399 pub region: String,
1405}
1406
1407impl Default for MinimaxConfig {
1408 fn default() -> Self {
1409 Self {
1411 enabled: false,
1412 accounts: Vec::new(),
1413 show_default_account: true,
1414 api_key_env: "MINIMAX_API_KEY".to_string(),
1415 api_key: None,
1416 region: "global".to_string(),
1417 }
1418 }
1419}
1420
1421#[derive(Debug, Clone, Deserialize, Serialize)]
1422#[serde(default)]
1423pub struct MoonshotConfig {
1424 pub enabled: bool,
1425 pub accounts: Vec<ApiKeyAccount>,
1428 pub show_default_account: bool,
1432 pub api_key_env: String,
1433 pub api_key: Option<String>,
1434 pub region: String,
1436 pub display_limit: Option<f64>,
1439 pub headline: Headline,
1441}
1442
1443impl Default for MoonshotConfig {
1444 fn default() -> Self {
1445 Self {
1447 enabled: false,
1448 accounts: Vec::new(),
1449 show_default_account: true,
1450 api_key_env: "MOONSHOT_API_KEY".to_string(),
1451 api_key: None,
1452 region: "global".to_string(),
1453 display_limit: None,
1454 headline: Headline::Amount,
1455 }
1456 }
1457}
1458
1459#[derive(Debug, Clone, Deserialize, Serialize)]
1460#[serde(default)]
1461pub struct GrokConfig {
1462 pub enabled: bool,
1463 pub accounts: Vec<ApiKeyAccount>,
1466 pub show_default_account: bool,
1470 pub api_key_env: String,
1472 pub api_key: Option<String>,
1473 pub team_id: Option<String>,
1476 pub display_limit: Option<f64>,
1478 pub headline: Headline,
1480}
1481
1482impl Default for GrokConfig {
1483 fn default() -> Self {
1484 Self {
1486 enabled: false,
1487 accounts: Vec::new(),
1488 show_default_account: true,
1489 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1490 api_key: None,
1491 team_id: None,
1492 display_limit: None,
1493 headline: Headline::Amount,
1494 }
1495 }
1496}
1497
1498#[derive(Debug, Clone, Deserialize, Serialize)]
1506#[serde(default)]
1507pub struct SuperGrokConfig {
1508 pub enabled: bool,
1509 pub grok_binary: PathBuf,
1513 pub auth_path: Option<PathBuf>,
1516 pub config_path: Option<PathBuf>,
1517}
1518
1519impl Default for SuperGrokConfig {
1520 fn default() -> Self {
1521 Self {
1522 enabled: false,
1523 grok_binary: default_grok_binary(),
1524 auth_path: None,
1525 config_path: None,
1526 }
1527 }
1528}
1529
1530#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1536#[serde(default)]
1537pub struct GrokbotConfig {
1538 pub enabled: bool,
1541 pub secrets_path: Option<PathBuf>,
1546}
1547
1548fn default_grok_binary() -> PathBuf {
1549 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1550 let grok_home = std::env::var_os("GROK_HOME")
1551 .filter(|value| !value.is_empty())
1552 .map(PathBuf::from)
1553 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1554 grok_home
1555 .map(|home| home.join("bin").join(executable))
1556 .unwrap_or_else(|| PathBuf::from(executable))
1557}
1558
1559#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1567#[serde(default)]
1568pub struct AntigravityConfig {
1569 pub enabled: bool,
1570 pub oauth_client_id: Option<String>,
1572 pub oauth_client_secret: Option<String>,
1576}
1577
1578#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1588#[serde(default)]
1589pub struct CursorConfig {
1590 pub enabled: bool,
1591 pub db_path: Option<PathBuf>,
1595 pub agent_auth_path: Option<PathBuf>,
1600}
1601
1602#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1611#[serde(default)]
1612pub struct KiroConfig {
1613 pub enabled: bool,
1614 pub db_path: Option<PathBuf>,
1618}
1619
1620#[derive(Debug, Clone, Deserialize, Serialize)]
1621#[serde(default)]
1622pub struct AnthropicApiConfig {
1623 pub enabled: bool,
1624 pub api_key_env: String,
1627 pub api_key: Option<String>,
1628 pub monthly_limit: Option<f64>,
1631}
1632
1633impl Default for AnthropicApiConfig {
1634 fn default() -> Self {
1635 Self {
1637 enabled: false,
1638 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
1639 api_key: None,
1640 monthly_limit: None,
1641 }
1642 }
1643}
1644
1645#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1655#[serde(default, remote = "Self")]
1656pub struct CustomProviderConfig {
1657 pub id: String,
1659 pub name: String,
1661 pub short_name: String,
1664 pub brand: Option<String>,
1667 pub enabled: bool,
1668 pub url: String,
1670 pub allow_http: bool,
1671 pub api_key_env: String,
1673 pub api_key: Option<String>,
1674 pub auth_header: String,
1676 pub auth_scheme: String,
1678 pub headers: BTreeMap<String, String>,
1680 pub plan: Option<String>,
1682 pub plan_path: Option<String>,
1684 pub cache_ttl_secs: u64,
1686 pub metrics: Vec<CustomMetricSpec>,
1687 pub texts: Vec<CustomTextSpec>,
1688}
1689
1690impl Default for CustomProviderConfig {
1691 fn default() -> Self {
1692 Self {
1693 id: String::new(),
1694 name: String::new(),
1695 short_name: String::new(),
1696 brand: None,
1697 enabled: false,
1698 url: String::new(),
1699 allow_http: false,
1700 api_key_env: String::new(),
1701 api_key: None,
1702 auth_header: "Authorization".to_string(),
1703 auth_scheme: "Bearer".to_string(),
1704 headers: BTreeMap::new(),
1705 plan: None,
1706 plan_path: None,
1707 cache_ttl_secs: 60,
1708 metrics: Vec::new(),
1709 texts: Vec::new(),
1710 }
1711 }
1712}
1713
1714impl<'de> Deserialize<'de> for CustomProviderConfig {
1719 fn deserialize<D: serde::Deserializer<'de>>(
1720 deserializer: D,
1721 ) -> std::result::Result<Self, D::Error> {
1722 let mut this = Self::deserialize(deserializer)?;
1723 if this.name.is_empty() {
1724 this.name = this.id.clone();
1725 }
1726 Ok(this)
1727 }
1728}
1729
1730impl Serialize for CustomProviderConfig {
1731 fn serialize<S: serde::Serializer>(
1732 &self,
1733 serializer: S,
1734 ) -> std::result::Result<S::Ok, S::Error> {
1735 Self::serialize(self, serializer)
1736 }
1737}
1738
1739#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1743#[serde(default)]
1744pub struct CustomMetricSpec {
1745 pub label: String,
1746 pub used: Option<String>,
1747 pub limit: Option<String>,
1748 pub percent: Option<String>,
1749 pub resets_at: Option<String>,
1751 pub window_secs: Option<u64>,
1753}
1754
1755#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1757#[serde(default)]
1758pub struct CustomTextSpec {
1759 pub label: String,
1760 pub value: String,
1761}
1762
1763impl CustomProviderConfig {
1764 pub fn section_label(&self) -> String {
1766 format!("[[custom]] id = {:?}", self.id)
1767 }
1768
1769 pub fn resolve_api_key(&self) -> Result<String> {
1772 if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
1773 return Ok(key);
1774 }
1775 let advice = if self.api_key_env.is_empty() {
1776 "set `api_key`, or name an environment variable in `api_key_env`".to_string()
1777 } else {
1778 format!("export {} or set `api_key`", self.api_key_env)
1779 };
1780 Err(AppError::Credentials(format!(
1781 "custom {}: no API key. Either {advice} under {} in {}.",
1782 self.id,
1783 self.section_label(),
1784 config_path_hint()
1785 )))
1786 }
1787
1788 pub fn cache_ttl(&self) -> std::time::Duration {
1789 std::time::Duration::from_secs(self.cache_ttl_secs)
1790 }
1791
1792 fn validate(&self, index: usize) -> Result<()> {
1796 if !is_valid_custom_id(&self.id) {
1797 return Err(AppError::Other(format!(
1798 "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
1799 index + 1,
1800 self.id
1801 )));
1802 }
1803 let section = self.section_label();
1804 let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
1805
1806 if VendorId::all().iter().any(|v| v.slug() == self.id) {
1807 return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
1808 }
1809 let name_len = self.name.chars().count();
1810 if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
1811 return Err(bad(
1812 "name must be 1 to 48 characters without control characters".into(),
1813 ));
1814 }
1815 if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
1816 return Err(bad(format!(
1817 "short_name {:?} must be exactly 3 lowercase ASCII letters",
1818 self.short_name
1819 )));
1820 }
1821 if let Some(brand) = &self.brand
1822 && !VendorId::all().iter().any(|v| v.slug() == brand)
1823 {
1824 return Err(bad(format!(
1825 "brand {brand:?} must name a built-in vendor (it borrows that \
1826 vendor's mark); leave it unset to keep the short_name tag"
1827 )));
1828 }
1829 let url = reqwest::Url::parse(&self.url)
1830 .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
1831 match url.scheme() {
1832 "https" => {}
1833 "http" if self.allow_http => {}
1834 "http" => {
1835 return Err(bad(
1836 "url must use https:// (set allow_http = true to permit http://)".into(),
1837 ));
1838 }
1839 other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
1840 }
1841 if !url.username().is_empty() || url.password().is_some() {
1842 return Err(bad("url must not carry credentials (user:pass@)".into()));
1843 }
1844 if url.host_str().is_none() {
1845 return Err(bad("url has no host".into()));
1846 }
1847 if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
1848 return Err(bad(format!(
1849 "api_key_env {:?} is not a valid environment variable name",
1850 self.api_key_env
1851 )));
1852 }
1853 validate_header_name(§ion, "auth_header", &self.auth_header)?;
1854 if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
1855 return Err(bad(
1856 "auth_scheme contains characters that are not valid in an HTTP header".into(),
1857 ));
1858 }
1859 for (name, value) in &self.headers {
1860 validate_header_name(§ion, "headers", name)?;
1861 if name.eq_ignore_ascii_case(&self.auth_header) {
1862 return Err(bad(format!(
1863 "headers must not repeat auth_header {:?}",
1864 self.auth_header
1865 )));
1866 }
1867 if reqwest::header::HeaderValue::from_str(value).is_err() {
1868 return Err(bad(format!(
1869 "header {name:?} has a value that is not valid in an HTTP header"
1870 )));
1871 }
1872 }
1873 if let Some(plan) = &self.plan {
1874 validate_custom_label(§ion, "plan", plan)?;
1875 }
1876 if let Some(pointer) = &self.plan_path {
1877 validate_pointer(§ion, "plan_path", pointer)?;
1878 }
1879 if !(10..=3600).contains(&self.cache_ttl_secs) {
1880 return Err(bad(format!(
1881 "cache_ttl_secs must be between 10 and 3600, got {}",
1882 self.cache_ttl_secs
1883 )));
1884 }
1885 if self.metrics.is_empty() && self.texts.is_empty() {
1886 return Err(bad(
1887 "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
1888 ));
1889 }
1890 let mut metric_labels = HashSet::new();
1891 for metric in &self.metrics {
1892 validate_custom_label(§ion, "metric label", &metric.label)?;
1893 if !metric_labels.insert(metric.label.as_str()) {
1894 return Err(bad(format!("duplicate metric label {:?}", metric.label)));
1895 }
1896 let pair = (metric.used.is_some(), metric.limit.is_some());
1897 let well_formed = if metric.percent.is_some() {
1898 pair == (false, false)
1899 } else {
1900 pair == (true, true)
1901 };
1902 if !well_formed {
1903 return Err(bad(format!(
1904 "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
1905 metric.label
1906 )));
1907 }
1908 for (field, pointer) in [
1909 ("used", &metric.used),
1910 ("limit", &metric.limit),
1911 ("percent", &metric.percent),
1912 ("resets_at", &metric.resets_at),
1913 ] {
1914 if let Some(pointer) = pointer {
1915 validate_pointer(§ion, field, pointer)?;
1916 }
1917 }
1918 if let Some(secs) = metric.window_secs
1919 && secs < 60
1920 {
1921 return Err(bad(format!(
1922 "metric {:?} window_secs must be at least 60, got {secs}",
1923 metric.label
1924 )));
1925 }
1926 }
1927 let mut text_labels = HashSet::new();
1928 for text in &self.texts {
1929 validate_custom_label(§ion, "text label", &text.label)?;
1930 if !text_labels.insert(text.label.as_str()) {
1931 return Err(bad(format!("duplicate text label {:?}", text.label)));
1932 }
1933 validate_pointer(§ion, "value", &text.value)?;
1934 }
1935 Ok(())
1936 }
1937}
1938
1939fn is_valid_custom_id(id: &str) -> bool {
1940 let bytes = id.as_bytes();
1941 let Some(&first) = bytes.first() else {
1942 return false;
1943 };
1944 bytes.len() <= 32
1945 && (first.is_ascii_lowercase() || first.is_ascii_digit())
1946 && bytes
1947 .iter()
1948 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
1949}
1950
1951fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
1952 if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
1953 return Err(AppError::Other(format!(
1954 "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
1955 )));
1956 }
1957 Ok(())
1958}
1959
1960fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
1961 let len = label.chars().count();
1962 if len == 0 || len > 64 || label.chars().any(char::is_control) {
1963 return Err(AppError::Other(format!(
1964 "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
1965 )));
1966 }
1967 Ok(())
1968}
1969
1970fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
1971 if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
1972 return Err(AppError::Other(format!(
1973 "{section}: {field} {name:?} is not a valid HTTP header name"
1974 )));
1975 }
1976 Ok(())
1977}
1978
1979pub fn resolve_api_key(
1982 vendor_label: &str,
1983 env_var_name: &str,
1984 inline: Option<&str>,
1985) -> crate::error::Result<String> {
1986 let section = match vendor_label {
1987 "OpenCode Go" => "[opencode-go]".to_string(),
1988 _ => format!("[{}]", vendor_label.to_lowercase()),
1989 };
1990 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
1991}
1992
1993pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
1997 if is_valid_env_var_name(env_var_name)
1998 && let Ok(v) = std::env::var(env_var_name)
1999 && !v.is_empty()
2000 {
2001 return Some(v);
2002 }
2003 inline.filter(|v| !v.is_empty()).map(str::to_string)
2004}
2005
2006fn resolve_api_key_in_section(
2007 vendor_label: &str,
2008 section: &str,
2009 env_var_name: &str,
2010 inline: Option<&str>,
2011) -> crate::error::Result<String> {
2012 if let Some(key) = optional_api_key(env_var_name, inline) {
2013 return Ok(key);
2014 }
2015 let valid_env_name = is_valid_env_var_name(env_var_name);
2016 let advice = if valid_env_name {
2017 "set an API key in a valid environment variable or set `api_key`"
2018 } else {
2019 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
2020 };
2021 Err(crate::error::AppError::Credentials(format!(
2022 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
2023 config_path_hint()
2024 )))
2025}
2026
2027pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
2028 let mut chars = name.chars();
2029 let Some(first) = chars.next() else {
2030 return false;
2031 };
2032 (first.is_ascii_alphabetic() || first == '_')
2033 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2034}
2035
2036impl Config {
2037 pub fn load() -> Result<Self> {
2040 let Some(path) = resolved_path() else {
2041 return Ok(Self::default());
2042 };
2043 Self::load_from(&path)
2044 }
2045
2046 pub fn load_from(path: &std::path::Path) -> Result<Self> {
2047 match std::fs::read_to_string(path) {
2048 Ok(s) => {
2049 let mut config: Self = toml::from_str(&s)?;
2050 config.expand_paths();
2054 config.validate()?;
2055 #[cfg(unix)]
2056 config.protect_inline_secrets(path)?;
2057 crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
2061 Ok(config)
2062 }
2063 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
2064 Err(e) => Err(AppError::io_at(path, e)),
2065 }
2066 }
2067
2068 fn expand_paths(&mut self) {
2069 expand_tilde_opt(&mut self.context.projects_path);
2070 expand_tilde_opt(&mut self.anthropic.credentials_path);
2071 expand_tilde_opt(&mut self.anthropic.accounts_dir);
2072 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
2073 expand_tilde_opt(&mut self.openai.codex_auth_path);
2074 expand_tilde_opt(&mut self.cursor.db_path);
2075 expand_tilde_opt(&mut self.cursor.agent_auth_path);
2076 expand_tilde_opt(&mut self.kiro.db_path);
2077 expand_tilde_opt(&mut self.kimi.credentials_path);
2078 expand_tilde_opt(&mut self.grokbot.secrets_path);
2079 expand_tilde_opt(&mut self.modelstudio.config_dir);
2080 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
2081 expand_tilde_opt(&mut self.supergrok.auth_path);
2082 expand_tilde_opt(&mut self.supergrok.config_path);
2083 for account in &mut self.anthropic.accounts {
2084 account.credentials_path = expand_tilde(&account.credentials_path);
2085 }
2086 for account in &mut self.openai.accounts {
2087 account.codex_auth_path = expand_tilde(&account.codex_auth_path);
2088 }
2089 }
2090
2091 #[cfg(unix)]
2095 fn has_inline_secrets(&self) -> bool {
2096 [
2097 self.zai.api_key.as_deref(),
2098 self.openrouter.api_key.as_deref(),
2099 self.deepseek.api_key.as_deref(),
2100 self.deepinfra.api_key.as_deref(),
2101 self.kimi.api_key.as_deref(),
2102 self.kilo.api_key.as_deref(),
2103 self.novita.api_key.as_deref(),
2104 self.minimax.api_key.as_deref(),
2105 self.moonshot.api_key.as_deref(),
2106 self.grok.api_key.as_deref(),
2107 self.anthropic_api.api_key.as_deref(),
2108 self.opencode_go.api_key.as_deref(),
2109 self.orcarouter.api_key.as_deref(),
2110 self.antigravity.oauth_client_secret.as_deref(),
2111 ]
2112 .into_iter()
2113 .chain(
2114 Self::API_KEY_ACCOUNT_VENDORS
2115 .into_iter()
2116 .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2117 .map(|account| account.api_key.as_deref()),
2118 )
2119 .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
2120 .any(|key| key.is_some_and(|key| !key.is_empty()))
2121 }
2122
2123 fn custom_secret_env_vars(&self) -> Vec<String> {
2124 self.custom
2125 .iter()
2126 .filter(|c| !c.api_key_env.is_empty())
2127 .map(|c| c.api_key_env.clone())
2128 .collect()
2129 }
2130
2131 pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
2133 self.custom.iter().filter(|c| c.enabled)
2134 }
2135
2136 pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
2138 self.custom.iter().find(|c| c.id == id)
2139 }
2140
2141 #[cfg(unix)]
2142 fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
2143 if !self.has_inline_secrets() {
2144 return Ok(());
2145 }
2146
2147 let metadata = std::fs::metadata(path).map_err(|_| {
2148 AppError::Credentials(format!(
2149 "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
2150 path.display()
2151 ))
2152 })?;
2153 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
2154 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
2155 AppError::Credentials(format!(
2156 "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",
2157 path.display()
2158 ))
2159 })?;
2160 }
2161 Ok(())
2162 }
2163
2164 pub fn is_enabled(&self, id: VendorId) -> bool {
2165 match id {
2166 VendorId::Anthropic => self.anthropic.enabled,
2167 VendorId::AnthropicApi => self.anthropic_api.enabled,
2168 VendorId::Openai => self.openai.enabled,
2169 VendorId::Copilot => self.copilot.enabled,
2170 VendorId::Zai => self.zai.enabled,
2171 VendorId::Openrouter => self.openrouter.enabled,
2172 VendorId::Deepseek => self.deepseek.enabled,
2173 VendorId::Deepinfra => self.deepinfra.enabled,
2174 VendorId::Kimi => self.kimi.enabled,
2175 VendorId::Kilo => self.kilo.enabled,
2176 VendorId::Novita => self.novita.enabled,
2177 VendorId::Moonshot => self.moonshot.enabled,
2178 VendorId::Grok => self.grok.enabled,
2179 VendorId::Supergrok => self.supergrok.enabled,
2180 VendorId::Grokbot => self.grokbot.enabled,
2181 VendorId::Antigravity => self.antigravity.enabled,
2182 VendorId::Cursor => self.cursor.enabled,
2183 VendorId::Minimax => self.minimax.enabled,
2184 VendorId::Kiro => self.kiro.enabled,
2185 VendorId::NousResearch => self.nous.enabled,
2186 VendorId::OpenCodeGo => self.opencode_go.enabled,
2187 VendorId::CommandCode => self.commandcode.enabled,
2188 VendorId::Ollama => self.ollama.enabled,
2189 VendorId::OrcaRouter => self.orcarouter.enabled,
2190 VendorId::ModelStudio => self.modelstudio.enabled,
2191 }
2192 }
2193
2194 pub fn api_key_env_for(&self, id: VendorId) -> &str {
2201 match id {
2202 VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
2203 VendorId::Zai => &self.zai.api_key_env,
2204 VendorId::Openrouter => &self.openrouter.api_key_env,
2205 VendorId::Deepseek => &self.deepseek.api_key_env,
2206 VendorId::Deepinfra => &self.deepinfra.api_key_env,
2207 VendorId::Kimi => &self.kimi.api_key_env,
2208 VendorId::Kilo => &self.kilo.api_key_env,
2209 VendorId::Novita => &self.novita.api_key_env,
2210 VendorId::Moonshot => &self.moonshot.api_key_env,
2211 VendorId::Grok => &self.grok.api_key_env,
2212 VendorId::Minimax => &self.minimax.api_key_env,
2213 VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
2214 VendorId::Ollama => &self.ollama.api_key_env,
2215 VendorId::OrcaRouter => &self.orcarouter.api_key_env,
2216 VendorId::Anthropic
2219 | VendorId::Openai
2220 | VendorId::Copilot
2221 | VendorId::Supergrok
2222 | VendorId::Grokbot
2223 | VendorId::Antigravity
2224 | VendorId::Cursor
2225 | VendorId::Kiro
2226 | VendorId::NousResearch
2227 | VendorId::CommandCode
2228 | VendorId::ModelStudio => id.api_key_env(),
2229 }
2230 }
2231
2232 pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
2236 let raw = match id {
2237 VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
2238 VendorId::Zai => self.zai.api_key.as_deref(),
2239 VendorId::Openrouter => self.openrouter.api_key.as_deref(),
2240 VendorId::Deepseek => self.deepseek.api_key.as_deref(),
2241 VendorId::Deepinfra => self.deepinfra.api_key.as_deref(),
2242 VendorId::Kimi => self.kimi.api_key.as_deref(),
2243 VendorId::Kilo => self.kilo.api_key.as_deref(),
2244 VendorId::Novita => self.novita.api_key.as_deref(),
2245 VendorId::Moonshot => self.moonshot.api_key.as_deref(),
2246 VendorId::Grok => self.grok.api_key.as_deref(),
2247 VendorId::Minimax => self.minimax.api_key.as_deref(),
2248 VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
2249 VendorId::Ollama => self.ollama.api_key.as_deref(),
2250 VendorId::OrcaRouter => self.orcarouter.api_key.as_deref(),
2251 VendorId::Anthropic
2252 | VendorId::Openai
2253 | VendorId::Copilot
2254 | VendorId::Supergrok
2255 | VendorId::Grokbot
2256 | VendorId::Antigravity
2257 | VendorId::Cursor
2258 | VendorId::Kiro
2259 | VendorId::NousResearch
2260 | VendorId::CommandCode
2261 | VendorId::ModelStudio => None,
2262 };
2263 raw.filter(|key| !key.is_empty())
2264 }
2265
2266 pub const API_KEY_ACCOUNT_VENDORS: [VendorId; 10] = [
2270 VendorId::Zai,
2271 VendorId::Openrouter,
2272 VendorId::Deepseek,
2273 VendorId::Deepinfra,
2274 VendorId::Kilo,
2275 VendorId::Novita,
2276 VendorId::Moonshot,
2277 VendorId::Grok,
2278 VendorId::Minimax,
2279 VendorId::OrcaRouter,
2280 ];
2281
2282 pub fn api_key_accounts(&self, id: VendorId) -> Option<&[ApiKeyAccount]> {
2285 match id {
2286 VendorId::Zai => Some(&self.zai.accounts),
2287 VendorId::Openrouter => Some(&self.openrouter.accounts),
2288 VendorId::Deepseek => Some(&self.deepseek.accounts),
2289 VendorId::Deepinfra => Some(&self.deepinfra.accounts),
2290 VendorId::Kilo => Some(&self.kilo.accounts),
2291 VendorId::Novita => Some(&self.novita.accounts),
2292 VendorId::Moonshot => Some(&self.moonshot.accounts),
2293 VendorId::Grok => Some(&self.grok.accounts),
2294 VendorId::Minimax => Some(&self.minimax.accounts),
2295 VendorId::OrcaRouter => Some(&self.orcarouter.accounts),
2296 _ => None,
2297 }
2298 }
2299
2300 pub fn show_default_api_key_account(&self, id: VendorId) -> bool {
2303 match id {
2304 VendorId::Zai => self.zai.show_default_account,
2305 VendorId::Openrouter => self.openrouter.show_default_account,
2306 VendorId::Deepseek => self.deepseek.show_default_account,
2307 VendorId::Deepinfra => self.deepinfra.show_default_account,
2308 VendorId::Kilo => self.kilo.show_default_account,
2309 VendorId::Novita => self.novita.show_default_account,
2310 VendorId::Moonshot => self.moonshot.show_default_account,
2311 VendorId::Grok => self.grok.show_default_account,
2312 VendorId::Minimax => self.minimax.show_default_account,
2313 VendorId::OrcaRouter => self.orcarouter.show_default_account,
2314 _ => true,
2315 }
2316 }
2317
2318 pub fn resolve_account_api_key_for(&self, id: VendorId, label: Option<&str>) -> Result<String> {
2324 let name = match id {
2327 VendorId::Zai => "Zai",
2328 other => other.display_name(),
2329 };
2330 let Some(label) = label else {
2331 return resolve_api_key(name, self.api_key_env_for(id), self.inline_api_key(id));
2332 };
2333 let slug = id.config_section();
2334 let account = api_key_account(slug, self.api_key_accounts(id).unwrap_or(&[]), label)?;
2335 resolve_api_key_in_section(
2337 &format!("{name} account {label:?}"),
2338 &format!("[[{slug}.accounts]]"),
2339 account.api_key_env.as_deref().unwrap_or(""),
2340 account.api_key.as_deref(),
2341 )
2342 }
2343
2344 pub fn display_prefs(&self, vendor: VendorId) -> DisplayPrefs {
2349 match vendor {
2350 VendorId::Deepseek => {
2351 DisplayPrefs::balance(self.deepseek.display_limit, self.deepseek.headline)
2352 }
2353 VendorId::Deepinfra => {
2354 DisplayPrefs::balance(self.deepinfra.display_limit, self.deepinfra.headline)
2355 }
2356 VendorId::Kilo => DisplayPrefs::balance(self.kilo.display_limit, self.kilo.headline),
2357 VendorId::Novita => {
2358 DisplayPrefs::balance(self.novita.display_limit, self.novita.headline)
2359 }
2360 VendorId::Moonshot => {
2361 DisplayPrefs::balance(self.moonshot.display_limit, self.moonshot.headline)
2362 }
2363 VendorId::Grok => DisplayPrefs::balance(self.grok.display_limit, self.grok.headline),
2364 VendorId::Openrouter => DisplayPrefs::balance(None, self.openrouter.headline),
2367 _ => DisplayPrefs::default(),
2368 }
2369 }
2370
2371 pub fn enabled_vendors(&self) -> Vec<VendorId> {
2372 VendorId::all()
2373 .iter()
2374 .copied()
2375 .filter(|id| self.is_enabled(*id))
2376 .collect()
2377 }
2378
2379 pub fn validate(&self) -> Result<()> {
2383 if let Some(minutes) = self.tray.refresh_minutes
2384 && !TRAY_REFRESH_MINUTES.contains(&minutes)
2385 {
2386 return Err(AppError::Other(format!(
2387 "[tray] refresh_minutes must be one of 1, 5 or 10, got {minutes}"
2388 )));
2389 }
2390 if !(1..=100).contains(&self.notifications.threshold) {
2391 return Err(AppError::Other(format!(
2392 "[notifications] threshold must be between 1 and 100, got {}",
2393 self.notifications.threshold
2394 )));
2395 }
2396 if self.context.context_window_tokens == Some(0) {
2397 return Err(AppError::Other(
2398 "[context] context_window_tokens must be greater than zero".into(),
2399 ));
2400 }
2401 for (model, tokens) in &self.context.model_context_window_tokens {
2402 if model.trim().is_empty() {
2403 return Err(AppError::Other(
2404 "[context] model_context_window_tokens keys must not be empty".into(),
2405 ));
2406 }
2407 if *tokens == 0 {
2408 return Err(AppError::Other(format!(
2409 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
2410 )));
2411 }
2412 }
2413 if let Some(limit) = self.anthropic_api.monthly_limit
2414 && (!limit.is_finite() || limit <= 0.0)
2415 {
2416 return Err(AppError::Other(
2417 "[anthropic_api] monthly_limit must be finite and greater than zero; \
2418 remove it to show spend without a limit"
2419 .into(),
2420 ));
2421 }
2422 for (section, limit) in [
2426 ("deepseek", self.deepseek.display_limit),
2427 ("deepinfra", self.deepinfra.display_limit),
2428 ("kilo", self.kilo.display_limit),
2429 ("novita", self.novita.display_limit),
2430 ("moonshot", self.moonshot.display_limit),
2431 ("grok", self.grok.display_limit),
2432 ] {
2433 if let Some(limit) = limit
2434 && (!limit.is_finite() || limit <= 0.0)
2435 {
2436 return Err(AppError::Other(format!(
2437 "[{section}] display_limit must be finite and greater than zero; \
2438 remove it to show the balance without a limit"
2439 )));
2440 }
2441 }
2442 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
2443 && !self.kimi.region.eq_ignore_ascii_case("auto")
2444 {
2445 return Err(AppError::Other(format!(
2446 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
2447 self.kimi.region
2448 )));
2449 }
2450 if !self.minimax.region.eq_ignore_ascii_case("global")
2451 && !self.minimax.region.eq_ignore_ascii_case("cn")
2452 {
2453 return Err(AppError::Other(format!(
2454 "[minimax] region must be \"global\" or \"cn\", got {:?}",
2455 self.minimax.region
2456 )));
2457 }
2458 if self.supergrok.grok_binary.as_os_str().is_empty() {
2459 return Err(AppError::Other(
2460 "[supergrok] grok_binary must not be empty".into(),
2461 ));
2462 }
2463 let mut labels = HashSet::new();
2464 for account in &self.anthropic.accounts {
2465 validate_account_label(&account.label)?;
2466 if !labels.insert(&account.label) {
2467 return Err(AppError::Credentials(format!(
2468 "duplicate anthropic account label {:?}",
2469 account.label
2470 )));
2471 }
2472 }
2473 let mut openai_labels = HashSet::new();
2474 for account in &self.openai.accounts {
2475 validate_account_label_for("openai", &account.label)?;
2476 if !openai_labels.insert(&account.label) {
2477 return Err(AppError::Credentials(format!(
2478 "duplicate openai account label {:?}",
2479 account.label
2480 )));
2481 }
2482 }
2483 for id in Self::API_KEY_ACCOUNT_VENDORS {
2487 let slug = id.config_section();
2488 let mut labels = HashSet::new();
2489 for account in self.api_key_accounts(id).unwrap_or(&[]) {
2490 validate_account_label_for(slug, &account.label)?;
2491 if !labels.insert(&account.label) {
2492 return Err(AppError::Credentials(format!(
2493 "duplicate {slug} account label {:?}",
2494 account.label
2495 )));
2496 }
2497 let has_env = account
2498 .api_key_env
2499 .as_deref()
2500 .is_some_and(|name| !name.is_empty());
2501 let has_inline = account
2502 .api_key
2503 .as_deref()
2504 .is_some_and(|key| !key.is_empty());
2505 if !has_env && !has_inline {
2506 return Err(AppError::Credentials(format!(
2507 "{slug} account {:?} must set api_key_env or api_key",
2508 account.label
2509 )));
2510 }
2511 }
2512 }
2513 self.validate_custom()
2514 }
2515
2516 fn validate_custom(&self) -> Result<()> {
2520 let mut ids = HashSet::new();
2521 let mut short_names: HashSet<&str> =
2522 VendorId::all().iter().map(|v| v.short_name()).collect();
2523 for (index, custom) in self.custom.iter().enumerate() {
2524 custom.validate(index)?;
2525 if !ids.insert(custom.id.as_str()) {
2526 return Err(AppError::Other(format!(
2527 "{}: duplicate id",
2528 custom.section_label()
2529 )));
2530 }
2531 if !short_names.insert(custom.short_name.as_str()) {
2532 return Err(AppError::Other(format!(
2533 "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
2534 custom.section_label(),
2535 custom.short_name
2536 )));
2537 }
2538 }
2539 Ok(())
2540 }
2541}
2542
2543#[cfg(unix)]
2544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2545enum InlineKeyPermissionDecision {
2546 Ok,
2547 Tighten,
2548}
2549
2550#[cfg(unix)]
2551fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
2552 if mode & 0o077 == 0 {
2553 InlineKeyPermissionDecision::Ok
2554 } else {
2555 InlineKeyPermissionDecision::Tighten
2556 }
2557}
2558
2559pub fn default_path() -> Option<PathBuf> {
2560 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
2561 Some(proj.config_dir().join("config.toml"))
2562}
2563
2564fn legacy_xdg_path() -> Option<PathBuf> {
2569 let home = crate::cache::home_dir().ok()?;
2570 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
2571}
2572
2573pub fn resolved_path() -> Option<PathBuf> {
2584 if let Some(path) = override_path() {
2585 return Some(path);
2586 }
2587 let canonical = default_path();
2588 if let Some(p) = &canonical
2589 && p.exists()
2590 {
2591 return canonical;
2592 }
2593 if let Some(legacy) = legacy_xdg_path()
2594 && legacy.exists()
2595 {
2596 return Some(legacy);
2597 }
2598 canonical
2599}
2600
2601static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
2602
2603pub fn set_override_path(path: &std::path::Path) {
2609 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2610 *slot = Some(path.to_path_buf());
2611 }
2612}
2613
2614#[doc(hidden)]
2617pub fn clear_override_path() {
2618 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2619 *slot = None;
2620 }
2621}
2622
2623fn override_path() -> Option<PathBuf> {
2624 PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
2625}
2626
2627#[doc(hidden)]
2633pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
2634 #[cfg(unix)]
2635 {
2636 use std::os::unix::ffi::{OsStrExt, OsStringExt};
2637 let rest = arg.as_bytes().strip_prefix(b"--config=")?;
2638 Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
2639 }
2640 #[cfg(windows)]
2641 {
2642 use std::os::windows::ffi::{OsStrExt, OsStringExt};
2643 const PREFIX: &[u16] = &[
2644 b'-' as u16,
2645 b'-' as u16,
2646 b'c' as u16,
2647 b'o' as u16,
2648 b'n' as u16,
2649 b'f' as u16,
2650 b'i' as u16,
2651 b'g' as u16,
2652 b'=' as u16,
2653 ];
2654 let wide: Vec<u16> = arg.encode_wide().collect();
2655 let rest = wide.strip_prefix(PREFIX)?;
2656 Some(std::ffi::OsString::from_wide(rest).into())
2657 }
2658 #[cfg(not(any(unix, windows)))]
2659 {
2660 Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
2661 }
2662}
2663
2664fn expand_tilde(p: &std::path::Path) -> PathBuf {
2667 let Some(s) = p.to_str() else {
2668 return p.to_path_buf();
2669 };
2670 let rest = if s == "~" {
2671 ""
2672 } else if let Some(r) = s.strip_prefix("~/") {
2673 r
2674 } else {
2675 return p.to_path_buf();
2676 };
2677 match crate::cache::home_dir() {
2678 Ok(home) if rest.is_empty() => home,
2679 Ok(home) => home.join(rest),
2680 Err(_) => p.to_path_buf(),
2681 }
2682}
2683
2684fn expand_tilde_opt(p: &mut Option<PathBuf>) {
2685 if let Some(inner) = p.as_ref() {
2686 *p = Some(expand_tilde(inner));
2687 }
2688}
2689
2690pub fn config_path_hint() -> String {
2695 resolved_path()
2696 .map(|p| p.display().to_string())
2697 .unwrap_or_else(|| "config.toml".to_string())
2698}
2699
2700#[cfg(test)]
2701mod tests {
2702 use super::*;
2703 use std::io::Write;
2704 use tempfile::NamedTempFile;
2705
2706 #[cfg(unix)]
2707 use std::os::unix::fs::{MetadataExt, PermissionsExt};
2708
2709 fn write_toml(s: &str) -> NamedTempFile {
2710 let mut f = NamedTempFile::new().unwrap();
2711 f.write_all(s.as_bytes()).unwrap();
2712 f.flush().unwrap();
2713 f
2714 }
2715
2716 #[test]
2720 fn openai_without_accounts_resolves_the_singular_path() {
2721 let explicit = OpenAiConfig {
2722 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2723 ..OpenAiConfig::default()
2724 };
2725 assert_eq!(
2726 explicit.resolve_auth_path(None).unwrap(),
2727 PathBuf::from("/tmp/codex/auth.json")
2728 );
2729
2730 let bare = OpenAiConfig::default();
2731 assert_eq!(
2732 bare.resolve_auth_path(None).unwrap(),
2733 crate::openai::creds::default_path().unwrap(),
2734 "no codex_auth_path must still mean ~/.codex/auth.json"
2735 );
2736 }
2737
2738 #[test]
2741 fn openai_named_accounts_resolve_their_own_auth_file() {
2742 let config: Config = toml::from_str(
2743 r#"
2744 [openai]
2745 codex_auth_path = "/tmp/personal/auth.json"
2746 [[openai.accounts]]
2747 label = "work"
2748 codex_auth_path = "/tmp/work/auth.json"
2749 "#,
2750 )
2751 .unwrap();
2752
2753 assert_eq!(
2754 config.openai.resolve_auth_path(Some("work")).unwrap(),
2755 PathBuf::from("/tmp/work/auth.json")
2756 );
2757 assert_eq!(
2758 config.openai.resolve_auth_path(None).unwrap(),
2759 PathBuf::from("/tmp/personal/auth.json")
2760 );
2761 }
2762
2763 #[test]
2766 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
2767 let config = OpenAiConfig {
2768 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
2769 accounts: vec![OpenAiAccount {
2770 label: "work".into(),
2771 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
2772 }],
2773 ..OpenAiConfig::default()
2774 };
2775 let err = config
2776 .resolve_auth_path(Some("nope"))
2777 .unwrap_err()
2778 .to_string();
2779 assert!(err.contains("nope"), "{err}");
2780 assert!(err.contains("[[openai.accounts]]"), "{err}");
2781 }
2782
2783 fn two_codex_accounts() -> OpenAiConfig {
2784 OpenAiConfig {
2785 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2786 accounts: vec![
2787 OpenAiAccount {
2788 label: "main".into(),
2789 codex_auth_path: PathBuf::from("/tmp/codex-main/auth.json"),
2790 },
2791 OpenAiAccount {
2792 label: "work".into(),
2793 codex_auth_path: PathBuf::from("/tmp/codex-work/auth.json"),
2794 },
2795 ],
2796 ..OpenAiConfig::default()
2797 }
2798 }
2799
2800 #[test]
2801 fn the_active_codex_account_is_read_from_the_default_slot() {
2802 let config = two_codex_accounts();
2803 assert_eq!(
2804 config
2805 .fetch_auth_path_probing("work", Some("work"), |_| false)
2806 .unwrap(),
2807 PathBuf::from("/tmp/codex/auth.json")
2808 );
2809 assert_eq!(
2810 config
2811 .fetch_auth_path_probing("main", Some("work"), |_| false)
2812 .unwrap(),
2813 PathBuf::from("/tmp/codex-main/auth.json")
2814 );
2815 }
2816
2817 #[test]
2818 fn an_active_codex_account_with_its_own_file_keeps_reading_it() {
2819 let config = two_codex_accounts();
2820 assert_eq!(
2821 config
2822 .fetch_auth_path_probing("work", Some("work"), |_| true)
2823 .unwrap(),
2824 PathBuf::from("/tmp/codex-work/auth.json")
2825 );
2826 }
2827
2828 #[test]
2829 fn adding_an_openai_account_keeps_the_rest_of_the_file() {
2830 let mut doc: toml_edit::DocumentMut = "# mine\n[zai]\nenabled = true\n".parse().unwrap();
2831 add_openai_account_to_doc(&mut doc, "work", "~/.codex-work/auth.json").unwrap();
2832 let text = doc.to_string();
2833 assert!(
2834 text.starts_with("# mine\n[zai]\nenabled = true\n"),
2835 "{text}"
2836 );
2837 let parsed: Config = toml::from_str(&text).unwrap();
2838 assert_eq!(parsed.openai.accounts[0].label, "work");
2839 assert!(add_openai_account_to_doc(&mut doc, "work", "x").is_err());
2840 assert!(add_openai_account_to_doc(&mut doc, "../x", "x").is_err());
2841 }
2842
2843 #[test]
2844 fn defaults_enable_only_the_four_core_vendors() {
2845 let c = Config::default();
2846 assert!(c.is_enabled(VendorId::Anthropic));
2847 assert!(c.is_enabled(VendorId::Openai));
2848 assert!(c.is_enabled(VendorId::Zai));
2849 assert!(c.is_enabled(VendorId::Openrouter));
2850 for opt_in in [
2851 VendorId::AnthropicApi,
2852 VendorId::Copilot,
2853 VendorId::Deepseek,
2854 VendorId::Kimi,
2855 VendorId::Kilo,
2856 VendorId::Novita,
2857 VendorId::Moonshot,
2858 VendorId::Grok,
2859 VendorId::Supergrok,
2860 VendorId::Grokbot,
2861 VendorId::Cursor,
2862 VendorId::Minimax,
2863 VendorId::Kiro,
2864 VendorId::CommandCode,
2865 VendorId::OrcaRouter,
2866 VendorId::ModelStudio,
2867 ] {
2868 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
2869 }
2870 assert_eq!(c.enabled_vendors().len(), 4);
2871 }
2872
2873 #[test]
2874 fn commandcode_is_opt_in_when_loading_existing_configs() {
2875 let absent: Config = toml::from_str("[openai]\nenabled = true\n").unwrap();
2876 assert!(!absent.is_enabled(VendorId::CommandCode));
2877
2878 let opted_in: Config = toml::from_str("[commandcode]\nenabled = true\n").unwrap();
2879 assert!(opted_in.is_enabled(VendorId::CommandCode));
2880
2881 let opted_out: Config = toml::from_str("[commandcode]\nenabled = false\n").unwrap();
2882 assert!(!opted_out.is_enabled(VendorId::CommandCode));
2883 }
2884
2885 #[test]
2886 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
2887 let config = Config::default();
2888 assert!(!config.is_enabled(VendorId::NousResearch));
2889 assert!(!config.is_enabled(VendorId::OpenCodeGo));
2890 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
2891 assert!(config.opencode_go.api_key.is_none());
2892 assert!(!config.is_enabled(VendorId::Copilot));
2893 }
2894
2895 #[cfg(unix)]
2896 #[test]
2897 fn inline_credentials_are_protected() {
2898 let mut config = Config::default();
2899 config.opencode_go.api_key = Some("<redacted>".to_string());
2900 assert!(config.has_inline_secrets());
2901 }
2902
2903 #[test]
2904 fn antigravity_oauth_client_overrides_parse() {
2905 let config: Config = toml::from_str(
2906 "[antigravity]
2907enabled = true
2908oauth_client_id = \"test-client\"
2909oauth_client_secret = \"test-client-secret\"
2910",
2911 )
2912 .unwrap();
2913 assert!(config.antigravity.enabled);
2914 assert_eq!(
2915 config.antigravity.oauth_client_id.as_deref(),
2916 Some("test-client")
2917 );
2918 assert_eq!(
2919 config.antigravity.oauth_client_secret.as_deref(),
2920 Some("test-client-secret")
2921 );
2922 let bare: Config = toml::from_str(
2923 "[antigravity]
2924enabled = true
2925",
2926 )
2927 .unwrap();
2928 assert!(bare.antigravity.oauth_client_id.is_none());
2929 assert!(bare.antigravity.oauth_client_secret.is_none());
2930 }
2931
2932 #[cfg(unix)]
2933 #[test]
2934 fn antigravity_inline_oauth_secret_receives_config_file_protection() {
2935 let mut config = Config::default();
2936 config.antigravity.oauth_client_id = Some("test-client".into());
2937 assert!(!config.has_inline_secrets());
2938 config.antigravity.oauth_client_secret = Some("<redacted>".into());
2939 assert!(config.has_inline_secrets());
2940 }
2941
2942 #[cfg(unix)]
2943 #[test]
2944 fn openrouter_named_inline_keys_receive_config_file_protection() {
2945 let mut config = Config::default();
2946 config.openrouter.accounts.push(ApiKeyAccount {
2947 label: "work".into(),
2948 api_key_env: None,
2949 api_key: Some("<redacted>".into()),
2950 });
2951 assert!(config.has_inline_secrets());
2952 }
2953
2954 #[cfg(unix)]
2955 #[test]
2956 fn every_named_inline_api_key_receives_config_file_protection() {
2957 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
2958 let section = vendor.config_section();
2959 let config: Config = toml::from_str(&format!(
2960 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"<redacted>\"\n"
2961 ))
2962 .unwrap();
2963 assert!(config.has_inline_secrets(), "{vendor:?}");
2964 }
2965 }
2966
2967 #[test]
2968 fn missing_file_uses_defaults() {
2969 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
2970 let c = Config::load_from(path).unwrap();
2971 assert!(c.is_enabled(VendorId::Anthropic));
2972 }
2973
2974 #[test]
2975 fn parses_full_config() {
2976 let f = write_toml(
2977 r#"
2978 [anthropic]
2979 enabled = true
2980
2981 [openai]
2982 enabled = false
2983 admin_key_env = "MY_ADMIN_KEY"
2984
2985 [zai]
2986 enabled = true
2987 api_key_env = "MY_ZAI"
2988 plan_tier = "pro"
2989
2990 [openrouter]
2991 enabled = false
2992 "#,
2993 );
2994 let c = Config::load_from(f.path()).unwrap();
2995 assert!(c.is_enabled(VendorId::Anthropic));
2996 assert!(!c.is_enabled(VendorId::Openai));
2997 assert!(c.is_enabled(VendorId::Zai));
2998 assert!(!c.is_enabled(VendorId::Openrouter));
2999 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
3000 assert_eq!(c.zai.api_key_env, "MY_ZAI");
3001 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
3002 assert!(c.openrouter.accounts.is_empty());
3003 assert!(c.openrouter.show_default_account);
3004 }
3005
3006 #[test]
3007 fn partial_config_falls_back_to_defaults() {
3008 let f = write_toml(
3009 r#"[openai]
3010enabled = false
3011"#,
3012 );
3013 let c = Config::load_from(f.path()).unwrap();
3014 assert!(!c.is_enabled(VendorId::Openai));
3015 assert!(c.is_enabled(VendorId::Anthropic));
3017 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
3018 }
3019
3020 #[test]
3021 fn malformed_toml_returns_error() {
3022 let f = write_toml("this is not = = valid");
3023 assert!(Config::load_from(f.path()).is_err());
3024 }
3025
3026 #[cfg(unix)]
3027 #[test]
3028 fn load_from_tightens_world_readable_config_with_inline_api_key() {
3029 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
3030 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3031
3032 Config::load_from(file.path()).unwrap();
3033
3034 assert_eq!(
3035 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3036 0o600
3037 );
3038 }
3039
3040 #[cfg(unix)]
3041 #[test]
3042 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
3043 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
3044 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3045
3046 Config::load_from(file.path()).unwrap();
3047
3048 assert_eq!(
3049 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3050 0o644
3051 );
3052 }
3053
3054 #[cfg(unix)]
3055 #[test]
3056 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
3057 assert_eq!(
3058 inline_key_permission_decision(0o600),
3059 InlineKeyPermissionDecision::Ok
3060 );
3061 assert_eq!(
3062 inline_key_permission_decision(0o640),
3063 InlineKeyPermissionDecision::Tighten
3064 );
3065 assert_eq!(
3066 inline_key_permission_decision(0o604),
3067 InlineKeyPermissionDecision::Tighten
3068 );
3069 }
3070
3071 #[test]
3072 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
3073 for value in ["0", "-1", "inf", "nan"] {
3074 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
3075 let error = Config::load_from(file.path()).unwrap_err().to_string();
3076 assert!(error.contains("monthly_limit"), "value {value}: {error}");
3077 }
3078
3079 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
3080 assert_eq!(
3081 Config::load_from(file.path())
3082 .unwrap()
3083 .anthropic_api
3084 .monthly_limit,
3085 Some(1000.0)
3086 );
3087 }
3088
3089 #[test]
3090 fn display_limit_must_be_positive_and_finite_on_every_balance_vendor() {
3091 for section in ["deepseek", "kilo", "novita", "moonshot", "grok"] {
3093 for value in ["0", "-1", "inf", "nan"] {
3094 let file = write_toml(&format!("[{section}]\ndisplay_limit = {value}\n"));
3095 let error = Config::load_from(file.path()).unwrap_err().to_string();
3096 assert!(
3097 error.contains(&format!("[{section}] display_limit")),
3098 "{section} = {value}: {error}"
3099 );
3100 }
3101 let file = write_toml(&format!("[{section}]\ndisplay_limit = 200\n"));
3102 let config = Config::load_from(file.path()).unwrap();
3103 assert_eq!(
3104 config.display_prefs(vendor_of(section)).display_limit,
3105 Some(200.0),
3106 "{section}"
3107 );
3108 }
3109 }
3110
3111 #[test]
3113 fn display_limit_is_absent_until_the_user_states_one() {
3114 let config = Config::default();
3115 for vendor in VendorId::all() {
3116 assert_eq!(
3117 config.display_prefs(*vendor).display_limit,
3118 None,
3119 "{vendor:?}"
3120 );
3121 }
3122 }
3123
3124 #[test]
3127 fn the_default_headline_follows_the_kind_of_vendor() {
3128 let config = Config::default();
3129 for vendor in [
3130 VendorId::Deepseek,
3131 VendorId::Kilo,
3132 VendorId::Novita,
3133 VendorId::Moonshot,
3134 VendorId::Grok,
3135 ] {
3136 assert_eq!(
3137 config.display_prefs(vendor).headline,
3138 Headline::Amount,
3139 "{vendor:?}"
3140 );
3141 }
3142 assert_eq!(
3143 config.display_prefs(VendorId::Openrouter).headline,
3144 Headline::Percent
3145 );
3146 assert_eq!(
3147 config.display_prefs(VendorId::Anthropic),
3148 DisplayPrefs::default()
3149 );
3150 }
3151
3152 #[test]
3153 fn the_headline_is_configurable_per_vendor_and_a_typo_is_loud() {
3154 let file = write_toml("[deepseek]\nheadline = \"percent\"\n");
3155 assert_eq!(
3156 Config::load_from(file.path())
3157 .unwrap()
3158 .display_prefs(VendorId::Deepseek)
3159 .headline,
3160 Headline::Percent
3161 );
3162
3163 let file = write_toml("[openrouter]\nheadline = \"amount\"\n");
3164 assert_eq!(
3165 Config::load_from(file.path())
3166 .unwrap()
3167 .display_prefs(VendorId::Openrouter)
3168 .headline,
3169 Headline::Amount
3170 );
3171
3172 let file = write_toml("[deepseek]\nheadline = \"dollars\"\n");
3173 let error = Config::load_from(file.path()).unwrap_err().to_string();
3174 assert!(error.contains("headline"), "{error}");
3175 }
3176
3177 #[test]
3183 fn openrouter_has_no_display_limit_to_be_ignored() {
3184 let file = write_toml("[openrouter]\ndisplay_limit = 200\nheadline = \"percent\"\n");
3185 let config = Config::load_from(file.path()).unwrap();
3186 let prefs = config.display_prefs(VendorId::Openrouter);
3187 assert_eq!(prefs.display_limit, None);
3188 assert_eq!(prefs.headline, Headline::Percent);
3189 }
3190
3191 #[test]
3194 fn a_display_limit_alone_leaves_the_headline_where_it_was() {
3195 let file = write_toml("[deepseek]\ndisplay_limit = 200\n");
3196 let prefs = Config::load_from(file.path())
3197 .unwrap()
3198 .display_prefs(VendorId::Deepseek);
3199 assert_eq!(prefs.display_limit, Some(200.0));
3200 assert_eq!(prefs.headline, Headline::Amount);
3201 }
3202
3203 fn vendor_of(section: &str) -> VendorId {
3204 VendorId::all()
3205 .iter()
3206 .copied()
3207 .find(|vendor| vendor.config_section() == section)
3208 .unwrap_or_else(|| panic!("no vendor for [{section}]"))
3209 }
3210
3211 #[test]
3212 fn minimax_region_accepts_only_known_instances() {
3213 for region in ["global", "GLOBAL", "cn", "CN"] {
3214 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3215 assert_eq!(
3216 Config::load_from(file.path()).unwrap().minimax.region,
3217 region
3218 );
3219 }
3220
3221 for region in ["", "china", "us"] {
3222 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3223 let error = Config::load_from(file.path()).unwrap_err().to_string();
3224 assert!(error.contains("[minimax] region"), "{error}");
3225 }
3226 }
3227
3228 #[test]
3229 fn kimi_region_accepts_auto_and_both_deployments() {
3230 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
3231 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3232 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
3233 }
3234
3235 for region in ["", "us", "oversea"] {
3236 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3237 let error = Config::load_from(file.path()).unwrap_err().to_string();
3238 assert!(error.contains("[kimi] region"), "{error}");
3239 }
3240 }
3241
3242 #[test]
3243 fn kimi_defaults_to_auto_region_and_no_credential_override() {
3244 let defaults = KimiConfig::default();
3245 assert_eq!(defaults.region, "auto");
3246 assert_eq!(defaults.credentials_path, None);
3247 assert!(!defaults.enabled);
3248 }
3249
3250 #[test]
3251 fn kimi_credentials_path_expands_a_tilde() {
3252 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
3253 let path = Config::load_from(file.path())
3254 .unwrap()
3255 .kimi
3256 .credentials_path
3257 .unwrap();
3258 assert!(!path.starts_with("~"), "{}", path.display());
3259 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
3260 }
3261
3262 #[test]
3263 fn grokbot_is_opt_in_and_takes_no_api_key() {
3264 let defaults = GrokbotConfig::default();
3265 assert!(!defaults.enabled);
3266 assert_eq!(defaults.secrets_path, None);
3267 let config = Config::default();
3269 assert_eq!(config.api_key_env_for(VendorId::Grokbot), "");
3270 assert_eq!(config.inline_api_key(VendorId::Grokbot), None);
3271
3272 let file = write_toml("[grokbot]\nenabled = true\n");
3273 let config = Config::load_from(file.path()).unwrap();
3274 assert!(config.is_enabled(VendorId::Grokbot));
3275 assert!(config.enabled_vendors().contains(&VendorId::Grokbot));
3276 }
3277
3278 #[test]
3279 fn grokbot_secrets_path_expands_a_tilde() {
3280 let file = write_toml("[grokbot]\nsecrets_path = \"~/gb/secrets.json\"\n");
3281 let path = Config::load_from(file.path())
3282 .unwrap()
3283 .grokbot
3284 .secrets_path
3285 .unwrap();
3286 assert!(!path.starts_with("~"), "{}", path.display());
3287 assert!(path.ends_with("gb/secrets.json"), "{}", path.display());
3288 }
3289
3290 #[test]
3291 fn modelstudio_is_opt_in_and_takes_no_api_key() {
3292 let defaults = ModelStudioConfig::default();
3293 assert!(!defaults.enabled);
3294 assert_eq!(defaults.config_dir, None);
3295 let config = Config::default();
3297 assert_eq!(config.api_key_env_for(VendorId::ModelStudio), "");
3298 assert_eq!(config.inline_api_key(VendorId::ModelStudio), None);
3299
3300 let file = write_toml("[modelstudio]\nenabled = true\n");
3301 let config = Config::load_from(file.path()).unwrap();
3302 assert!(config.is_enabled(VendorId::ModelStudio));
3303 assert!(config.enabled_vendors().contains(&VendorId::ModelStudio));
3304 }
3305
3306 #[test]
3307 fn modelstudio_config_dir_expands_a_tilde() {
3308 let file = write_toml("[modelstudio]\nconfig_dir = \"~/bl\"\n");
3309 let path = Config::load_from(file.path())
3310 .unwrap()
3311 .modelstudio
3312 .config_dir
3313 .unwrap();
3314 assert!(!path.starts_with("~"), "{}", path.display());
3315 assert!(path.ends_with("bl"), "{}", path.display());
3316 }
3317
3318 #[test]
3319 fn optional_api_key_reports_absence_instead_of_failing() {
3320 assert_eq!(
3321 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", Some("inline")),
3322 Some("inline".to_string())
3323 );
3324 assert_eq!(
3325 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", None),
3326 None
3327 );
3328 assert_eq!(optional_api_key("KIMI_API_KEY_UNSET", Some("")), None);
3329 assert_eq!(
3332 optional_api_key("9INVALID", Some("inline")),
3333 Some("inline".to_string())
3334 );
3335 }
3336
3337 #[test]
3338 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
3339 let defaults = Config::default();
3340 assert!(!defaults.context.enabled);
3341 assert_eq!(
3342 defaults.context.window_tokens_for(Some("claude-test")),
3343 None
3344 );
3345
3346 let file = write_toml(
3347 r#"
3348 [context]
3349 enabled = true
3350 context_window_tokens = 200000
3351
3352 [context.model_context_window_tokens]
3353 claude-opus-1m = 1000000
3354 "claude exact id" = 300000
3355 "#,
3356 );
3357 let config = Config::load_from(file.path()).unwrap();
3358 assert!(config.context.enabled);
3359 assert_eq!(
3360 config.context.window_tokens_for(Some("claude-opus-1m")),
3361 Some(1_000_000)
3362 );
3363 assert_eq!(
3364 config.context.window_tokens_for(Some("claude exact id")),
3365 Some(300_000)
3366 );
3367 assert_eq!(
3368 config.context.window_tokens_for(Some("another-model")),
3369 Some(200_000)
3370 );
3371 }
3372
3373 #[test]
3374 fn context_layout_defaults_to_full_and_parses_each_variant() {
3375 assert_eq!(Config::default().context.layout, ContextLayout::Full);
3376 for (text, want) in [
3377 ("full", ContextLayout::Full),
3378 ("split", ContextLayout::Split),
3379 ("bottom", ContextLayout::Bottom),
3380 ] {
3381 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
3382 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
3383 }
3384 let file = write_toml("[context]\nlayout = \"floating\"\n");
3385 assert!(
3386 Config::load_from(file.path()).is_err(),
3387 "an unknown layout must be rejected, not silently defaulted"
3388 );
3389 }
3390
3391 #[test]
3392 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
3393 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
3394 for (text, want) in [
3395 ("sidebar", VendorBoxStyle::Sidebar),
3396 ("navbar", VendorBoxStyle::Navbar),
3397 ("none", VendorBoxStyle::None),
3398 ] {
3399 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
3400 assert_eq!(
3401 Config::load_from(file.path()).unwrap().ui.vendor_box(),
3402 want
3403 );
3404 }
3405 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
3406 assert!(
3407 Config::load_from(file.path()).is_err(),
3408 "an unknown vendor_box style must be rejected, not silently defaulted"
3409 );
3410 }
3411
3412 #[test]
3413 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
3414 for source in [
3415 "[context]\ncontext_window_tokens = 0\n",
3416 "[context.model_context_window_tokens]\nclaude = 0\n",
3417 "[context.model_context_window_tokens]\n\" \" = 200000\n",
3418 ] {
3419 let file = write_toml(source);
3420 let error = Config::load_from(file.path()).unwrap_err().to_string();
3421 assert!(error.contains("context"), "{error}");
3422 }
3423 }
3424
3425 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
3427 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
3428 M.lock().unwrap_or_else(|p| p.into_inner())
3429 }
3430
3431 #[test]
3432 fn resolve_api_key_prefers_env_over_inline() {
3433 let _g = env_guard();
3434 let var = "AI_USAGEBAR_TEST_ENV_WINS";
3436 unsafe { std::env::set_var(var, "from-env") };
3438 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
3439 unsafe { std::env::remove_var(var) };
3440 assert_eq!(got, "from-env");
3441 }
3442
3443 #[test]
3444 fn resolve_api_key_falls_back_to_inline() {
3445 let _g = env_guard();
3446 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
3447 unsafe { std::env::remove_var(var) };
3448 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
3449 assert_eq!(got, "inline-key");
3450 }
3451
3452 #[test]
3453 fn copilot_token_prefers_explicit_environment_over_gh_cli() {
3454 struct NeverRun;
3455 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
3456 fn run(
3457 &self,
3458 _: &crate::copilot::credentials::GhAuthTokenCommand,
3459 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3460 panic!("environment override must not invoke gh")
3461 }
3462 }
3463
3464 let token = CopilotConfig::default()
3465 .resolve_token_with(
3466 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
3467 &NeverRun,
3468 )
3469 .unwrap();
3470 assert_eq!(token, "from-environment");
3471 }
3472
3473 #[test]
3474 fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
3475 struct FailedGh;
3476 impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
3477 fn run(
3478 &self,
3479 _: &crate::copilot::credentials::GhAuthTokenCommand,
3480 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3481 Ok(crate::copilot::credentials::GhAuthTokenOutput {
3482 success: false,
3483 stdout: b"never-echo-gh-output".to_vec(),
3484 })
3485 }
3486 }
3487 let error = CopilotConfig::default()
3488 .resolve_token_with(|_| None, &FailedGh)
3489 .unwrap_err()
3490 .to_string();
3491 assert!(error.contains("gh auth login --web"));
3492 assert!(!error.contains("never-echo-gh-output"));
3493 }
3494
3495 #[test]
3496 fn resolve_api_key_errors_when_both_missing() {
3497 let _g = env_guard();
3498 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
3499 unsafe { std::env::remove_var(var) };
3500 let err = resolve_api_key("Zai", var, None).unwrap_err();
3501 match err {
3502 crate::error::AppError::Credentials(msg) => {
3503 assert!(
3504 msg.contains("api_key"),
3505 "error should suggest config field: {msg}"
3506 );
3507 }
3508 other => panic!("expected Credentials error, got {other:?}"),
3509 }
3510 }
3511
3512 #[test]
3513 fn resolve_api_key_uses_exact_opencode_go_section_name() {
3514 let _g = env_guard();
3515 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
3516 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
3517 let message = err.to_string();
3518 assert!(
3519 message.contains("[opencode-go]"),
3520 "wrong section hint: {message}"
3521 );
3522 assert!(
3523 !message.contains("[opencode go]"),
3524 "wrong section hint: {message}"
3525 );
3526 }
3527
3528 fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
3529 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
3530 M.lock().unwrap_or_else(|p| p.into_inner())
3531 }
3532
3533 struct ScopedPathOverride {
3539 _serial: std::sync::MutexGuard<'static, ()>,
3540 }
3541
3542 impl Drop for ScopedPathOverride {
3543 fn drop(&mut self) {
3544 clear_override_path();
3545 }
3546 }
3547
3548 fn scoped_path_override() -> ScopedPathOverride {
3549 ScopedPathOverride {
3550 _serial: path_override_guard(),
3551 }
3552 }
3553
3554 #[test]
3555 fn override_path_wins_over_canonical_and_legacy() {
3556 let _scoped = scoped_path_override();
3557 let file = NamedTempFile::new().unwrap();
3558 set_override_path(file.path());
3559 assert_eq!(resolved_path().as_deref(), Some(file.path()));
3560 assert_eq!(config_path_hint(), file.path().display().to_string());
3561 clear_override_path();
3562 let p = resolved_path().expect("a config path must resolve");
3564 assert!(p.ends_with("config.toml"));
3565 }
3566
3567 #[test]
3568 fn scoped_override_guard_clears_the_override_on_panic() {
3569 let hook = std::panic::take_hook();
3572 std::panic::set_hook(Box::new(|_| {}));
3573 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3574 let _scoped = scoped_path_override();
3575 set_override_path(std::path::Path::new("panicked-override.toml"));
3576 panic!("simulated mid-test failure");
3577 }))
3578 .is_err();
3579 std::panic::set_hook(hook);
3580 assert!(panicked, "the simulated failure must run");
3581 let _serial = path_override_guard();
3582 assert!(
3583 override_path().is_none(),
3584 "a panicking test must not leak the override into siblings"
3585 );
3586 }
3587
3588 #[test]
3589 fn config_path_hint_ends_with_config_toml() {
3590 let _g = path_override_guard();
3591 assert!(config_path_hint().ends_with("config.toml"));
3594 }
3595
3596 #[test]
3597 fn config_flag_value_splits_the_equals_form() {
3598 use std::ffi::OsStr;
3599 assert_eq!(
3600 config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
3601 Some(std::path::Path::new("work.toml"))
3602 );
3603 assert_eq!(
3604 config_flag_value(OsStr::new("--config=")).as_deref(),
3605 Some(std::path::Path::new(""))
3606 );
3607 assert_eq!(config_flag_value(OsStr::new("--config")), None);
3608 assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
3609 assert_eq!(config_flag_value(OsStr::new("account")), None);
3610 }
3611
3612 #[cfg(unix)]
3616 #[test]
3617 fn config_flag_value_keeps_undecodable_bytes_intact() {
3618 use std::ffi::OsString;
3619 use std::os::unix::ffi::{OsStrExt, OsStringExt};
3620 let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
3621 let value = config_flag_value(&raw).expect("prefix matches");
3622 assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
3623 }
3624
3625 #[cfg(windows)]
3626 #[test]
3627 fn config_flag_value_keeps_lone_surrogates_intact() {
3628 use std::ffi::OsString;
3629 use std::os::windows::ffi::{OsStrExt, OsStringExt};
3630 let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
3631 wide.push(0xDC00); wide.extend("x.toml".encode_utf16());
3633 let raw = OsString::from_wide(&wide);
3634 let value = config_flag_value(&raw).expect("prefix matches");
3635 let mut expected = vec![0xDC00u16];
3636 expected.extend("x.toml".encode_utf16());
3637 assert_eq!(
3638 value.as_os_str().encode_wide().collect::<Vec<_>>(),
3639 expected
3640 );
3641 }
3642
3643 #[test]
3644 fn resolve_api_key_treats_empty_env_as_unset() {
3645 let _g = env_guard();
3646 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
3647 unsafe { std::env::set_var(var, "") };
3648 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
3649 unsafe { std::env::remove_var(var) };
3650 assert_eq!(got, "inline");
3651 }
3652
3653 #[test]
3654 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
3655 let _g = env_guard();
3656 let bad = "sk-kimi-very-real-looking-pasted-secret";
3658 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
3659 let msg = err.to_string();
3660 assert!(
3661 msg.contains("invalid") && msg.contains("api_key_env"),
3662 "error should explain misconfiguration: {msg}"
3663 );
3664 assert!(
3665 !msg.contains(bad),
3666 "error must not echo the misconfigured value: {msg}"
3667 );
3668 assert!(msg.contains("valid environment variable name"));
3669 assert!(
3670 msg.contains("[kimi]"),
3671 "error should point at the lowercase TOML section: {msg}"
3672 );
3673 }
3674
3675 #[test]
3676 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
3677 let _g = env_guard();
3678 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
3679 assert_eq!(got, "inline-key");
3680 }
3681
3682 #[test]
3683 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
3684 let _g = env_guard();
3685 let pasted_secret = "sk_pasted_secret";
3688 unsafe { std::env::remove_var(pasted_secret) };
3689 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
3690 assert!(
3691 !err.to_string().contains(pasted_secret),
3692 "error must not echo configured api_key_env values"
3693 );
3694 }
3695
3696 #[test]
3697 fn is_valid_env_var_name_rules() {
3698 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
3700 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
3701 }
3702 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
3704 assert!(
3705 !is_valid_env_var_name(invalid),
3706 "{invalid} should be invalid"
3707 );
3708 }
3709 }
3710
3711 #[test]
3712 fn config_parses_with_inline_api_key_and_primary() {
3713 let f = write_toml(
3714 r#"
3715 [ui]
3716 primary = "openrouter"
3717
3718 [zai]
3719 enabled = true
3720 api_key_env = "MY_ZAI"
3721 api_key = "sk-zai-inline"
3722
3723 [openrouter]
3724 enabled = true
3725 api_key = "sk-or-inline"
3726 "#,
3727 );
3728 let c = Config::load_from(f.path()).unwrap();
3729 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
3730 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
3731 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
3732 }
3733
3734 #[test]
3735 fn openrouter_named_accounts_preserve_the_default_contract() {
3736 let f = write_toml(
3737 r#"
3738 [openrouter]
3739 enabled = true
3740 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
3741 api_key = "default-inline"
3742 show_default_account = false
3743
3744 [[openrouter.accounts]]
3745 label = "work"
3746 api_key_env = "OPENROUTER_WORK_API_KEY"
3747
3748 [[openrouter.accounts]]
3749 label = "personal"
3750 api_key = "personal-inline"
3751 "#,
3752 );
3753 let _g = env_guard();
3754 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
3755 let config = Config::load_from(f.path()).unwrap();
3756 assert!(!config.openrouter.show_default_account);
3757 assert_eq!(config.openrouter.accounts.len(), 2);
3758 assert_eq!(
3759 config
3760 .resolve_account_api_key_for(VendorId::Openrouter, None)
3761 .unwrap(),
3762 "default-inline"
3763 );
3764 assert_eq!(
3765 config
3766 .resolve_account_api_key_for(VendorId::Openrouter, Some("personal"))
3767 .unwrap(),
3768 "personal-inline"
3769 );
3770 }
3771
3772 #[test]
3773 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
3774 for source in [
3775 r#"
3776 [[openrouter.accounts]]
3777 label = "work"
3778 api_key = "one"
3779 [[openrouter.accounts]]
3780 label = "work"
3781 api_key = "two"
3782 "#,
3783 r#"
3784 [[openrouter.accounts]]
3785 label = "../work"
3786 api_key = "one"
3787 "#,
3788 r#"
3789 [[openrouter.accounts]]
3790 label = "work"
3791 "#,
3792 ] {
3793 let f = write_toml(source);
3794 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
3795 }
3796 }
3797
3798 #[test]
3799 fn openrouter_unknown_account_never_falls_back_to_default_key() {
3800 let mut config = Config::default();
3801 config.openrouter.api_key = Some("default-secret".into());
3802 config.openrouter.accounts.push(ApiKeyAccount {
3803 label: "work".into(),
3804 api_key_env: None,
3805 api_key: Some("work-secret".into()),
3806 });
3807 let message = config
3808 .resolve_account_api_key_for(VendorId::Openrouter, Some("missing"))
3809 .unwrap_err()
3810 .to_string();
3811 assert!(message.contains("missing") && message.contains("work"));
3812 assert!(!message.contains("default-secret"));
3813 assert!(!message.contains("work-secret"));
3814 }
3815
3816 #[test]
3817 fn openrouter_account_key_errors_do_not_echo_configured_values() {
3818 let mut config = Config::default();
3819 config.openrouter.accounts.push(ApiKeyAccount {
3820 label: "work".into(),
3821 api_key_env: Some("sk_pasted_secret".into()),
3822 api_key: None,
3823 });
3824 let _g = env_guard();
3825 unsafe { std::env::remove_var("sk_pasted_secret") };
3826 let message = config
3827 .resolve_account_api_key_for(VendorId::Openrouter, Some("work"))
3828 .unwrap_err()
3829 .to_string();
3830 assert!(message.contains("[[openrouter.accounts]]"));
3831 assert!(!message.contains("sk_pasted_secret"));
3832 }
3833
3834 #[test]
3835 fn every_api_key_account_vendor_validates_its_array_like_openrouter() {
3836 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
3837 let section = vendor.config_section();
3838 let parse = |accounts: &str| -> Result<Config> {
3839 let config: Config =
3840 toml::from_str(&format!("[{section}]\nenabled = true\n{accounts}")).unwrap();
3841 config.validate().map(|()| config)
3842 };
3843 let config = parse(&format!(
3844 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k\"\n"
3845 ))
3846 .unwrap();
3847 assert_eq!(
3848 config.api_key_accounts(vendor).unwrap().len(),
3849 1,
3850 "{vendor:?}"
3851 );
3852 assert!(config.show_default_api_key_account(vendor), "{vendor:?}");
3853
3854 let duplicate = parse(&format!(
3855 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"a\"\n\
3856 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"b\"\n"
3857 ))
3858 .unwrap_err()
3859 .to_string();
3860 assert!(
3861 duplicate.contains(&format!("duplicate {section} account label")),
3862 "{duplicate}"
3863 );
3864
3865 let keyless = parse(&format!("[[{section}.accounts]]\nlabel = \"work\"\n"))
3866 .unwrap_err()
3867 .to_string();
3868 assert!(
3869 keyless.contains("must set api_key_env or api_key"),
3870 "{keyless}"
3871 );
3872
3873 assert!(
3875 parse(&format!(
3876 "[[{section}.accounts]]\nlabel = \"../x\"\napi_key = \"k\"\n"
3877 ))
3878 .is_err(),
3879 "{vendor:?}"
3880 );
3881 }
3882 }
3883
3884 #[test]
3885 fn named_api_key_accounts_resolve_their_own_key_and_never_the_default() {
3886 let mut config = Config::default();
3887 config.deepseek.api_key_env.clear();
3888 config.deepseek.api_key = Some("default-key".into());
3889 config.deepseek.accounts.push(ApiKeyAccount {
3890 label: "work".into(),
3891 api_key_env: Some("AI_USAGEBAR_TEST_DEEPSEEK_WORK".into()),
3892 api_key: Some("work-inline".into()),
3893 });
3894 let _g = env_guard();
3895 unsafe { std::env::set_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK", "work-env") };
3896 let resolve = |label| config.resolve_account_api_key_for(VendorId::Deepseek, label);
3897 assert_eq!(resolve(None).unwrap(), "default-key");
3898 assert_eq!(resolve(Some("work")).unwrap(), "work-env", "env wins");
3899 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK") };
3900 assert_eq!(resolve(Some("work")).unwrap(), "work-inline");
3901
3902 let unknown = resolve(Some("typo")).unwrap_err().to_string();
3903 assert!(unknown.contains("[[deepseek.accounts]]"), "{unknown}");
3904 assert!(
3905 unknown.contains("\"work\""),
3906 "lists known labels: {unknown}"
3907 );
3908 assert!(!unknown.contains("default-key"), "{unknown}");
3909 }
3910
3911 #[test]
3912 fn the_default_key_error_keeps_each_vendors_historic_wording() {
3913 let mut config = Config::default();
3914 config.zai.api_key_env.clear();
3915 config.minimax.api_key_env.clear();
3916 let zai = config
3917 .resolve_account_api_key_for(VendorId::Zai, None)
3918 .unwrap_err()
3919 .to_string();
3920 let expected = resolve_api_key("Zai", "", None).unwrap_err().to_string();
3921 assert_eq!(zai, expected);
3922 assert!(zai.contains("[zai]"), "{zai}");
3923 let minimax = config
3924 .resolve_account_api_key_for(VendorId::Minimax, None)
3925 .unwrap_err()
3926 .to_string();
3927 assert_eq!(
3928 minimax,
3929 resolve_api_key("MiniMax", "", None)
3930 .unwrap_err()
3931 .to_string()
3932 );
3933 }
3934
3935 #[test]
3936 fn enabled_vendors_preserves_canonical_order() {
3937 let c = Config::default();
3940 assert_eq!(
3941 c.enabled_vendors(),
3942 vec![
3943 VendorId::Anthropic,
3944 VendorId::Openai,
3945 VendorId::Zai,
3946 VendorId::Openrouter,
3947 ]
3948 );
3949 }
3950
3951 #[test]
3952 fn deepseek_appears_when_enabled() {
3953 let f = write_toml(
3954 r#"
3955 [deepseek]
3956 enabled = true
3957 api_key = "sk-test"
3958 "#,
3959 );
3960 let c = Config::load_from(f.path()).unwrap();
3961 assert!(c.is_enabled(VendorId::Deepseek));
3962 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
3963 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
3964 }
3965
3966 #[test]
3967 fn tilde_paths_are_expanded_on_load() {
3968 let f = write_toml(
3972 r#"
3973 [context]
3974 projects_path = "~/.claude/projects"
3975
3976 [anthropic]
3977 credentials_path = "~/.claude/.credentials.json"
3978
3979 [[anthropic.accounts]]
3980 label = "work"
3981 credentials_path = "~/work.json"
3982 "#,
3983 );
3984 let c = Config::load_from(f.path()).unwrap();
3985 let home = crate::cache::home_dir().unwrap();
3986
3987 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
3988 let got = c.anthropic.credentials_path.unwrap();
3989 assert_eq!(got, home.join(".claude/.credentials.json"));
3990 assert!(!got.to_string_lossy().contains('~'));
3991 assert_eq!(
3992 c.anthropic.accounts[0].credentials_path,
3993 home.join("work.json")
3994 );
3995 }
3996
3997 #[test]
3998 fn absolute_and_relative_paths_are_left_alone() {
3999 let f = write_toml(
4000 r#"
4001 [anthropic]
4002 credentials_path = "/etc/creds.json"
4003 "#,
4004 );
4005 let c = Config::load_from(f.path()).unwrap();
4006 assert_eq!(
4007 c.anthropic.credentials_path.unwrap(),
4008 std::path::Path::new("/etc/creds.json")
4009 );
4010
4011 let f2 = write_toml(
4013 r#"
4014 [anthropic]
4015 credentials_path = "~someone/creds.json"
4016 "#,
4017 );
4018 let c2 = Config::load_from(f2.path()).unwrap();
4019 assert_eq!(
4020 c2.anthropic.credentials_path.unwrap(),
4021 std::path::Path::new("~someone/creds.json")
4022 );
4023 }
4024
4025 #[test]
4026 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
4027 let _g = path_override_guard();
4028 let p = resolved_path().expect("a config path must resolve");
4031 assert!(p.ends_with("config.toml"));
4032 let canonical = default_path().unwrap();
4033 let legacy = legacy_xdg_path().unwrap();
4034 assert!(
4035 p == canonical || p == legacy,
4036 "resolved to an unexpected location: {}",
4037 p.display()
4038 );
4039 }
4040
4041 #[test]
4042 fn misspelled_section_is_rejected_not_ignored() {
4043 let f = write_toml(
4046 r#"
4047 [openrouer]
4048 enabled = true
4049 api_key = "sk-or-v1-typo"
4050 "#,
4051 );
4052 let err = Config::load_from(f.path()).unwrap_err().to_string();
4053 assert!(
4054 err.contains("openrouer"),
4055 "error should name the typo: {err}"
4056 );
4057 }
4058
4059 #[test]
4060 fn invalid_toml_is_an_error_not_silent_defaults() {
4061 let f = write_toml("[zai\nenabled = true\n");
4062 assert!(Config::load_from(f.path()).is_err());
4063 }
4064
4065 #[test]
4066 fn a_missing_file_is_still_just_defaults() {
4067 let dir = tempfile::tempdir().unwrap();
4070 let missing = dir.path().join("nope").join("config.toml");
4071 let c = Config::load_from(&missing).unwrap();
4072 assert!(c.is_enabled(VendorId::Anthropic));
4073 }
4074
4075 #[test]
4076 fn kimi_appears_when_enabled() {
4077 let f = write_toml(
4078 r#"
4079 [kimi]
4080 enabled = true
4081 api_key = "sk-test"
4082 "#,
4083 );
4084 let c = Config::load_from(f.path()).unwrap();
4085 assert!(c.is_enabled(VendorId::Kimi));
4086 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
4087 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
4088 }
4089
4090 #[test]
4091 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
4092 let f = write_toml(
4093 r#"
4094 [deepseek]
4095 enabled = true
4096 api_key = "sk-ds"
4097
4098 [kimi]
4099 enabled = true
4100 api_key = "sk-kimi"
4101 "#,
4102 );
4103 let c = Config::load_from(f.path()).unwrap();
4104 assert_eq!(
4105 c.enabled_vendors(),
4106 vec![
4107 VendorId::Anthropic,
4108 VendorId::Openai,
4109 VendorId::Zai,
4110 VendorId::Openrouter,
4111 VendorId::Deepseek,
4112 VendorId::Kimi,
4113 ]
4114 );
4115 }
4116
4117 #[test]
4118 fn parses_anthropic_accounts_and_looks_them_up() {
4119 let f = write_toml(
4120 r#"
4121 [anthropic]
4122 enabled = true
4123
4124 [[anthropic.accounts]]
4125 label = "personal"
4126 credentials_path = "/creds/personal.json"
4127
4128 [[anthropic.accounts]]
4129 label = "work"
4130 credentials_path = "/creds/work.json"
4131 "#,
4132 );
4133 let c = Config::load_from(f.path()).unwrap();
4134 assert_eq!(c.anthropic.accounts.len(), 2);
4135 let work = c.anthropic.account("work").unwrap();
4136 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
4137 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
4139 assert!(err.contains("missing") && err.contains("work"), "{err}");
4140 }
4141
4142 #[test]
4143 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
4144 let f = write_toml(
4145 r#"
4146 [[anthropic.accounts]]
4147 label = "work"
4148 credentials_path = "/creds/work-one.json"
4149
4150 [[anthropic.accounts]]
4151 label = "work"
4152 credentials_path = "/creds/work-two.json"
4153 "#,
4154 );
4155 let err = Config::load_from(f.path()).unwrap_err().to_string();
4156 assert!(
4157 err.contains("duplicate anthropic account label \"work\""),
4158 "{err}"
4159 );
4160 }
4161
4162 #[test]
4163 fn account_label_rejects_path_like_names() {
4164 let cfg = AnthropicConfig::default();
4165 for bad in [
4166 "",
4167 ".",
4168 "..",
4169 "a/b",
4170 r"a\b",
4171 "C:work",
4172 "line\nbreak",
4173 "tab\tname",
4174 "usage.json",
4175 ".stale",
4176 ".last_error",
4177 ".fetch.lock",
4178 ] {
4179 let err = cfg.account(bad).unwrap_err();
4180 assert!(
4181 format!("{err:?}").contains("invalid anthropic account label"),
4182 "{bad:?} should be rejected as a label"
4183 );
4184 }
4185 }
4186
4187 #[test]
4188 fn anthropic_accounts_default_to_empty() {
4189 assert!(Config::default().anthropic.accounts.is_empty());
4192 assert!(Config::default().anthropic.accounts_dir.is_none());
4193 }
4194
4195 fn seed_account_dir(root: &std::path::Path, label: &str) {
4201 let dir = root.join(label);
4202 std::fs::create_dir_all(&dir).unwrap();
4203 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
4204 }
4205
4206 #[test]
4207 fn discovers_account_dirs_in_claude_config_dir_layout() {
4208 let td = tempfile::tempdir().unwrap();
4209 seed_account_dir(td.path(), "work");
4210 seed_account_dir(td.path(), "personal");
4211 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
4214 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
4216
4217 let cfg = AnthropicConfig {
4218 accounts_dir: Some(td.path().to_path_buf()),
4219 ..Default::default()
4220 };
4221 let all = cfg.all_accounts();
4222 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
4223 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
4224 assert_eq!(
4225 all[2].credentials_path,
4226 td.path().join("work").join(".credentials.json")
4227 );
4228 }
4229
4230 #[test]
4231 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
4232 let td = tempfile::tempdir().unwrap();
4233 seed_account_dir(td.path(), "work");
4234 let cfg = AnthropicConfig {
4235 accounts: vec![AnthropicAccount {
4236 label: "work".into(),
4237 credentials_path: "/explicit/work.json".into(),
4238 }],
4239 accounts_dir: Some(td.path().to_path_buf()),
4240 ..Default::default()
4241 };
4242 let all = cfg.all_accounts();
4243 assert_eq!(all.len(), 1, "no duplicate label");
4244 assert_eq!(
4245 all[0].credentials_path,
4246 std::path::Path::new("/explicit/work.json"),
4247 "explicit entry wins"
4248 );
4249 seed_account_dir(td.path(), "other");
4251 assert_eq!(cfg.account("other").unwrap().label, "other");
4252 }
4253
4254 #[test]
4255 fn missing_accounts_dir_is_silently_empty_not_an_error() {
4256 let cfg = AnthropicConfig {
4257 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
4258 ..Default::default()
4259 };
4260 assert!(cfg.all_accounts().is_empty());
4261 }
4262
4263 #[test]
4264 fn openai_account_auth_paths_are_tilde_expanded_on_load() {
4265 let f = write_toml(
4266 r#"
4267 [[openai.accounts]]
4268 label = "work"
4269 codex_auth_path = "~/.codex-work/auth.json"
4270 "#,
4271 );
4272 let c = Config::load_from(f.path()).unwrap();
4273 let home = crate::cache::home_dir().unwrap();
4274 assert_eq!(
4275 c.openai.accounts[0].codex_auth_path,
4276 home.join(".codex-work/auth.json")
4277 );
4278 }
4279
4280 #[test]
4281 fn accounts_dir_is_tilde_expanded_on_load() {
4282 let f = write_toml(
4283 r#"
4284 [anthropic]
4285 accounts_dir = "~/.config/ai-usagebar/accounts"
4286 "#,
4287 );
4288 let c = Config::load_from(f.path()).unwrap();
4289 let home = crate::cache::home_dir().unwrap();
4290 assert_eq!(
4291 c.anthropic.accounts_dir,
4292 Some(home.join(".config/ai-usagebar/accounts"))
4293 );
4294 }
4295
4296 #[test]
4297 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
4298 let f = write_toml(
4299 r#"
4300 [anthropic]
4301 desktop_profiles_dir = "~/.claude-acc/profiles"
4302 "#,
4303 );
4304 let c = Config::load_from(f.path()).unwrap();
4305 let home = crate::cache::home_dir().unwrap();
4306 assert_eq!(
4307 c.anthropic.desktop_profiles_dir,
4308 Some(home.join(".claude-acc/profiles"))
4309 );
4310 }
4311
4312 #[test]
4313 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
4314 let cfg = AnthropicConfig {
4315 accounts: vec![
4316 AnthropicAccount {
4317 label: "work".into(),
4318 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
4319 },
4320 AnthropicAccount {
4321 label: "personal".into(),
4322 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
4323 },
4324 ],
4325 ..Default::default()
4326 };
4327
4328 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
4329 assert!(
4330 matches!(&idle, CredsTarget::Named { config_dir, .. }
4331 if config_dir == std::path::Path::new("/tmp/accounts/work")),
4332 "{idle:?}"
4333 );
4334
4335 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
4337 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
4338
4339 assert_eq!(idle_cache.dir(), live_cache.dir());
4342 }
4343
4344 #[test]
4345 fn the_live_cli_account_keeps_its_own_slot_while_that_file_is_there() {
4346 let cfg = AnthropicConfig {
4351 accounts: vec![AnthropicAccount {
4352 label: "personal".into(),
4353 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
4354 }],
4355 ..Default::default()
4356 };
4357
4358 let (present, _) = cfg
4359 .account_target_probing("personal", Some("personal"), |_| true)
4360 .unwrap();
4361 assert!(
4362 matches!(&present, CredsTarget::Named { path, .. }
4363 if path == Path::new("/tmp/accounts/personal/.credentials.json")),
4364 "{present:?}"
4365 );
4366
4367 let (moved, _) = cfg
4369 .account_target_probing("personal", Some("personal"), |_| false)
4370 .unwrap();
4371 assert!(matches!(moved, CredsTarget::Default(_)), "{moved:?}");
4372 }
4373
4374 #[test]
4375 fn the_live_cli_accounts_own_file_is_probed_on_disk() {
4376 let creds = NamedTempFile::new().unwrap();
4380 let cfg = AnthropicConfig {
4381 accounts: vec![AnthropicAccount {
4382 label: "personal".into(),
4383 credentials_path: creds.path().to_path_buf(),
4384 }],
4385 ..Default::default()
4386 };
4387 let (target, _) = cfg
4388 .account_target_with("personal", Some("personal"))
4389 .unwrap();
4390 assert!(
4391 matches!(&target, CredsTarget::Named { path, .. } if path == creds.path()),
4392 "read {target:?} instead of the account's own file"
4393 );
4394 }
4395
4396 #[test]
4397 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
4398 let cfg = AnthropicConfig {
4399 accounts: vec![AnthropicAccount {
4400 label: "work".into(),
4401 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
4402 }],
4403 ..Default::default()
4404 };
4405 let (target, _) = cfg.account_target_with("work", None).unwrap();
4406 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
4407 }
4408
4409 fn config_example() -> PathBuf {
4413 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
4414 }
4415
4416 #[test]
4417 fn shipped_example_parses_as_a_real_config() {
4418 let c = Config::load_from(&config_example()).unwrap();
4423 assert!(!c.context.enabled);
4424 assert!(c.is_enabled(VendorId::Anthropic));
4425 assert!(c.is_enabled(VendorId::Openai));
4426 assert!(!c.is_enabled(VendorId::AnthropicApi));
4427 assert!(!c.is_enabled(VendorId::Deepseek));
4428 assert!(!c.is_enabled(VendorId::Kimi));
4429 assert!(!c.is_enabled(VendorId::Kilo));
4430 assert!(!c.is_enabled(VendorId::Novita));
4431 assert!(!c.is_enabled(VendorId::Moonshot));
4432 assert!(!c.is_enabled(VendorId::Grok));
4433 assert!(!c.is_enabled(VendorId::Cursor));
4434 assert!(!c.is_enabled(VendorId::Minimax));
4435 }
4436
4437 #[test]
4438 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
4439 let text = std::fs::read_to_string(config_example()).unwrap();
4444 let live: Vec<&str> = text
4445 .lines()
4446 .map(str::trim)
4447 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
4448 .collect();
4449 assert!(
4450 live.is_empty(),
4451 "admin_key_env must stay commented out while it is inert: {live:?}"
4452 );
4453 assert!(
4456 text.contains("admin_key_env") && text.contains("RESERVED"),
4457 "the example should keep describing admin_key_env as reserved"
4458 );
4459 }
4460
4461 #[test]
4462 fn admin_key_env_is_accepted_but_changes_nothing() {
4463 let f = write_toml(
4467 r#"
4468 [openai]
4469 admin_key_env = "SOME_ADMIN_KEY"
4470 "#,
4471 );
4472 let c = Config::load_from(f.path()).unwrap();
4473 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
4474 let default = OpenAiConfig::default();
4476 assert_eq!(c.openai.enabled, default.enabled);
4477 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
4478 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
4479 }
4480
4481 #[test]
4482 fn config_example_documents_every_vendor_without_secrets() {
4483 let raw = std::fs::read_to_string(config_example()).unwrap();
4484 let cfg = Config::load_from(&config_example()).unwrap();
4485 for id in VendorId::all() {
4488 let section = id.slug();
4489 assert!(
4490 raw.contains(&format!("[{section}]")),
4491 "config.example.toml has no [{section}] section"
4492 );
4493 }
4494
4495 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
4498 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
4499 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
4500 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
4501 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
4502 assert!(!cfg.supergrok.enabled);
4503 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
4504 assert_eq!(
4505 cfg.supergrok
4506 .grok_binary
4507 .file_name()
4508 .and_then(|p| p.to_str()),
4509 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
4510 );
4511 assert!(cfg.supergrok.auth_path.is_none());
4512 assert!(cfg.supergrok.config_path.is_none());
4513 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
4514 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
4515 }
4516
4517 #[test]
4518 fn supergrok_binary_must_not_be_empty() {
4519 let file = write_toml(
4520 r#"
4521 [supergrok]
4522 enabled = true
4523 grok_binary = ""
4524 "#,
4525 );
4526 let error = Config::load_from(file.path()).unwrap_err().to_string();
4527 assert!(error.contains("grok_binary must not be empty"));
4528 }
4529
4530 #[test]
4531 fn supergrok_paths_are_tilde_expanded() {
4532 let file = write_toml(
4533 r#"
4534 [supergrok]
4535 grok_binary = "~/bin/grok"
4536 auth_path = "~/.grok/auth.json"
4537 config_path = "~/.grok/config.toml"
4538 "#,
4539 );
4540 let config = Config::load_from(file.path()).unwrap();
4541 let home = crate::cache::home_dir().unwrap();
4542 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
4543 assert_eq!(
4544 config.supergrok.auth_path,
4545 Some(home.join(".grok/auth.json"))
4546 );
4547 assert_eq!(
4548 config.supergrok.config_path,
4549 Some(home.join(".grok/config.toml"))
4550 );
4551 }
4552
4553 #[test]
4554 fn kiro_db_path_is_tilde_expanded() {
4555 let f = write_toml(
4556 r#"
4557 [kiro]
4558 db_path = "~/kiro-data.sqlite3"
4559 "#,
4560 );
4561 let c = Config::load_from(f.path()).unwrap();
4562 let home = crate::cache::home_dir().unwrap();
4563 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
4564 }
4565
4566 #[test]
4567 fn kiro_appears_when_enabled() {
4568 let f = write_toml(
4569 r#"
4570 [kiro]
4571 enabled = true
4572 "#,
4573 );
4574 let c = Config::load_from(f.path()).unwrap();
4575 assert!(c.is_enabled(VendorId::Kiro));
4576 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
4577 }
4578
4579 #[test]
4580 fn cursor_db_path_is_tilde_expanded() {
4581 let f = write_toml(
4582 r#"
4583 [cursor]
4584 db_path = "~/cursor-state.vscdb"
4585 "#,
4586 );
4587 let c = Config::load_from(f.path()).unwrap();
4588 let home = crate::cache::home_dir().unwrap();
4589 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
4590 }
4591
4592 #[test]
4593 fn cursor_agent_auth_path_is_tilde_expanded() {
4594 let f = write_toml(
4595 r#"
4596 [cursor]
4597 agent_auth_path = "~/cursor-agent-auth.json"
4598 "#,
4599 );
4600 let c = Config::load_from(f.path()).unwrap();
4601 let home = crate::cache::home_dir().unwrap();
4602 assert_eq!(
4603 c.cursor.agent_auth_path,
4604 Some(home.join("cursor-agent-auth.json"))
4605 );
4606 }
4607
4608 #[test]
4609 fn cursor_appears_when_enabled() {
4610 let f = write_toml(
4611 r#"
4612 [cursor]
4613 enabled = true
4614 "#,
4615 );
4616 let c = Config::load_from(f.path()).unwrap();
4617 assert!(c.is_enabled(VendorId::Cursor));
4618 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
4619 }
4620
4621 #[test]
4622 fn add_account_appends_and_preserves_existing() {
4623 let mut doc: toml_edit::DocumentMut = r#"
4624# keep me
4625[anthropic]
4626enabled = true
4627
4628[[anthropic.accounts]]
4629label = "personal"
4630credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
4631"#
4632 .parse()
4633 .unwrap();
4634 add_anthropic_account_to_doc(
4635 &mut doc,
4636 "work",
4637 "~/.config/ai-usagebar/accounts/work/.credentials.json",
4638 )
4639 .unwrap();
4640 let rendered = doc.to_string();
4641 assert!(rendered.contains("# keep me"), "comment must survive");
4642 let f = write_toml(&rendered);
4644 let c = Config::load_from(f.path()).unwrap();
4645 let labels: Vec<&str> = c
4646 .anthropic
4647 .accounts
4648 .iter()
4649 .map(|a| a.label.as_str())
4650 .collect();
4651 assert_eq!(labels, vec!["personal", "work"]);
4652 }
4653
4654 #[test]
4655 fn add_account_to_empty_doc_is_loadable() {
4656 let mut doc = toml_edit::DocumentMut::new();
4657 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
4658 let f = write_toml(&doc.to_string());
4659 let c = Config::load_from(f.path()).unwrap();
4660 assert_eq!(c.anthropic.accounts.len(), 1);
4661 assert_eq!(c.anthropic.accounts[0].label, "solo");
4662 }
4663
4664 #[test]
4665 fn add_account_rejects_duplicate_label() {
4666 let mut doc: toml_edit::DocumentMut = r#"
4667[[anthropic.accounts]]
4668label = "work"
4669credentials_path = "~/w/.credentials.json"
4670"#
4671 .parse()
4672 .unwrap();
4673 assert!(
4674 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
4675 "a duplicate label must be rejected, not appended"
4676 );
4677 }
4678
4679 #[test]
4680 fn add_account_rejects_bad_label() {
4681 let mut doc = toml_edit::DocumentMut::new();
4682 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
4683 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
4684 }
4685
4686 #[test]
4687 fn tildify_collapses_home_only() {
4688 let home = Path::new("/Users/me");
4689 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
4690 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
4691 }
4692
4693 #[test]
4694 fn default_account_credentials_path_nests_under_config_dir() {
4695 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
4696 assert_eq!(
4697 default_account_credentials_path(cfg, "work"),
4698 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
4699 );
4700 }
4701 const CUSTOM_BLOCK: &str = r#"
4704[[custom]]
4705id = "mytool"
4706name = "My Tool"
4707short_name = "myt"
4708enabled = true
4709url = "https://api.example.test/v1/usage"
4710api_key_env = "MYTOOL_API_KEY"
4711auth_header = "Authorization"
4712auth_scheme = "Bearer"
4713plan = "Pro"
4714cache_ttl_secs = 120
4715[custom.headers]
4716X-Org = "org_1"
4717[[custom.metrics]]
4718label = "Requests"
4719used = "/requests/used"
4720limit = "/requests/limit"
4721resets_at = "/requests/reset"
4722window_secs = 3600
4723[[custom.texts]]
4724label = "Tier"
4725value = "/tier"
4726"#;
4727
4728 fn custom_with(from: &str, to: &str) -> String {
4729 assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
4730 CUSTOM_BLOCK.replace(from, to)
4731 }
4732
4733 fn custom_error(toml: &str) -> String {
4734 Config::load_from(write_toml(toml).path())
4735 .unwrap_err()
4736 .to_string()
4737 }
4738
4739 fn assert_custom_rejected(toml: &str, needle: &str) {
4740 let msg = custom_error(toml);
4741 assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
4742 assert!(
4743 msg.contains("[[custom]]"),
4744 "the error must locate the section: {msg}"
4745 );
4746 }
4747
4748 #[test]
4749 fn custom_block_parses_every_field() {
4750 let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
4751 assert_eq!(config.custom.len(), 1);
4752 let c = &config.custom[0];
4753 assert_eq!(c.id, "mytool");
4754 assert_eq!(c.name, "My Tool");
4755 assert_eq!(c.short_name, "myt");
4756 assert_eq!(
4757 c.brand, None,
4758 "a custom provider has no mark unless it asks"
4759 );
4760 assert!(c.enabled);
4761 assert_eq!(c.url, "https://api.example.test/v1/usage");
4762 assert!(!c.allow_http);
4763 assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
4764 assert_eq!(c.api_key, None);
4765 assert_eq!(c.auth_header, "Authorization");
4766 assert_eq!(c.auth_scheme, "Bearer");
4767 assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
4768 assert_eq!(c.plan.as_deref(), Some("Pro"));
4769 assert_eq!(c.plan_path, None);
4770 assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
4771 assert_eq!(c.metrics.len(), 1);
4772 assert_eq!(c.metrics[0].label, "Requests");
4773 assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
4774 assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
4775 assert_eq!(c.metrics[0].percent, None);
4776 assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
4777 assert_eq!(c.metrics[0].window_secs, Some(3600));
4778 assert_eq!(c.texts.len(), 1);
4779 assert_eq!(c.texts[0].label, "Tier");
4780 assert_eq!(c.texts[0].value, "/tier");
4781 assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
4782 }
4783
4784 #[test]
4785 fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
4786 let config: Config = toml::from_str(
4787 r#"
4788 [[custom]]
4789 id = "bare"
4790 short_name = "bre"
4791 url = "https://example.test/u"
4792 [[custom.metrics]]
4793 label = "Q"
4794 percent = "/pct"
4795 "#,
4796 )
4797 .unwrap();
4798 let c = &config.custom[0];
4799 assert_eq!(c.name, "bare", "name must default to id on a plain parse");
4800 assert!(!c.enabled);
4801 assert!(!c.allow_http);
4802 assert_eq!(c.api_key_env, "");
4803 assert_eq!(c.auth_header, "Authorization");
4804 assert_eq!(c.auth_scheme, "Bearer");
4805 assert_eq!(c.cache_ttl_secs, 60);
4806 assert!(config.validate().is_ok());
4807 assert!(Config::default().custom.is_empty());
4808 }
4809
4810 #[test]
4811 fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
4812 let config = Config::load_from(
4813 write_toml(&custom_with(
4814 r#"short_name = "myt""#,
4815 "short_name = \"myt\"\nbrand = \"opencode-go\"",
4816 ))
4817 .path(),
4818 )
4819 .unwrap();
4820 assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
4821
4822 for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
4826 assert_custom_rejected(
4827 &custom_with(
4828 r#"short_name = "myt""#,
4829 &format!("short_name = \"myt\"\nbrand = {brand:?}"),
4830 ),
4831 "must name a built-in vendor",
4832 );
4833 }
4834 }
4835
4836 #[test]
4837 fn custom_rejects_a_malformed_id() {
4838 let long = "a".repeat(33);
4839 for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
4840 let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
4841 assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
4842 assert!(msg.contains("must match"), "{id:?}: {msg}");
4843 }
4844 }
4845
4846 #[test]
4847 fn custom_rejects_a_builtin_slug_as_id() {
4848 assert_custom_rejected(
4849 &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
4850 "is a built-in vendor",
4851 );
4852 assert_custom_rejected(
4853 &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
4854 "is a built-in vendor",
4855 );
4856 }
4857
4858 #[test]
4859 fn custom_rejects_duplicate_ids() {
4860 let twice = format!(
4861 "{}{}",
4862 CUSTOM_BLOCK,
4863 custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
4864 );
4865 assert_custom_rejected(&twice, "duplicate id");
4866 }
4867
4868 #[test]
4869 fn custom_rejects_a_name_over_48_chars() {
4870 let long = "n".repeat(49);
4871 assert_custom_rejected(
4872 &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
4873 "name must be 1 to 48 characters",
4874 );
4875 }
4876
4877 #[test]
4878 fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
4879 for short in ["my", "myto", "MYT", "m1t"] {
4880 assert_custom_rejected(
4881 &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
4882 "exactly 3 lowercase ASCII letters",
4883 );
4884 }
4885 }
4886
4887 #[test]
4888 fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
4889 assert_custom_rejected(
4890 &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
4891 "already used by a built-in vendor",
4892 );
4893 let twice = format!(
4894 "{}{}",
4895 CUSTOM_BLOCK,
4896 custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
4897 );
4898 assert_custom_rejected(&twice, "already used by a built-in vendor");
4899 }
4900
4901 #[test]
4902 fn custom_rejects_http_unless_allowed() {
4903 let plain = custom_with(
4904 r#"url = "https://api.example.test/v1/usage""#,
4905 r#"url = "http://localhost:8080/usage""#,
4906 );
4907 assert_custom_rejected(&plain, "url must use https://");
4908 let allowed = plain.replace(
4909 r#"url = "http://localhost:8080/usage""#,
4910 "url = \"http://localhost:8080/usage\"\nallow_http = true",
4911 );
4912 assert!(
4913 Config::load_from(write_toml(&allowed).path()).is_ok(),
4914 "allow_http must permit http://"
4915 );
4916 }
4917
4918 #[test]
4919 fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
4920 assert_custom_rejected(
4921 &custom_with(
4922 r#"url = "https://api.example.test/v1/usage""#,
4923 r#"url = "https://user:pw@api.example.test/v1/usage""#,
4924 ),
4925 "must not carry credentials",
4926 );
4927 assert_custom_rejected(
4928 &custom_with(
4929 r#"url = "https://api.example.test/v1/usage""#,
4930 r#"url = "not a url""#,
4931 ),
4932 "is not a valid URL",
4933 );
4934 assert_custom_rejected(
4935 &custom_with(
4936 r#"url = "https://api.example.test/v1/usage""#,
4937 r#"url = "ftp://api.example.test/v1/usage""#,
4938 ),
4939 "is not http or https",
4940 );
4941 }
4942
4943 #[test]
4944 fn custom_rejects_an_invalid_api_key_env() {
4945 assert_custom_rejected(
4946 &custom_with(
4947 r#"api_key_env = "MYTOOL_API_KEY""#,
4948 r#"api_key_env = "1BAD-NAME""#,
4949 ),
4950 "is not a valid environment variable name",
4951 );
4952 let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
4953 assert!(
4954 Config::load_from(write_toml(&none).path()).is_ok(),
4955 "an empty api_key_env means inline-only and is valid"
4956 );
4957 }
4958
4959 #[test]
4960 fn custom_rejects_an_invalid_auth_header_name() {
4961 assert_custom_rejected(
4962 &custom_with(
4963 r#"auth_header = "Authorization""#,
4964 r#"auth_header = "X Api Key""#,
4965 ),
4966 "auth_header \"X Api Key\" is not a valid HTTP header name",
4967 );
4968 }
4969
4970 #[test]
4971 fn custom_rejects_a_control_char_in_auth_scheme() {
4972 assert_custom_rejected(
4973 &custom_with(
4974 r#"auth_scheme = "Bearer""#,
4975 "auth_scheme = \"Bearer\\u0007\"",
4976 ),
4977 "auth_scheme contains characters that are not valid",
4978 );
4979 let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
4980 assert!(
4981 Config::load_from(write_toml(&bare).path()).is_ok(),
4982 "an empty scheme (bare key) is valid"
4983 );
4984 }
4985
4986 #[test]
4987 fn custom_rejects_a_bad_extra_header() {
4988 assert_custom_rejected(
4989 &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
4990 "headers must not repeat auth_header",
4991 );
4992 assert_custom_rejected(
4993 &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
4994 "is not a valid HTTP header name",
4995 );
4996 assert_custom_rejected(
4997 &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
4998 "has a value that is not valid in an HTTP header",
4999 );
5000 }
5001
5002 #[test]
5003 fn custom_rejects_cache_ttl_outside_10_to_3600() {
5004 for ttl in ["9", "3601"] {
5005 assert_custom_rejected(
5006 &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
5007 "cache_ttl_secs must be between 10 and 3600",
5008 );
5009 }
5010 }
5011
5012 #[test]
5013 fn custom_rejects_an_entry_with_no_metrics_or_texts() {
5014 let toml = r#"
5015[[custom]]
5016id = "empty"
5017short_name = "emp"
5018url = "https://example.test/u"
5019"#;
5020 assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
5021 }
5022
5023 #[test]
5024 fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
5025 assert_custom_rejected(
5026 &custom_with(
5027 r#"limit = "/requests/limit""#,
5028 "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
5029 ),
5030 "must set `percent`, or both `used` and `limit`",
5031 );
5032 assert_custom_rejected(
5033 &custom_with("limit = \"/requests/limit\"\n", ""),
5034 "must set `percent`, or both `used` and `limit`",
5035 );
5036 }
5037
5038 #[test]
5039 fn custom_rejects_a_pointer_without_a_leading_slash() {
5040 assert_custom_rejected(
5041 &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
5042 "used \"requests.used\" must be an RFC 6901 JSON Pointer",
5043 );
5044 assert_custom_rejected(
5045 &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
5046 "value \"tier\" must be an RFC 6901 JSON Pointer",
5047 );
5048 assert_custom_rejected(
5049 &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
5050 "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
5051 );
5052 assert_custom_rejected(
5053 &custom_with(
5054 r#"resets_at = "/requests/reset""#,
5055 "resets_at = \"/re\\u001bset\"",
5056 ),
5057 "resets_at",
5058 );
5059 }
5060
5061 #[test]
5062 fn custom_rejects_a_label_outside_1_to_64_chars() {
5063 let long = "l".repeat(65);
5064 assert_custom_rejected(
5065 &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
5066 "metric label",
5067 );
5068 assert_custom_rejected(
5069 &custom_with(r#"label = "Tier""#, r#"label = """#),
5070 "text label \"\" must be 1 to 64 characters",
5071 );
5072 }
5073
5074 #[test]
5075 fn custom_rejects_window_secs_under_60() {
5076 assert_custom_rejected(
5077 &custom_with("window_secs = 3600", "window_secs = 59"),
5078 "window_secs must be at least 60",
5079 );
5080 }
5081
5082 #[test]
5083 fn custom_rejects_duplicate_metric_and_text_labels() {
5084 let metric_twice = custom_with(
5085 "window_secs = 3600\n",
5086 "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
5087 );
5088 assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
5089 let text_twice =
5090 format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
5091 assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
5092 }
5093
5094 #[test]
5095 fn enabled_custom_and_custom_by_id_select_entries() {
5096 let two = format!(
5097 "{}{}",
5098 CUSTOM_BLOCK,
5099 custom_with(r#"id = "mytool""#, r#"id = "off""#)
5100 .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
5101 .replace("enabled = true", "enabled = false")
5102 );
5103 let config = Config::load_from(write_toml(&two).path()).unwrap();
5104 let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
5105 assert_eq!(enabled, ["mytool"]);
5106 assert_eq!(
5107 config.custom_by_id("off").map(|c| c.name.as_str()),
5108 Some("My Tool")
5109 );
5110 assert!(config.custom_by_id("nope").is_none());
5111 }
5112
5113 #[cfg(unix)]
5114 #[test]
5115 fn has_inline_secrets_sees_a_custom_inline_key() {
5116 let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
5117 assert!(!without.has_inline_secrets());
5118 let with: Config = toml::from_str(&custom_with(
5119 r#"api_key_env = "MYTOOL_API_KEY""#,
5120 "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
5121 ))
5122 .unwrap();
5123 assert!(with.has_inline_secrets());
5124 }
5125
5126 #[test]
5127 fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
5128 let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
5129 let mut spec = CustomProviderConfig {
5130 id: "mytool".into(),
5131 api_key_env: var.into(),
5132 api_key: Some("sk-inline-secret".into()),
5133 ..CustomProviderConfig::default()
5134 };
5135 unsafe { std::env::set_var(var, "sk-env-secret") };
5136 let from_env = spec.resolve_api_key();
5137 unsafe { std::env::remove_var(var) };
5138 assert_eq!(from_env.unwrap(), "sk-env-secret");
5139
5140 assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
5141
5142 spec.api_key = Some(String::new());
5143 let err = spec.resolve_api_key().unwrap_err();
5144 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
5145 let msg = err.to_string();
5146 assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
5147 assert!(msg.contains(var), "{msg}");
5148 assert!(!msg.contains("secret"), "{msg}");
5149
5150 spec.api_key_env = String::new();
5151 let msg = spec.resolve_api_key().unwrap_err().to_string();
5152 assert!(msg.contains("set `api_key`"), "{msg}");
5153 }
5154
5155 #[test]
5156 fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
5157 let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
5158 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
5159 let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
5160 Config::load_from(file.path()).unwrap();
5161 assert!(
5162 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
5163 "a custom provider's env var must be scrubbed from subprocesses"
5164 );
5165 }
5166
5167 #[test]
5172 fn every_config_section_parses_to_its_vendors_enabled_switch() {
5173 for vendor in VendorId::all() {
5174 let text = format!(
5175 "[{}]
5176enabled = true
5177",
5178 vendor.config_section()
5179 );
5180 let config: Config = toml::from_str(&text)
5181 .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
5182 assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
5183 let others = VendorId::all()
5184 .iter()
5185 .filter(|other| *other != vendor && config.is_enabled(**other))
5186 .count();
5187 assert_eq!(
5188 others,
5189 Config::default().enabled_vendors().len()
5190 - usize::from(Config::default().is_enabled(*vendor)),
5191 "[{}] enabled a different vendor",
5192 vendor.config_section()
5193 );
5194 }
5195 }
5196
5197 #[test]
5198 fn tray_section_parses_and_defaults_to_notify() {
5199 let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
5200 let config = Config::load_from(file.path()).unwrap();
5201 assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
5202 assert_eq!(config.tray.updates(), UpdateMode::Auto);
5203
5204 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
5205 assert_eq!(empty.tray, TrayConfig::default());
5206 assert_eq!(empty.tray.updates(), UpdateMode::Notify);
5207 assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
5208 assert_eq!(UpdateMode::parse("weekly"), None);
5209 assert_eq!(UpdateMode::Auto.as_str(), "auto");
5210 }
5211
5212 #[test]
5213 fn tray_ignores_removed_menu_bar_keys_for_back_compatibility() {
5214 let legacy = write_toml(
5215 "[tray]\nmenu_bar_show_all = false\nmenu_bar_hide_value = true\nmenu_bar_names = \"short\"\nmenu_bar_provider = \"anthropic\"\nmenu_bar_window = \"weekly\"\n",
5216 );
5217 let config = Config::load_from(legacy.path()).unwrap();
5218 assert_eq!(config.tray, TrayConfig::default());
5219 }
5220
5221 #[test]
5222 fn tray_section_rejects_a_misspelled_mode() {
5223 let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
5224 assert!(Config::load_from(file.path()).is_err());
5225 }
5226
5227 #[test]
5228 fn tray_refresh_minutes_defaults_to_five_and_parses() {
5229 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
5230 assert_eq!(empty.tray.refresh_minutes, None);
5231 assert_eq!(empty.tray.refresh_minutes(), 5);
5232
5233 let file = write_toml("[tray]\nrefresh_minutes = 10\n");
5234 let config = Config::load_from(file.path()).unwrap();
5235 assert_eq!(config.tray.refresh_minutes(), 10);
5236 }
5237
5238 #[test]
5239 fn tray_refresh_minutes_rejects_values_outside_the_menu() {
5240 for minutes in ["3", "0"] {
5241 let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
5242 let error = Config::load_from(file.path()).unwrap_err().to_string();
5243 assert!(error.contains("[tray] refresh_minutes"), "{error}");
5244 assert!(error.contains("1, 5 or 10"), "{error}");
5245 }
5246 }
5247
5248 #[test]
5249 fn notifications_default_on_at_97_and_parse_overrides() {
5250 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
5251 assert!(empty.notifications.enabled);
5252 assert_eq!(empty.notifications.threshold, 97);
5253
5254 let file = write_toml("[notifications]\nenabled = false\nthreshold = 100\n");
5255 let config = Config::load_from(file.path()).unwrap();
5256 assert!(!config.notifications.enabled);
5257 assert_eq!(config.notifications.threshold, 100);
5258 }
5259
5260 #[test]
5261 fn notifications_threshold_rejects_values_outside_1_to_100() {
5262 for threshold in ["0", "101", "255"] {
5263 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
5264 let error = Config::load_from(file.path()).unwrap_err().to_string();
5265 assert!(
5266 error.contains("[notifications] threshold must be between 1 and 100"),
5267 "threshold {threshold}: {error}"
5268 );
5269 }
5270 for threshold in ["1", "50", "100"] {
5272 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
5273 let config = Config::load_from(file.path()).unwrap();
5274 assert_eq!(config.notifications.threshold.to_string(), threshold);
5275 }
5276 }
5277
5278 #[test]
5279 fn notification_preferences_round_trip_without_changing_other_sections() {
5280 let dir = tempfile::tempdir().unwrap();
5281 let path = dir.path().join("config.toml");
5282 std::fs::write(&path, "# keep this\n[tray]\nrefresh_minutes = 10\n").unwrap();
5283 set_notification_value(&path, "enabled", false.into()).unwrap();
5284 set_notification_value(&path, "threshold", 85i64.into()).unwrap();
5285 let text = std::fs::read_to_string(&path).unwrap();
5286 assert!(text.contains("# keep this\n[tray]\nrefresh_minutes = 10"));
5287 let config = Config::load_from(&path).unwrap();
5288 assert!(!config.notifications.enabled);
5289 assert_eq!(config.notifications.threshold, 85);
5290 assert!(set_notification_value(&path, "threshold", 101i64.into()).is_err());
5291 assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
5292 }
5293
5294 #[test]
5299 fn vendor_enabled_round_trips_and_rejects_unknown_slugs() {
5300 let dir = tempfile::tempdir().unwrap();
5301 let path = dir.path().join("config.toml");
5302 std::fs::write(&path, "# keep\n[zai]\nenabled = true # mine\n").unwrap();
5303
5304 set_vendor_enabled(&path, "zai", false).unwrap();
5305 let text = std::fs::read_to_string(&path).unwrap();
5306 assert!(text.starts_with("# keep\n"), "{text}");
5307 assert!(text.contains("enabled = false # mine"), "{text}");
5308 assert!(!config_enabled(&path, VendorId::Zai));
5309
5310 set_vendor_enabled(&path, "grok", true).unwrap();
5311 let config = Config::load_from(&path).unwrap();
5312 assert!(config.is_enabled(VendorId::Grok));
5313 assert!(
5314 !config.is_enabled(VendorId::Deepseek),
5315 "only the named vendor moves; untouched opt-in vendors stay off"
5316 );
5317 assert!(
5318 config.is_enabled(VendorId::Openai),
5319 "an untouched default-on vendor is not switched off either"
5320 );
5321
5322 for bad in ["", "custom", "not-a-vendor", "anthropic "] {
5323 let error = set_vendor_enabled(&path, bad, true)
5324 .unwrap_err()
5325 .to_string();
5326 assert!(error.contains("unknown provider"), "{bad}: {error}");
5327 }
5328 assert!(
5330 std::fs::read_to_string(&path)
5331 .unwrap()
5332 .contains("enabled = false # mine")
5333 );
5334 }
5335
5336 #[test]
5339 fn set_vendor_enabled_in_doc_targets_the_section_and_stays_idempotent() {
5340 let dir = tempfile::tempdir().unwrap();
5341 let path = dir.path().join("config.toml");
5342 std::fs::write(&path, "[grok]\nenabled = true\napi_key = \"k\"\n").unwrap();
5343 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
5344
5345 set_vendor_enabled(&path, "grok", true).unwrap();
5346
5347 assert_eq!(
5348 std::fs::read_to_string(&path).unwrap(),
5349 "[grok]\nenabled = true\napi_key = \"k\"\n"
5350 );
5351 assert_eq!(
5352 std::fs::metadata(&path).unwrap().modified().unwrap(),
5353 before
5354 );
5355
5356 let mut doc = toml_edit::DocumentMut::new();
5357 set_vendor_enabled_in_doc(&mut doc, "opencode-go", true).unwrap();
5358 assert_eq!(doc.to_string(), "[opencode-go]\nenabled = true\n");
5359 assert!(set_vendor_enabled_in_doc(&mut doc, "mytool", true).is_err());
5360 }
5361
5362 fn config_enabled(path: &std::path::Path, vendor: VendorId) -> bool {
5363 Config::load_from(path).unwrap().is_enabled(vendor)
5364 }
5365
5366 #[test]
5367 fn set_tray_value_writes_refresh_minutes_as_an_integer() {
5368 let dir = tempfile::tempdir().unwrap();
5369 let path = dir.path().join("config.toml");
5370 std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
5371
5372 set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
5373 let text = std::fs::read_to_string(&path).unwrap();
5374 assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
5375 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
5376
5377 set_tray_value(&path, "refresh_minutes", None).unwrap();
5378 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
5379 }
5380
5381 #[test]
5382 fn set_tray_value_creates_replaces_and_removes_keys() {
5383 let dir = tempfile::tempdir().unwrap();
5384 let path = dir.path().join("config.toml");
5385 std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
5386
5387 set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
5388 let text = std::fs::read_to_string(&path).unwrap();
5389 assert!(text.contains("# primary = \"anthropic\""), "{text}");
5390 assert!(
5391 text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
5392 "{text}"
5393 );
5394
5395 set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
5396 set_tray_value(&path, "updates", Some("off".into())).unwrap();
5397 let config = Config::load_from(&path).unwrap();
5398 assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
5399 assert_eq!(config.tray.updates(), UpdateMode::Off);
5400
5401 set_tray_value(&path, "shortcut", None).unwrap();
5402 let text = std::fs::read_to_string(&path).unwrap();
5403 assert!(!text.contains("shortcut"), "{text}");
5404 assert!(text.contains("updates = \"off\""), "{text}");
5405
5406 let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
5408 set_tray_value(&path, "shortcut", None).unwrap();
5409 assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
5410 }
5411
5412 #[test]
5413 fn set_value_keeps_the_trailing_comment_when_replacing() {
5414 let mut doc: toml_edit::DocumentMut =
5415 "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
5416 set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
5417 assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
5418 }
5419
5420 #[test]
5421 fn enable_vendors_in_creates_a_missing_config() {
5422 let dir = tempfile::TempDir::new().unwrap();
5423 let path = dir.path().join("sub").join("config.toml");
5424
5425 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5426
5427 assert_eq!(
5428 std::fs::read_to_string(&path).unwrap(),
5429 "[grok]
5430enabled = true
5431"
5432 );
5433 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
5434 }
5435
5436 #[test]
5437 fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
5438 let dir = tempfile::TempDir::new().unwrap();
5439 let path = dir.path().join("config.toml");
5440 let original = "# my settings
5441[zai]
5442api_key = \"x\" # keep
5443enabled = false
5444";
5445 std::fs::write(&path, original).unwrap();
5446
5447 enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
5448
5449 let text = std::fs::read_to_string(&path).unwrap();
5450 assert!(
5451 text.starts_with(
5452 "# my settings
5453"
5454 ),
5455 "{text}"
5456 );
5457 assert!(
5458 text.contains(
5459 "api_key = \"x\" # keep
5460"
5461 ),
5462 "{text}"
5463 );
5464 assert!(
5465 text.contains(
5466 "[grok]
5467enabled = true
5468"
5469 ),
5470 "{text}"
5471 );
5472 assert!(
5473 text.contains(
5474 "[opencode-go]
5475enabled = true
5476"
5477 ),
5478 "{text}"
5479 );
5480 let config = Config::load_from(&path).unwrap();
5481 assert!(
5482 !config.is_enabled(VendorId::Zai),
5483 "never widens to false, never flips others"
5484 );
5485 assert!(config.is_enabled(VendorId::Grok));
5486 assert!(config.is_enabled(VendorId::OpenCodeGo));
5487 }
5488
5489 #[test]
5490 fn enable_vendors_in_leaves_an_explicit_false_alone() {
5491 let dir = tempfile::TempDir::new().unwrap();
5492 let path = dir.path().join("config.toml");
5493 let original = "[grok]
5494enabled = false # off
5495api_key = \"k\"
5496";
5497 std::fs::write(&path, original).unwrap();
5498
5499 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5503
5504 assert!(written.is_empty(), "{written:?}");
5505 assert_eq!(
5506 std::fs::read_to_string(&path).unwrap(),
5507 original,
5508 "the file must not be rewritten at all"
5509 );
5510 }
5511
5512 #[test]
5513 fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
5514 let dir = tempfile::TempDir::new().unwrap();
5515 let path = dir.path().join("config.toml");
5516 std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
5517
5518 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5519
5520 assert_eq!(written, vec![VendorId::Grok]);
5521 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
5522 }
5523
5524 #[test]
5525 fn enable_vendors_in_is_textually_idempotent() {
5526 let dir = tempfile::TempDir::new().unwrap();
5527 let path = dir.path().join("config.toml");
5528 let original = "[grok]
5529enabled = true
5530
5531# trailing
5532";
5533 std::fs::write(&path, original).unwrap();
5534 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
5535
5536 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5537
5538 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
5539 assert_eq!(
5540 std::fs::metadata(&path).unwrap().modified().unwrap(),
5541 before,
5542 "an unchanged document must not be rewritten"
5543 );
5544 }
5545
5546 #[test]
5547 fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
5548 let dir = tempfile::TempDir::new().unwrap();
5549 let path = dir.path().join("config.toml");
5550
5551 enable_vendors_in(&path, &[]).unwrap();
5552
5553 assert!(!path.exists());
5554 }
5555}