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