1use std::collections::{BTreeMap, HashSet};
23use std::path::{Path, PathBuf};
24
25#[cfg(unix)]
26use std::os::unix::fs::{MetadataExt, PermissionsExt};
27
28use serde::{Deserialize, Serialize};
29
30use crate::anthropic::creds::CredsTarget;
31use crate::balance::{DisplayPrefs, Headline};
32use crate::cache::Cache;
33use crate::error::{AppError, Result};
34use crate::vendor::VendorId;
35
36#[derive(Debug, Clone, Default, Deserialize, Serialize)]
43#[serde(default, deny_unknown_fields)]
44pub struct Config {
45 pub ui: UiConfig,
46 pub tray: TrayConfig,
47 pub context: ContextConfig,
48 pub anthropic: AnthropicConfig,
49 pub anthropic_api: AnthropicApiConfig,
50 pub openai: OpenAiConfig,
51 pub copilot: CopilotConfig,
52 pub zai: ZaiConfig,
53 pub openrouter: OpenRouterConfig,
54 pub deepseek: DeepseekConfig,
55 pub deepinfra: DeepInfraConfig,
56 pub kimi: KimiConfig,
57 pub kilo: KiloConfig,
58 pub novita: NovitaConfig,
59 pub moonshot: MoonshotConfig,
60 pub grok: GrokConfig,
61 pub supergrok: SuperGrokConfig,
62 pub grokbot: GrokbotConfig,
63 pub antigravity: AntigravityConfig,
64 pub cursor: CursorConfig,
65 pub minimax: MinimaxConfig,
66 pub kiro: KiroConfig,
67 pub nous: NousConfig,
68 #[serde(rename = "opencode-go")]
69 pub opencode_go: OpenCodeGoConfig,
70 pub commandcode: CommandCodeConfig,
71 pub ollama: OllamaConfig,
72 pub orcarouter: OrcaRouterConfig,
73 pub modelstudio: ModelStudioConfig,
74 pub lyceum: LyceumConfig,
75 pub notifications: NotificationsConfig,
77 pub custom: Vec<CustomProviderConfig>,
79}
80
81#[derive(Debug, Clone, Default, Deserialize, Serialize)]
85#[serde(default)]
86pub struct UiConfig {
87 pub primary: Option<VendorId>,
89 pub overview_vendors: Option<Vec<VendorId>>,
93 pub vendor_box: Option<VendorBoxStyle>,
95}
96
97impl UiConfig {
98 pub fn vendor_box(&self) -> VendorBoxStyle {
99 self.vendor_box.unwrap_or_default()
100 }
101}
102
103#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
107#[serde(default)]
108pub struct TrayConfig {
109 pub shortcut: Option<String>,
112 pub refresh_minutes: Option<u64>,
115 pub updates: Option<UpdateMode>,
117 pub menu_bar_style: Option<String>,
120 pub menu_bar_short_name: Option<bool>,
123}
124
125pub const TRAY_REFRESH_MINUTES: [u64; 3] = [1, 5, 10];
128const DEFAULT_TRAY_REFRESH_MINUTES: u64 = 5;
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
134#[serde(default)]
135pub struct NotificationsConfig {
136 pub enabled: bool,
137 pub threshold: u8,
140}
141
142impl Default for NotificationsConfig {
143 fn default() -> Self {
144 Self {
145 enabled: true,
146 threshold: 97,
147 }
148 }
149}
150
151impl TrayConfig {
152 pub fn refresh_minutes(&self) -> u64 {
153 self.refresh_minutes.unwrap_or(DEFAULT_TRAY_REFRESH_MINUTES)
154 }
155
156 pub fn updates(&self) -> UpdateMode {
157 self.updates.unwrap_or_default()
158 }
159
160 pub fn menu_bar_short_name(&self) -> bool {
161 self.menu_bar_short_name.unwrap_or(true)
162 }
163}
164
165#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
168#[serde(rename_all = "lowercase")]
169pub enum UpdateMode {
170 Auto,
171 #[default]
172 Notify,
173 Off,
174}
175
176impl UpdateMode {
177 pub fn as_str(self) -> &'static str {
178 match self {
179 Self::Auto => "auto",
180 Self::Notify => "notify",
181 Self::Off => "off",
182 }
183 }
184
185 pub fn parse(text: &str) -> Option<Self> {
186 match text.trim().to_ascii_lowercase().as_str() {
187 "auto" => Some(Self::Auto),
188 "notify" => Some(Self::Notify),
189 "off" => Some(Self::Off),
190 _ => None,
191 }
192 }
193}
194
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
197#[serde(rename_all = "lowercase")]
198pub enum VendorBoxStyle {
199 #[default]
201 Sidebar,
202 Navbar,
204 None,
206}
207
208#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
211#[serde(rename_all = "lowercase")]
212pub enum ContextLayout {
213 #[default]
215 Full,
216 Split,
218 Bottom,
220}
221
222impl ContextLayout {
223 pub fn next(self) -> Self {
224 match self {
225 ContextLayout::Full => ContextLayout::Split,
226 ContextLayout::Split => ContextLayout::Bottom,
227 ContextLayout::Bottom => ContextLayout::Full,
228 }
229 }
230
231 pub fn label(self) -> &'static str {
232 match self {
233 ContextLayout::Full => "full",
234 ContextLayout::Split => "split",
235 ContextLayout::Bottom => "bottom",
236 }
237 }
238}
239
240#[derive(Debug, Clone, Default, Deserialize, Serialize)]
245#[serde(default)]
246pub struct ContextConfig {
247 pub enabled: bool,
250 pub projects_path: Option<PathBuf>,
252 pub context_window_tokens: Option<u64>,
255 pub model_context_window_tokens: BTreeMap<String, u64>,
258 pub layout: ContextLayout,
260}
261
262impl ContextConfig {
263 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
264 model
265 .and_then(|model| self.model_context_window_tokens.get(model).copied())
266 .filter(|tokens| *tokens > 0)
267 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
268 }
269}
270
271#[derive(Debug, Clone, Deserialize, Serialize)]
272#[serde(default)]
273pub struct AnthropicConfig {
274 pub enabled: bool,
275 pub credentials_path: Option<PathBuf>,
278 pub accounts: Vec<AnthropicAccount>,
282 pub accounts_dir: Option<PathBuf>,
290 pub show_default_account: bool,
296 pub desktop_profiles_dir: Option<PathBuf>,
302}
303
304impl Default for AnthropicConfig {
305 fn default() -> Self {
306 Self {
307 enabled: true,
308 credentials_path: None,
309 accounts: Vec::new(),
310 accounts_dir: None,
311 show_default_account: true,
312 desktop_profiles_dir: None,
313 }
314 }
315}
316
317#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
327pub struct AnthropicAccount {
328 pub label: String,
331 pub credentials_path: PathBuf,
335}
336
337impl AnthropicAccount {
338 pub fn config_dir(&self) -> PathBuf {
343 self.credentials_path
344 .parent()
345 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
346 }
347}
348
349impl AnthropicConfig {
350 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
356 let mut out = self.accounts.clone();
357 if let Some(dir) = &self.accounts_dir {
358 for acct in discover_accounts(dir) {
359 if !out.iter().any(|a| a.label == acct.label) {
360 out.push(acct);
361 }
362 }
363 }
364 out
365 }
366
367 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
372 validate_account_label(label)?;
373 let all = self.all_accounts();
374 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
375 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
376 AppError::Credentials(format!(
377 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
378 known labels: {known:?}"
379 ))
380 })
381 }
382
383 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
394 let active = crate::anthropic::cli_account::home_claude_json()
395 .ok()
396 .and_then(|path| {
397 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
398 });
399 self.account_target_with(label, active.as_deref())
400 }
401
402 pub fn account_target_with(
410 &self,
411 label: &str,
412 cli_active: Option<&str>,
413 ) -> Result<(CredsTarget, Cache)> {
414 self.account_target_probing(label, cli_active, |path| path.exists())
415 }
416
417 pub fn account_target_probing(
437 &self,
438 label: &str,
439 cli_active: Option<&str>,
440 exists: impl Fn(&Path) -> bool,
441 ) -> Result<(CredsTarget, Cache)> {
442 let account = self.account(label)?;
443 let cache = Cache::for_vendor_account("anthropic", label)?;
444 if cli_active == Some(label) && !exists(&account.credentials_path) {
445 return Ok((
446 CredsTarget::Default(crate::anthropic::creds::default_path()?),
447 cache,
448 ));
449 }
450 Ok((
451 CredsTarget::Named {
452 config_dir: account.config_dir(),
453 path: account.credentials_path,
454 },
455 cache,
456 ))
457 }
458}
459
460pub fn validate_account_label(label: &str) -> Result<()> {
466 validate_account_label_for("anthropic", label)
467}
468
469fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
470 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
471 let bad = label.is_empty()
472 || label == "."
473 || label == ".."
474 || label.contains(['/', '\\'])
475 || label.contains(':')
476 || label.chars().any(char::is_control)
477 || RESERVED.contains(&label);
478 if bad {
479 return Err(AppError::Credentials(format!(
480 "invalid {vendor} account label {label:?}: must be a non-empty name \
481 without path separators, drive prefixes, control characters, or reserved cache names"
482 )));
483 }
484 Ok(())
485}
486
487fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
495 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
496 return Vec::new();
497 };
498 let mut found: Vec<AnthropicAccount> = entries
499 .flatten()
500 .filter_map(|entry| {
501 let path = entry.path();
502 if !path.is_dir() {
503 return None;
504 }
505 let label = path.file_name()?.to_str()?.to_string();
506 validate_account_label(&label).ok()?;
507 Some(AnthropicAccount {
508 label,
509 credentials_path: path.join(".credentials.json"),
510 })
511 })
512 .collect();
513 found.sort_by(|a, b| a.label.cmp(&b.label));
514 found
515}
516
517pub fn tildify(path: &Path, home: &Path) -> String {
521 path.strip_prefix(home)
522 .map(|rest| {
523 let rendered = rest.display().to_string();
524 #[cfg(windows)]
527 let rendered = rendered.replace('\\', "/");
528 format!("~/{rendered}")
529 })
530 .unwrap_or_else(|_| path.display().to_string())
531}
532
533pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
538 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
539 base.join("accounts").join(label).join(".credentials.json")
540}
541
542pub fn add_anthropic_account_to_doc(
548 doc: &mut toml_edit::DocumentMut,
549 label: &str,
550 credentials_path: &str,
551) -> Result<()> {
552 use toml_edit::{Item, Table, value};
553
554 validate_account_label(label)?;
555
556 let anthropic = doc
557 .entry("anthropic")
558 .or_insert_with(|| Item::Table(Table::new()));
559 let anthropic = anthropic
560 .as_table_mut()
561 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
562
563 let accounts = anthropic
564 .entry("accounts")
565 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
566 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
567 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
568 })?;
569
570 let exists = accounts
571 .iter()
572 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
573 if exists {
574 return Err(AppError::Credentials(format!(
575 "anthropic account {label:?} already exists in config.toml"
576 )));
577 }
578
579 let mut table = Table::new();
580 table["label"] = value(label);
581 table["credentials_path"] = value(credentials_path);
582 accounts.push(table);
583 Ok(())
584}
585
586pub fn default_codex_auth_path(home: &Path, label: &str) -> PathBuf {
590 home.join(format!(".codex-{label}")).join("auth.json")
591}
592
593pub fn add_openai_account_to_doc(
598 doc: &mut toml_edit::DocumentMut,
599 label: &str,
600 codex_auth_path: &str,
601) -> Result<()> {
602 use toml_edit::{Item, Table, value};
603
604 validate_account_label_for("openai", label)?;
605
606 let openai = doc
607 .entry("openai")
608 .or_insert_with(|| Item::Table(Table::new()));
609 let openai = openai
610 .as_table_mut()
611 .ok_or_else(|| AppError::Other("[openai] in config.toml is not a table".into()))?;
612
613 let accounts = openai
614 .entry("accounts")
615 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
616 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
617 AppError::Other("[[openai.accounts]] in config.toml is not an array of tables".into())
618 })?;
619
620 if accounts
621 .iter()
622 .any(|t| t.get("label").and_then(Item::as_str) == Some(label))
623 {
624 return Err(AppError::Credentials(format!(
625 "openai account {label:?} already exists in config.toml"
626 )));
627 }
628
629 let mut table = Table::new();
630 table["label"] = value(label);
631 table["codex_auth_path"] = value(codex_auth_path);
632 accounts.push(table);
633 Ok(())
634}
635
636pub(crate) fn set_bool(
640 doc: &mut toml_edit::DocumentMut,
641 section: &str,
642 key: &str,
643 new_value: bool,
644) -> Result<()> {
645 let table = doc
646 .entry(section)
647 .or_insert_with(toml_edit::table)
648 .as_table_mut()
649 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
650
651 if let Some(item) = table.get_mut(key)
652 && let Some(v) = item.as_value_mut()
653 {
654 let suffix = v.decor().suffix().cloned();
658 *v = toml_edit::Value::from(new_value);
659 v.decor_mut().set_prefix(" ");
660 if let Some(suffix) = suffix {
661 v.decor_mut().set_suffix(suffix);
662 }
663 return Ok(());
664 }
665 table.insert(key, toml_edit::value(new_value));
666 Ok(())
667}
668
669pub(crate) fn set_value(
675 doc: &mut toml_edit::DocumentMut,
676 section: &str,
677 key: &str,
678 new_value: Option<toml_edit::Value>,
679) -> Result<()> {
680 let table = doc
681 .entry(section)
682 .or_insert_with(toml_edit::table)
683 .as_table_mut()
684 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
685
686 let Some(mut new_value) = new_value else {
687 table.remove(key);
688 return Ok(());
689 };
690 if let Some(item) = table.get_mut(key)
691 && let Some(v) = item.as_value_mut()
692 {
693 let suffix = v.decor().suffix().cloned();
694 new_value.decor_mut().set_prefix(" ");
695 if let Some(suffix) = suffix {
696 new_value.decor_mut().set_suffix(suffix);
697 }
698 *v = new_value;
699 return Ok(());
700 }
701 table.insert(key, toml_edit::Item::Value(new_value));
702 Ok(())
703}
704
705pub fn set_tray_value(path: &Path, key: &str, value: Option<toml_edit::Value>) -> Result<()> {
711 let mut doc = read_config_document(path)?;
712 let before = doc.to_string();
713 set_value(&mut doc, "tray", key, value)?;
714 if doc.to_string() == before {
715 return Ok(());
716 }
717 write_config_document(path, &doc)
718}
719
720pub fn set_notification_value(path: &Path, key: &str, value: toml_edit::Value) -> Result<()> {
723 match key {
724 "enabled" if value.as_bool().is_some() => {}
725 "threshold" if value.as_integer().is_some_and(|n| (1..=100).contains(&n)) => {}
726 _ => {
727 return Err(AppError::Other(format!(
728 "invalid notification preference: {key}"
729 )));
730 }
731 }
732 let mut doc = read_config_document(path)?;
733 let before = doc.to_string();
734 set_value(&mut doc, "notifications", key, Some(value))?;
735 if doc.to_string() == before {
736 return Ok(());
737 }
738 write_config_document(path, &doc)
739}
740
741pub fn set_vendor_enabled(path: &Path, slug: &str, enabled: bool) -> Result<()> {
749 let mut doc = read_config_document(path)?;
750 let before = doc.to_string();
751 set_vendor_enabled_in_doc(&mut doc, slug, enabled)?;
752 if doc.to_string() == before {
753 return Ok(());
754 }
755 write_config_document(path, &doc)
756}
757
758pub fn set_vendor_enabled_in_doc(
763 doc: &mut toml_edit::DocumentMut,
764 slug: &str,
765 enabled: bool,
766) -> Result<()> {
767 let Some(vendor) = VendorId::from_slug(slug) else {
768 return Err(AppError::Other(format!(
769 "unknown provider {slug:?}: not a built-in vendor"
770 )));
771 };
772 set_bool(doc, vendor.config_section(), "enabled", enabled)
773}
774
775pub(crate) fn toml_error_summary(
781 input: &str,
782 span: Option<std::ops::Range<usize>>,
783 message: &str,
784) -> String {
785 let message = message.trim_end();
786 match span.and_then(|range| input.get(..range.start)) {
787 Some(before) => {
788 let line = before.matches('\n').count() + 1;
789 let line_start = before.rfind('\n').map_or(0, |i| i + 1);
790 let column = before[line_start..].chars().count() + 1;
791 format!("line {line}, column {column}: {message}")
792 }
793 None => message.to_string(),
794 }
795}
796
797pub(crate) fn read_config_document(path: &Path) -> Result<toml_edit::DocumentMut> {
801 let original = match std::fs::read_to_string(path) {
802 Ok(contents) => contents,
803 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
804 Err(error) => return Err(AppError::io_at(path, error)),
805 };
806 if original.trim().is_empty() {
807 return Ok(toml_edit::DocumentMut::new());
808 }
809 original.parse().map_err(|e: toml_edit::TomlError| {
810 let summary = toml_error_summary(&original, e.span(), e.message());
811 AppError::Other(format!("config.toml not parseable: {summary}"))
812 })
813}
814
815pub(crate) fn write_config_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> {
819 let bytes = doc.to_string();
820 crate::cache::atomic_write(path, bytes.as_bytes())?;
821
822 #[cfg(unix)]
823 {
824 if let Ok(meta) = std::fs::metadata(path) {
825 let mut perms = meta.permissions();
826 perms.set_mode(0o600);
827 let _ = std::fs::set_permissions(path, perms);
828 }
829 }
830 Ok(())
831}
832
833pub fn enable_vendors_in(path: &Path, vendors: &[VendorId]) -> Result<Vec<VendorId>> {
841 let mut doc = read_config_document(path)?;
842 let before = doc.to_string();
843 let written: Vec<VendorId> = vendors
844 .iter()
845 .copied()
846 .filter(|vendor| !is_explicitly_disabled(&doc, *vendor))
847 .collect();
848 for vendor in &written {
849 set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
850 }
851 if doc.to_string() == before {
852 return Ok(written);
853 }
854 write_config_document(path, &doc)?;
855 Ok(written)
856}
857
858fn is_explicitly_disabled(doc: &toml_edit::DocumentMut, vendor: VendorId) -> bool {
869 doc.get(vendor.config_section())
870 .and_then(|section| section.get("enabled"))
871 .and_then(|enabled| enabled.as_bool())
872 == Some(false)
873}
874
875#[derive(Debug, Clone, Deserialize, Serialize)]
876#[serde(default)]
877pub struct OpenAiConfig {
878 pub enabled: bool,
879 pub codex_auth_path: Option<PathBuf>,
881 #[serde(default)]
886 pub accounts: Vec<OpenAiAccount>,
887 pub show_default_account: bool,
893 pub admin_key_env: String,
901}
902
903#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
914pub struct OpenAiAccount {
915 pub label: String,
918 pub codex_auth_path: PathBuf,
921}
922
923impl OpenAiConfig {
924 pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
928 let Some(label) = label else {
929 return match &self.codex_auth_path {
930 Some(path) => Ok(path.clone()),
931 None => crate::openai::creds::default_path(),
932 };
933 };
934 self.accounts
935 .iter()
936 .find(|account| account.label == label)
937 .map(|account| account.codex_auth_path.clone())
938 .ok_or_else(|| {
939 AppError::Credentials(format!(
940 "no OpenAI account named {label:?}. Add it under \
941 [[openai.accounts]], or drop --account to use the default login."
942 ))
943 })
944 }
945
946 pub fn fetch_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
953 let Some(label) = label else {
954 return self.resolve_auth_path(None);
955 };
956 let default = self.resolve_auth_path(None)?;
957 let active = crate::openai::account::resolve_active_label(&default, &self.accounts);
958 self.fetch_auth_path_probing(label, active.as_deref(), Path::exists)
959 }
960
961 pub fn fetch_auth_path_probing(
966 &self,
967 label: &str,
968 active: Option<&str>,
969 exists: impl Fn(&Path) -> bool,
970 ) -> Result<PathBuf> {
971 let own = self.resolve_auth_path(Some(label))?;
972 if active == Some(label) && !exists(&own) {
973 return self.resolve_auth_path(None);
974 }
975 Ok(own)
976 }
977}
978
979impl Default for OpenAiConfig {
980 fn default() -> Self {
981 Self {
982 enabled: true,
983 codex_auth_path: None,
984 accounts: Vec::new(),
985 show_default_account: true,
986 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
987 }
988 }
989}
990
991#[derive(Debug, Clone, Default, Deserialize, Serialize)]
995#[serde(default)]
996pub struct CopilotConfig {
997 pub enabled: bool,
998 pub gh_binary: Option<PathBuf>,
1001}
1002
1003impl CopilotConfig {
1004 pub fn resolve_token(&self) -> Result<String> {
1005 self.resolve_token_with(
1006 |name| std::env::var_os(name),
1007 &crate::copilot::credentials::SystemGhAuthTokenRunner,
1008 )
1009 }
1010
1011 fn resolve_token_with(
1012 &self,
1013 environment: impl Fn(&str) -> Option<std::ffi::OsString>,
1014 runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
1015 ) -> Result<String> {
1016 if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
1017 let token = value.into_string().map_err(|_| {
1018 AppError::Credentials(
1019 "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
1020 )
1021 })?;
1022 if !token.is_empty() {
1023 return Ok(token);
1024 }
1025 }
1026 crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref())
1027 }
1028}
1029
1030#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1031#[serde(default)]
1032pub struct NousConfig {
1033 pub enabled: bool,
1034}
1035
1036#[derive(Debug, Clone, Deserialize, Serialize)]
1037#[serde(default)]
1038pub struct OpenCodeGoConfig {
1039 pub enabled: bool,
1040 pub api_key_env: String,
1041 pub api_key: Option<String>,
1042}
1043
1044#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1049#[serde(default)]
1050pub struct CommandCodeConfig {
1051 pub enabled: bool,
1052 pub auth_paths: Option<Vec<PathBuf>>,
1053}
1054
1055#[derive(Debug, Clone, Deserialize, Serialize)]
1060#[serde(default)]
1061pub struct OllamaConfig {
1062 pub enabled: bool,
1063 pub api_key_env: String,
1064 pub api_key: Option<String>,
1065 pub plan: String,
1068}
1069
1070impl Default for OllamaConfig {
1071 fn default() -> Self {
1072 Self {
1073 enabled: false,
1074 api_key_env: "OLLAMA_API_KEY".to_string(),
1075 api_key: None,
1076 plan: "pro".to_string(),
1077 }
1078 }
1079}
1080
1081#[derive(Debug, Clone, Deserialize, Serialize)]
1084#[serde(default)]
1085pub struct OrcaRouterConfig {
1086 pub enabled: bool,
1087 pub accounts: Vec<ApiKeyAccount>,
1090 pub show_default_account: bool,
1094 pub api_key_env: String,
1095 pub api_key: Option<String>,
1096}
1097
1098impl Default for OrcaRouterConfig {
1099 fn default() -> Self {
1100 Self {
1101 enabled: false,
1102 accounts: Vec::new(),
1103 show_default_account: true,
1104 api_key_env: "ORCAROUTER_API_KEY".to_string(),
1105 api_key: None,
1106 }
1107 }
1108}
1109
1110#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1117#[serde(default)]
1118pub struct ModelStudioConfig {
1119 pub enabled: bool,
1122 pub config_dir: Option<PathBuf>,
1125}
1126
1127impl Default for OpenCodeGoConfig {
1128 fn default() -> Self {
1129 Self {
1130 enabled: false,
1131 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
1132 api_key: None,
1133 }
1134 }
1135}
1136
1137#[derive(Debug, Clone, Deserialize, Serialize)]
1138#[serde(default)]
1139pub struct ZaiConfig {
1140 pub enabled: bool,
1141 pub accounts: Vec<ApiKeyAccount>,
1144 pub show_default_account: bool,
1148 pub api_key_env: String,
1150 pub api_key: Option<String>,
1153 pub plan_tier: Option<String>,
1155}
1156
1157impl Default for ZaiConfig {
1158 fn default() -> Self {
1159 Self {
1160 enabled: true,
1161 accounts: Vec::new(),
1162 show_default_account: true,
1163 api_key_env: "ZAI_API_KEY".to_string(),
1164 api_key: None,
1165 plan_tier: None,
1166 }
1167 }
1168}
1169
1170#[derive(Debug, Clone, Deserialize, Serialize)]
1171#[serde(default)]
1172pub struct OpenRouterConfig {
1173 pub enabled: bool,
1174 pub accounts: Vec<ApiKeyAccount>,
1177 pub show_default_account: bool,
1181 pub api_key_env: String,
1182 pub api_key: Option<String>,
1183 pub management_api_key_env: String,
1188 pub headline: Headline,
1200}
1201
1202impl Default for OpenRouterConfig {
1203 fn default() -> Self {
1204 Self {
1205 enabled: true,
1206 accounts: Vec::new(),
1207 show_default_account: true,
1208 api_key_env: "OPENROUTER_API_KEY".to_string(),
1209 api_key: None,
1210 management_api_key_env: "OPENROUTER_MANAGEMENT_API_KEY".to_string(),
1211 headline: Headline::Percent,
1212 }
1213 }
1214}
1215
1216#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1221pub struct ApiKeyAccount {
1222 pub label: String,
1224 #[serde(default)]
1226 pub api_key_env: Option<String>,
1227 #[serde(default)]
1229 pub api_key: Option<String>,
1230 #[serde(default)]
1234 pub management_api_key_env: Option<String>,
1235}
1236
1237fn api_key_account<'a>(
1241 slug: &str,
1242 accounts: &'a [ApiKeyAccount],
1243 label: &str,
1244) -> Result<&'a ApiKeyAccount> {
1245 validate_account_label_for(slug, label)?;
1246 accounts
1247 .iter()
1248 .find(|account| account.label == label)
1249 .ok_or_else(|| {
1250 let known: Vec<&str> = accounts
1251 .iter()
1252 .map(|account| account.label.as_str())
1253 .collect();
1254 AppError::Credentials(format!(
1255 "{slug} account {label:?} not found in [[{slug}.accounts]]; \
1256 known labels: {known:?}"
1257 ))
1258 })
1259}
1260
1261#[derive(Debug, Clone, Deserialize, Serialize)]
1262#[serde(default)]
1263pub struct DeepseekConfig {
1264 pub enabled: bool,
1265 pub accounts: Vec<ApiKeyAccount>,
1268 pub show_default_account: bool,
1272 pub api_key_env: String,
1273 pub api_key: Option<String>,
1274 pub display_limit: Option<f64>,
1277 pub headline: Headline,
1279}
1280
1281impl Default for DeepseekConfig {
1282 fn default() -> Self {
1283 Self {
1284 enabled: false,
1285 accounts: Vec::new(),
1286 show_default_account: true,
1287 api_key_env: "DEEPSEEK_API_KEY".to_string(),
1288 api_key: None,
1289 display_limit: None,
1290 headline: Headline::Amount,
1291 }
1292 }
1293}
1294
1295#[derive(Debug, Clone, Deserialize, Serialize)]
1296#[serde(default)]
1297pub struct DeepInfraConfig {
1298 pub enabled: bool,
1299 pub accounts: Vec<ApiKeyAccount>,
1300 pub show_default_account: bool,
1301 pub api_key_env: String,
1302 pub api_key: Option<String>,
1303 pub display_limit: Option<f64>,
1305 pub headline: Headline,
1306}
1307
1308impl Default for DeepInfraConfig {
1309 fn default() -> Self {
1310 Self {
1311 enabled: false,
1312 accounts: Vec::new(),
1313 show_default_account: true,
1314 api_key_env: "DEEPINFRA_API_KEY".to_string(),
1315 api_key: None,
1316 display_limit: None,
1317 headline: Headline::Amount,
1318 }
1319 }
1320}
1321
1322#[derive(Debug, Clone, Deserialize, Serialize)]
1323#[serde(default)]
1324pub struct KimiConfig {
1325 pub enabled: bool,
1326 pub api_key_env: String,
1327 pub api_key: Option<String>,
1330 pub credentials_path: Option<PathBuf>,
1334 pub region: String,
1339}
1340
1341impl Default for KimiConfig {
1342 fn default() -> Self {
1343 Self {
1344 enabled: false,
1345 api_key_env: "KIMI_API_KEY".to_string(),
1346 api_key: None,
1347 credentials_path: None,
1348 region: "auto".to_string(),
1349 }
1350 }
1351}
1352
1353#[derive(Debug, Clone, Deserialize, Serialize)]
1354#[serde(default)]
1355pub struct KiloConfig {
1356 pub enabled: bool,
1357 pub accounts: Vec<ApiKeyAccount>,
1360 pub show_default_account: bool,
1364 pub api_key_env: String,
1365 pub api_key: Option<String>,
1366 pub organization_id: Option<String>,
1369 pub display_limit: Option<f64>,
1372 pub headline: Headline,
1374}
1375
1376impl Default for KiloConfig {
1377 fn default() -> Self {
1378 Self {
1381 enabled: false,
1382 accounts: Vec::new(),
1383 show_default_account: true,
1384 api_key_env: "KILO_API_KEY".to_string(),
1385 api_key: None,
1386 organization_id: None,
1387 display_limit: None,
1388 headline: Headline::Amount,
1389 }
1390 }
1391}
1392
1393#[derive(Debug, Clone, Deserialize, Serialize)]
1394#[serde(default)]
1395pub struct NovitaConfig {
1396 pub enabled: bool,
1397 pub accounts: Vec<ApiKeyAccount>,
1400 pub show_default_account: bool,
1404 pub api_key_env: String,
1405 pub api_key: Option<String>,
1406 pub display_limit: Option<f64>,
1410 pub headline: Headline,
1412}
1413
1414impl Default for NovitaConfig {
1415 fn default() -> Self {
1416 Self {
1418 enabled: false,
1419 accounts: Vec::new(),
1420 show_default_account: true,
1421 api_key_env: "NOVITA_API_KEY".to_string(),
1422 api_key: None,
1423 display_limit: None,
1424 headline: Headline::Amount,
1425 }
1426 }
1427}
1428
1429#[derive(Debug, Clone, Deserialize, Serialize)]
1430#[serde(default)]
1431pub struct LyceumConfig {
1432 pub enabled: bool,
1433 pub accounts: Vec<ApiKeyAccount>,
1434 pub show_default_account: bool,
1435 pub api_key_env: String,
1436 pub api_key: Option<String>,
1437}
1438impl Default for LyceumConfig {
1439 fn default() -> Self {
1440 Self {
1441 enabled: false,
1442 accounts: Vec::new(),
1443 show_default_account: true,
1444 api_key_env: "LYCEUM_API_KEY".into(),
1445 api_key: None,
1446 }
1447 }
1448}
1449
1450#[derive(Debug, Clone, Deserialize, Serialize)]
1451#[serde(default)]
1452pub struct MinimaxConfig {
1453 pub enabled: bool,
1454 pub accounts: Vec<ApiKeyAccount>,
1457 pub show_default_account: bool,
1461 pub api_key_env: String,
1462 pub api_key: Option<String>,
1463 pub region: String,
1469}
1470
1471impl Default for MinimaxConfig {
1472 fn default() -> Self {
1473 Self {
1475 enabled: false,
1476 accounts: Vec::new(),
1477 show_default_account: true,
1478 api_key_env: "MINIMAX_API_KEY".to_string(),
1479 api_key: None,
1480 region: "global".to_string(),
1481 }
1482 }
1483}
1484
1485#[derive(Debug, Clone, Deserialize, Serialize)]
1486#[serde(default)]
1487pub struct MoonshotConfig {
1488 pub enabled: bool,
1489 pub accounts: Vec<ApiKeyAccount>,
1492 pub show_default_account: bool,
1496 pub api_key_env: String,
1497 pub api_key: Option<String>,
1498 pub region: String,
1500 pub display_limit: Option<f64>,
1503 pub headline: Headline,
1505}
1506
1507impl Default for MoonshotConfig {
1508 fn default() -> Self {
1509 Self {
1511 enabled: false,
1512 accounts: Vec::new(),
1513 show_default_account: true,
1514 api_key_env: "MOONSHOT_API_KEY".to_string(),
1515 api_key: None,
1516 region: "global".to_string(),
1517 display_limit: None,
1518 headline: Headline::Amount,
1519 }
1520 }
1521}
1522
1523#[derive(Debug, Clone, Deserialize, Serialize)]
1524#[serde(default)]
1525pub struct GrokConfig {
1526 pub enabled: bool,
1527 pub accounts: Vec<ApiKeyAccount>,
1530 pub show_default_account: bool,
1534 pub api_key_env: String,
1536 pub api_key: Option<String>,
1537 pub team_id: Option<String>,
1540 pub display_limit: Option<f64>,
1542 pub headline: Headline,
1544}
1545
1546impl Default for GrokConfig {
1547 fn default() -> Self {
1548 Self {
1550 enabled: false,
1551 accounts: Vec::new(),
1552 show_default_account: true,
1553 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1554 api_key: None,
1555 team_id: None,
1556 display_limit: None,
1557 headline: Headline::Amount,
1558 }
1559 }
1560}
1561
1562#[derive(Debug, Clone, Deserialize, Serialize)]
1570#[serde(default)]
1571pub struct SuperGrokConfig {
1572 pub enabled: bool,
1573 pub grok_binary: PathBuf,
1577 pub auth_path: Option<PathBuf>,
1580 pub config_path: Option<PathBuf>,
1581}
1582
1583impl Default for SuperGrokConfig {
1584 fn default() -> Self {
1585 Self {
1586 enabled: false,
1587 grok_binary: default_grok_binary(),
1588 auth_path: None,
1589 config_path: None,
1590 }
1591 }
1592}
1593
1594#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1600#[serde(default)]
1601pub struct GrokbotConfig {
1602 pub enabled: bool,
1605 pub secrets_path: Option<PathBuf>,
1610}
1611
1612fn default_grok_binary() -> PathBuf {
1613 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1614 let grok_home = std::env::var_os("GROK_HOME")
1615 .filter(|value| !value.is_empty())
1616 .map(PathBuf::from)
1617 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1618 grok_home
1619 .map(|home| home.join("bin").join(executable))
1620 .unwrap_or_else(|| PathBuf::from(executable))
1621}
1622
1623#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1631#[serde(default)]
1632pub struct AntigravityConfig {
1633 pub enabled: bool,
1634 pub oauth_client_id: Option<String>,
1636 pub oauth_client_secret: Option<String>,
1640}
1641
1642#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1652#[serde(default)]
1653pub struct CursorConfig {
1654 pub enabled: bool,
1655 pub db_path: Option<PathBuf>,
1659 pub agent_auth_path: Option<PathBuf>,
1664}
1665
1666#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1675#[serde(default)]
1676pub struct KiroConfig {
1677 pub enabled: bool,
1678 pub db_path: Option<PathBuf>,
1682}
1683
1684#[derive(Debug, Clone, Deserialize, Serialize)]
1685#[serde(default)]
1686pub struct AnthropicApiConfig {
1687 pub enabled: bool,
1688 pub api_key_env: String,
1691 pub api_key: Option<String>,
1692 pub monthly_limit: Option<f64>,
1695}
1696
1697impl Default for AnthropicApiConfig {
1698 fn default() -> Self {
1699 Self {
1701 enabled: false,
1702 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
1703 api_key: None,
1704 monthly_limit: None,
1705 }
1706 }
1707}
1708
1709#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1719#[serde(default, remote = "Self")]
1720pub struct CustomProviderConfig {
1721 pub id: String,
1723 pub name: String,
1725 pub short_name: String,
1728 pub brand: Option<String>,
1731 pub enabled: bool,
1732 pub url: String,
1734 pub allow_http: bool,
1735 pub api_key_env: String,
1737 pub api_key: Option<String>,
1738 pub auth_header: String,
1740 pub auth_scheme: String,
1742 pub headers: BTreeMap<String, String>,
1744 pub plan: Option<String>,
1746 pub plan_path: Option<String>,
1748 pub cache_ttl_secs: u64,
1750 pub metrics: Vec<CustomMetricSpec>,
1751 pub texts: Vec<CustomTextSpec>,
1752}
1753
1754impl Default for CustomProviderConfig {
1755 fn default() -> Self {
1756 Self {
1757 id: String::new(),
1758 name: String::new(),
1759 short_name: String::new(),
1760 brand: None,
1761 enabled: false,
1762 url: String::new(),
1763 allow_http: false,
1764 api_key_env: String::new(),
1765 api_key: None,
1766 auth_header: "Authorization".to_string(),
1767 auth_scheme: "Bearer".to_string(),
1768 headers: BTreeMap::new(),
1769 plan: None,
1770 plan_path: None,
1771 cache_ttl_secs: 60,
1772 metrics: Vec::new(),
1773 texts: Vec::new(),
1774 }
1775 }
1776}
1777
1778impl<'de> Deserialize<'de> for CustomProviderConfig {
1783 fn deserialize<D: serde::Deserializer<'de>>(
1784 deserializer: D,
1785 ) -> std::result::Result<Self, D::Error> {
1786 let mut this = Self::deserialize(deserializer)?;
1787 if this.name.is_empty() {
1788 this.name = this.id.clone();
1789 }
1790 Ok(this)
1791 }
1792}
1793
1794impl Serialize for CustomProviderConfig {
1795 fn serialize<S: serde::Serializer>(
1796 &self,
1797 serializer: S,
1798 ) -> std::result::Result<S::Ok, S::Error> {
1799 Self::serialize(self, serializer)
1800 }
1801}
1802
1803#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1807#[serde(default)]
1808pub struct CustomMetricSpec {
1809 pub label: String,
1810 pub used: Option<String>,
1811 pub limit: Option<String>,
1812 pub percent: Option<String>,
1813 pub resets_at: Option<String>,
1815 pub window_secs: Option<u64>,
1817}
1818
1819#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1821#[serde(default)]
1822pub struct CustomTextSpec {
1823 pub label: String,
1824 pub value: String,
1825}
1826
1827impl CustomProviderConfig {
1828 pub fn section_label(&self) -> String {
1830 format!("[[custom]] id = {:?}", self.id)
1831 }
1832
1833 pub fn resolve_api_key(&self) -> Result<String> {
1836 if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
1837 return Ok(key);
1838 }
1839 let advice = if self.api_key_env.is_empty() {
1840 "set `api_key`, or name an environment variable in `api_key_env`".to_string()
1841 } else {
1842 format!("export {} or set `api_key`", self.api_key_env)
1843 };
1844 Err(AppError::Credentials(format!(
1845 "custom {}: no API key. Either {advice} under {} in {}.",
1846 self.id,
1847 self.section_label(),
1848 config_path_hint()
1849 )))
1850 }
1851
1852 pub fn cache_ttl(&self) -> std::time::Duration {
1853 std::time::Duration::from_secs(self.cache_ttl_secs)
1854 }
1855
1856 fn validate(&self, index: usize) -> Result<()> {
1860 if !is_valid_custom_id(&self.id) {
1861 return Err(AppError::Other(format!(
1862 "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
1863 index + 1,
1864 self.id
1865 )));
1866 }
1867 let section = self.section_label();
1868 let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
1869
1870 if VendorId::all().iter().any(|v| v.slug() == self.id) {
1871 return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
1872 }
1873 let name_len = self.name.chars().count();
1874 if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
1875 return Err(bad(
1876 "name must be 1 to 48 characters without control characters".into(),
1877 ));
1878 }
1879 if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
1880 return Err(bad(format!(
1881 "short_name {:?} must be exactly 3 lowercase ASCII letters",
1882 self.short_name
1883 )));
1884 }
1885 if let Some(brand) = &self.brand
1886 && !VendorId::all().iter().any(|v| v.slug() == brand)
1887 {
1888 return Err(bad(format!(
1889 "brand {brand:?} must name a built-in vendor (it borrows that \
1890 vendor's mark); leave it unset to keep the short_name tag"
1891 )));
1892 }
1893 let url = reqwest::Url::parse(&self.url)
1894 .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
1895 match url.scheme() {
1896 "https" => {}
1897 "http" if self.allow_http => {}
1898 "http" => {
1899 return Err(bad(
1900 "url must use https:// (set allow_http = true to permit http://)".into(),
1901 ));
1902 }
1903 other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
1904 }
1905 if !url.username().is_empty() || url.password().is_some() {
1906 return Err(bad("url must not carry credentials (user:pass@)".into()));
1907 }
1908 if url.host_str().is_none() {
1909 return Err(bad("url has no host".into()));
1910 }
1911 if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
1912 return Err(bad(
1916 "api_key_env is not a valid environment variable name".into()
1917 ));
1918 }
1919 validate_header_name(§ion, "auth_header", &self.auth_header)?;
1920 if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
1921 return Err(bad(
1922 "auth_scheme contains characters that are not valid in an HTTP header".into(),
1923 ));
1924 }
1925 for (name, value) in &self.headers {
1926 validate_header_name(§ion, "headers", name)?;
1927 if name.eq_ignore_ascii_case(&self.auth_header) {
1928 return Err(bad(format!(
1929 "headers must not repeat auth_header {:?}",
1930 self.auth_header
1931 )));
1932 }
1933 if reqwest::header::HeaderValue::from_str(value).is_err() {
1934 return Err(bad(format!(
1935 "header {name:?} has a value that is not valid in an HTTP header"
1936 )));
1937 }
1938 }
1939 if let Some(plan) = &self.plan {
1940 validate_custom_label(§ion, "plan", plan)?;
1941 }
1942 if let Some(pointer) = &self.plan_path {
1943 validate_pointer(§ion, "plan_path", pointer)?;
1944 }
1945 if !(10..=3600).contains(&self.cache_ttl_secs) {
1946 return Err(bad(format!(
1947 "cache_ttl_secs must be between 10 and 3600, got {}",
1948 self.cache_ttl_secs
1949 )));
1950 }
1951 if self.metrics.is_empty() && self.texts.is_empty() {
1952 return Err(bad(
1953 "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
1954 ));
1955 }
1956 let mut metric_labels = HashSet::new();
1957 for metric in &self.metrics {
1958 validate_custom_label(§ion, "metric label", &metric.label)?;
1959 if !metric_labels.insert(metric.label.as_str()) {
1960 return Err(bad(format!("duplicate metric label {:?}", metric.label)));
1961 }
1962 let pair = (metric.used.is_some(), metric.limit.is_some());
1963 let well_formed = if metric.percent.is_some() {
1964 pair == (false, false)
1965 } else {
1966 pair == (true, true)
1967 };
1968 if !well_formed {
1969 return Err(bad(format!(
1970 "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
1971 metric.label
1972 )));
1973 }
1974 for (field, pointer) in [
1975 ("used", &metric.used),
1976 ("limit", &metric.limit),
1977 ("percent", &metric.percent),
1978 ("resets_at", &metric.resets_at),
1979 ] {
1980 if let Some(pointer) = pointer {
1981 validate_pointer(§ion, field, pointer)?;
1982 }
1983 }
1984 if let Some(secs) = metric.window_secs
1985 && secs < 60
1986 {
1987 return Err(bad(format!(
1988 "metric {:?} window_secs must be at least 60, got {secs}",
1989 metric.label
1990 )));
1991 }
1992 }
1993 let mut text_labels = HashSet::new();
1994 for text in &self.texts {
1995 validate_custom_label(§ion, "text label", &text.label)?;
1996 if !text_labels.insert(text.label.as_str()) {
1997 return Err(bad(format!("duplicate text label {:?}", text.label)));
1998 }
1999 validate_pointer(§ion, "value", &text.value)?;
2000 }
2001 Ok(())
2002 }
2003}
2004
2005fn is_valid_custom_id(id: &str) -> bool {
2006 let bytes = id.as_bytes();
2007 let Some(&first) = bytes.first() else {
2008 return false;
2009 };
2010 bytes.len() <= 32
2011 && (first.is_ascii_lowercase() || first.is_ascii_digit())
2012 && bytes
2013 .iter()
2014 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
2015}
2016
2017fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
2018 if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
2019 return Err(AppError::Other(format!(
2020 "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
2021 )));
2022 }
2023 Ok(())
2024}
2025
2026fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
2027 let len = label.chars().count();
2028 if len == 0 || len > 64 || label.chars().any(char::is_control) {
2029 return Err(AppError::Other(format!(
2030 "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
2031 )));
2032 }
2033 Ok(())
2034}
2035
2036fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
2037 if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
2038 return Err(AppError::Other(format!(
2039 "{section}: {field} {name:?} is not a valid HTTP header name"
2040 )));
2041 }
2042 Ok(())
2043}
2044
2045pub fn resolve_api_key(
2048 vendor_label: &str,
2049 env_var_name: &str,
2050 inline: Option<&str>,
2051) -> crate::error::Result<String> {
2052 let section = match vendor_label {
2053 "OpenCode Go" => "[opencode-go]".to_string(),
2054 _ => format!("[{}]", vendor_label.to_lowercase()),
2055 };
2056 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
2057}
2058
2059pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
2063 if is_valid_env_var_name(env_var_name)
2064 && let Ok(v) = std::env::var(env_var_name)
2065 && !v.is_empty()
2066 {
2067 return Some(v);
2068 }
2069 inline.filter(|v| !v.is_empty()).map(str::to_string)
2070}
2071
2072fn resolve_api_key_in_section(
2073 vendor_label: &str,
2074 section: &str,
2075 env_var_name: &str,
2076 inline: Option<&str>,
2077) -> crate::error::Result<String> {
2078 if let Some(key) = optional_api_key(env_var_name, inline) {
2079 return Ok(key);
2080 }
2081 let valid_env_name = is_valid_env_var_name(env_var_name);
2082 let advice = if valid_env_name {
2083 "set an API key in a valid environment variable or set `api_key`"
2084 } else {
2085 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
2086 };
2087 Err(crate::error::AppError::Credentials(format!(
2088 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
2089 config_path_hint()
2090 )))
2091}
2092
2093pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
2094 let mut chars = name.chars();
2095 let Some(first) = chars.next() else {
2096 return false;
2097 };
2098 (first.is_ascii_alphabetic() || first == '_')
2099 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2100}
2101
2102impl Config {
2103 pub fn load() -> Result<Self> {
2106 let Some(path) = resolved_path() else {
2107 return Ok(Self::default());
2108 };
2109 Self::load_from(&path)
2110 }
2111
2112 pub fn load_from(path: &std::path::Path) -> Result<Self> {
2113 match std::fs::read_to_string(path) {
2114 Ok(s) => {
2115 let mut config: Self = toml::from_str(&s).map_err(|mut e| {
2116 let span = e.span();
2117 e.set_input(None);
2120 let summary = toml_error_summary(&s, span, &e.to_string());
2121 AppError::Other(format!("config.toml: {summary}"))
2122 })?;
2123 config.expand_paths();
2127 config.validate()?;
2128 #[cfg(unix)]
2129 config.protect_inline_secrets(path)?;
2130 crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
2134 crate::vendor::register_secret_env_vars(&config.provider_secret_env_vars());
2135 Ok(config)
2136 }
2137 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
2138 Err(e) => Err(AppError::io_at(path, e)),
2139 }
2140 }
2141
2142 fn expand_paths(&mut self) {
2143 expand_tilde_opt(&mut self.context.projects_path);
2144 expand_tilde_opt(&mut self.anthropic.credentials_path);
2145 expand_tilde_opt(&mut self.anthropic.accounts_dir);
2146 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
2147 expand_tilde_opt(&mut self.openai.codex_auth_path);
2148 expand_tilde_opt(&mut self.cursor.db_path);
2149 expand_tilde_opt(&mut self.cursor.agent_auth_path);
2150 expand_tilde_opt(&mut self.kiro.db_path);
2151 expand_tilde_opt(&mut self.kimi.credentials_path);
2152 expand_tilde_opt(&mut self.grokbot.secrets_path);
2153 expand_tilde_opt(&mut self.modelstudio.config_dir);
2154 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
2155 expand_tilde_opt(&mut self.supergrok.auth_path);
2156 expand_tilde_opt(&mut self.supergrok.config_path);
2157 expand_tilde_opt(&mut self.copilot.gh_binary);
2158 for account in &mut self.anthropic.accounts {
2159 account.credentials_path = expand_tilde(&account.credentials_path);
2160 }
2161 for account in &mut self.openai.accounts {
2162 account.codex_auth_path = expand_tilde(&account.codex_auth_path);
2163 }
2164 if let Some(paths) = &mut self.commandcode.auth_paths {
2165 for path in paths {
2166 *path = expand_tilde(path);
2167 }
2168 }
2169 }
2170
2171 #[cfg(unix)]
2175 fn has_inline_secrets(&self) -> bool {
2176 [
2177 self.zai.api_key.as_deref(),
2178 self.openrouter.api_key.as_deref(),
2179 self.deepseek.api_key.as_deref(),
2180 self.deepinfra.api_key.as_deref(),
2181 self.kimi.api_key.as_deref(),
2182 self.kilo.api_key.as_deref(),
2183 self.novita.api_key.as_deref(),
2184 self.minimax.api_key.as_deref(),
2185 self.moonshot.api_key.as_deref(),
2186 self.grok.api_key.as_deref(),
2187 self.anthropic_api.api_key.as_deref(),
2188 self.opencode_go.api_key.as_deref(),
2189 self.ollama.api_key.as_deref(),
2190 self.orcarouter.api_key.as_deref(),
2191 self.lyceum.api_key.as_deref(),
2192 self.antigravity.oauth_client_secret.as_deref(),
2193 ]
2194 .into_iter()
2195 .chain(
2196 Self::API_KEY_ACCOUNT_VENDORS
2197 .into_iter()
2198 .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2199 .map(|account| account.api_key.as_deref()),
2200 )
2201 .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
2202 .any(|key| key.is_some_and(|key| !key.is_empty()))
2203 }
2204
2205 fn custom_secret_env_vars(&self) -> Vec<String> {
2206 self.custom
2207 .iter()
2208 .filter(|c| !c.api_key_env.is_empty())
2209 .map(|c| c.api_key_env.clone())
2210 .collect()
2211 }
2212
2213 fn provider_secret_env_vars(&self) -> Vec<String> {
2219 let renamed = VendorId::all().iter().map(|&id| self.api_key_env_for(id));
2220 let management = std::iter::once(self.openrouter.management_api_key_env.as_str());
2221 let accounts = Self::API_KEY_ACCOUNT_VENDORS
2222 .into_iter()
2223 .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2224 .flat_map(|account| [&account.api_key_env, &account.management_api_key_env])
2225 .filter_map(|name| name.as_deref());
2226 renamed
2227 .chain(management)
2228 .chain(accounts)
2229 .map(str::to_string)
2230 .collect()
2231 }
2232
2233 pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
2235 self.custom.iter().filter(|c| c.enabled)
2236 }
2237
2238 pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
2240 self.custom.iter().find(|c| c.id == id)
2241 }
2242
2243 #[cfg(unix)]
2244 fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
2245 if !self.has_inline_secrets() {
2246 return Ok(());
2247 }
2248
2249 let metadata = std::fs::metadata(path).map_err(|_| {
2250 AppError::Credentials(format!(
2251 "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
2252 path.display()
2253 ))
2254 })?;
2255 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
2256 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
2257 AppError::Credentials(format!(
2258 "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",
2259 path.display()
2260 ))
2261 })?;
2262 }
2263 Ok(())
2264 }
2265
2266 pub fn is_enabled(&self, id: VendorId) -> bool {
2267 match id {
2268 VendorId::Anthropic => self.anthropic.enabled,
2269 VendorId::AnthropicApi => self.anthropic_api.enabled,
2270 VendorId::Openai => self.openai.enabled,
2271 VendorId::Copilot => self.copilot.enabled,
2272 VendorId::Zai => self.zai.enabled,
2273 VendorId::Openrouter => self.openrouter.enabled,
2274 VendorId::Deepseek => self.deepseek.enabled,
2275 VendorId::Deepinfra => self.deepinfra.enabled,
2276 VendorId::Kimi => self.kimi.enabled,
2277 VendorId::Kilo => self.kilo.enabled,
2278 VendorId::Novita => self.novita.enabled,
2279 VendorId::Moonshot => self.moonshot.enabled,
2280 VendorId::Grok => self.grok.enabled,
2281 VendorId::Supergrok => self.supergrok.enabled,
2282 VendorId::Grokbot => self.grokbot.enabled,
2283 VendorId::Antigravity => self.antigravity.enabled,
2284 VendorId::Cursor => self.cursor.enabled,
2285 VendorId::Minimax => self.minimax.enabled,
2286 VendorId::Kiro => self.kiro.enabled,
2287 VendorId::NousResearch => self.nous.enabled,
2288 VendorId::OpenCodeGo => self.opencode_go.enabled,
2289 VendorId::CommandCode => self.commandcode.enabled,
2290 VendorId::Ollama => self.ollama.enabled,
2291 VendorId::OrcaRouter => self.orcarouter.enabled,
2292 VendorId::ModelStudio => self.modelstudio.enabled,
2293 VendorId::Lyceum => self.lyceum.enabled,
2294 }
2295 }
2296
2297 pub fn api_key_env_for(&self, id: VendorId) -> &str {
2304 match id {
2305 VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
2306 VendorId::Zai => &self.zai.api_key_env,
2307 VendorId::Openrouter => &self.openrouter.api_key_env,
2308 VendorId::Deepseek => &self.deepseek.api_key_env,
2309 VendorId::Deepinfra => &self.deepinfra.api_key_env,
2310 VendorId::Kimi => &self.kimi.api_key_env,
2311 VendorId::Kilo => &self.kilo.api_key_env,
2312 VendorId::Novita => &self.novita.api_key_env,
2313 VendorId::Moonshot => &self.moonshot.api_key_env,
2314 VendorId::Grok => &self.grok.api_key_env,
2315 VendorId::Minimax => &self.minimax.api_key_env,
2316 VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
2317 VendorId::Ollama => &self.ollama.api_key_env,
2318 VendorId::OrcaRouter => &self.orcarouter.api_key_env,
2319 VendorId::Lyceum => &self.lyceum.api_key_env,
2320 VendorId::Anthropic
2323 | VendorId::Openai
2324 | VendorId::Copilot
2325 | VendorId::Supergrok
2326 | VendorId::Grokbot
2327 | VendorId::Antigravity
2328 | VendorId::Cursor
2329 | VendorId::Kiro
2330 | VendorId::NousResearch
2331 | VendorId::CommandCode
2332 | VendorId::ModelStudio => id.api_key_env(),
2333 }
2334 }
2335
2336 pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
2340 let raw = match id {
2341 VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
2342 VendorId::Zai => self.zai.api_key.as_deref(),
2343 VendorId::Openrouter => self.openrouter.api_key.as_deref(),
2344 VendorId::Deepseek => self.deepseek.api_key.as_deref(),
2345 VendorId::Deepinfra => self.deepinfra.api_key.as_deref(),
2346 VendorId::Kimi => self.kimi.api_key.as_deref(),
2347 VendorId::Kilo => self.kilo.api_key.as_deref(),
2348 VendorId::Novita => self.novita.api_key.as_deref(),
2349 VendorId::Moonshot => self.moonshot.api_key.as_deref(),
2350 VendorId::Grok => self.grok.api_key.as_deref(),
2351 VendorId::Minimax => self.minimax.api_key.as_deref(),
2352 VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
2353 VendorId::Ollama => self.ollama.api_key.as_deref(),
2354 VendorId::OrcaRouter => self.orcarouter.api_key.as_deref(),
2355 VendorId::Lyceum => self.lyceum.api_key.as_deref(),
2356 VendorId::Anthropic
2357 | VendorId::Openai
2358 | VendorId::Copilot
2359 | VendorId::Supergrok
2360 | VendorId::Grokbot
2361 | VendorId::Antigravity
2362 | VendorId::Cursor
2363 | VendorId::Kiro
2364 | VendorId::NousResearch
2365 | VendorId::CommandCode
2366 | VendorId::ModelStudio => None,
2367 };
2368 raw.filter(|key| !key.is_empty())
2369 }
2370
2371 pub const API_KEY_ACCOUNT_VENDORS: [VendorId; 11] = [
2375 VendorId::Zai,
2376 VendorId::Openrouter,
2377 VendorId::Deepseek,
2378 VendorId::Deepinfra,
2379 VendorId::Kilo,
2380 VendorId::Novita,
2381 VendorId::Moonshot,
2382 VendorId::Grok,
2383 VendorId::Minimax,
2384 VendorId::OrcaRouter,
2385 VendorId::Lyceum,
2386 ];
2387
2388 pub fn api_key_accounts(&self, id: VendorId) -> Option<&[ApiKeyAccount]> {
2391 match id {
2392 VendorId::Zai => Some(&self.zai.accounts),
2393 VendorId::Openrouter => Some(&self.openrouter.accounts),
2394 VendorId::Deepseek => Some(&self.deepseek.accounts),
2395 VendorId::Deepinfra => Some(&self.deepinfra.accounts),
2396 VendorId::Kilo => Some(&self.kilo.accounts),
2397 VendorId::Novita => Some(&self.novita.accounts),
2398 VendorId::Moonshot => Some(&self.moonshot.accounts),
2399 VendorId::Grok => Some(&self.grok.accounts),
2400 VendorId::Minimax => Some(&self.minimax.accounts),
2401 VendorId::OrcaRouter => Some(&self.orcarouter.accounts),
2402 VendorId::Lyceum => Some(&self.lyceum.accounts),
2403 _ => None,
2404 }
2405 }
2406
2407 pub fn show_default_api_key_account(&self, id: VendorId) -> bool {
2410 match id {
2411 VendorId::Zai => self.zai.show_default_account,
2412 VendorId::Openrouter => self.openrouter.show_default_account,
2413 VendorId::Deepseek => self.deepseek.show_default_account,
2414 VendorId::Deepinfra => self.deepinfra.show_default_account,
2415 VendorId::Kilo => self.kilo.show_default_account,
2416 VendorId::Novita => self.novita.show_default_account,
2417 VendorId::Moonshot => self.moonshot.show_default_account,
2418 VendorId::Grok => self.grok.show_default_account,
2419 VendorId::Minimax => self.minimax.show_default_account,
2420 VendorId::OrcaRouter => self.orcarouter.show_default_account,
2421 VendorId::Lyceum => self.lyceum.show_default_account,
2422 _ => true,
2423 }
2424 }
2425
2426 pub fn resolve_account_api_key_for(&self, id: VendorId, label: Option<&str>) -> Result<String> {
2432 let name = match id {
2435 VendorId::Zai => "Zai",
2436 other => other.display_name(),
2437 };
2438 let Some(label) = label else {
2439 return resolve_api_key(name, self.api_key_env_for(id), self.inline_api_key(id));
2440 };
2441 let slug = id.config_section();
2442 let account = api_key_account(slug, self.api_key_accounts(id).unwrap_or(&[]), label)?;
2443 resolve_api_key_in_section(
2445 &format!("{name} account {label:?}"),
2446 &format!("[[{slug}.accounts]]"),
2447 account.api_key_env.as_deref().unwrap_or(""),
2448 account.api_key.as_deref(),
2449 )
2450 }
2451
2452 pub fn openrouter_management_key(&self, label: Option<&str>) -> Option<String> {
2459 let env_name = match label {
2460 Some(label) => api_key_account("openrouter", &self.openrouter.accounts, label)
2461 .ok()?
2462 .management_api_key_env
2463 .as_deref()?,
2464 None => self.openrouter.management_api_key_env.as_str(),
2465 };
2466 optional_api_key(env_name, None)
2467 }
2468
2469 pub fn display_prefs(&self, vendor: VendorId) -> DisplayPrefs {
2474 match vendor {
2475 VendorId::Deepseek => {
2476 DisplayPrefs::balance(self.deepseek.display_limit, self.deepseek.headline)
2477 }
2478 VendorId::Deepinfra => {
2479 DisplayPrefs::balance(self.deepinfra.display_limit, self.deepinfra.headline)
2480 }
2481 VendorId::Kilo => DisplayPrefs::balance(self.kilo.display_limit, self.kilo.headline),
2482 VendorId::Novita => {
2483 DisplayPrefs::balance(self.novita.display_limit, self.novita.headline)
2484 }
2485 VendorId::Moonshot => {
2486 DisplayPrefs::balance(self.moonshot.display_limit, self.moonshot.headline)
2487 }
2488 VendorId::Grok => DisplayPrefs::balance(self.grok.display_limit, self.grok.headline),
2489 VendorId::Openrouter => DisplayPrefs::balance(None, self.openrouter.headline),
2492 _ => DisplayPrefs::default(),
2493 }
2494 }
2495
2496 pub fn enabled_vendors(&self) -> Vec<VendorId> {
2497 VendorId::all()
2498 .iter()
2499 .copied()
2500 .filter(|id| self.is_enabled(*id))
2501 .collect()
2502 }
2503
2504 pub fn validate(&self) -> Result<()> {
2508 if let Some(minutes) = self.tray.refresh_minutes
2509 && !TRAY_REFRESH_MINUTES.contains(&minutes)
2510 {
2511 return Err(AppError::Other(format!(
2512 "[tray] refresh_minutes must be one of 1, 5 or 10, got {minutes}"
2513 )));
2514 }
2515 if !(1..=100).contains(&self.notifications.threshold) {
2516 return Err(AppError::Other(format!(
2517 "[notifications] threshold must be between 1 and 100, got {}",
2518 self.notifications.threshold
2519 )));
2520 }
2521 if self.context.context_window_tokens == Some(0) {
2522 return Err(AppError::Other(
2523 "[context] context_window_tokens must be greater than zero".into(),
2524 ));
2525 }
2526 for (model, tokens) in &self.context.model_context_window_tokens {
2527 if model.trim().is_empty() {
2528 return Err(AppError::Other(
2529 "[context] model_context_window_tokens keys must not be empty".into(),
2530 ));
2531 }
2532 if *tokens == 0 {
2533 return Err(AppError::Other(format!(
2534 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
2535 )));
2536 }
2537 }
2538 if let Some(limit) = self.anthropic_api.monthly_limit
2539 && (!limit.is_finite() || limit <= 0.0)
2540 {
2541 return Err(AppError::Other(
2542 "[anthropic_api] monthly_limit must be finite and greater than zero; \
2543 remove it to show spend without a limit"
2544 .into(),
2545 ));
2546 }
2547 for (section, limit) in [
2551 ("deepseek", self.deepseek.display_limit),
2552 ("deepinfra", self.deepinfra.display_limit),
2553 ("kilo", self.kilo.display_limit),
2554 ("novita", self.novita.display_limit),
2555 ("moonshot", self.moonshot.display_limit),
2556 ("grok", self.grok.display_limit),
2557 ] {
2558 if let Some(limit) = limit
2559 && (!limit.is_finite() || limit <= 0.0)
2560 {
2561 return Err(AppError::Other(format!(
2562 "[{section}] display_limit must be finite and greater than zero; \
2563 remove it to show the balance without a limit"
2564 )));
2565 }
2566 }
2567 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
2568 && !self.kimi.region.eq_ignore_ascii_case("auto")
2569 {
2570 return Err(AppError::Other(format!(
2571 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
2572 self.kimi.region
2573 )));
2574 }
2575 if !self.minimax.region.eq_ignore_ascii_case("global")
2576 && !self.minimax.region.eq_ignore_ascii_case("cn")
2577 {
2578 return Err(AppError::Other(format!(
2579 "[minimax] region must be \"global\" or \"cn\", got {:?}",
2580 self.minimax.region
2581 )));
2582 }
2583 if self.supergrok.grok_binary.as_os_str().is_empty() {
2584 return Err(AppError::Other(
2585 "[supergrok] grok_binary must not be empty".into(),
2586 ));
2587 }
2588 let mut labels = HashSet::new();
2589 for account in &self.anthropic.accounts {
2590 validate_account_label(&account.label)?;
2591 if !labels.insert(&account.label) {
2592 return Err(AppError::Credentials(format!(
2593 "duplicate anthropic account label {:?}",
2594 account.label
2595 )));
2596 }
2597 }
2598 let mut openai_labels = HashSet::new();
2599 for account in &self.openai.accounts {
2600 validate_account_label_for("openai", &account.label)?;
2601 if !openai_labels.insert(&account.label) {
2602 return Err(AppError::Credentials(format!(
2603 "duplicate openai account label {:?}",
2604 account.label
2605 )));
2606 }
2607 }
2608 for id in Self::API_KEY_ACCOUNT_VENDORS {
2612 let slug = id.config_section();
2613 let mut labels = HashSet::new();
2614 for account in self.api_key_accounts(id).unwrap_or(&[]) {
2615 validate_account_label_for(slug, &account.label)?;
2616 if !labels.insert(&account.label) {
2617 return Err(AppError::Credentials(format!(
2618 "duplicate {slug} account label {:?}",
2619 account.label
2620 )));
2621 }
2622 let has_env = account
2623 .api_key_env
2624 .as_deref()
2625 .is_some_and(|name| !name.is_empty());
2626 let has_inline = account
2627 .api_key
2628 .as_deref()
2629 .is_some_and(|key| !key.is_empty());
2630 if !has_env && !has_inline {
2631 return Err(AppError::Credentials(format!(
2632 "{slug} account {:?} must set api_key_env or api_key",
2633 account.label
2634 )));
2635 }
2636 }
2637 }
2638 self.validate_custom()
2639 }
2640
2641 fn validate_custom(&self) -> Result<()> {
2645 let mut ids = HashSet::new();
2646 let mut short_names: HashSet<&str> =
2647 VendorId::all().iter().map(|v| v.short_name()).collect();
2648 for (index, custom) in self.custom.iter().enumerate() {
2649 custom.validate(index)?;
2650 if !ids.insert(custom.id.as_str()) {
2651 return Err(AppError::Other(format!(
2652 "{}: duplicate id",
2653 custom.section_label()
2654 )));
2655 }
2656 if !short_names.insert(custom.short_name.as_str()) {
2657 return Err(AppError::Other(format!(
2658 "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
2659 custom.section_label(),
2660 custom.short_name
2661 )));
2662 }
2663 }
2664 Ok(())
2665 }
2666}
2667
2668#[cfg(unix)]
2669#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2670enum InlineKeyPermissionDecision {
2671 Ok,
2672 Tighten,
2673}
2674
2675#[cfg(unix)]
2676fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
2677 if mode & 0o077 == 0 {
2678 InlineKeyPermissionDecision::Ok
2679 } else {
2680 InlineKeyPermissionDecision::Tighten
2681 }
2682}
2683
2684pub fn default_path() -> Option<PathBuf> {
2685 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
2686 Some(proj.config_dir().join("config.toml"))
2687}
2688
2689fn legacy_xdg_path() -> Option<PathBuf> {
2694 let home = crate::cache::home_dir().ok()?;
2695 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
2696}
2697
2698pub fn resolved_path() -> Option<PathBuf> {
2709 if let Some(path) = override_path() {
2710 return Some(path);
2711 }
2712 let canonical = default_path();
2713 if let Some(p) = &canonical
2714 && p.exists()
2715 {
2716 return canonical;
2717 }
2718 if let Some(legacy) = legacy_xdg_path()
2719 && legacy.exists()
2720 {
2721 return Some(legacy);
2722 }
2723 canonical
2724}
2725
2726static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
2727
2728pub fn set_override_path(path: &std::path::Path) {
2734 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2735 *slot = Some(path.to_path_buf());
2736 }
2737}
2738
2739#[doc(hidden)]
2742pub fn clear_override_path() {
2743 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2744 *slot = None;
2745 }
2746}
2747
2748fn override_path() -> Option<PathBuf> {
2749 PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
2750}
2751
2752#[doc(hidden)]
2758pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
2759 #[cfg(unix)]
2760 {
2761 use std::os::unix::ffi::{OsStrExt, OsStringExt};
2762 let rest = arg.as_bytes().strip_prefix(b"--config=")?;
2763 Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
2764 }
2765 #[cfg(windows)]
2766 {
2767 use std::os::windows::ffi::{OsStrExt, OsStringExt};
2768 const PREFIX: &[u16] = &[
2769 b'-' as u16,
2770 b'-' as u16,
2771 b'c' as u16,
2772 b'o' as u16,
2773 b'n' as u16,
2774 b'f' as u16,
2775 b'i' as u16,
2776 b'g' as u16,
2777 b'=' as u16,
2778 ];
2779 let wide: Vec<u16> = arg.encode_wide().collect();
2780 let rest = wide.strip_prefix(PREFIX)?;
2781 Some(std::ffi::OsString::from_wide(rest).into())
2782 }
2783 #[cfg(not(any(unix, windows)))]
2784 {
2785 Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
2786 }
2787}
2788
2789fn expand_tilde(p: &std::path::Path) -> PathBuf {
2792 let Some(s) = p.to_str() else {
2793 return p.to_path_buf();
2794 };
2795 let rest = if s == "~" {
2796 ""
2797 } else if let Some(r) = s.strip_prefix("~/") {
2798 r
2799 } else {
2800 return p.to_path_buf();
2801 };
2802 match crate::cache::home_dir() {
2803 Ok(home) if rest.is_empty() => home,
2804 Ok(home) => home.join(rest),
2805 Err(_) => p.to_path_buf(),
2806 }
2807}
2808
2809fn expand_tilde_opt(p: &mut Option<PathBuf>) {
2810 if let Some(inner) = p.as_ref() {
2811 *p = Some(expand_tilde(inner));
2812 }
2813}
2814
2815pub fn config_path_hint() -> String {
2820 resolved_path()
2821 .map(|p| p.display().to_string())
2822 .unwrap_or_else(|| "config.toml".to_string())
2823}
2824
2825#[cfg(test)]
2826mod tests {
2827 use super::*;
2828 use std::io::Write;
2829 use tempfile::NamedTempFile;
2830
2831 #[cfg(unix)]
2832 use std::os::unix::fs::{MetadataExt, PermissionsExt};
2833
2834 fn write_toml(s: &str) -> NamedTempFile {
2835 let mut f = NamedTempFile::new().unwrap();
2836 f.write_all(s.as_bytes()).unwrap();
2837 f.flush().unwrap();
2838 f
2839 }
2840
2841 #[test]
2845 fn openai_without_accounts_resolves_the_singular_path() {
2846 let explicit = OpenAiConfig {
2847 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2848 ..OpenAiConfig::default()
2849 };
2850 assert_eq!(
2851 explicit.resolve_auth_path(None).unwrap(),
2852 PathBuf::from("/tmp/codex/auth.json")
2853 );
2854
2855 let bare = OpenAiConfig::default();
2856 assert_eq!(
2857 bare.resolve_auth_path(None).unwrap(),
2858 crate::openai::creds::default_path().unwrap(),
2859 "no codex_auth_path must still mean ~/.codex/auth.json"
2860 );
2861 }
2862
2863 #[test]
2866 fn openai_named_accounts_resolve_their_own_auth_file() {
2867 let config: Config = toml::from_str(
2868 r#"
2869 [openai]
2870 codex_auth_path = "/tmp/personal/auth.json"
2871 [[openai.accounts]]
2872 label = "work"
2873 codex_auth_path = "/tmp/work/auth.json"
2874 "#,
2875 )
2876 .unwrap();
2877
2878 assert_eq!(
2879 config.openai.resolve_auth_path(Some("work")).unwrap(),
2880 PathBuf::from("/tmp/work/auth.json")
2881 );
2882 assert_eq!(
2883 config.openai.resolve_auth_path(None).unwrap(),
2884 PathBuf::from("/tmp/personal/auth.json")
2885 );
2886 }
2887
2888 #[test]
2891 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
2892 let config = OpenAiConfig {
2893 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
2894 accounts: vec![OpenAiAccount {
2895 label: "work".into(),
2896 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
2897 }],
2898 ..OpenAiConfig::default()
2899 };
2900 let err = config
2901 .resolve_auth_path(Some("nope"))
2902 .unwrap_err()
2903 .to_string();
2904 assert!(err.contains("nope"), "{err}");
2905 assert!(err.contains("[[openai.accounts]]"), "{err}");
2906 }
2907
2908 fn two_codex_accounts() -> OpenAiConfig {
2909 OpenAiConfig {
2910 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2911 accounts: vec![
2912 OpenAiAccount {
2913 label: "main".into(),
2914 codex_auth_path: PathBuf::from("/tmp/codex-main/auth.json"),
2915 },
2916 OpenAiAccount {
2917 label: "work".into(),
2918 codex_auth_path: PathBuf::from("/tmp/codex-work/auth.json"),
2919 },
2920 ],
2921 ..OpenAiConfig::default()
2922 }
2923 }
2924
2925 #[test]
2926 fn the_active_codex_account_is_read_from_the_default_slot() {
2927 let config = two_codex_accounts();
2928 assert_eq!(
2929 config
2930 .fetch_auth_path_probing("work", Some("work"), |_| false)
2931 .unwrap(),
2932 PathBuf::from("/tmp/codex/auth.json")
2933 );
2934 assert_eq!(
2935 config
2936 .fetch_auth_path_probing("main", Some("work"), |_| false)
2937 .unwrap(),
2938 PathBuf::from("/tmp/codex-main/auth.json")
2939 );
2940 }
2941
2942 #[test]
2943 fn an_active_codex_account_with_its_own_file_keeps_reading_it() {
2944 let config = two_codex_accounts();
2945 assert_eq!(
2946 config
2947 .fetch_auth_path_probing("work", Some("work"), |_| true)
2948 .unwrap(),
2949 PathBuf::from("/tmp/codex-work/auth.json")
2950 );
2951 }
2952
2953 #[test]
2954 fn adding_an_openai_account_keeps_the_rest_of_the_file() {
2955 let mut doc: toml_edit::DocumentMut = "# mine\n[zai]\nenabled = true\n".parse().unwrap();
2956 add_openai_account_to_doc(&mut doc, "work", "~/.codex-work/auth.json").unwrap();
2957 let text = doc.to_string();
2958 assert!(
2959 text.starts_with("# mine\n[zai]\nenabled = true\n"),
2960 "{text}"
2961 );
2962 let parsed: Config = toml::from_str(&text).unwrap();
2963 assert_eq!(parsed.openai.accounts[0].label, "work");
2964 assert!(add_openai_account_to_doc(&mut doc, "work", "x").is_err());
2965 assert!(add_openai_account_to_doc(&mut doc, "../x", "x").is_err());
2966 }
2967
2968 #[test]
2969 fn defaults_enable_only_the_four_core_vendors() {
2970 let c = Config::default();
2971 assert!(c.is_enabled(VendorId::Anthropic));
2972 assert!(c.is_enabled(VendorId::Openai));
2973 assert!(c.is_enabled(VendorId::Zai));
2974 assert!(c.is_enabled(VendorId::Openrouter));
2975 for opt_in in [
2976 VendorId::AnthropicApi,
2977 VendorId::Copilot,
2978 VendorId::Deepseek,
2979 VendorId::Kimi,
2980 VendorId::Kilo,
2981 VendorId::Novita,
2982 VendorId::Moonshot,
2983 VendorId::Grok,
2984 VendorId::Supergrok,
2985 VendorId::Grokbot,
2986 VendorId::Cursor,
2987 VendorId::Minimax,
2988 VendorId::Kiro,
2989 VendorId::CommandCode,
2990 VendorId::OrcaRouter,
2991 VendorId::ModelStudio,
2992 ] {
2993 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
2994 }
2995 assert_eq!(c.enabled_vendors().len(), 4);
2996 }
2997
2998 #[test]
2999 fn commandcode_is_opt_in_when_loading_existing_configs() {
3000 let absent: Config = toml::from_str("[openai]\nenabled = true\n").unwrap();
3001 assert!(!absent.is_enabled(VendorId::CommandCode));
3002
3003 let opted_in: Config = toml::from_str("[commandcode]\nenabled = true\n").unwrap();
3004 assert!(opted_in.is_enabled(VendorId::CommandCode));
3005
3006 let opted_out: Config = toml::from_str("[commandcode]\nenabled = false\n").unwrap();
3007 assert!(!opted_out.is_enabled(VendorId::CommandCode));
3008 }
3009
3010 #[test]
3011 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
3012 let config = Config::default();
3013 assert!(!config.is_enabled(VendorId::NousResearch));
3014 assert!(!config.is_enabled(VendorId::OpenCodeGo));
3015 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
3016 assert!(config.opencode_go.api_key.is_none());
3017 assert!(!config.is_enabled(VendorId::Copilot));
3018 }
3019
3020 #[cfg(unix)]
3021 #[test]
3022 fn inline_credentials_are_protected() {
3023 let mut config = Config::default();
3024 config.opencode_go.api_key = Some("<redacted>".to_string());
3025 assert!(config.has_inline_secrets());
3026 }
3027
3028 #[test]
3029 fn antigravity_oauth_client_overrides_parse() {
3030 let config: Config = toml::from_str(
3031 "[antigravity]
3032enabled = true
3033oauth_client_id = \"test-client\"
3034oauth_client_secret = \"test-client-secret\"
3035",
3036 )
3037 .unwrap();
3038 assert!(config.antigravity.enabled);
3039 assert_eq!(
3040 config.antigravity.oauth_client_id.as_deref(),
3041 Some("test-client")
3042 );
3043 assert_eq!(
3044 config.antigravity.oauth_client_secret.as_deref(),
3045 Some("test-client-secret")
3046 );
3047 let bare: Config = toml::from_str(
3048 "[antigravity]
3049enabled = true
3050",
3051 )
3052 .unwrap();
3053 assert!(bare.antigravity.oauth_client_id.is_none());
3054 assert!(bare.antigravity.oauth_client_secret.is_none());
3055 }
3056
3057 #[cfg(unix)]
3058 #[test]
3059 fn antigravity_inline_oauth_secret_receives_config_file_protection() {
3060 let mut config = Config::default();
3061 config.antigravity.oauth_client_id = Some("test-client".into());
3062 assert!(!config.has_inline_secrets());
3063 config.antigravity.oauth_client_secret = Some("<redacted>".into());
3064 assert!(config.has_inline_secrets());
3065 }
3066
3067 #[cfg(unix)]
3068 #[test]
3069 fn openrouter_named_inline_keys_receive_config_file_protection() {
3070 let mut config = Config::default();
3071 config.openrouter.accounts.push(ApiKeyAccount {
3072 label: "work".into(),
3073 api_key_env: None,
3074 api_key: Some("<redacted>".into()),
3075 management_api_key_env: None,
3076 });
3077 assert!(config.has_inline_secrets());
3078 }
3079
3080 #[cfg(unix)]
3081 #[test]
3082 fn every_named_inline_api_key_receives_config_file_protection() {
3083 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
3084 let section = vendor.config_section();
3085 let config: Config = toml::from_str(&format!(
3086 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"<redacted>\"\n"
3087 ))
3088 .unwrap();
3089 assert!(config.has_inline_secrets(), "{vendor:?}");
3090 }
3091 }
3092
3093 #[cfg(unix)]
3097 #[test]
3098 fn every_inline_api_key_receives_config_file_protection() {
3099 for &vendor in VendorId::all() {
3100 let section = vendor.config_section();
3101 let config: Config =
3102 toml::from_str(&format!("[{section}]\napi_key = \"<redacted>\"\n")).unwrap();
3103 if config.inline_api_key(vendor).is_some() {
3104 assert!(config.has_inline_secrets(), "{vendor:?}");
3105 }
3106 }
3107 }
3108
3109 #[test]
3110 fn missing_file_uses_defaults() {
3111 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
3112 let c = Config::load_from(path).unwrap();
3113 assert!(c.is_enabled(VendorId::Anthropic));
3114 }
3115
3116 #[test]
3117 fn parses_full_config() {
3118 let f = write_toml(
3119 r#"
3120 [anthropic]
3121 enabled = true
3122
3123 [openai]
3124 enabled = false
3125 admin_key_env = "MY_ADMIN_KEY"
3126
3127 [zai]
3128 enabled = true
3129 api_key_env = "MY_ZAI"
3130 plan_tier = "pro"
3131
3132 [openrouter]
3133 enabled = false
3134 "#,
3135 );
3136 let c = Config::load_from(f.path()).unwrap();
3137 assert!(c.is_enabled(VendorId::Anthropic));
3138 assert!(!c.is_enabled(VendorId::Openai));
3139 assert!(c.is_enabled(VendorId::Zai));
3140 assert!(!c.is_enabled(VendorId::Openrouter));
3141 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
3142 assert_eq!(c.zai.api_key_env, "MY_ZAI");
3143 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
3144 assert!(c.openrouter.accounts.is_empty());
3145 assert!(c.openrouter.show_default_account);
3146 }
3147
3148 #[test]
3149 fn partial_config_falls_back_to_defaults() {
3150 let f = write_toml(
3151 r#"[openai]
3152enabled = false
3153"#,
3154 );
3155 let c = Config::load_from(f.path()).unwrap();
3156 assert!(!c.is_enabled(VendorId::Openai));
3157 assert!(c.is_enabled(VendorId::Anthropic));
3159 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
3160 }
3161
3162 #[test]
3163 fn malformed_toml_returns_error() {
3164 let f = write_toml("this is not = = valid");
3165 assert!(Config::load_from(f.path()).is_err());
3166 }
3167
3168 #[cfg(unix)]
3169 #[test]
3170 fn load_from_tightens_world_readable_config_with_inline_api_key() {
3171 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
3172 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3173
3174 Config::load_from(file.path()).unwrap();
3175
3176 assert_eq!(
3177 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3178 0o600
3179 );
3180 }
3181
3182 #[cfg(unix)]
3183 #[test]
3184 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
3185 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
3186 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3187
3188 Config::load_from(file.path()).unwrap();
3189
3190 assert_eq!(
3191 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3192 0o644
3193 );
3194 }
3195
3196 #[cfg(unix)]
3197 #[test]
3198 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
3199 assert_eq!(
3200 inline_key_permission_decision(0o600),
3201 InlineKeyPermissionDecision::Ok
3202 );
3203 assert_eq!(
3204 inline_key_permission_decision(0o640),
3205 InlineKeyPermissionDecision::Tighten
3206 );
3207 assert_eq!(
3208 inline_key_permission_decision(0o604),
3209 InlineKeyPermissionDecision::Tighten
3210 );
3211 }
3212
3213 #[test]
3214 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
3215 for value in ["0", "-1", "inf", "nan"] {
3216 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
3217 let error = Config::load_from(file.path()).unwrap_err().to_string();
3218 assert!(error.contains("monthly_limit"), "value {value}: {error}");
3219 }
3220
3221 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
3222 assert_eq!(
3223 Config::load_from(file.path())
3224 .unwrap()
3225 .anthropic_api
3226 .monthly_limit,
3227 Some(1000.0)
3228 );
3229 }
3230
3231 #[test]
3232 fn display_limit_must_be_positive_and_finite_on_every_balance_vendor() {
3233 for section in ["deepseek", "kilo", "novita", "moonshot", "grok"] {
3235 for value in ["0", "-1", "inf", "nan"] {
3236 let file = write_toml(&format!("[{section}]\ndisplay_limit = {value}\n"));
3237 let error = Config::load_from(file.path()).unwrap_err().to_string();
3238 assert!(
3239 error.contains(&format!("[{section}] display_limit")),
3240 "{section} = {value}: {error}"
3241 );
3242 }
3243 let file = write_toml(&format!("[{section}]\ndisplay_limit = 200\n"));
3244 let config = Config::load_from(file.path()).unwrap();
3245 assert_eq!(
3246 config.display_prefs(vendor_of(section)).display_limit,
3247 Some(200.0),
3248 "{section}"
3249 );
3250 }
3251 }
3252
3253 #[test]
3255 fn display_limit_is_absent_until_the_user_states_one() {
3256 let config = Config::default();
3257 for vendor in VendorId::all() {
3258 assert_eq!(
3259 config.display_prefs(*vendor).display_limit,
3260 None,
3261 "{vendor:?}"
3262 );
3263 }
3264 }
3265
3266 #[test]
3269 fn the_default_headline_follows_the_kind_of_vendor() {
3270 let config = Config::default();
3271 for vendor in [
3272 VendorId::Deepseek,
3273 VendorId::Kilo,
3274 VendorId::Novita,
3275 VendorId::Moonshot,
3276 VendorId::Grok,
3277 ] {
3278 assert_eq!(
3279 config.display_prefs(vendor).headline,
3280 Headline::Amount,
3281 "{vendor:?}"
3282 );
3283 }
3284 assert_eq!(
3285 config.display_prefs(VendorId::Openrouter).headline,
3286 Headline::Percent
3287 );
3288 assert_eq!(
3289 config.display_prefs(VendorId::Anthropic),
3290 DisplayPrefs::default()
3291 );
3292 }
3293
3294 #[test]
3295 fn the_headline_is_configurable_per_vendor_and_a_typo_is_loud() {
3296 let file = write_toml("[deepseek]\nheadline = \"percent\"\n");
3297 assert_eq!(
3298 Config::load_from(file.path())
3299 .unwrap()
3300 .display_prefs(VendorId::Deepseek)
3301 .headline,
3302 Headline::Percent
3303 );
3304
3305 let file = write_toml("[openrouter]\nheadline = \"amount\"\n");
3306 assert_eq!(
3307 Config::load_from(file.path())
3308 .unwrap()
3309 .display_prefs(VendorId::Openrouter)
3310 .headline,
3311 Headline::Amount
3312 );
3313
3314 let file = write_toml("[deepseek]\nheadline = \"dollars\"\n");
3315 let error = Config::load_from(file.path()).unwrap_err().to_string();
3316 assert!(error.contains("headline"), "{error}");
3317 }
3318
3319 #[test]
3325 fn openrouter_has_no_display_limit_to_be_ignored() {
3326 let file = write_toml("[openrouter]\ndisplay_limit = 200\nheadline = \"percent\"\n");
3327 let config = Config::load_from(file.path()).unwrap();
3328 let prefs = config.display_prefs(VendorId::Openrouter);
3329 assert_eq!(prefs.display_limit, None);
3330 assert_eq!(prefs.headline, Headline::Percent);
3331 }
3332
3333 #[test]
3336 fn a_display_limit_alone_leaves_the_headline_where_it_was() {
3337 let file = write_toml("[deepseek]\ndisplay_limit = 200\n");
3338 let prefs = Config::load_from(file.path())
3339 .unwrap()
3340 .display_prefs(VendorId::Deepseek);
3341 assert_eq!(prefs.display_limit, Some(200.0));
3342 assert_eq!(prefs.headline, Headline::Amount);
3343 }
3344
3345 fn vendor_of(section: &str) -> VendorId {
3346 VendorId::all()
3347 .iter()
3348 .copied()
3349 .find(|vendor| vendor.config_section() == section)
3350 .unwrap_or_else(|| panic!("no vendor for [{section}]"))
3351 }
3352
3353 #[test]
3354 fn minimax_region_accepts_only_known_instances() {
3355 for region in ["global", "GLOBAL", "cn", "CN"] {
3356 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3357 assert_eq!(
3358 Config::load_from(file.path()).unwrap().minimax.region,
3359 region
3360 );
3361 }
3362
3363 for region in ["", "china", "us"] {
3364 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3365 let error = Config::load_from(file.path()).unwrap_err().to_string();
3366 assert!(error.contains("[minimax] region"), "{error}");
3367 }
3368 }
3369
3370 #[test]
3371 fn kimi_region_accepts_auto_and_both_deployments() {
3372 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
3373 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3374 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
3375 }
3376
3377 for region in ["", "us", "oversea"] {
3378 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3379 let error = Config::load_from(file.path()).unwrap_err().to_string();
3380 assert!(error.contains("[kimi] region"), "{error}");
3381 }
3382 }
3383
3384 #[test]
3385 fn kimi_defaults_to_auto_region_and_no_credential_override() {
3386 let defaults = KimiConfig::default();
3387 assert_eq!(defaults.region, "auto");
3388 assert_eq!(defaults.credentials_path, None);
3389 assert!(!defaults.enabled);
3390 }
3391
3392 #[test]
3393 fn kimi_credentials_path_expands_a_tilde() {
3394 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
3395 let path = Config::load_from(file.path())
3396 .unwrap()
3397 .kimi
3398 .credentials_path
3399 .unwrap();
3400 assert!(!path.starts_with("~"), "{}", path.display());
3401 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
3402 }
3403
3404 #[test]
3405 fn grokbot_is_opt_in_and_takes_no_api_key() {
3406 let defaults = GrokbotConfig::default();
3407 assert!(!defaults.enabled);
3408 assert_eq!(defaults.secrets_path, None);
3409 let config = Config::default();
3411 assert_eq!(config.api_key_env_for(VendorId::Grokbot), "");
3412 assert_eq!(config.inline_api_key(VendorId::Grokbot), None);
3413
3414 let file = write_toml("[grokbot]\nenabled = true\n");
3415 let config = Config::load_from(file.path()).unwrap();
3416 assert!(config.is_enabled(VendorId::Grokbot));
3417 assert!(config.enabled_vendors().contains(&VendorId::Grokbot));
3418 }
3419
3420 #[test]
3421 fn grokbot_secrets_path_expands_a_tilde() {
3422 let file = write_toml("[grokbot]\nsecrets_path = \"~/gb/secrets.json\"\n");
3423 let path = Config::load_from(file.path())
3424 .unwrap()
3425 .grokbot
3426 .secrets_path
3427 .unwrap();
3428 assert!(!path.starts_with("~"), "{}", path.display());
3429 assert!(path.ends_with("gb/secrets.json"), "{}", path.display());
3430 }
3431
3432 #[test]
3433 fn modelstudio_is_opt_in_and_takes_no_api_key() {
3434 let defaults = ModelStudioConfig::default();
3435 assert!(!defaults.enabled);
3436 assert_eq!(defaults.config_dir, None);
3437 let config = Config::default();
3439 assert_eq!(config.api_key_env_for(VendorId::ModelStudio), "");
3440 assert_eq!(config.inline_api_key(VendorId::ModelStudio), None);
3441
3442 let file = write_toml("[modelstudio]\nenabled = true\n");
3443 let config = Config::load_from(file.path()).unwrap();
3444 assert!(config.is_enabled(VendorId::ModelStudio));
3445 assert!(config.enabled_vendors().contains(&VendorId::ModelStudio));
3446 }
3447
3448 #[test]
3449 fn modelstudio_config_dir_expands_a_tilde() {
3450 let file = write_toml("[modelstudio]\nconfig_dir = \"~/bl\"\n");
3451 let path = Config::load_from(file.path())
3452 .unwrap()
3453 .modelstudio
3454 .config_dir
3455 .unwrap();
3456 assert!(!path.starts_with("~"), "{}", path.display());
3457 assert!(path.ends_with("bl"), "{}", path.display());
3458 }
3459
3460 #[test]
3461 fn lyceum_is_opt_in_and_uses_shared_api_key_account_resolution() {
3462 let defaults = LyceumConfig::default();
3463 assert!(!defaults.enabled);
3464 assert_eq!(defaults.api_key_env, "LYCEUM_API_KEY");
3465 let override_file = write_toml("[lyceum]\napi_key_env = \"LYCEUM_CUSTOM_TEST_KEY\"\n");
3466 let override_config = Config::load_from(override_file.path()).unwrap();
3467 assert_eq!(
3468 override_config.api_key_env_for(VendorId::Lyceum),
3469 "LYCEUM_CUSTOM_TEST_KEY"
3470 );
3471 let file = write_toml(
3472 r#"[lyceum]
3473enabled = true
3474api_key_env = ""
3475api_key = "synthetic-inline"
3476[[lyceum.accounts]]
3477label = "work"
3478api_key = "synthetic-account"
3479"#,
3480 );
3481 let config = Config::load_from(file.path()).unwrap();
3482 assert!(config.is_enabled(VendorId::Lyceum));
3483 assert_eq!(config.api_key_env_for(VendorId::Lyceum), "");
3484 assert_eq!(
3485 config
3486 .resolve_account_api_key_for(VendorId::Lyceum, None)
3487 .unwrap(),
3488 "synthetic-inline"
3489 );
3490 assert_eq!(
3491 config
3492 .resolve_account_api_key_for(VendorId::Lyceum, Some("work"))
3493 .unwrap(),
3494 "synthetic-account"
3495 );
3496 }
3497
3498 #[test]
3499 fn optional_api_key_reports_absence_instead_of_failing() {
3500 assert_eq!(
3501 optional_api_key("9INVALID", Some("inline")),
3502 Some("inline".to_string())
3503 );
3504 assert_eq!(optional_api_key("9INVALID", None), None);
3505 assert_eq!(optional_api_key("9INVALID", Some("")), None);
3506 assert_eq!(
3509 optional_api_key("9INVALID", Some("inline")),
3510 Some("inline".to_string())
3511 );
3512 }
3513
3514 #[test]
3515 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
3516 let defaults = Config::default();
3517 assert!(!defaults.context.enabled);
3518 assert_eq!(
3519 defaults.context.window_tokens_for(Some("claude-test")),
3520 None
3521 );
3522
3523 let file = write_toml(
3524 r#"
3525 [context]
3526 enabled = true
3527 context_window_tokens = 200000
3528
3529 [context.model_context_window_tokens]
3530 claude-opus-1m = 1000000
3531 "claude exact id" = 300000
3532 "#,
3533 );
3534 let config = Config::load_from(file.path()).unwrap();
3535 assert!(config.context.enabled);
3536 assert_eq!(
3537 config.context.window_tokens_for(Some("claude-opus-1m")),
3538 Some(1_000_000)
3539 );
3540 assert_eq!(
3541 config.context.window_tokens_for(Some("claude exact id")),
3542 Some(300_000)
3543 );
3544 assert_eq!(
3545 config.context.window_tokens_for(Some("another-model")),
3546 Some(200_000)
3547 );
3548 }
3549
3550 #[test]
3551 fn context_layout_defaults_to_full_and_parses_each_variant() {
3552 assert_eq!(Config::default().context.layout, ContextLayout::Full);
3553 for (text, want) in [
3554 ("full", ContextLayout::Full),
3555 ("split", ContextLayout::Split),
3556 ("bottom", ContextLayout::Bottom),
3557 ] {
3558 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
3559 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
3560 }
3561 let file = write_toml("[context]\nlayout = \"floating\"\n");
3562 assert!(
3563 Config::load_from(file.path()).is_err(),
3564 "an unknown layout must be rejected, not silently defaulted"
3565 );
3566 }
3567
3568 #[test]
3569 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
3570 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
3571 for (text, want) in [
3572 ("sidebar", VendorBoxStyle::Sidebar),
3573 ("navbar", VendorBoxStyle::Navbar),
3574 ("none", VendorBoxStyle::None),
3575 ] {
3576 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
3577 assert_eq!(
3578 Config::load_from(file.path()).unwrap().ui.vendor_box(),
3579 want
3580 );
3581 }
3582 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
3583 assert!(
3584 Config::load_from(file.path()).is_err(),
3585 "an unknown vendor_box style must be rejected, not silently defaulted"
3586 );
3587 }
3588
3589 #[test]
3590 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
3591 for source in [
3592 "[context]\ncontext_window_tokens = 0\n",
3593 "[context.model_context_window_tokens]\nclaude = 0\n",
3594 "[context.model_context_window_tokens]\n\" \" = 200000\n",
3595 ] {
3596 let file = write_toml(source);
3597 let error = Config::load_from(file.path()).unwrap_err().to_string();
3598 assert!(error.contains("context"), "{error}");
3599 }
3600 }
3601
3602 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
3604 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
3605 M.lock().unwrap_or_else(|p| p.into_inner())
3606 }
3607
3608 #[test]
3609 fn resolve_api_key_prefers_env_over_inline() {
3610 let _g = env_guard();
3611 let var = "AI_USAGEBAR_TEST_ENV_WINS";
3613 unsafe { std::env::set_var(var, "from-env") };
3615 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
3616 unsafe { std::env::remove_var(var) };
3617 assert_eq!(got, "from-env");
3618 }
3619
3620 #[test]
3621 fn resolve_api_key_falls_back_to_inline() {
3622 let _g = env_guard();
3623 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
3624 unsafe { std::env::remove_var(var) };
3625 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
3626 assert_eq!(got, "inline-key");
3627 }
3628
3629 #[test]
3630 fn copilot_token_prefers_explicit_environment_over_gh_cli() {
3631 struct NeverRun;
3632 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
3633 fn run(
3634 &self,
3635 _: &crate::copilot::credentials::GhAuthTokenCommand,
3636 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3637 panic!("environment override must not invoke gh")
3638 }
3639 }
3640
3641 let token = CopilotConfig::default()
3642 .resolve_token_with(
3643 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
3644 &NeverRun,
3645 )
3646 .unwrap();
3647 assert_eq!(token, "from-environment");
3648 }
3649
3650 #[test]
3651 fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
3652 struct FailedGh;
3653 impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
3654 fn run(
3655 &self,
3656 _: &crate::copilot::credentials::GhAuthTokenCommand,
3657 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3658 Ok(crate::copilot::credentials::GhAuthTokenOutput {
3659 success: false,
3660 stdout: b"never-echo-gh-output".to_vec(),
3661 })
3662 }
3663 }
3664 let error = CopilotConfig::default()
3665 .resolve_token_with(|_| None, &FailedGh)
3666 .unwrap_err()
3667 .to_string();
3668 assert!(error.contains("gh auth login --web"));
3669 assert!(!error.contains("never-echo-gh-output"));
3670 }
3671
3672 #[test]
3673 fn resolve_api_key_errors_when_both_missing() {
3674 let _g = env_guard();
3675 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
3676 unsafe { std::env::remove_var(var) };
3677 let err = resolve_api_key("Zai", var, None).unwrap_err();
3678 match err {
3679 crate::error::AppError::Credentials(msg) => {
3680 assert!(
3681 msg.contains("api_key"),
3682 "error should suggest config field: {msg}"
3683 );
3684 }
3685 other => panic!("expected Credentials error, got {other:?}"),
3686 }
3687 }
3688
3689 #[test]
3690 fn resolve_api_key_uses_exact_opencode_go_section_name() {
3691 let _g = env_guard();
3692 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
3693 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
3694 let message = err.to_string();
3695 assert!(
3696 message.contains("[opencode-go]"),
3697 "wrong section hint: {message}"
3698 );
3699 assert!(
3700 !message.contains("[opencode go]"),
3701 "wrong section hint: {message}"
3702 );
3703 }
3704
3705 fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
3706 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
3707 M.lock().unwrap_or_else(|p| p.into_inner())
3708 }
3709
3710 struct ScopedPathOverride {
3716 _serial: std::sync::MutexGuard<'static, ()>,
3717 }
3718
3719 impl Drop for ScopedPathOverride {
3720 fn drop(&mut self) {
3721 clear_override_path();
3722 }
3723 }
3724
3725 fn scoped_path_override() -> ScopedPathOverride {
3726 ScopedPathOverride {
3727 _serial: path_override_guard(),
3728 }
3729 }
3730
3731 #[test]
3732 fn override_path_wins_over_canonical_and_legacy() {
3733 let _scoped = scoped_path_override();
3734 let file = NamedTempFile::new().unwrap();
3735 set_override_path(file.path());
3736 assert_eq!(resolved_path().as_deref(), Some(file.path()));
3737 assert_eq!(config_path_hint(), file.path().display().to_string());
3738 clear_override_path();
3739 let p = resolved_path().expect("a config path must resolve");
3741 assert!(p.ends_with("config.toml"));
3742 }
3743
3744 #[test]
3745 fn scoped_override_guard_clears_the_override_on_panic() {
3746 let hook = std::panic::take_hook();
3749 std::panic::set_hook(Box::new(|_| {}));
3750 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3751 let _scoped = scoped_path_override();
3752 set_override_path(std::path::Path::new("panicked-override.toml"));
3753 panic!("simulated mid-test failure");
3754 }))
3755 .is_err();
3756 std::panic::set_hook(hook);
3757 assert!(panicked, "the simulated failure must run");
3758 let _serial = path_override_guard();
3759 assert!(
3760 override_path().is_none(),
3761 "a panicking test must not leak the override into siblings"
3762 );
3763 }
3764
3765 #[test]
3766 fn config_path_hint_ends_with_config_toml() {
3767 let _g = path_override_guard();
3768 assert!(config_path_hint().ends_with("config.toml"));
3771 }
3772
3773 #[test]
3774 fn config_flag_value_splits_the_equals_form() {
3775 use std::ffi::OsStr;
3776 assert_eq!(
3777 config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
3778 Some(std::path::Path::new("work.toml"))
3779 );
3780 assert_eq!(
3781 config_flag_value(OsStr::new("--config=")).as_deref(),
3782 Some(std::path::Path::new(""))
3783 );
3784 assert_eq!(config_flag_value(OsStr::new("--config")), None);
3785 assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
3786 assert_eq!(config_flag_value(OsStr::new("account")), None);
3787 }
3788
3789 #[cfg(unix)]
3793 #[test]
3794 fn config_flag_value_keeps_undecodable_bytes_intact() {
3795 use std::ffi::OsString;
3796 use std::os::unix::ffi::{OsStrExt, OsStringExt};
3797 let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
3798 let value = config_flag_value(&raw).expect("prefix matches");
3799 assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
3800 }
3801
3802 #[cfg(windows)]
3803 #[test]
3804 fn config_flag_value_keeps_lone_surrogates_intact() {
3805 use std::ffi::OsString;
3806 use std::os::windows::ffi::{OsStrExt, OsStringExt};
3807 let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
3808 wide.push(0xDC00); wide.extend("x.toml".encode_utf16());
3810 let raw = OsString::from_wide(&wide);
3811 let value = config_flag_value(&raw).expect("prefix matches");
3812 let mut expected = vec![0xDC00u16];
3813 expected.extend("x.toml".encode_utf16());
3814 assert_eq!(
3815 value.as_os_str().encode_wide().collect::<Vec<_>>(),
3816 expected
3817 );
3818 }
3819
3820 #[test]
3821 fn resolve_api_key_treats_empty_env_as_unset() {
3822 let _g = env_guard();
3823 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
3824 unsafe { std::env::set_var(var, "") };
3825 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
3826 unsafe { std::env::remove_var(var) };
3827 assert_eq!(got, "inline");
3828 }
3829
3830 #[test]
3831 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
3832 let _g = env_guard();
3833 let bad = "sk-kimi-very-real-looking-pasted-secret";
3835 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
3836 let msg = err.to_string();
3837 assert!(
3838 msg.contains("invalid") && msg.contains("api_key_env"),
3839 "error should explain misconfiguration: {msg}"
3840 );
3841 assert!(
3842 !msg.contains(bad),
3843 "error must not echo the misconfigured value: {msg}"
3844 );
3845 assert!(msg.contains("valid environment variable name"));
3846 assert!(
3847 msg.contains("[kimi]"),
3848 "error should point at the lowercase TOML section: {msg}"
3849 );
3850 }
3851
3852 #[test]
3853 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
3854 let _g = env_guard();
3855 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
3856 assert_eq!(got, "inline-key");
3857 }
3858
3859 #[test]
3860 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
3861 let _g = env_guard();
3862 let pasted_secret = "sk_pasted_secret";
3865 unsafe { std::env::remove_var(pasted_secret) };
3866 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
3867 assert!(
3868 !err.to_string().contains(pasted_secret),
3869 "error must not echo configured api_key_env values"
3870 );
3871 }
3872
3873 #[test]
3874 fn is_valid_env_var_name_rules() {
3875 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
3877 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
3878 }
3879 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
3881 assert!(
3882 !is_valid_env_var_name(invalid),
3883 "{invalid} should be invalid"
3884 );
3885 }
3886 }
3887
3888 #[test]
3889 fn config_parses_with_inline_api_key_and_primary() {
3890 let f = write_toml(
3891 r#"
3892 [ui]
3893 primary = "openrouter"
3894
3895 [zai]
3896 enabled = true
3897 api_key_env = "MY_ZAI"
3898 api_key = "sk-zai-inline"
3899
3900 [openrouter]
3901 enabled = true
3902 api_key = "sk-or-inline"
3903 "#,
3904 );
3905 let c = Config::load_from(f.path()).unwrap();
3906 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
3907 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
3908 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
3909 }
3910
3911 #[test]
3912 fn openrouter_named_accounts_preserve_the_default_contract() {
3913 let f = write_toml(
3914 r#"
3915 [openrouter]
3916 enabled = true
3917 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
3918 api_key = "default-inline"
3919 show_default_account = false
3920
3921 [[openrouter.accounts]]
3922 label = "work"
3923 api_key_env = "OPENROUTER_WORK_API_KEY"
3924
3925 [[openrouter.accounts]]
3926 label = "personal"
3927 api_key = "personal-inline"
3928 "#,
3929 );
3930 let _g = env_guard();
3931 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
3932 let config = Config::load_from(f.path()).unwrap();
3933 assert!(!config.openrouter.show_default_account);
3934 assert_eq!(config.openrouter.accounts.len(), 2);
3935 assert_eq!(
3936 config
3937 .resolve_account_api_key_for(VendorId::Openrouter, None)
3938 .unwrap(),
3939 "default-inline"
3940 );
3941 assert_eq!(
3942 config
3943 .resolve_account_api_key_for(VendorId::Openrouter, Some("personal"))
3944 .unwrap(),
3945 "personal-inline"
3946 );
3947 }
3948
3949 #[test]
3950 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
3951 for source in [
3952 r#"
3953 [[openrouter.accounts]]
3954 label = "work"
3955 api_key = "one"
3956 [[openrouter.accounts]]
3957 label = "work"
3958 api_key = "two"
3959 "#,
3960 r#"
3961 [[openrouter.accounts]]
3962 label = "../work"
3963 api_key = "one"
3964 "#,
3965 r#"
3966 [[openrouter.accounts]]
3967 label = "work"
3968 "#,
3969 ] {
3970 let f = write_toml(source);
3971 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
3972 }
3973 }
3974
3975 #[test]
3976 fn openrouter_unknown_account_never_falls_back_to_default_key() {
3977 let mut config = Config::default();
3978 config.openrouter.api_key = Some("default-secret".into());
3979 config.openrouter.accounts.push(ApiKeyAccount {
3980 label: "work".into(),
3981 api_key_env: None,
3982 api_key: Some("work-secret".into()),
3983 management_api_key_env: None,
3984 });
3985 let message = config
3986 .resolve_account_api_key_for(VendorId::Openrouter, Some("missing"))
3987 .unwrap_err()
3988 .to_string();
3989 assert!(message.contains("missing") && message.contains("work"));
3990 assert!(!message.contains("default-secret"));
3991 assert!(!message.contains("work-secret"));
3992 }
3993
3994 #[test]
3995 fn openrouter_account_key_errors_do_not_echo_configured_values() {
3996 let mut config = Config::default();
3997 config.openrouter.accounts.push(ApiKeyAccount {
3998 label: "work".into(),
3999 api_key_env: Some("sk_pasted_secret".into()),
4000 api_key: None,
4001 management_api_key_env: None,
4002 });
4003 let _g = env_guard();
4004 unsafe { std::env::remove_var("sk_pasted_secret") };
4005 let message = config
4006 .resolve_account_api_key_for(VendorId::Openrouter, Some("work"))
4007 .unwrap_err()
4008 .to_string();
4009 assert!(message.contains("[[openrouter.accounts]]"));
4010 assert!(!message.contains("sk_pasted_secret"));
4011 }
4012
4013 #[test]
4014 fn openrouter_management_key_resolves_per_account_and_never_inherits() {
4015 let mut config = Config::default();
4016 config.openrouter.accounts = vec![
4017 ApiKeyAccount {
4018 label: "work".into(),
4019 api_key_env: None,
4020 api_key: Some("work-key".into()),
4021 management_api_key_env: Some("AI_USAGEBAR_TEST_OR_MGMT_WORK".into()),
4022 },
4023 ApiKeyAccount {
4024 label: "plain".into(),
4025 api_key_env: None,
4026 api_key: Some("plain-key".into()),
4027 management_api_key_env: None,
4028 },
4029 ];
4030 let _g = env_guard();
4031 unsafe { std::env::remove_var("OPENROUTER_MANAGEMENT_API_KEY") };
4032 unsafe { std::env::set_var("AI_USAGEBAR_TEST_OR_MGMT_WORK", "mgmt-work") };
4033
4034 assert_eq!(config.openrouter_management_key(None), None);
4036 assert_eq!(
4038 config.openrouter_management_key(Some("work")).as_deref(),
4039 Some("mgmt-work")
4040 );
4041 unsafe { std::env::set_var("OPENROUTER_MANAGEMENT_API_KEY", "mgmt-default") };
4044 assert_eq!(
4045 config.openrouter_management_key(None).as_deref(),
4046 Some("mgmt-default")
4047 );
4048 assert_eq!(config.openrouter_management_key(Some("plain")), None);
4049 assert_eq!(config.openrouter_management_key(Some("typo")), None);
4051
4052 config.openrouter.management_api_key_env = "AI_USAGEBAR_TEST_OR_MGMT_DEFAULT".into();
4054 unsafe { std::env::set_var("AI_USAGEBAR_TEST_OR_MGMT_DEFAULT", "mgmt-renamed") };
4055 assert_eq!(
4056 config.openrouter_management_key(None).as_deref(),
4057 Some("mgmt-renamed")
4058 );
4059 unsafe { std::env::remove_var("OPENROUTER_MANAGEMENT_API_KEY") };
4060 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_MGMT_WORK") };
4061 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_MGMT_DEFAULT") };
4062 }
4063
4064 #[test]
4065 fn every_api_key_account_vendor_validates_its_array_like_openrouter() {
4066 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
4067 let section = vendor.config_section();
4068 let parse = |accounts: &str| -> Result<Config> {
4069 let config: Config =
4070 toml::from_str(&format!("[{section}]\nenabled = true\n{accounts}")).unwrap();
4071 config.validate().map(|()| config)
4072 };
4073 let config = parse(&format!(
4074 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k\"\n"
4075 ))
4076 .unwrap();
4077 assert_eq!(
4078 config.api_key_accounts(vendor).unwrap().len(),
4079 1,
4080 "{vendor:?}"
4081 );
4082 assert!(config.show_default_api_key_account(vendor), "{vendor:?}");
4083
4084 let duplicate = parse(&format!(
4085 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"a\"\n\
4086 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"b\"\n"
4087 ))
4088 .unwrap_err()
4089 .to_string();
4090 assert!(
4091 duplicate.contains(&format!("duplicate {section} account label")),
4092 "{duplicate}"
4093 );
4094
4095 let keyless = parse(&format!("[[{section}.accounts]]\nlabel = \"work\"\n"))
4096 .unwrap_err()
4097 .to_string();
4098 assert!(
4099 keyless.contains("must set api_key_env or api_key"),
4100 "{keyless}"
4101 );
4102
4103 assert!(
4105 parse(&format!(
4106 "[[{section}.accounts]]\nlabel = \"../x\"\napi_key = \"k\"\n"
4107 ))
4108 .is_err(),
4109 "{vendor:?}"
4110 );
4111 }
4112 }
4113
4114 #[test]
4115 fn named_api_key_accounts_resolve_their_own_key_and_never_the_default() {
4116 let mut config = Config::default();
4117 config.deepseek.api_key_env.clear();
4118 config.deepseek.api_key = Some("default-key".into());
4119 config.deepseek.accounts.push(ApiKeyAccount {
4120 label: "work".into(),
4121 api_key_env: Some("AI_USAGEBAR_TEST_DEEPSEEK_WORK".into()),
4122 api_key: Some("work-inline".into()),
4123 management_api_key_env: None,
4124 });
4125 let _g = env_guard();
4126 unsafe { std::env::set_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK", "work-env") };
4127 let resolve = |label| config.resolve_account_api_key_for(VendorId::Deepseek, label);
4128 assert_eq!(resolve(None).unwrap(), "default-key");
4129 assert_eq!(resolve(Some("work")).unwrap(), "work-env", "env wins");
4130 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK") };
4131 assert_eq!(resolve(Some("work")).unwrap(), "work-inline");
4132
4133 let unknown = resolve(Some("typo")).unwrap_err().to_string();
4134 assert!(unknown.contains("[[deepseek.accounts]]"), "{unknown}");
4135 assert!(
4136 unknown.contains("\"work\""),
4137 "lists known labels: {unknown}"
4138 );
4139 assert!(!unknown.contains("default-key"), "{unknown}");
4140 }
4141
4142 #[test]
4143 fn the_default_key_error_keeps_each_vendors_historic_wording() {
4144 let mut config = Config::default();
4145 config.zai.api_key_env.clear();
4146 config.minimax.api_key_env.clear();
4147 let zai = config
4148 .resolve_account_api_key_for(VendorId::Zai, None)
4149 .unwrap_err()
4150 .to_string();
4151 let expected = resolve_api_key("Zai", "", None).unwrap_err().to_string();
4152 assert_eq!(zai, expected);
4153 assert!(zai.contains("[zai]"), "{zai}");
4154 let minimax = config
4155 .resolve_account_api_key_for(VendorId::Minimax, None)
4156 .unwrap_err()
4157 .to_string();
4158 assert_eq!(
4159 minimax,
4160 resolve_api_key("MiniMax", "", None)
4161 .unwrap_err()
4162 .to_string()
4163 );
4164 }
4165
4166 #[test]
4167 fn enabled_vendors_preserves_canonical_order() {
4168 let c = Config::default();
4171 assert_eq!(
4172 c.enabled_vendors(),
4173 vec![
4174 VendorId::Anthropic,
4175 VendorId::Openai,
4176 VendorId::Zai,
4177 VendorId::Openrouter,
4178 ]
4179 );
4180 }
4181
4182 #[test]
4183 fn deepseek_appears_when_enabled() {
4184 let f = write_toml(
4185 r#"
4186 [deepseek]
4187 enabled = true
4188 api_key = "sk-test"
4189 "#,
4190 );
4191 let c = Config::load_from(f.path()).unwrap();
4192 assert!(c.is_enabled(VendorId::Deepseek));
4193 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
4194 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
4195 }
4196
4197 #[test]
4198 fn tilde_paths_are_expanded_on_load() {
4199 let f = write_toml(
4203 r#"
4204 [context]
4205 projects_path = "~/.claude/projects"
4206
4207 [anthropic]
4208 credentials_path = "~/.claude/.credentials.json"
4209
4210 [[anthropic.accounts]]
4211 label = "work"
4212 credentials_path = "~/work.json"
4213 "#,
4214 );
4215 let c = Config::load_from(f.path()).unwrap();
4216 let home = crate::cache::home_dir().unwrap();
4217
4218 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
4219 let got = c.anthropic.credentials_path.unwrap();
4220 assert_eq!(got, home.join(".claude/.credentials.json"));
4221 assert!(!got.to_string_lossy().contains('~'));
4222 assert_eq!(
4223 c.anthropic.accounts[0].credentials_path,
4224 home.join("work.json")
4225 );
4226 }
4227
4228 #[test]
4229 fn absolute_and_relative_paths_are_left_alone() {
4230 let f = write_toml(
4231 r#"
4232 [anthropic]
4233 credentials_path = "/etc/creds.json"
4234 "#,
4235 );
4236 let c = Config::load_from(f.path()).unwrap();
4237 assert_eq!(
4238 c.anthropic.credentials_path.unwrap(),
4239 std::path::Path::new("/etc/creds.json")
4240 );
4241
4242 let f2 = write_toml(
4244 r#"
4245 [anthropic]
4246 credentials_path = "~someone/creds.json"
4247 "#,
4248 );
4249 let c2 = Config::load_from(f2.path()).unwrap();
4250 assert_eq!(
4251 c2.anthropic.credentials_path.unwrap(),
4252 std::path::Path::new("~someone/creds.json")
4253 );
4254 }
4255
4256 #[test]
4257 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
4258 let _g = path_override_guard();
4259 let p = resolved_path().expect("a config path must resolve");
4262 assert!(p.ends_with("config.toml"));
4263 let canonical = default_path().unwrap();
4264 let legacy = legacy_xdg_path().unwrap();
4265 assert!(
4266 p == canonical || p == legacy,
4267 "resolved to an unexpected location: {}",
4268 p.display()
4269 );
4270 }
4271
4272 #[test]
4273 fn misspelled_section_is_rejected_not_ignored() {
4274 let f = write_toml(
4277 r#"
4278 [openrouer]
4279 enabled = true
4280 api_key = "sk-or-v1-typo"
4281 "#,
4282 );
4283 let err = Config::load_from(f.path()).unwrap_err().to_string();
4284 assert!(
4285 err.contains("openrouer"),
4286 "error should name the typo: {err}"
4287 );
4288 }
4289
4290 #[test]
4291 fn invalid_toml_is_an_error_not_silent_defaults() {
4292 let f = write_toml("[zai\nenabled = true\n");
4293 assert!(Config::load_from(f.path()).is_err());
4294 }
4295
4296 #[test]
4300 fn a_parse_error_names_the_line_without_quoting_it() {
4301 let f = write_toml("[openrouter]\nenabled = true\napi_key = sk-or-v1-unquoted\n");
4302 let err = Config::load_from(f.path()).unwrap_err().to_string();
4303 assert!(err.contains("line 3,"), "{err}");
4304 assert!(!err.contains("sk-or-v1-unquoted"), "{err}");
4305 }
4306
4307 #[test]
4310 fn a_writer_parse_error_names_the_line_without_quoting_it() {
4311 let (_dir, path) =
4312 crate::cache::closed_temp_file("config.toml", Some("[zai]\napi_key = zk-unquoted\n"));
4313 let err = read_config_document(&path).unwrap_err().to_string();
4314 assert!(err.contains("line 2,"), "{err}");
4315 assert!(!err.contains("zk-unquoted"), "{err}");
4316 }
4317
4318 #[test]
4319 fn toml_error_summary_keeps_the_position_and_the_message() {
4320 let input = "a = 1\nkey = sk-secret\n";
4321 assert_eq!(
4322 toml_error_summary(input, Some(12..21), "string values must be quoted\n"),
4323 "line 2, column 7: string values must be quoted"
4324 );
4325 assert_eq!(toml_error_summary(input, None, "bad"), "bad");
4326 }
4327
4328 #[test]
4329 fn a_missing_file_is_still_just_defaults() {
4330 let dir = tempfile::tempdir().unwrap();
4333 let missing = dir.path().join("nope").join("config.toml");
4334 let c = Config::load_from(&missing).unwrap();
4335 assert!(c.is_enabled(VendorId::Anthropic));
4336 }
4337
4338 #[test]
4339 fn kimi_appears_when_enabled() {
4340 let f = write_toml(
4341 r#"
4342 [kimi]
4343 enabled = true
4344 api_key = "sk-test"
4345 "#,
4346 );
4347 let c = Config::load_from(f.path()).unwrap();
4348 assert!(c.is_enabled(VendorId::Kimi));
4349 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
4350 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
4351 }
4352
4353 #[test]
4354 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
4355 let f = write_toml(
4356 r#"
4357 [deepseek]
4358 enabled = true
4359 api_key = "sk-ds"
4360
4361 [kimi]
4362 enabled = true
4363 api_key = "sk-kimi"
4364 "#,
4365 );
4366 let c = Config::load_from(f.path()).unwrap();
4367 assert_eq!(
4368 c.enabled_vendors(),
4369 vec![
4370 VendorId::Anthropic,
4371 VendorId::Openai,
4372 VendorId::Zai,
4373 VendorId::Openrouter,
4374 VendorId::Deepseek,
4375 VendorId::Kimi,
4376 ]
4377 );
4378 }
4379
4380 #[test]
4381 fn parses_anthropic_accounts_and_looks_them_up() {
4382 let f = write_toml(
4383 r#"
4384 [anthropic]
4385 enabled = true
4386
4387 [[anthropic.accounts]]
4388 label = "personal"
4389 credentials_path = "/creds/personal.json"
4390
4391 [[anthropic.accounts]]
4392 label = "work"
4393 credentials_path = "/creds/work.json"
4394 "#,
4395 );
4396 let c = Config::load_from(f.path()).unwrap();
4397 assert_eq!(c.anthropic.accounts.len(), 2);
4398 let work = c.anthropic.account("work").unwrap();
4399 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
4400 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
4402 assert!(err.contains("missing") && err.contains("work"), "{err}");
4403 }
4404
4405 #[test]
4406 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
4407 let f = write_toml(
4408 r#"
4409 [[anthropic.accounts]]
4410 label = "work"
4411 credentials_path = "/creds/work-one.json"
4412
4413 [[anthropic.accounts]]
4414 label = "work"
4415 credentials_path = "/creds/work-two.json"
4416 "#,
4417 );
4418 let err = Config::load_from(f.path()).unwrap_err().to_string();
4419 assert!(
4420 err.contains("duplicate anthropic account label \"work\""),
4421 "{err}"
4422 );
4423 }
4424
4425 #[test]
4426 fn account_label_rejects_path_like_names() {
4427 let cfg = AnthropicConfig::default();
4428 for bad in [
4429 "",
4430 ".",
4431 "..",
4432 "a/b",
4433 r"a\b",
4434 "C:work",
4435 "line\nbreak",
4436 "tab\tname",
4437 "usage.json",
4438 ".stale",
4439 ".last_error",
4440 ".fetch.lock",
4441 ] {
4442 let err = cfg.account(bad).unwrap_err();
4443 assert!(
4444 format!("{err:?}").contains("invalid anthropic account label"),
4445 "{bad:?} should be rejected as a label"
4446 );
4447 }
4448 }
4449
4450 #[test]
4451 fn anthropic_accounts_default_to_empty() {
4452 assert!(Config::default().anthropic.accounts.is_empty());
4455 assert!(Config::default().anthropic.accounts_dir.is_none());
4456 }
4457
4458 fn seed_account_dir(root: &std::path::Path, label: &str) {
4464 let dir = root.join(label);
4465 std::fs::create_dir_all(&dir).unwrap();
4466 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
4467 }
4468
4469 #[test]
4470 fn discovers_account_dirs_in_claude_config_dir_layout() {
4471 let td = tempfile::tempdir().unwrap();
4472 seed_account_dir(td.path(), "work");
4473 seed_account_dir(td.path(), "personal");
4474 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
4477 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
4479
4480 let cfg = AnthropicConfig {
4481 accounts_dir: Some(td.path().to_path_buf()),
4482 ..Default::default()
4483 };
4484 let all = cfg.all_accounts();
4485 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
4486 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
4487 assert_eq!(
4488 all[2].credentials_path,
4489 td.path().join("work").join(".credentials.json")
4490 );
4491 }
4492
4493 #[test]
4494 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
4495 let td = tempfile::tempdir().unwrap();
4496 seed_account_dir(td.path(), "work");
4497 let cfg = AnthropicConfig {
4498 accounts: vec![AnthropicAccount {
4499 label: "work".into(),
4500 credentials_path: "/explicit/work.json".into(),
4501 }],
4502 accounts_dir: Some(td.path().to_path_buf()),
4503 ..Default::default()
4504 };
4505 let all = cfg.all_accounts();
4506 assert_eq!(all.len(), 1, "no duplicate label");
4507 assert_eq!(
4508 all[0].credentials_path,
4509 std::path::Path::new("/explicit/work.json"),
4510 "explicit entry wins"
4511 );
4512 seed_account_dir(td.path(), "other");
4514 assert_eq!(cfg.account("other").unwrap().label, "other");
4515 }
4516
4517 #[test]
4518 fn missing_accounts_dir_is_silently_empty_not_an_error() {
4519 let cfg = AnthropicConfig {
4520 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
4521 ..Default::default()
4522 };
4523 assert!(cfg.all_accounts().is_empty());
4524 }
4525
4526 #[test]
4527 fn openai_account_auth_paths_are_tilde_expanded_on_load() {
4528 let f = write_toml(
4529 r#"
4530 [[openai.accounts]]
4531 label = "work"
4532 codex_auth_path = "~/.codex-work/auth.json"
4533 "#,
4534 );
4535 let c = Config::load_from(f.path()).unwrap();
4536 let home = crate::cache::home_dir().unwrap();
4537 assert_eq!(
4538 c.openai.accounts[0].codex_auth_path,
4539 home.join(".codex-work/auth.json")
4540 );
4541 }
4542
4543 #[test]
4544 fn accounts_dir_is_tilde_expanded_on_load() {
4545 let f = write_toml(
4546 r#"
4547 [anthropic]
4548 accounts_dir = "~/.config/ai-usagebar/accounts"
4549 "#,
4550 );
4551 let c = Config::load_from(f.path()).unwrap();
4552 let home = crate::cache::home_dir().unwrap();
4553 assert_eq!(
4554 c.anthropic.accounts_dir,
4555 Some(home.join(".config/ai-usagebar/accounts"))
4556 );
4557 }
4558
4559 #[test]
4560 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
4561 let f = write_toml(
4562 r#"
4563 [anthropic]
4564 desktop_profiles_dir = "~/.claude-acc/profiles"
4565 "#,
4566 );
4567 let c = Config::load_from(f.path()).unwrap();
4568 let home = crate::cache::home_dir().unwrap();
4569 assert_eq!(
4570 c.anthropic.desktop_profiles_dir,
4571 Some(home.join(".claude-acc/profiles"))
4572 );
4573 }
4574
4575 #[test]
4576 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
4577 let cfg = AnthropicConfig {
4578 accounts: vec![
4579 AnthropicAccount {
4580 label: "work".into(),
4581 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
4582 },
4583 AnthropicAccount {
4584 label: "personal".into(),
4585 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
4586 },
4587 ],
4588 ..Default::default()
4589 };
4590
4591 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
4592 assert!(
4593 matches!(&idle, CredsTarget::Named { config_dir, .. }
4594 if config_dir == std::path::Path::new("/tmp/accounts/work")),
4595 "{idle:?}"
4596 );
4597
4598 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
4600 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
4601
4602 assert_eq!(idle_cache.dir(), live_cache.dir());
4605 }
4606
4607 #[test]
4608 fn the_live_cli_account_keeps_its_own_slot_while_that_file_is_there() {
4609 let cfg = AnthropicConfig {
4614 accounts: vec![AnthropicAccount {
4615 label: "personal".into(),
4616 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
4617 }],
4618 ..Default::default()
4619 };
4620
4621 let (present, _) = cfg
4622 .account_target_probing("personal", Some("personal"), |_| true)
4623 .unwrap();
4624 assert!(
4625 matches!(&present, CredsTarget::Named { path, .. }
4626 if path == Path::new("/tmp/accounts/personal/.credentials.json")),
4627 "{present:?}"
4628 );
4629
4630 let (moved, _) = cfg
4632 .account_target_probing("personal", Some("personal"), |_| false)
4633 .unwrap();
4634 assert!(matches!(moved, CredsTarget::Default(_)), "{moved:?}");
4635 }
4636
4637 #[test]
4638 fn the_live_cli_accounts_own_file_is_probed_on_disk() {
4639 let creds = NamedTempFile::new().unwrap();
4643 let cfg = AnthropicConfig {
4644 accounts: vec![AnthropicAccount {
4645 label: "personal".into(),
4646 credentials_path: creds.path().to_path_buf(),
4647 }],
4648 ..Default::default()
4649 };
4650 let (target, _) = cfg
4651 .account_target_with("personal", Some("personal"))
4652 .unwrap();
4653 assert!(
4654 matches!(&target, CredsTarget::Named { path, .. } if path == creds.path()),
4655 "read {target:?} instead of the account's own file"
4656 );
4657 }
4658
4659 #[test]
4660 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
4661 let cfg = AnthropicConfig {
4662 accounts: vec![AnthropicAccount {
4663 label: "work".into(),
4664 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
4665 }],
4666 ..Default::default()
4667 };
4668 let (target, _) = cfg.account_target_with("work", None).unwrap();
4669 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
4670 }
4671
4672 fn config_example() -> PathBuf {
4676 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
4677 }
4678
4679 #[test]
4680 fn shipped_example_parses_as_a_real_config() {
4681 let c = Config::load_from(&config_example()).unwrap();
4686 assert!(!c.context.enabled);
4687 assert!(c.is_enabled(VendorId::Anthropic));
4688 assert!(c.is_enabled(VendorId::Openai));
4689 assert!(!c.is_enabled(VendorId::AnthropicApi));
4690 assert!(!c.is_enabled(VendorId::Deepseek));
4691 assert!(!c.is_enabled(VendorId::Kimi));
4692 assert!(!c.is_enabled(VendorId::Kilo));
4693 assert!(!c.is_enabled(VendorId::Novita));
4694 assert!(!c.is_enabled(VendorId::Moonshot));
4695 assert!(!c.is_enabled(VendorId::Grok));
4696 assert!(!c.is_enabled(VendorId::Cursor));
4697 assert!(!c.is_enabled(VendorId::Minimax));
4698 }
4699
4700 #[test]
4701 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
4702 let text = std::fs::read_to_string(config_example()).unwrap();
4707 let live: Vec<&str> = text
4708 .lines()
4709 .map(str::trim)
4710 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
4711 .collect();
4712 assert!(
4713 live.is_empty(),
4714 "admin_key_env must stay commented out while it is inert: {live:?}"
4715 );
4716 assert!(
4719 text.contains("admin_key_env") && text.contains("RESERVED"),
4720 "the example should keep describing admin_key_env as reserved"
4721 );
4722 }
4723
4724 #[test]
4725 fn admin_key_env_is_accepted_but_changes_nothing() {
4726 let f = write_toml(
4730 r#"
4731 [openai]
4732 admin_key_env = "SOME_ADMIN_KEY"
4733 "#,
4734 );
4735 let c = Config::load_from(f.path()).unwrap();
4736 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
4737 let default = OpenAiConfig::default();
4739 assert_eq!(c.openai.enabled, default.enabled);
4740 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
4741 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
4742 }
4743
4744 #[test]
4745 fn config_example_documents_every_vendor_without_secrets() {
4746 let raw = std::fs::read_to_string(config_example()).unwrap();
4747 let cfg = Config::load_from(&config_example()).unwrap();
4748 for id in VendorId::all() {
4751 let section = id.slug();
4752 assert!(
4753 raw.contains(&format!("[{section}]")),
4754 "config.example.toml has no [{section}] section"
4755 );
4756 }
4757
4758 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
4761 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
4762 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
4763 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
4764 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
4765 assert!(!cfg.supergrok.enabled);
4766 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
4767 assert_eq!(
4768 cfg.supergrok
4769 .grok_binary
4770 .file_name()
4771 .and_then(|p| p.to_str()),
4772 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
4773 );
4774 assert!(cfg.supergrok.auth_path.is_none());
4775 assert!(cfg.supergrok.config_path.is_none());
4776 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
4777 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
4778 }
4779
4780 #[test]
4781 fn supergrok_binary_must_not_be_empty() {
4782 let file = write_toml(
4783 r#"
4784 [supergrok]
4785 enabled = true
4786 grok_binary = ""
4787 "#,
4788 );
4789 let error = Config::load_from(file.path()).unwrap_err().to_string();
4790 assert!(error.contains("grok_binary must not be empty"));
4791 }
4792
4793 #[test]
4794 fn supergrok_paths_are_tilde_expanded() {
4795 let file = write_toml(
4796 r#"
4797 [supergrok]
4798 grok_binary = "~/bin/grok"
4799 auth_path = "~/.grok/auth.json"
4800 config_path = "~/.grok/config.toml"
4801 "#,
4802 );
4803 let config = Config::load_from(file.path()).unwrap();
4804 let home = crate::cache::home_dir().unwrap();
4805 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
4806 assert_eq!(
4807 config.supergrok.auth_path,
4808 Some(home.join(".grok/auth.json"))
4809 );
4810 assert_eq!(
4811 config.supergrok.config_path,
4812 Some(home.join(".grok/config.toml"))
4813 );
4814 }
4815
4816 #[test]
4819 fn commandcode_and_copilot_paths_are_tilde_expanded() {
4820 let file = write_toml(
4821 r#"
4822 [commandcode]
4823 auth_paths = ["~/.commandcode/auth.json", "/etc/commandcode/auth.json"]
4824
4825 [copilot]
4826 gh_binary = "~/bin/gh"
4827 "#,
4828 );
4829 let config = Config::load_from(file.path()).unwrap();
4830 let home = crate::cache::home_dir().unwrap();
4831 assert_eq!(
4832 config.commandcode.auth_paths,
4833 Some(vec![
4834 home.join(".commandcode/auth.json"),
4835 PathBuf::from("/etc/commandcode/auth.json"),
4836 ])
4837 );
4838 assert_eq!(config.copilot.gh_binary, Some(home.join("bin/gh")));
4839 }
4840
4841 #[test]
4842 fn kiro_db_path_is_tilde_expanded() {
4843 let f = write_toml(
4844 r#"
4845 [kiro]
4846 db_path = "~/kiro-data.sqlite3"
4847 "#,
4848 );
4849 let c = Config::load_from(f.path()).unwrap();
4850 let home = crate::cache::home_dir().unwrap();
4851 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
4852 }
4853
4854 #[test]
4855 fn kiro_appears_when_enabled() {
4856 let f = write_toml(
4857 r#"
4858 [kiro]
4859 enabled = true
4860 "#,
4861 );
4862 let c = Config::load_from(f.path()).unwrap();
4863 assert!(c.is_enabled(VendorId::Kiro));
4864 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
4865 }
4866
4867 #[test]
4868 fn cursor_db_path_is_tilde_expanded() {
4869 let f = write_toml(
4870 r#"
4871 [cursor]
4872 db_path = "~/cursor-state.vscdb"
4873 "#,
4874 );
4875 let c = Config::load_from(f.path()).unwrap();
4876 let home = crate::cache::home_dir().unwrap();
4877 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
4878 }
4879
4880 #[test]
4881 fn cursor_agent_auth_path_is_tilde_expanded() {
4882 let f = write_toml(
4883 r#"
4884 [cursor]
4885 agent_auth_path = "~/cursor-agent-auth.json"
4886 "#,
4887 );
4888 let c = Config::load_from(f.path()).unwrap();
4889 let home = crate::cache::home_dir().unwrap();
4890 assert_eq!(
4891 c.cursor.agent_auth_path,
4892 Some(home.join("cursor-agent-auth.json"))
4893 );
4894 }
4895
4896 #[test]
4897 fn cursor_appears_when_enabled() {
4898 let f = write_toml(
4899 r#"
4900 [cursor]
4901 enabled = true
4902 "#,
4903 );
4904 let c = Config::load_from(f.path()).unwrap();
4905 assert!(c.is_enabled(VendorId::Cursor));
4906 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
4907 }
4908
4909 #[test]
4910 fn add_account_appends_and_preserves_existing() {
4911 let mut doc: toml_edit::DocumentMut = r#"
4912# keep me
4913[anthropic]
4914enabled = true
4915
4916[[anthropic.accounts]]
4917label = "personal"
4918credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
4919"#
4920 .parse()
4921 .unwrap();
4922 add_anthropic_account_to_doc(
4923 &mut doc,
4924 "work",
4925 "~/.config/ai-usagebar/accounts/work/.credentials.json",
4926 )
4927 .unwrap();
4928 let rendered = doc.to_string();
4929 assert!(rendered.contains("# keep me"), "comment must survive");
4930 let f = write_toml(&rendered);
4932 let c = Config::load_from(f.path()).unwrap();
4933 let labels: Vec<&str> = c
4934 .anthropic
4935 .accounts
4936 .iter()
4937 .map(|a| a.label.as_str())
4938 .collect();
4939 assert_eq!(labels, vec!["personal", "work"]);
4940 }
4941
4942 #[test]
4943 fn add_account_to_empty_doc_is_loadable() {
4944 let mut doc = toml_edit::DocumentMut::new();
4945 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
4946 let f = write_toml(&doc.to_string());
4947 let c = Config::load_from(f.path()).unwrap();
4948 assert_eq!(c.anthropic.accounts.len(), 1);
4949 assert_eq!(c.anthropic.accounts[0].label, "solo");
4950 }
4951
4952 #[test]
4953 fn add_account_rejects_duplicate_label() {
4954 let mut doc: toml_edit::DocumentMut = r#"
4955[[anthropic.accounts]]
4956label = "work"
4957credentials_path = "~/w/.credentials.json"
4958"#
4959 .parse()
4960 .unwrap();
4961 assert!(
4962 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
4963 "a duplicate label must be rejected, not appended"
4964 );
4965 }
4966
4967 #[test]
4968 fn add_account_rejects_bad_label() {
4969 let mut doc = toml_edit::DocumentMut::new();
4970 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
4971 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
4972 }
4973
4974 #[test]
4975 fn tildify_collapses_home_only() {
4976 let home = Path::new("/Users/me");
4977 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
4978 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
4979 }
4980
4981 #[test]
4982 fn default_account_credentials_path_nests_under_config_dir() {
4983 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
4984 assert_eq!(
4985 default_account_credentials_path(cfg, "work"),
4986 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
4987 );
4988 }
4989 const CUSTOM_BLOCK: &str = r#"
4992[[custom]]
4993id = "mytool"
4994name = "My Tool"
4995short_name = "myt"
4996enabled = true
4997url = "https://api.example.test/v1/usage"
4998api_key_env = "MYTOOL_API_KEY"
4999auth_header = "Authorization"
5000auth_scheme = "Bearer"
5001plan = "Pro"
5002cache_ttl_secs = 120
5003[custom.headers]
5004X-Org = "org_1"
5005[[custom.metrics]]
5006label = "Requests"
5007used = "/requests/used"
5008limit = "/requests/limit"
5009resets_at = "/requests/reset"
5010window_secs = 3600
5011[[custom.texts]]
5012label = "Tier"
5013value = "/tier"
5014"#;
5015
5016 fn custom_with(from: &str, to: &str) -> String {
5017 assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
5018 CUSTOM_BLOCK.replace(from, to)
5019 }
5020
5021 fn custom_error(toml: &str) -> String {
5022 Config::load_from(write_toml(toml).path())
5023 .unwrap_err()
5024 .to_string()
5025 }
5026
5027 fn assert_custom_rejected(toml: &str, needle: &str) {
5028 let msg = custom_error(toml);
5029 assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
5030 assert!(
5031 msg.contains("[[custom]]"),
5032 "the error must locate the section: {msg}"
5033 );
5034 }
5035
5036 #[test]
5037 fn custom_block_parses_every_field() {
5038 let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
5039 assert_eq!(config.custom.len(), 1);
5040 let c = &config.custom[0];
5041 assert_eq!(c.id, "mytool");
5042 assert_eq!(c.name, "My Tool");
5043 assert_eq!(c.short_name, "myt");
5044 assert_eq!(
5045 c.brand, None,
5046 "a custom provider has no mark unless it asks"
5047 );
5048 assert!(c.enabled);
5049 assert_eq!(c.url, "https://api.example.test/v1/usage");
5050 assert!(!c.allow_http);
5051 assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
5052 assert_eq!(c.api_key, None);
5053 assert_eq!(c.auth_header, "Authorization");
5054 assert_eq!(c.auth_scheme, "Bearer");
5055 assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
5056 assert_eq!(c.plan.as_deref(), Some("Pro"));
5057 assert_eq!(c.plan_path, None);
5058 assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
5059 assert_eq!(c.metrics.len(), 1);
5060 assert_eq!(c.metrics[0].label, "Requests");
5061 assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
5062 assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
5063 assert_eq!(c.metrics[0].percent, None);
5064 assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
5065 assert_eq!(c.metrics[0].window_secs, Some(3600));
5066 assert_eq!(c.texts.len(), 1);
5067 assert_eq!(c.texts[0].label, "Tier");
5068 assert_eq!(c.texts[0].value, "/tier");
5069 assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
5070 }
5071
5072 #[test]
5073 fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
5074 let config: Config = toml::from_str(
5075 r#"
5076 [[custom]]
5077 id = "bare"
5078 short_name = "bre"
5079 url = "https://example.test/u"
5080 [[custom.metrics]]
5081 label = "Q"
5082 percent = "/pct"
5083 "#,
5084 )
5085 .unwrap();
5086 let c = &config.custom[0];
5087 assert_eq!(c.name, "bare", "name must default to id on a plain parse");
5088 assert!(!c.enabled);
5089 assert!(!c.allow_http);
5090 assert_eq!(c.api_key_env, "");
5091 assert_eq!(c.auth_header, "Authorization");
5092 assert_eq!(c.auth_scheme, "Bearer");
5093 assert_eq!(c.cache_ttl_secs, 60);
5094 assert!(config.validate().is_ok());
5095 assert!(Config::default().custom.is_empty());
5096 }
5097
5098 #[test]
5099 fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
5100 let config = Config::load_from(
5101 write_toml(&custom_with(
5102 r#"short_name = "myt""#,
5103 "short_name = \"myt\"\nbrand = \"opencode-go\"",
5104 ))
5105 .path(),
5106 )
5107 .unwrap();
5108 assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
5109
5110 for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
5114 assert_custom_rejected(
5115 &custom_with(
5116 r#"short_name = "myt""#,
5117 &format!("short_name = \"myt\"\nbrand = {brand:?}"),
5118 ),
5119 "must name a built-in vendor",
5120 );
5121 }
5122 }
5123
5124 #[test]
5125 fn custom_rejects_a_malformed_id() {
5126 let long = "a".repeat(33);
5127 for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
5128 let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
5129 assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
5130 assert!(msg.contains("must match"), "{id:?}: {msg}");
5131 }
5132 }
5133
5134 #[test]
5135 fn custom_rejects_a_builtin_slug_as_id() {
5136 assert_custom_rejected(
5137 &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
5138 "is a built-in vendor",
5139 );
5140 assert_custom_rejected(
5141 &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
5142 "is a built-in vendor",
5143 );
5144 }
5145
5146 #[test]
5147 fn custom_rejects_duplicate_ids() {
5148 let twice = format!(
5149 "{}{}",
5150 CUSTOM_BLOCK,
5151 custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
5152 );
5153 assert_custom_rejected(&twice, "duplicate id");
5154 }
5155
5156 #[test]
5157 fn custom_rejects_a_name_over_48_chars() {
5158 let long = "n".repeat(49);
5159 assert_custom_rejected(
5160 &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
5161 "name must be 1 to 48 characters",
5162 );
5163 }
5164
5165 #[test]
5166 fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
5167 for short in ["my", "myto", "MYT", "m1t"] {
5168 assert_custom_rejected(
5169 &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
5170 "exactly 3 lowercase ASCII letters",
5171 );
5172 }
5173 }
5174
5175 #[test]
5176 fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
5177 assert_custom_rejected(
5178 &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
5179 "already used by a built-in vendor",
5180 );
5181 let twice = format!(
5182 "{}{}",
5183 CUSTOM_BLOCK,
5184 custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
5185 );
5186 assert_custom_rejected(&twice, "already used by a built-in vendor");
5187 }
5188
5189 #[test]
5190 fn custom_rejects_http_unless_allowed() {
5191 let plain = custom_with(
5192 r#"url = "https://api.example.test/v1/usage""#,
5193 r#"url = "http://localhost:8080/usage""#,
5194 );
5195 assert_custom_rejected(&plain, "url must use https://");
5196 let allowed = plain.replace(
5197 r#"url = "http://localhost:8080/usage""#,
5198 "url = \"http://localhost:8080/usage\"\nallow_http = true",
5199 );
5200 assert!(
5201 Config::load_from(write_toml(&allowed).path()).is_ok(),
5202 "allow_http must permit http://"
5203 );
5204 }
5205
5206 #[test]
5207 fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
5208 assert_custom_rejected(
5209 &custom_with(
5210 r#"url = "https://api.example.test/v1/usage""#,
5211 r#"url = "https://user:pw@api.example.test/v1/usage""#,
5212 ),
5213 "must not carry credentials",
5214 );
5215 assert_custom_rejected(
5216 &custom_with(
5217 r#"url = "https://api.example.test/v1/usage""#,
5218 r#"url = "not a url""#,
5219 ),
5220 "is not a valid URL",
5221 );
5222 assert_custom_rejected(
5223 &custom_with(
5224 r#"url = "https://api.example.test/v1/usage""#,
5225 r#"url = "ftp://api.example.test/v1/usage""#,
5226 ),
5227 "is not http or https",
5228 );
5229 }
5230
5231 #[test]
5232 fn custom_rejects_an_invalid_api_key_env() {
5233 assert_custom_rejected(
5234 &custom_with(
5235 r#"api_key_env = "MYTOOL_API_KEY""#,
5236 r#"api_key_env = "1BAD-NAME""#,
5237 ),
5238 "is not a valid environment variable name",
5239 );
5240 let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
5241 assert!(
5242 Config::load_from(write_toml(&none).path()).is_ok(),
5243 "an empty api_key_env means inline-only and is valid"
5244 );
5245 }
5246
5247 #[test]
5251 fn custom_invalid_api_key_env_is_not_repeated() {
5252 let msg = custom_error(&custom_with(
5253 r#"api_key_env = "MYTOOL_API_KEY""#,
5254 r#"api_key_env = "sk-live-pasted-secret""#,
5255 ));
5256 assert!(msg.contains("api_key_env"), "{msg}");
5257 assert!(!msg.contains("sk-live-pasted-secret"), "{msg}");
5258 }
5259
5260 #[test]
5261 fn custom_rejects_an_invalid_auth_header_name() {
5262 assert_custom_rejected(
5263 &custom_with(
5264 r#"auth_header = "Authorization""#,
5265 r#"auth_header = "X Api Key""#,
5266 ),
5267 "auth_header \"X Api Key\" is not a valid HTTP header name",
5268 );
5269 }
5270
5271 #[test]
5272 fn custom_rejects_a_control_char_in_auth_scheme() {
5273 assert_custom_rejected(
5274 &custom_with(
5275 r#"auth_scheme = "Bearer""#,
5276 "auth_scheme = \"Bearer\\u0007\"",
5277 ),
5278 "auth_scheme contains characters that are not valid",
5279 );
5280 let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
5281 assert!(
5282 Config::load_from(write_toml(&bare).path()).is_ok(),
5283 "an empty scheme (bare key) is valid"
5284 );
5285 }
5286
5287 #[test]
5288 fn custom_rejects_a_bad_extra_header() {
5289 assert_custom_rejected(
5290 &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
5291 "headers must not repeat auth_header",
5292 );
5293 assert_custom_rejected(
5294 &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
5295 "is not a valid HTTP header name",
5296 );
5297 assert_custom_rejected(
5298 &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
5299 "has a value that is not valid in an HTTP header",
5300 );
5301 }
5302
5303 #[test]
5304 fn custom_rejects_cache_ttl_outside_10_to_3600() {
5305 for ttl in ["9", "3601"] {
5306 assert_custom_rejected(
5307 &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
5308 "cache_ttl_secs must be between 10 and 3600",
5309 );
5310 }
5311 }
5312
5313 #[test]
5314 fn custom_rejects_an_entry_with_no_metrics_or_texts() {
5315 let toml = r#"
5316[[custom]]
5317id = "empty"
5318short_name = "emp"
5319url = "https://example.test/u"
5320"#;
5321 assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
5322 }
5323
5324 #[test]
5325 fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
5326 assert_custom_rejected(
5327 &custom_with(
5328 r#"limit = "/requests/limit""#,
5329 "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
5330 ),
5331 "must set `percent`, or both `used` and `limit`",
5332 );
5333 assert_custom_rejected(
5334 &custom_with("limit = \"/requests/limit\"\n", ""),
5335 "must set `percent`, or both `used` and `limit`",
5336 );
5337 }
5338
5339 #[test]
5340 fn custom_rejects_a_pointer_without_a_leading_slash() {
5341 assert_custom_rejected(
5342 &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
5343 "used \"requests.used\" must be an RFC 6901 JSON Pointer",
5344 );
5345 assert_custom_rejected(
5346 &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
5347 "value \"tier\" must be an RFC 6901 JSON Pointer",
5348 );
5349 assert_custom_rejected(
5350 &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
5351 "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
5352 );
5353 assert_custom_rejected(
5354 &custom_with(
5355 r#"resets_at = "/requests/reset""#,
5356 "resets_at = \"/re\\u001bset\"",
5357 ),
5358 "resets_at",
5359 );
5360 }
5361
5362 #[test]
5363 fn custom_rejects_a_label_outside_1_to_64_chars() {
5364 let long = "l".repeat(65);
5365 assert_custom_rejected(
5366 &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
5367 "metric label",
5368 );
5369 assert_custom_rejected(
5370 &custom_with(r#"label = "Tier""#, r#"label = """#),
5371 "text label \"\" must be 1 to 64 characters",
5372 );
5373 }
5374
5375 #[test]
5376 fn custom_rejects_window_secs_under_60() {
5377 assert_custom_rejected(
5378 &custom_with("window_secs = 3600", "window_secs = 59"),
5379 "window_secs must be at least 60",
5380 );
5381 }
5382
5383 #[test]
5384 fn custom_rejects_duplicate_metric_and_text_labels() {
5385 let metric_twice = custom_with(
5386 "window_secs = 3600\n",
5387 "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
5388 );
5389 assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
5390 let text_twice =
5391 format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
5392 assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
5393 }
5394
5395 #[test]
5396 fn enabled_custom_and_custom_by_id_select_entries() {
5397 let two = format!(
5398 "{}{}",
5399 CUSTOM_BLOCK,
5400 custom_with(r#"id = "mytool""#, r#"id = "off""#)
5401 .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
5402 .replace("enabled = true", "enabled = false")
5403 );
5404 let config = Config::load_from(write_toml(&two).path()).unwrap();
5405 let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
5406 assert_eq!(enabled, ["mytool"]);
5407 assert_eq!(
5408 config.custom_by_id("off").map(|c| c.name.as_str()),
5409 Some("My Tool")
5410 );
5411 assert!(config.custom_by_id("nope").is_none());
5412 }
5413
5414 #[cfg(unix)]
5415 #[test]
5416 fn has_inline_secrets_sees_a_custom_inline_key() {
5417 let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
5418 assert!(!without.has_inline_secrets());
5419 let with: Config = toml::from_str(&custom_with(
5420 r#"api_key_env = "MYTOOL_API_KEY""#,
5421 "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
5422 ))
5423 .unwrap();
5424 assert!(with.has_inline_secrets());
5425 }
5426
5427 #[test]
5428 fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
5429 let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
5430 let mut spec = CustomProviderConfig {
5431 id: "mytool".into(),
5432 api_key_env: var.into(),
5433 api_key: Some("sk-inline-secret".into()),
5434 ..CustomProviderConfig::default()
5435 };
5436 unsafe { std::env::set_var(var, "sk-env-secret") };
5437 let from_env = spec.resolve_api_key();
5438 unsafe { std::env::remove_var(var) };
5439 assert_eq!(from_env.unwrap(), "sk-env-secret");
5440
5441 assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
5442
5443 spec.api_key = Some(String::new());
5444 let err = spec.resolve_api_key().unwrap_err();
5445 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
5446 let msg = err.to_string();
5447 assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
5448 assert!(msg.contains(var), "{msg}");
5449 assert!(!msg.contains("secret"), "{msg}");
5450
5451 spec.api_key_env = String::new();
5452 let msg = spec.resolve_api_key().unwrap_err().to_string();
5453 assert!(msg.contains("set `api_key`"), "{msg}");
5454 }
5455
5456 #[test]
5457 fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
5458 let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
5459 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
5460 let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
5461 Config::load_from(file.path()).unwrap();
5462 assert!(
5463 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
5464 "a custom provider's env var must be scrubbed from subprocesses"
5465 );
5466 }
5467
5468 #[test]
5469 fn loading_a_config_registers_account_and_renamed_env_vars_for_scrubbing() {
5470 let account = "AI_USAGEBAR_ACCOUNT_SCRUB_TEST_4C2E";
5471 let renamed = "AI_USAGEBAR_RENAMED_SCRUB_TEST_7A3F";
5472 let before = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
5473 assert!(!before.contains(&account));
5474 assert!(!before.contains(&renamed));
5475 let file = write_toml(&format!(
5476 "[zai]\napi_key_env = \"{renamed}\"\n\
5477 [[deepseek.accounts]]\nlabel = \"work\"\napi_key_env = \"{account}\"\n"
5478 ));
5479 Config::load_from(file.path()).unwrap();
5480 let after = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
5481 assert!(after.contains(&account), "a named account's key variable");
5482 assert!(after.contains(&renamed), "a renamed api_key_env");
5483 }
5484
5485 #[test]
5486 fn loading_a_config_registers_openrouter_management_env_vars_for_scrubbing() {
5487 let renamed = "AI_USAGEBAR_MGMT_RENAMED_SCRUB_TEST_5D1B";
5488 let account = "AI_USAGEBAR_MGMT_ACCOUNT_SCRUB_TEST_8E6C";
5489 let before = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
5490 assert!(!before.contains(&renamed));
5491 assert!(!before.contains(&account));
5492 let file = write_toml(&format!(
5493 "[openrouter]\nmanagement_api_key_env = \"{renamed}\"\n\
5494 [[openrouter.accounts]]\nlabel = \"work\"\napi_key_env = \"OR_WORK_KEY\"\n\
5495 management_api_key_env = \"{account}\"\n"
5496 ));
5497 Config::load_from(file.path()).unwrap();
5498 let after = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
5499 assert!(after.contains(&renamed), "a renamed management_api_key_env");
5500 assert!(
5501 after.contains(&account),
5502 "an account's management_api_key_env"
5503 );
5504 }
5505
5506 #[test]
5511 fn every_config_section_parses_to_its_vendors_enabled_switch() {
5512 for vendor in VendorId::all() {
5513 let text = format!(
5514 "[{}]
5515enabled = true
5516",
5517 vendor.config_section()
5518 );
5519 let config: Config = toml::from_str(&text)
5520 .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
5521 assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
5522 let others = VendorId::all()
5523 .iter()
5524 .filter(|other| *other != vendor && config.is_enabled(**other))
5525 .count();
5526 assert_eq!(
5527 others,
5528 Config::default().enabled_vendors().len()
5529 - usize::from(Config::default().is_enabled(*vendor)),
5530 "[{}] enabled a different vendor",
5531 vendor.config_section()
5532 );
5533 }
5534 }
5535
5536 #[test]
5537 fn tray_section_parses_and_defaults_to_notify() {
5538 let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
5539 let config = Config::load_from(file.path()).unwrap();
5540 assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
5541 assert_eq!(config.tray.updates(), UpdateMode::Auto);
5542
5543 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
5544 assert_eq!(empty.tray, TrayConfig::default());
5545 assert_eq!(empty.tray.updates(), UpdateMode::Notify);
5546 assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
5547 assert_eq!(UpdateMode::parse("weekly"), None);
5548 assert_eq!(UpdateMode::Auto.as_str(), "auto");
5549 }
5550
5551 #[test]
5552 fn tray_ignores_removed_menu_bar_keys_for_back_compatibility() {
5553 let legacy = write_toml(
5554 "[tray]\nmenu_bar_show_all = false\nmenu_bar_hide_value = true\nmenu_bar_names = \"short\"\nmenu_bar_provider = \"anthropic\"\nmenu_bar_window = \"weekly\"\n",
5555 );
5556 let config = Config::load_from(legacy.path()).unwrap();
5557 assert_eq!(config.tray, TrayConfig::default());
5558 }
5559
5560 #[test]
5561 fn tray_section_rejects_a_misspelled_mode() {
5562 let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
5563 assert!(Config::load_from(file.path()).is_err());
5564 }
5565
5566 #[test]
5567 fn tray_refresh_minutes_defaults_to_five_and_parses() {
5568 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
5569 assert_eq!(empty.tray.refresh_minutes, None);
5570 assert_eq!(empty.tray.refresh_minutes(), 5);
5571
5572 let file = write_toml("[tray]\nrefresh_minutes = 10\n");
5573 let config = Config::load_from(file.path()).unwrap();
5574 assert_eq!(config.tray.refresh_minutes(), 10);
5575 }
5576
5577 #[test]
5578 fn tray_refresh_minutes_rejects_values_outside_the_menu() {
5579 for minutes in ["3", "0"] {
5580 let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
5581 let error = Config::load_from(file.path()).unwrap_err().to_string();
5582 assert!(error.contains("[tray] refresh_minutes"), "{error}");
5583 assert!(error.contains("1, 5 or 10"), "{error}");
5584 }
5585 }
5586
5587 #[test]
5588 fn notifications_default_on_at_97_and_parse_overrides() {
5589 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
5590 assert!(empty.notifications.enabled);
5591 assert_eq!(empty.notifications.threshold, 97);
5592
5593 let file = write_toml("[notifications]\nenabled = false\nthreshold = 100\n");
5594 let config = Config::load_from(file.path()).unwrap();
5595 assert!(!config.notifications.enabled);
5596 assert_eq!(config.notifications.threshold, 100);
5597 }
5598
5599 #[test]
5600 fn notifications_threshold_rejects_values_outside_1_to_100() {
5601 for threshold in ["0", "101", "255"] {
5602 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
5603 let error = Config::load_from(file.path()).unwrap_err().to_string();
5604 assert!(
5605 error.contains("[notifications] threshold must be between 1 and 100"),
5606 "threshold {threshold}: {error}"
5607 );
5608 }
5609 for threshold in ["1", "50", "100"] {
5611 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
5612 let config = Config::load_from(file.path()).unwrap();
5613 assert_eq!(config.notifications.threshold.to_string(), threshold);
5614 }
5615 }
5616
5617 #[test]
5618 fn notification_preferences_round_trip_without_changing_other_sections() {
5619 let dir = tempfile::tempdir().unwrap();
5620 let path = dir.path().join("config.toml");
5621 std::fs::write(&path, "# keep this\n[tray]\nrefresh_minutes = 10\n").unwrap();
5622 set_notification_value(&path, "enabled", false.into()).unwrap();
5623 set_notification_value(&path, "threshold", 85i64.into()).unwrap();
5624 let text = std::fs::read_to_string(&path).unwrap();
5625 assert!(text.contains("# keep this\n[tray]\nrefresh_minutes = 10"));
5626 let config = Config::load_from(&path).unwrap();
5627 assert!(!config.notifications.enabled);
5628 assert_eq!(config.notifications.threshold, 85);
5629 assert!(set_notification_value(&path, "threshold", 101i64.into()).is_err());
5630 assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
5631 }
5632
5633 #[test]
5638 fn vendor_enabled_round_trips_and_rejects_unknown_slugs() {
5639 let dir = tempfile::tempdir().unwrap();
5640 let path = dir.path().join("config.toml");
5641 std::fs::write(&path, "# keep\n[zai]\nenabled = true # mine\n").unwrap();
5642
5643 set_vendor_enabled(&path, "zai", false).unwrap();
5644 let text = std::fs::read_to_string(&path).unwrap();
5645 assert!(text.starts_with("# keep\n"), "{text}");
5646 assert!(text.contains("enabled = false # mine"), "{text}");
5647 assert!(!config_enabled(&path, VendorId::Zai));
5648
5649 set_vendor_enabled(&path, "grok", true).unwrap();
5650 let config = Config::load_from(&path).unwrap();
5651 assert!(config.is_enabled(VendorId::Grok));
5652 assert!(
5653 !config.is_enabled(VendorId::Deepseek),
5654 "only the named vendor moves; untouched opt-in vendors stay off"
5655 );
5656 assert!(
5657 config.is_enabled(VendorId::Openai),
5658 "an untouched default-on vendor is not switched off either"
5659 );
5660
5661 for bad in ["", "custom", "not-a-vendor", "anthropic "] {
5662 let error = set_vendor_enabled(&path, bad, true)
5663 .unwrap_err()
5664 .to_string();
5665 assert!(error.contains("unknown provider"), "{bad}: {error}");
5666 }
5667 assert!(
5669 std::fs::read_to_string(&path)
5670 .unwrap()
5671 .contains("enabled = false # mine")
5672 );
5673 }
5674
5675 #[test]
5678 fn set_vendor_enabled_in_doc_targets_the_section_and_stays_idempotent() {
5679 let dir = tempfile::tempdir().unwrap();
5680 let path = dir.path().join("config.toml");
5681 std::fs::write(&path, "[grok]\nenabled = true\napi_key = \"k\"\n").unwrap();
5682 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
5683
5684 set_vendor_enabled(&path, "grok", true).unwrap();
5685
5686 assert_eq!(
5687 std::fs::read_to_string(&path).unwrap(),
5688 "[grok]\nenabled = true\napi_key = \"k\"\n"
5689 );
5690 assert_eq!(
5691 std::fs::metadata(&path).unwrap().modified().unwrap(),
5692 before
5693 );
5694
5695 let mut doc = toml_edit::DocumentMut::new();
5696 set_vendor_enabled_in_doc(&mut doc, "opencode-go", true).unwrap();
5697 assert_eq!(doc.to_string(), "[opencode-go]\nenabled = true\n");
5698 assert!(set_vendor_enabled_in_doc(&mut doc, "mytool", true).is_err());
5699 }
5700
5701 fn config_enabled(path: &std::path::Path, vendor: VendorId) -> bool {
5702 Config::load_from(path).unwrap().is_enabled(vendor)
5703 }
5704
5705 #[test]
5706 fn menu_bar_short_name_defaults_on_and_persists_as_a_bool() {
5707 let dir = tempfile::tempdir().unwrap();
5708 let path = dir.path().join("config.toml");
5709 std::fs::write(&path, "[tray]\n").unwrap();
5710 assert!(Config::load_from(&path).unwrap().tray.menu_bar_short_name());
5711
5712 set_tray_value(&path, "menu_bar_short_name", Some(false.into())).unwrap();
5713 let text = std::fs::read_to_string(&path).unwrap();
5714 assert_eq!(text, "[tray]\nmenu_bar_short_name = false\n");
5715 assert!(!Config::load_from(&path).unwrap().tray.menu_bar_short_name());
5716 }
5717
5718 #[test]
5719 fn set_tray_value_writes_refresh_minutes_as_an_integer() {
5720 let dir = tempfile::tempdir().unwrap();
5721 let path = dir.path().join("config.toml");
5722 std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
5723
5724 set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
5725 let text = std::fs::read_to_string(&path).unwrap();
5726 assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
5727 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
5728
5729 set_tray_value(&path, "refresh_minutes", None).unwrap();
5730 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
5731 }
5732
5733 #[test]
5734 fn set_tray_value_creates_replaces_and_removes_keys() {
5735 let dir = tempfile::tempdir().unwrap();
5736 let path = dir.path().join("config.toml");
5737 std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
5738
5739 set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
5740 let text = std::fs::read_to_string(&path).unwrap();
5741 assert!(text.contains("# primary = \"anthropic\""), "{text}");
5742 assert!(
5743 text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
5744 "{text}"
5745 );
5746
5747 set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
5748 set_tray_value(&path, "updates", Some("off".into())).unwrap();
5749 let config = Config::load_from(&path).unwrap();
5750 assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
5751 assert_eq!(config.tray.updates(), UpdateMode::Off);
5752
5753 set_tray_value(&path, "shortcut", None).unwrap();
5754 let text = std::fs::read_to_string(&path).unwrap();
5755 assert!(!text.contains("shortcut"), "{text}");
5756 assert!(text.contains("updates = \"off\""), "{text}");
5757
5758 let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
5760 set_tray_value(&path, "shortcut", None).unwrap();
5761 assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
5762 }
5763
5764 #[test]
5765 fn set_value_keeps_the_trailing_comment_when_replacing() {
5766 let mut doc: toml_edit::DocumentMut =
5767 "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
5768 set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
5769 assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
5770 }
5771
5772 #[test]
5773 fn enable_vendors_in_creates_a_missing_config() {
5774 let dir = tempfile::TempDir::new().unwrap();
5775 let path = dir.path().join("sub").join("config.toml");
5776
5777 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5778
5779 assert_eq!(
5780 std::fs::read_to_string(&path).unwrap(),
5781 "[grok]
5782enabled = true
5783"
5784 );
5785 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
5786 }
5787
5788 #[test]
5789 fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
5790 let dir = tempfile::TempDir::new().unwrap();
5791 let path = dir.path().join("config.toml");
5792 let original = "# my settings
5793[zai]
5794api_key = \"x\" # keep
5795enabled = false
5796";
5797 std::fs::write(&path, original).unwrap();
5798
5799 enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
5800
5801 let text = std::fs::read_to_string(&path).unwrap();
5802 assert!(
5803 text.starts_with(
5804 "# my settings
5805"
5806 ),
5807 "{text}"
5808 );
5809 assert!(
5810 text.contains(
5811 "api_key = \"x\" # keep
5812"
5813 ),
5814 "{text}"
5815 );
5816 assert!(
5817 text.contains(
5818 "[grok]
5819enabled = true
5820"
5821 ),
5822 "{text}"
5823 );
5824 assert!(
5825 text.contains(
5826 "[opencode-go]
5827enabled = true
5828"
5829 ),
5830 "{text}"
5831 );
5832 let config = Config::load_from(&path).unwrap();
5833 assert!(
5834 !config.is_enabled(VendorId::Zai),
5835 "never widens to false, never flips others"
5836 );
5837 assert!(config.is_enabled(VendorId::Grok));
5838 assert!(config.is_enabled(VendorId::OpenCodeGo));
5839 }
5840
5841 #[test]
5842 fn enable_vendors_in_leaves_an_explicit_false_alone() {
5843 let dir = tempfile::TempDir::new().unwrap();
5844 let path = dir.path().join("config.toml");
5845 let original = "[grok]
5846enabled = false # off
5847api_key = \"k\"
5848";
5849 std::fs::write(&path, original).unwrap();
5850
5851 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5855
5856 assert!(written.is_empty(), "{written:?}");
5857 assert_eq!(
5858 std::fs::read_to_string(&path).unwrap(),
5859 original,
5860 "the file must not be rewritten at all"
5861 );
5862 }
5863
5864 #[test]
5865 fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
5866 let dir = tempfile::TempDir::new().unwrap();
5867 let path = dir.path().join("config.toml");
5868 std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
5869
5870 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5871
5872 assert_eq!(written, vec![VendorId::Grok]);
5873 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
5874 }
5875
5876 #[test]
5877 fn enable_vendors_in_is_textually_idempotent() {
5878 let dir = tempfile::TempDir::new().unwrap();
5879 let path = dir.path().join("config.toml");
5880 let original = "[grok]
5881enabled = true
5882
5883# trailing
5884";
5885 std::fs::write(&path, original).unwrap();
5886 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
5887
5888 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
5889
5890 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
5891 assert_eq!(
5892 std::fs::metadata(&path).unwrap().modified().unwrap(),
5893 before,
5894 "an unchanged document must not be rewritten"
5895 );
5896 }
5897
5898 #[test]
5899 fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
5900 let dir = tempfile::TempDir::new().unwrap();
5901 let path = dir.path().join("config.toml");
5902
5903 enable_vendors_in(&path, &[]).unwrap();
5904
5905 assert!(!path.exists());
5906 }
5907}