1use std::collections::{BTreeMap, HashSet};
23use std::path::{Path, PathBuf};
24use std::time::Duration;
25
26#[cfg(unix)]
27use std::os::unix::fs::{MetadataExt, PermissionsExt};
28
29use serde::{Deserialize, Serialize};
30
31use crate::anthropic::creds::CredsTarget;
32use crate::balance::{DisplayPrefs, Headline};
33use crate::cache::Cache;
34use crate::error::{AppError, Result};
35use crate::vendor::VendorId;
36
37#[derive(Debug, Clone, Default, Deserialize, Serialize)]
44#[serde(default, deny_unknown_fields)]
45pub struct Config {
46 pub ui: UiConfig,
47 pub tray: TrayConfig,
48 pub context: ContextConfig,
49 pub anthropic: AnthropicConfig,
50 pub anthropic_api: AnthropicApiConfig,
51 pub openai: OpenAiConfig,
52 pub copilot: CopilotConfig,
53 pub zai: ZaiConfig,
54 pub openrouter: OpenRouterConfig,
55 pub deepseek: DeepseekConfig,
56 pub deepinfra: DeepInfraConfig,
57 pub kimi: KimiConfig,
58 pub kilo: KiloConfig,
59 pub novita: NovitaConfig,
60 pub synthetic: SyntheticConfig,
61 pub moonshot: MoonshotConfig,
62 pub grok: GrokConfig,
63 pub supergrok: SuperGrokConfig,
64 pub grokbot: GrokbotConfig,
65 pub antigravity: AntigravityConfig,
66 pub cursor: CursorConfig,
67 pub minimax: MinimaxConfig,
68 pub kiro: KiroConfig,
69 pub nous: NousConfig,
70 #[serde(rename = "opencode-go")]
71 pub opencode_go: OpenCodeGoConfig,
72 pub commandcode: CommandCodeConfig,
73 pub ollama: OllamaConfig,
74 pub orcarouter: OrcaRouterConfig,
75 pub modelstudio: ModelStudioConfig,
76 pub lyceum: LyceumConfig,
77 pub devin: DevinConfig,
78 pub droid: DroidConfig,
79 #[serde(rename = "muse-code")]
80 pub muse: MuseConfig,
81 pub notifications: NotificationsConfig,
83 pub custom: Vec<CustomProviderConfig>,
85 pub cline: ClineConfig,
86}
87
88#[derive(Debug, Clone, Default, Deserialize, Serialize)]
92#[serde(default)]
93pub struct UiConfig {
94 pub primary: Option<VendorId>,
96 pub overview_vendors: Option<Vec<VendorId>>,
101 pub vendor_box: Option<VendorBoxStyle>,
103}
104
105impl UiConfig {
106 pub fn vendor_box(&self) -> VendorBoxStyle {
107 self.vendor_box.unwrap_or_default()
108 }
109}
110
111#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
115#[serde(default)]
116pub struct TrayConfig {
117 pub shortcut: Option<String>,
120 pub refresh_minutes: Option<u64>,
123 pub updates: Option<UpdateMode>,
125 pub menu_bar_style: Option<String>,
128 pub menu_bar_short_name: Option<bool>,
131}
132
133pub const TRAY_REFRESH_MINUTES: [u64; 6] = [1, 5, 10, 15, 30, 60];
136const DEFAULT_TRAY_REFRESH_MINUTES: u64 = 5;
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
142#[serde(default)]
143pub struct NotificationsConfig {
144 pub enabled: bool,
145 pub threshold: u8,
148}
149
150impl Default for NotificationsConfig {
151 fn default() -> Self {
152 Self {
153 enabled: true,
154 threshold: 97,
155 }
156 }
157}
158
159impl TrayConfig {
160 pub fn refresh_minutes(&self) -> u64 {
161 self.refresh_minutes.unwrap_or(DEFAULT_TRAY_REFRESH_MINUTES)
162 }
163
164 pub fn updates(&self) -> UpdateMode {
165 self.updates.unwrap_or_default()
166 }
167
168 pub fn menu_bar_short_name(&self) -> bool {
169 self.menu_bar_short_name.unwrap_or(true)
170 }
171}
172
173#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
176#[serde(rename_all = "lowercase")]
177pub enum UpdateMode {
178 Auto,
179 #[default]
180 Notify,
181 Off,
182}
183
184impl UpdateMode {
185 pub fn as_str(self) -> &'static str {
186 match self {
187 Self::Auto => "auto",
188 Self::Notify => "notify",
189 Self::Off => "off",
190 }
191 }
192
193 pub fn parse(text: &str) -> Option<Self> {
194 match text.trim().to_ascii_lowercase().as_str() {
195 "auto" => Some(Self::Auto),
196 "notify" => Some(Self::Notify),
197 "off" => Some(Self::Off),
198 _ => None,
199 }
200 }
201}
202
203#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
205#[serde(rename_all = "lowercase")]
206pub enum VendorBoxStyle {
207 #[default]
209 Sidebar,
210 Navbar,
212 None,
214}
215
216#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
219#[serde(rename_all = "lowercase")]
220pub enum ContextLayout {
221 #[default]
223 Full,
224 Split,
226 Bottom,
228}
229
230impl ContextLayout {
231 pub fn next(self) -> Self {
232 match self {
233 ContextLayout::Full => ContextLayout::Split,
234 ContextLayout::Split => ContextLayout::Bottom,
235 ContextLayout::Bottom => ContextLayout::Full,
236 }
237 }
238
239 pub fn label(self) -> &'static str {
240 match self {
241 ContextLayout::Full => "full",
242 ContextLayout::Split => "split",
243 ContextLayout::Bottom => "bottom",
244 }
245 }
246}
247
248#[derive(Debug, Clone, Default, Deserialize, Serialize)]
253#[serde(default)]
254pub struct ContextConfig {
255 pub enabled: bool,
258 pub projects_path: Option<PathBuf>,
260 pub context_window_tokens: Option<u64>,
263 pub model_context_window_tokens: BTreeMap<String, u64>,
266 pub layout: ContextLayout,
268}
269
270impl ContextConfig {
271 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
272 model
273 .and_then(|model| self.model_context_window_tokens.get(model).copied())
274 .filter(|tokens| *tokens > 0)
275 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
276 }
277}
278
279#[derive(Debug, Clone, Deserialize, Serialize)]
280#[serde(default)]
281pub struct AnthropicConfig {
282 pub enabled: bool,
283 pub credentials_path: Option<PathBuf>,
286 pub accounts: Vec<AnthropicAccount>,
290 pub accounts_dir: Option<PathBuf>,
298 pub show_default_account: bool,
304 pub desktop_profiles_dir: Option<PathBuf>,
310 pub desktop_prepare_binary: Option<PathBuf>,
313}
314
315impl Default for AnthropicConfig {
316 fn default() -> Self {
317 Self {
318 enabled: true,
319 credentials_path: None,
320 accounts: Vec::new(),
321 accounts_dir: None,
322 show_default_account: true,
323 desktop_profiles_dir: None,
324 desktop_prepare_binary: None,
325 }
326 }
327}
328
329#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
339pub struct AnthropicAccount {
340 pub label: String,
343 pub credentials_path: PathBuf,
347}
348
349impl AnthropicAccount {
350 pub fn config_dir(&self) -> PathBuf {
355 self.credentials_path
356 .parent()
357 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
358 }
359}
360
361impl AnthropicConfig {
362 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
368 let mut out = self.accounts.clone();
369 if let Some(dir) = &self.accounts_dir {
370 for acct in discover_accounts(dir) {
371 if !out.iter().any(|a| a.label == acct.label) {
372 out.push(acct);
373 }
374 }
375 }
376 out
377 }
378
379 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
384 validate_account_label(label)?;
385 let all = self.all_accounts();
386 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
387 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
388 AppError::Credentials(format!(
389 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
390 known labels: {known:?}"
391 ))
392 })
393 }
394
395 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
406 self.account_target_with(label, self.active_cli_label().as_deref())
407 }
408
409 pub fn active_cli_label(&self) -> Option<String> {
415 crate::anthropic::cli_account::home_claude_json()
416 .ok()
417 .and_then(|path| {
418 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
419 })
420 }
421
422 pub fn default_creds_target(&self) -> CredsTarget {
426 match self.credentials_path.clone() {
427 Some(path) => CredsTarget::Explicit(path),
428 None => {
429 CredsTarget::Default(crate::anthropic::creds::default_path().unwrap_or_default())
430 }
431 }
432 }
433
434 pub fn account_target_with(
442 &self,
443 label: &str,
444 cli_active: Option<&str>,
445 ) -> Result<(CredsTarget, Cache)> {
446 self.account_target_probing(label, cli_active, |path| path.exists())
447 }
448
449 pub fn account_target_probing(
469 &self,
470 label: &str,
471 cli_active: Option<&str>,
472 exists: impl Fn(&Path) -> bool,
473 ) -> Result<(CredsTarget, Cache)> {
474 let account = self.account(label)?;
475 let cache = Cache::for_vendor_account("anthropic", label)?;
476 if cli_active == Some(label) && !exists(&account.credentials_path) {
477 return Ok((
478 CredsTarget::Default(crate::anthropic::creds::default_path()?),
479 cache,
480 ));
481 }
482 Ok((
483 CredsTarget::Named {
484 config_dir: account.config_dir(),
485 path: account.credentials_path,
486 },
487 cache,
488 ))
489 }
490}
491
492pub fn validate_account_label(label: &str) -> Result<()> {
498 validate_account_label_for("anthropic", label)
499}
500
501fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
502 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
503 let bad = label.is_empty()
504 || label == "."
505 || label == ".."
506 || label.contains(['/', '\\'])
507 || label.contains(':')
508 || label.chars().any(char::is_control)
509 || RESERVED.contains(&label);
510 if bad {
511 return Err(AppError::Credentials(format!(
512 "invalid {vendor} account label {label:?}: must be a non-empty name \
513 without path separators, drive prefixes, control characters, or reserved cache names"
514 )));
515 }
516 Ok(())
517}
518
519fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
527 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
528 return Vec::new();
529 };
530 let mut found: Vec<AnthropicAccount> = entries
531 .flatten()
532 .filter_map(|entry| {
533 let path = entry.path();
534 if !path.is_dir() {
535 return None;
536 }
537 let label = path.file_name()?.to_str()?.to_string();
538 validate_account_label(&label).ok()?;
539 Some(AnthropicAccount {
540 label,
541 credentials_path: path.join(".credentials.json"),
542 })
543 })
544 .collect();
545 found.sort_by(|a, b| a.label.cmp(&b.label));
546 found
547}
548
549pub fn tildify(path: &Path, home: &Path) -> String {
553 path.strip_prefix(home)
554 .map(|rest| {
555 let rendered = rest.display().to_string();
556 #[cfg(windows)]
559 let rendered = rendered.replace('\\', "/");
560 format!("~/{rendered}")
561 })
562 .unwrap_or_else(|_| path.display().to_string())
563}
564
565pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
570 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
571 base.join("accounts").join(label).join(".credentials.json")
572}
573
574pub fn add_anthropic_account_to_doc(
580 doc: &mut toml_edit::DocumentMut,
581 label: &str,
582 credentials_path: &str,
583) -> Result<()> {
584 use toml_edit::{Item, Table, value};
585
586 validate_account_label(label)?;
587
588 let anthropic = doc
589 .entry("anthropic")
590 .or_insert_with(|| Item::Table(Table::new()));
591 let anthropic = anthropic
592 .as_table_mut()
593 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
594
595 let accounts = anthropic
596 .entry("accounts")
597 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
598 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
599 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
600 })?;
601
602 let exists = accounts
603 .iter()
604 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
605 if exists {
606 return Err(AppError::Credentials(format!(
607 "anthropic account {label:?} already exists in config.toml"
608 )));
609 }
610
611 let mut table = Table::new();
612 table["label"] = value(label);
613 table["credentials_path"] = value(credentials_path);
614 accounts.push(table);
615 Ok(())
616}
617
618pub fn default_codex_auth_path(home: &Path, label: &str) -> PathBuf {
622 home.join(format!(".codex-{label}")).join("auth.json")
623}
624
625pub fn add_openai_account_to_doc(
630 doc: &mut toml_edit::DocumentMut,
631 label: &str,
632 codex_auth_path: &str,
633) -> Result<()> {
634 use toml_edit::{Item, Table, value};
635
636 validate_account_label_for("openai", label)?;
637
638 let openai = doc
639 .entry("openai")
640 .or_insert_with(|| Item::Table(Table::new()));
641 let openai = openai
642 .as_table_mut()
643 .ok_or_else(|| AppError::Other("[openai] in config.toml is not a table".into()))?;
644
645 let accounts = openai
646 .entry("accounts")
647 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
648 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
649 AppError::Other("[[openai.accounts]] in config.toml is not an array of tables".into())
650 })?;
651
652 if accounts
653 .iter()
654 .any(|t| t.get("label").and_then(Item::as_str) == Some(label))
655 {
656 return Err(AppError::Credentials(format!(
657 "openai account {label:?} already exists in config.toml"
658 )));
659 }
660
661 let mut table = Table::new();
662 table["label"] = value(label);
663 table["codex_auth_path"] = value(codex_auth_path);
664 accounts.push(table);
665 Ok(())
666}
667
668pub(crate) fn set_bool(
672 doc: &mut toml_edit::DocumentMut,
673 section: &str,
674 key: &str,
675 new_value: bool,
676) -> Result<()> {
677 let table = doc
678 .entry(section)
679 .or_insert_with(toml_edit::table)
680 .as_table_like_mut()
681 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
682
683 if let Some(item) = table.get_mut(key)
684 && let Some(v) = item.as_value_mut()
685 {
686 let suffix = v.decor().suffix().cloned();
690 *v = toml_edit::Value::from(new_value);
691 v.decor_mut().set_prefix(" ");
692 if let Some(suffix) = suffix {
693 v.decor_mut().set_suffix(suffix);
694 }
695 return Ok(());
696 }
697 table.insert(key, toml_edit::value(new_value));
698 Ok(())
699}
700
701pub(crate) fn set_value(
707 doc: &mut toml_edit::DocumentMut,
708 section: &str,
709 key: &str,
710 new_value: Option<toml_edit::Value>,
711) -> Result<()> {
712 let table = doc
713 .entry(section)
714 .or_insert_with(toml_edit::table)
715 .as_table_like_mut()
716 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
717
718 let Some(mut new_value) = new_value else {
719 table.remove(key);
720 return Ok(());
721 };
722 if let Some(item) = table.get_mut(key)
723 && let Some(v) = item.as_value_mut()
724 {
725 let suffix = v.decor().suffix().cloned();
726 new_value.decor_mut().set_prefix(" ");
727 if let Some(suffix) = suffix {
728 new_value.decor_mut().set_suffix(suffix);
729 }
730 *v = new_value;
731 return Ok(());
732 }
733 table.insert(key, toml_edit::Item::Value(new_value));
734 Ok(())
735}
736
737pub fn set_tray_value(path: &Path, key: &str, value: Option<toml_edit::Value>) -> Result<()> {
743 edit_config_document(path, |doc| set_value(doc, "tray", key, value))
744}
745
746pub fn set_notification_value(path: &Path, key: &str, value: toml_edit::Value) -> Result<()> {
749 match key {
750 "enabled" if value.as_bool().is_some() => {}
751 "threshold" if value.as_integer().is_some_and(|n| (1..=100).contains(&n)) => {}
752 _ => {
753 return Err(AppError::Other(format!(
754 "invalid notification preference: {key}"
755 )));
756 }
757 }
758 edit_config_document(path, |doc| {
759 set_value(doc, "notifications", key, Some(value))
760 })
761}
762
763pub fn set_vendor_enabled(path: &Path, slug: &str, enabled: bool) -> Result<()> {
771 edit_config_document(path, |doc| set_vendor_enabled_in_doc(doc, slug, enabled))
772}
773
774pub fn set_vendor_enabled_in_doc(
779 doc: &mut toml_edit::DocumentMut,
780 slug: &str,
781 enabled: bool,
782) -> Result<()> {
783 let Some(vendor) = VendorId::from_slug(slug) else {
784 return Err(AppError::Other(format!(
785 "unknown provider {slug:?}: not a built-in vendor"
786 )));
787 };
788 set_bool(doc, vendor.config_section(), "enabled", enabled)
789}
790
791pub(crate) fn toml_error_summary(
797 input: &str,
798 span: Option<std::ops::Range<usize>>,
799 message: &str,
800) -> String {
801 let message = message.trim_end();
802 match span.and_then(|range| input.get(..range.start)) {
803 Some(before) => {
804 let line = before.matches('\n').count() + 1;
805 let line_start = before.rfind('\n').map_or(0, |i| i + 1);
806 let column = before[line_start..].chars().count() + 1;
807 format!("line {line}, column {column}: {message}")
808 }
809 None => message.to_string(),
810 }
811}
812
813pub(crate) fn read_config_document(path: &Path) -> Result<toml_edit::DocumentMut> {
817 let original = match std::fs::read_to_string(path) {
818 Ok(contents) => contents,
819 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
820 Err(error) => return Err(AppError::io_at(path, error)),
821 };
822 if original.trim().is_empty() {
823 return Ok(toml_edit::DocumentMut::new());
824 }
825 original.parse().map_err(|e: toml_edit::TomlError| {
826 let summary = toml_error_summary(&original, e.span(), e.message());
827 AppError::Other(format!("config.toml not parseable: {summary}"))
828 })
829}
830
831pub(crate) fn write_config_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> {
837 let bytes = doc.to_string();
838 crate::cache::atomic_write(path, bytes.as_bytes())?;
839
840 #[cfg(unix)]
841 {
842 if let Ok(meta) = std::fs::metadata(path) {
843 let mut perms = meta.permissions();
844 perms.set_mode(0o600);
845 let _ = std::fs::set_permissions(path, perms);
846 }
847 }
848 Ok(())
849}
850
851pub(crate) const CONFIG_LOCK_TIMEOUT: Duration = Duration::from_secs(5);
855
856pub(crate) fn config_lock_path(path: &Path) -> PathBuf {
861 let name = path
862 .file_name()
863 .and_then(|n| n.to_str())
864 .unwrap_or("config.toml");
865 path.with_file_name(format!("{name}.lock"))
866}
867
868pub(crate) fn edit_config_document<T>(
893 path: &Path,
894 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<T>,
895) -> Result<T> {
896 edit_config_document_within(path, CONFIG_LOCK_TIMEOUT, edit)
897}
898
899fn edit_config_document_within<T>(
902 path: &Path,
903 timeout: Duration,
904 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<T>,
905) -> Result<T> {
906 let _guard = match crate::cache::acquire_lock(&config_lock_path(path), timeout) {
910 Ok(guard) => guard,
911 Err(AppError::Other(message)) if message.starts_with("cache lock timeout") => {
912 return Err(AppError::Other(
913 "config.toml is being saved by another process; try again.".to_string(),
914 ));
915 }
916 Err(error) => return Err(error),
917 };
918 let mut doc = read_config_document(path)?;
919 let before = doc.to_string();
920 let value = edit(&mut doc)?;
921 if doc.to_string() != before {
922 if doc.to_string().trim().is_empty() {
923 match std::fs::remove_file(path) {
929 Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
930 return Err(error.into());
931 }
932 _ => {}
933 }
934 } else {
935 write_config_document(path, &doc)?;
936 }
937 }
938 Ok(value)
939}
940
941pub fn enable_vendors_in(path: &Path, vendors: &[VendorId]) -> Result<Vec<VendorId>> {
949 edit_config_document(path, |doc| {
950 let written: Vec<VendorId> = vendors
951 .iter()
952 .copied()
953 .filter(|vendor| !is_explicitly_disabled(doc, *vendor))
954 .collect();
955 for vendor in &written {
956 set_bool(doc, vendor.config_section(), "enabled", true)?;
957 }
958 Ok(written)
959 })
960}
961
962fn is_explicitly_disabled(doc: &toml_edit::DocumentMut, vendor: VendorId) -> bool {
973 doc.get(vendor.config_section())
974 .and_then(|section| section.get("enabled"))
975 .and_then(|enabled| enabled.as_bool())
976 == Some(false)
977}
978
979#[derive(Debug, Clone, Deserialize, Serialize)]
980#[serde(default)]
981pub struct OpenAiConfig {
982 pub enabled: bool,
983 pub codex_auth_path: Option<PathBuf>,
985 #[serde(default)]
990 pub accounts: Vec<OpenAiAccount>,
991 pub show_default_account: bool,
997 pub admin_key_env: String,
1005}
1006
1007#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1018pub struct OpenAiAccount {
1019 pub label: String,
1022 pub codex_auth_path: PathBuf,
1025}
1026
1027impl OpenAiConfig {
1028 pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
1032 let Some(label) = label else {
1033 return match &self.codex_auth_path {
1034 Some(path) => Ok(path.clone()),
1035 None => crate::openai::creds::default_path(),
1036 };
1037 };
1038 self.accounts
1039 .iter()
1040 .find(|account| account.label == label)
1041 .map(|account| account.codex_auth_path.clone())
1042 .ok_or_else(|| {
1043 AppError::Credentials(format!(
1044 "no OpenAI account named {label:?}. Add it under \
1045 [[openai.accounts]], or drop --account to use the default login."
1046 ))
1047 })
1048 }
1049
1050 pub fn fetch_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
1057 let Some(label) = label else {
1058 return self.resolve_auth_path(None);
1059 };
1060 let default = self.resolve_auth_path(None)?;
1061 let active = crate::openai::account::resolve_active_label(&default, &self.accounts);
1062 self.fetch_auth_path_probing(label, active.as_deref(), Path::exists)
1063 }
1064
1065 pub fn fetch_auth_path_probing(
1070 &self,
1071 label: &str,
1072 active: Option<&str>,
1073 exists: impl Fn(&Path) -> bool,
1074 ) -> Result<PathBuf> {
1075 let own = self.resolve_auth_path(Some(label))?;
1076 if active == Some(label) && !exists(&own) {
1077 return self.resolve_auth_path(None);
1078 }
1079 Ok(own)
1080 }
1081}
1082
1083impl Default for OpenAiConfig {
1084 fn default() -> Self {
1085 Self {
1086 enabled: true,
1087 codex_auth_path: None,
1088 accounts: Vec::new(),
1089 show_default_account: true,
1090 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
1091 }
1092 }
1093}
1094
1095#[derive(Debug, Clone, Deserialize, Serialize)]
1099#[serde(default)]
1100pub struct CopilotConfig {
1101 pub enabled: bool,
1102 pub gh_binary: Option<PathBuf>,
1105 pub accounts: Vec<CopilotAccount>,
1109 pub show_default_account: bool,
1115}
1116
1117impl Default for CopilotConfig {
1118 fn default() -> Self {
1119 Self {
1120 enabled: false,
1123 gh_binary: None,
1124 accounts: Vec::new(),
1125 show_default_account: true,
1126 }
1127 }
1128}
1129
1130#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1149pub struct CopilotAccount {
1150 pub label: String,
1153 pub user: String,
1156}
1157
1158impl CopilotConfig {
1159 pub fn resolve_user(&self, label: Option<&str>) -> Result<Option<&str>> {
1163 let Some(label) = label else {
1164 return Ok(None);
1165 };
1166 self.accounts
1167 .iter()
1168 .find(|account| account.label == label)
1169 .map(|account| Some(account.user.as_str()))
1170 .ok_or_else(|| {
1171 let known = self
1172 .accounts
1173 .iter()
1174 .map(|account| account.label.as_str())
1175 .collect::<Vec<_>>();
1176 AppError::Other(if known.is_empty() {
1177 format!(
1178 "copilot: no account {label:?} — [copilot] has no accounts array; \
1179 add [[copilot.accounts]] with label and user"
1180 )
1181 } else {
1182 format!(
1183 "copilot: no account {label:?}; known labels: {}",
1184 known.join(", ")
1185 )
1186 })
1187 })
1188 }
1189
1190 pub fn resolve_token(&self, label: Option<&str>) -> Result<String> {
1191 self.resolve_token_with(
1192 |name| std::env::var_os(name),
1193 &crate::copilot::credentials::SystemGhAuthTokenRunner,
1194 label,
1195 )
1196 }
1197
1198 pub async fn resolve_token_async(&self, label: Option<&str>) -> Result<String> {
1201 let user = self.resolve_user(label)?;
1202 if let Some(value) = std::env::var_os("GITHUB_COPILOT_TOKEN") {
1203 let token = value.into_string().map_err(|_| {
1204 AppError::Credentials(
1205 "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
1206 )
1207 })?;
1208 if !token.is_empty() {
1209 if let Some(label) = label {
1210 return Err(AppError::Credentials(format!(
1211 "GitHub Copilot: GITHUB_COPILOT_TOKEN is set, so it is unclear whether \
1212 --account {label:?} should use that token or {user:?}'s `gh` login. \
1213 Unset the variable to use named accounts."
1214 )));
1215 }
1216 return Ok(token);
1217 }
1218 }
1219 crate::copilot::credentials::resolve_async(self.gh_binary.as_deref(), user).await
1220 }
1221
1222 fn resolve_token_with(
1223 &self,
1224 environment: impl Fn(&str) -> Option<std::ffi::OsString>,
1225 runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
1226 label: Option<&str>,
1227 ) -> Result<String> {
1228 let user = self.resolve_user(label)?;
1229 if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
1230 let token = value.into_string().map_err(|_| {
1231 AppError::Credentials(
1232 "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
1233 )
1234 })?;
1235 if !token.is_empty() {
1236 if let Some(label) = label {
1240 return Err(AppError::Credentials(format!(
1241 "GitHub Copilot: GITHUB_COPILOT_TOKEN is set, so it is unclear whether \
1242 --account {label:?} should use that token or {user:?}'s `gh` login. \
1243 Unset the variable to use named accounts."
1244 )));
1245 }
1246 return Ok(token);
1247 }
1248 }
1249 crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref(), user)
1250 }
1251}
1252
1253#[derive(Debug, Clone, Deserialize, Serialize)]
1254#[serde(default)]
1255pub struct NousConfig {
1256 pub enabled: bool,
1257 pub headline: Headline,
1261}
1262
1263impl Default for NousConfig {
1264 fn default() -> Self {
1265 Self {
1266 enabled: false,
1267 headline: Headline::Percent,
1270 }
1271 }
1272}
1273
1274#[derive(Debug, Clone, Deserialize, Serialize)]
1275#[serde(default)]
1276pub struct OpenCodeGoConfig {
1277 pub enabled: bool,
1278 pub api_key_env: String,
1279 pub api_key: Option<String>,
1280 pub auth_paths: Option<Vec<PathBuf>>,
1285}
1286
1287#[derive(Debug, Clone, Deserialize, Serialize)]
1293#[serde(default)]
1294pub struct ClineConfig {
1295 pub enabled: bool,
1296 pub auth_paths: Option<Vec<PathBuf>>,
1297 pub api_key_env: String,
1298 pub api_key: Option<String>,
1299}
1300
1301impl Default for ClineConfig {
1302 fn default() -> Self {
1303 Self {
1304 enabled: false,
1305 auth_paths: None,
1306 api_key_env: crate::cline::creds::API_KEY_ENV.to_string(),
1307 api_key: None,
1308 }
1309 }
1310}
1311
1312#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1317#[serde(default)]
1318pub struct CommandCodeConfig {
1319 pub enabled: bool,
1320 pub auth_paths: Option<Vec<PathBuf>>,
1321}
1322
1323#[derive(Debug, Clone, Deserialize, Serialize)]
1328#[serde(default)]
1329pub struct OllamaConfig {
1330 pub enabled: bool,
1331 pub api_key_env: String,
1332 pub api_key: Option<String>,
1333 pub plan: String,
1336}
1337
1338impl Default for OllamaConfig {
1339 fn default() -> Self {
1340 Self {
1341 enabled: false,
1342 api_key_env: "OLLAMA_API_KEY".to_string(),
1343 api_key: None,
1344 plan: "pro".to_string(),
1345 }
1346 }
1347}
1348
1349#[derive(Debug, Clone, Deserialize, Serialize)]
1352#[serde(default)]
1353pub struct OrcaRouterConfig {
1354 pub enabled: bool,
1355 pub accounts: Vec<ApiKeyAccount>,
1358 pub show_default_account: bool,
1362 pub api_key_env: String,
1363 pub api_key: Option<String>,
1364}
1365
1366impl Default for OrcaRouterConfig {
1367 fn default() -> Self {
1368 Self {
1369 enabled: false,
1370 accounts: Vec::new(),
1371 show_default_account: true,
1372 api_key_env: "ORCAROUTER_API_KEY".to_string(),
1373 api_key: None,
1374 }
1375 }
1376}
1377
1378#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1385#[serde(default)]
1386pub struct ModelStudioConfig {
1387 pub enabled: bool,
1390 pub config_dir: Option<PathBuf>,
1393}
1394
1395#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1397#[serde(default)]
1398pub struct MuseConfig {
1399 pub enabled: bool,
1400 pub credentials_path: Option<PathBuf>,
1401}
1402
1403#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1411pub struct DevinAccount {
1412 pub label: String,
1415 pub credentials_path: PathBuf,
1417}
1418
1419#[derive(Debug, Clone, Deserialize, Serialize)]
1421#[serde(default)]
1422pub struct DevinConfig {
1423 pub enabled: bool,
1424 pub credentials_path: Option<PathBuf>,
1428 #[serde(default)]
1430 pub accounts: Vec<DevinAccount>,
1431 pub show_default_account: bool,
1435}
1436
1437impl Default for DevinConfig {
1438 fn default() -> Self {
1439 Self {
1440 enabled: false,
1441 credentials_path: None,
1442 accounts: Vec::new(),
1443 show_default_account: true,
1444 }
1445 }
1446}
1447
1448impl DevinConfig {
1449 pub fn resolve_credentials_path(&self, label: Option<&str>) -> Result<PathBuf> {
1452 let Some(label) = label else {
1453 return crate::devin::credentials_path(self);
1454 };
1455 self.accounts
1456 .iter()
1457 .find(|account| account.label == label)
1458 .map(|account| account.credentials_path.clone())
1459 .ok_or_else(|| {
1460 AppError::Credentials(format!(
1461 "no Devin account named {label:?}. Add it under \
1462 [[devin.accounts]], or drop --account to use the default login."
1463 ))
1464 })
1465 }
1466}
1467
1468#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1472#[serde(default)]
1473pub struct DroidConfig {
1474 pub enabled: bool,
1475 pub cli_renewal: bool,
1480 pub cli_path: Option<PathBuf>,
1482}
1483
1484impl Default for OpenCodeGoConfig {
1485 fn default() -> Self {
1486 Self {
1487 enabled: false,
1488 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
1489 api_key: None,
1490 auth_paths: None,
1491 }
1492 }
1493}
1494
1495#[derive(Debug, Clone, Deserialize, Serialize)]
1496#[serde(default)]
1497pub struct ZaiConfig {
1498 pub enabled: bool,
1499 pub accounts: Vec<ApiKeyAccount>,
1502 pub show_default_account: bool,
1506 pub api_key_env: String,
1508 pub api_key: Option<String>,
1511 pub plan_tier: Option<String>,
1513}
1514
1515impl Default for ZaiConfig {
1516 fn default() -> Self {
1517 Self {
1518 enabled: true,
1519 accounts: Vec::new(),
1520 show_default_account: true,
1521 api_key_env: "ZAI_API_KEY".to_string(),
1522 api_key: None,
1523 plan_tier: None,
1524 }
1525 }
1526}
1527
1528#[derive(Debug, Clone, Deserialize, Serialize)]
1529#[serde(default)]
1530pub struct OpenRouterConfig {
1531 pub enabled: bool,
1532 pub accounts: Vec<ApiKeyAccount>,
1535 pub show_default_account: bool,
1539 pub api_key_env: String,
1540 pub api_key: Option<String>,
1541 pub management_api_key_env: String,
1546 pub headline: Headline,
1558}
1559
1560impl Default for OpenRouterConfig {
1561 fn default() -> Self {
1562 Self {
1563 enabled: true,
1564 accounts: Vec::new(),
1565 show_default_account: true,
1566 api_key_env: "OPENROUTER_API_KEY".to_string(),
1567 api_key: None,
1568 management_api_key_env: "OPENROUTER_MANAGEMENT_API_KEY".to_string(),
1569 headline: Headline::Percent,
1570 }
1571 }
1572}
1573
1574#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1579pub struct ApiKeyAccount {
1580 pub label: String,
1582 #[serde(default)]
1584 pub api_key_env: Option<String>,
1585 #[serde(default)]
1587 pub api_key: Option<String>,
1588 #[serde(default)]
1592 pub management_api_key_env: Option<String>,
1593}
1594
1595fn api_key_account<'a>(
1599 slug: &str,
1600 accounts: &'a [ApiKeyAccount],
1601 label: &str,
1602) -> Result<&'a ApiKeyAccount> {
1603 validate_account_label_for(slug, label)?;
1604 accounts
1605 .iter()
1606 .find(|account| account.label == label)
1607 .ok_or_else(|| {
1608 let known: Vec<&str> = accounts
1609 .iter()
1610 .map(|account| account.label.as_str())
1611 .collect();
1612 AppError::Credentials(format!(
1613 "{slug} account {label:?} not found in [[{slug}.accounts]]; \
1614 known labels: {known:?}"
1615 ))
1616 })
1617}
1618
1619#[derive(Debug, Clone, Deserialize, Serialize)]
1620#[serde(default)]
1621pub struct DeepseekConfig {
1622 pub enabled: bool,
1623 pub accounts: Vec<ApiKeyAccount>,
1626 pub show_default_account: bool,
1630 pub api_key_env: String,
1631 pub api_key: Option<String>,
1632 pub display_limit: Option<f64>,
1635 pub headline: Headline,
1637}
1638
1639impl Default for DeepseekConfig {
1640 fn default() -> Self {
1641 Self {
1642 enabled: false,
1643 accounts: Vec::new(),
1644 show_default_account: true,
1645 api_key_env: "DEEPSEEK_API_KEY".to_string(),
1646 api_key: None,
1647 display_limit: None,
1648 headline: Headline::Amount,
1649 }
1650 }
1651}
1652
1653#[derive(Debug, Clone, Deserialize, Serialize)]
1654#[serde(default)]
1655pub struct DeepInfraConfig {
1656 pub enabled: bool,
1657 pub accounts: Vec<ApiKeyAccount>,
1658 pub show_default_account: bool,
1659 pub api_key_env: String,
1660 pub api_key: Option<String>,
1661 pub display_limit: Option<f64>,
1663 pub headline: Headline,
1664}
1665
1666impl Default for DeepInfraConfig {
1667 fn default() -> Self {
1668 Self {
1669 enabled: false,
1670 accounts: Vec::new(),
1671 show_default_account: true,
1672 api_key_env: "DEEPINFRA_API_KEY".to_string(),
1673 api_key: None,
1674 display_limit: None,
1675 headline: Headline::Amount,
1676 }
1677 }
1678}
1679
1680#[derive(Debug, Clone, Deserialize, Serialize)]
1681#[serde(default)]
1682pub struct KimiConfig {
1683 pub enabled: bool,
1684 pub api_key_env: String,
1685 pub api_key: Option<String>,
1688 pub credentials_path: Option<PathBuf>,
1692 pub region: String,
1697}
1698
1699impl Default for KimiConfig {
1700 fn default() -> Self {
1701 Self {
1702 enabled: false,
1703 api_key_env: "KIMI_API_KEY".to_string(),
1704 api_key: None,
1705 credentials_path: None,
1706 region: "auto".to_string(),
1707 }
1708 }
1709}
1710
1711#[derive(Debug, Clone, Deserialize, Serialize)]
1712#[serde(default)]
1713pub struct KiloConfig {
1714 pub enabled: bool,
1715 pub accounts: Vec<ApiKeyAccount>,
1718 pub show_default_account: bool,
1722 pub api_key_env: String,
1723 pub api_key: Option<String>,
1724 pub organization_id: Option<String>,
1727 pub display_limit: Option<f64>,
1730 pub headline: Headline,
1732}
1733
1734impl Default for KiloConfig {
1735 fn default() -> Self {
1736 Self {
1739 enabled: false,
1740 accounts: Vec::new(),
1741 show_default_account: true,
1742 api_key_env: "KILO_API_KEY".to_string(),
1743 api_key: None,
1744 organization_id: None,
1745 display_limit: None,
1746 headline: Headline::Amount,
1747 }
1748 }
1749}
1750
1751#[derive(Debug, Clone, Deserialize, Serialize)]
1752#[serde(default)]
1753pub struct NovitaConfig {
1754 pub enabled: bool,
1755 pub accounts: Vec<ApiKeyAccount>,
1758 pub show_default_account: bool,
1762 pub api_key_env: String,
1763 pub api_key: Option<String>,
1764 pub display_limit: Option<f64>,
1768 pub headline: Headline,
1770}
1771
1772impl Default for NovitaConfig {
1773 fn default() -> Self {
1774 Self {
1776 enabled: false,
1777 accounts: Vec::new(),
1778 show_default_account: true,
1779 api_key_env: "NOVITA_API_KEY".to_string(),
1780 api_key: None,
1781 display_limit: None,
1782 headline: Headline::Amount,
1783 }
1784 }
1785}
1786
1787#[derive(Debug, Clone, Deserialize, Serialize)]
1788#[serde(default)]
1789pub struct SyntheticConfig {
1790 pub enabled: bool,
1791 pub api_key_env: String,
1792 pub api_key: Option<String>,
1793 pub plan_tier: Option<String>,
1796}
1797
1798impl Default for SyntheticConfig {
1799 fn default() -> Self {
1800 Self {
1801 enabled: false,
1802 api_key_env: "SYNTHETIC_API_KEY".into(),
1803 api_key: None,
1804 plan_tier: None,
1805 }
1806 }
1807}
1808
1809#[derive(Debug, Clone, Deserialize, Serialize)]
1810#[serde(default)]
1811pub struct LyceumConfig {
1812 pub enabled: bool,
1813 pub accounts: Vec<ApiKeyAccount>,
1814 pub show_default_account: bool,
1815 pub api_key_env: String,
1816 pub api_key: Option<String>,
1817}
1818impl Default for LyceumConfig {
1819 fn default() -> Self {
1820 Self {
1821 enabled: false,
1822 accounts: Vec::new(),
1823 show_default_account: true,
1824 api_key_env: "LYCEUM_API_KEY".into(),
1825 api_key: None,
1826 }
1827 }
1828}
1829
1830#[derive(Debug, Clone, Deserialize, Serialize)]
1831#[serde(default)]
1832pub struct MinimaxConfig {
1833 pub enabled: bool,
1834 pub accounts: Vec<ApiKeyAccount>,
1837 pub show_default_account: bool,
1841 pub api_key_env: String,
1842 pub api_key: Option<String>,
1843 pub region: String,
1849}
1850
1851impl Default for MinimaxConfig {
1852 fn default() -> Self {
1853 Self {
1855 enabled: false,
1856 accounts: Vec::new(),
1857 show_default_account: true,
1858 api_key_env: "MINIMAX_API_KEY".to_string(),
1859 api_key: None,
1860 region: "global".to_string(),
1861 }
1862 }
1863}
1864
1865#[derive(Debug, Clone, Deserialize, Serialize)]
1866#[serde(default)]
1867pub struct MoonshotConfig {
1868 pub enabled: bool,
1869 pub accounts: Vec<ApiKeyAccount>,
1872 pub show_default_account: bool,
1876 pub api_key_env: String,
1877 pub api_key: Option<String>,
1878 pub region: String,
1880 pub display_limit: Option<f64>,
1883 pub headline: Headline,
1885}
1886
1887impl Default for MoonshotConfig {
1888 fn default() -> Self {
1889 Self {
1891 enabled: false,
1892 accounts: Vec::new(),
1893 show_default_account: true,
1894 api_key_env: "MOONSHOT_API_KEY".to_string(),
1895 api_key: None,
1896 region: "global".to_string(),
1897 display_limit: None,
1898 headline: Headline::Amount,
1899 }
1900 }
1901}
1902
1903#[derive(Debug, Clone, Deserialize, Serialize)]
1904#[serde(default)]
1905pub struct GrokConfig {
1906 pub enabled: bool,
1907 pub accounts: Vec<ApiKeyAccount>,
1910 pub show_default_account: bool,
1914 pub api_key_env: String,
1916 pub api_key: Option<String>,
1917 pub team_id: Option<String>,
1920 pub display_limit: Option<f64>,
1922 pub headline: Headline,
1924}
1925
1926impl Default for GrokConfig {
1927 fn default() -> Self {
1928 Self {
1930 enabled: false,
1931 accounts: Vec::new(),
1932 show_default_account: true,
1933 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1934 api_key: None,
1935 team_id: None,
1936 display_limit: None,
1937 headline: Headline::Amount,
1938 }
1939 }
1940}
1941
1942#[derive(Debug, Clone, Deserialize, Serialize)]
1950#[serde(default)]
1951pub struct SuperGrokConfig {
1952 pub enabled: bool,
1953 pub grok_binary: PathBuf,
1957 pub auth_path: Option<PathBuf>,
1960 pub config_path: Option<PathBuf>,
1961}
1962
1963impl Default for SuperGrokConfig {
1964 fn default() -> Self {
1965 Self {
1966 enabled: false,
1967 grok_binary: default_grok_binary(),
1968 auth_path: None,
1969 config_path: None,
1970 }
1971 }
1972}
1973
1974#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1980#[serde(default)]
1981pub struct GrokbotConfig {
1982 pub enabled: bool,
1985 pub secrets_path: Option<PathBuf>,
1990}
1991
1992fn default_grok_binary() -> PathBuf {
1993 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1994 let grok_home = std::env::var_os("GROK_HOME")
1995 .filter(|value| !value.is_empty())
1996 .map(PathBuf::from)
1997 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1998 grok_home
1999 .map(|home| home.join("bin").join(executable))
2000 .unwrap_or_else(|| PathBuf::from(executable))
2001}
2002
2003#[derive(Debug, Clone, Deserialize, Serialize)]
2011#[serde(default)]
2012pub struct AntigravityConfig {
2013 pub enabled: bool,
2014 pub agy_binary: Option<PathBuf>,
2017 pub oauth_client_id: Option<String>,
2019 pub oauth_client_secret: Option<String>,
2023 pub ide_language_server: bool,
2028}
2029
2030impl Default for AntigravityConfig {
2031 fn default() -> Self {
2032 Self {
2033 enabled: false,
2034 agy_binary: None,
2035 oauth_client_id: None,
2036 oauth_client_secret: None,
2037 ide_language_server: true,
2038 }
2039 }
2040}
2041
2042#[derive(Debug, Clone, Default, Deserialize, Serialize)]
2052#[serde(default)]
2053pub struct CursorConfig {
2054 pub enabled: bool,
2055 pub db_path: Option<PathBuf>,
2059 pub agent_auth_path: Option<PathBuf>,
2064}
2065
2066#[derive(Debug, Clone, Default, Deserialize, Serialize)]
2075#[serde(default)]
2076pub struct KiroConfig {
2077 pub enabled: bool,
2078 pub db_path: Option<PathBuf>,
2082}
2083
2084#[derive(Debug, Clone, Deserialize, Serialize)]
2085#[serde(default)]
2086pub struct AnthropicApiConfig {
2087 pub enabled: bool,
2088 pub api_key_env: String,
2091 pub api_key: Option<String>,
2092 pub monthly_limit: Option<f64>,
2095}
2096
2097impl Default for AnthropicApiConfig {
2098 fn default() -> Self {
2099 Self {
2101 enabled: false,
2102 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
2103 api_key: None,
2104 monthly_limit: None,
2105 }
2106 }
2107}
2108
2109#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
2119#[serde(default, remote = "Self")]
2120pub struct CustomProviderConfig {
2121 pub id: String,
2123 pub name: String,
2125 pub short_name: String,
2128 pub brand: Option<String>,
2131 pub enabled: bool,
2132 pub url: String,
2134 pub allow_http: bool,
2135 pub api_key_env: String,
2137 pub api_key: Option<String>,
2138 pub auth_header: String,
2140 pub auth_scheme: String,
2142 pub headers: BTreeMap<String, String>,
2144 pub plan: Option<String>,
2146 pub plan_path: Option<String>,
2148 pub cache_ttl_secs: u64,
2150 pub metrics: Vec<CustomMetricSpec>,
2151 pub texts: Vec<CustomTextSpec>,
2152}
2153
2154impl Default for CustomProviderConfig {
2155 fn default() -> Self {
2156 Self {
2157 id: String::new(),
2158 name: String::new(),
2159 short_name: String::new(),
2160 brand: None,
2161 enabled: false,
2162 url: String::new(),
2163 allow_http: false,
2164 api_key_env: String::new(),
2165 api_key: None,
2166 auth_header: "Authorization".to_string(),
2167 auth_scheme: "Bearer".to_string(),
2168 headers: BTreeMap::new(),
2169 plan: None,
2170 plan_path: None,
2171 cache_ttl_secs: 60,
2172 metrics: Vec::new(),
2173 texts: Vec::new(),
2174 }
2175 }
2176}
2177
2178impl<'de> Deserialize<'de> for CustomProviderConfig {
2183 fn deserialize<D: serde::Deserializer<'de>>(
2184 deserializer: D,
2185 ) -> std::result::Result<Self, D::Error> {
2186 let mut this = Self::deserialize(deserializer)?;
2187 if this.name.is_empty() {
2188 this.name = this.id.clone();
2189 }
2190 Ok(this)
2191 }
2192}
2193
2194impl Serialize for CustomProviderConfig {
2195 fn serialize<S: serde::Serializer>(
2196 &self,
2197 serializer: S,
2198 ) -> std::result::Result<S::Ok, S::Error> {
2199 Self::serialize(self, serializer)
2200 }
2201}
2202
2203#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
2207#[serde(default)]
2208pub struct CustomMetricSpec {
2209 pub label: String,
2210 pub used: Option<String>,
2211 pub limit: Option<String>,
2212 pub percent: Option<String>,
2213 pub resets_at: Option<String>,
2215 pub window_secs: Option<u64>,
2217 pub currency: Option<String>,
2219}
2220
2221#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
2223#[serde(default)]
2224pub struct CustomTextSpec {
2225 pub label: String,
2226 pub value: String,
2227 pub currency: Option<String>,
2229}
2230
2231impl CustomProviderConfig {
2232 pub fn section_label(&self) -> String {
2234 format!("[[custom]] id = {:?}", self.id)
2235 }
2236
2237 pub fn resolve_api_key(&self) -> Result<String> {
2240 if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
2241 return Ok(key);
2242 }
2243 let advice = if self.api_key_env.is_empty() {
2244 "set `api_key`, or name an environment variable in `api_key_env`".to_string()
2245 } else {
2246 format!("export {} or set `api_key`", self.api_key_env)
2247 };
2248 Err(AppError::Credentials(format!(
2249 "custom {}: no API key. Either {advice} under {} in {}.",
2250 self.id,
2251 self.section_label(),
2252 config_path_hint()
2253 )))
2254 }
2255
2256 pub fn cache_ttl(&self) -> std::time::Duration {
2257 std::time::Duration::from_secs(self.cache_ttl_secs)
2258 }
2259
2260 fn validate(&self, index: usize) -> Result<()> {
2264 if !is_valid_custom_id(&self.id) {
2265 return Err(AppError::Other(format!(
2266 "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
2267 index + 1,
2268 self.id
2269 )));
2270 }
2271 let section = self.section_label();
2272 let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
2273
2274 if VendorId::all().iter().any(|v| v.slug() == self.id) {
2275 return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
2276 }
2277 let name_len = self.name.chars().count();
2278 if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
2279 return Err(bad(
2280 "name must be 1 to 48 characters without control characters".into(),
2281 ));
2282 }
2283 if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
2284 return Err(bad(format!(
2285 "short_name {:?} must be exactly 3 lowercase ASCII letters",
2286 self.short_name
2287 )));
2288 }
2289 if let Some(brand) = &self.brand
2290 && !VendorId::all().iter().any(|v| v.slug() == brand)
2291 {
2292 return Err(bad(format!(
2293 "brand {brand:?} must name a built-in vendor (it borrows that \
2294 vendor's mark); leave it unset to keep the short_name tag"
2295 )));
2296 }
2297 let url = reqwest::Url::parse(&self.url)
2298 .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
2299 match url.scheme() {
2300 "https" => {}
2301 "http" if self.allow_http => {}
2302 "http" => {
2303 return Err(bad(
2304 "url must use https:// (set allow_http = true to permit http://)".into(),
2305 ));
2306 }
2307 other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
2308 }
2309 if !url.username().is_empty() || url.password().is_some() {
2310 return Err(bad("url must not carry credentials (user:pass@)".into()));
2311 }
2312 if url.host_str().is_none() {
2313 return Err(bad("url has no host".into()));
2314 }
2315 if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
2316 return Err(bad(
2320 "api_key_env is not a valid environment variable name".into()
2321 ));
2322 }
2323 validate_header_name(§ion, "auth_header", &self.auth_header)?;
2324 if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
2325 return Err(bad(
2326 "auth_scheme contains characters that are not valid in an HTTP header".into(),
2327 ));
2328 }
2329 for (name, value) in &self.headers {
2330 validate_header_name(§ion, "headers", name)?;
2331 if name.eq_ignore_ascii_case(&self.auth_header) {
2332 return Err(bad(format!(
2333 "headers must not repeat auth_header {:?}",
2334 self.auth_header
2335 )));
2336 }
2337 if reqwest::header::HeaderValue::from_str(value).is_err() {
2338 return Err(bad(format!(
2339 "header {name:?} has a value that is not valid in an HTTP header"
2340 )));
2341 }
2342 }
2343 if let Some(plan) = &self.plan {
2344 validate_custom_label(§ion, "plan", plan)?;
2345 }
2346 if let Some(pointer) = &self.plan_path {
2347 validate_pointer(§ion, "plan_path", pointer)?;
2348 }
2349 if !(10..=3600).contains(&self.cache_ttl_secs) {
2350 return Err(bad(format!(
2351 "cache_ttl_secs must be between 10 and 3600, got {}",
2352 self.cache_ttl_secs
2353 )));
2354 }
2355 if self.metrics.is_empty() && self.texts.is_empty() {
2356 return Err(bad(
2357 "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
2358 ));
2359 }
2360 let mut metric_labels = HashSet::new();
2361 for metric in &self.metrics {
2362 validate_custom_label(§ion, "metric label", &metric.label)?;
2363 if !metric_labels.insert(metric.label.as_str()) {
2364 return Err(bad(format!("duplicate metric label {:?}", metric.label)));
2365 }
2366 let pair = (metric.used.is_some(), metric.limit.is_some());
2367 let well_formed = if metric.percent.is_some() {
2368 pair == (false, false)
2369 } else {
2370 pair == (true, true)
2371 };
2372 if !well_formed {
2373 return Err(bad(format!(
2374 "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
2375 metric.label
2376 )));
2377 }
2378 if metric.currency.is_some() && metric.percent.is_some() {
2379 return Err(bad(format!(
2380 "metric {:?} cannot combine `currency` with `percent`",
2381 metric.label
2382 )));
2383 }
2384 if let Some(currency) = &metric.currency {
2385 validate_currency(§ion, &format!("metric {:?}", metric.label), currency)?;
2386 }
2387 for (field, pointer) in [
2388 ("used", &metric.used),
2389 ("limit", &metric.limit),
2390 ("percent", &metric.percent),
2391 ("resets_at", &metric.resets_at),
2392 ] {
2393 if let Some(pointer) = pointer {
2394 validate_pointer(§ion, field, pointer)?;
2395 }
2396 }
2397 if let Some(secs) = metric.window_secs
2398 && secs < 60
2399 {
2400 return Err(bad(format!(
2401 "metric {:?} window_secs must be at least 60, got {secs}",
2402 metric.label
2403 )));
2404 }
2405 }
2406 let mut text_labels = HashSet::new();
2407 for text in &self.texts {
2408 validate_custom_label(§ion, "text label", &text.label)?;
2409 if !text_labels.insert(text.label.as_str()) {
2410 return Err(bad(format!("duplicate text label {:?}", text.label)));
2411 }
2412 validate_pointer(§ion, "value", &text.value)?;
2413 if let Some(currency) = &text.currency {
2414 validate_currency(§ion, &format!("text {:?}", text.label), currency)?;
2415 }
2416 }
2417 Ok(())
2418 }
2419}
2420
2421fn is_valid_custom_id(id: &str) -> bool {
2422 let bytes = id.as_bytes();
2423 let Some(&first) = bytes.first() else {
2424 return false;
2425 };
2426 bytes.len() <= 32
2427 && (first.is_ascii_lowercase() || first.is_ascii_digit())
2428 && bytes
2429 .iter()
2430 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
2431}
2432
2433fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
2434 if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
2435 return Err(AppError::Other(format!(
2436 "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
2437 )));
2438 }
2439 Ok(())
2440}
2441
2442fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
2443 let len = label.chars().count();
2444 if len == 0 || len > 64 || label.chars().any(char::is_control) {
2445 return Err(AppError::Other(format!(
2446 "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
2447 )));
2448 }
2449 Ok(())
2450}
2451
2452fn validate_currency(section: &str, context: &str, currency: &str) -> Result<()> {
2453 if currency.len() != 3 || !currency.bytes().all(|b| b.is_ascii_uppercase()) {
2454 return Err(AppError::Other(format!(
2455 "{section}: {context} currency {currency:?} must be a 3-letter uppercase code (e.g. \"USD\")"
2456 )));
2457 }
2458 Ok(())
2459}
2460
2461fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
2462 if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
2463 return Err(AppError::Other(format!(
2464 "{section}: {field} {name:?} is not a valid HTTP header name"
2465 )));
2466 }
2467 Ok(())
2468}
2469
2470pub fn resolve_api_key(
2473 vendor_label: &str,
2474 env_var_name: &str,
2475 inline: Option<&str>,
2476) -> crate::error::Result<String> {
2477 let section = match vendor_label {
2478 "OpenCode Go" => "[opencode-go]".to_string(),
2479 _ => format!("[{}]", vendor_label.to_lowercase()),
2480 };
2481 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
2482}
2483
2484pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
2488 optional_api_key_with(env_var_name, inline, |name| std::env::var(name).ok())
2489}
2490
2491fn optional_api_key_with(
2492 env_var_name: &str,
2493 inline: Option<&str>,
2494 environment: impl FnOnce(&str) -> Option<String>,
2495) -> Option<String> {
2496 if is_valid_env_var_name(env_var_name)
2497 && let Some(v) = environment(env_var_name)
2498 && !v.is_empty()
2499 {
2500 return Some(v);
2501 }
2502 inline.filter(|v| !v.is_empty()).map(str::to_string)
2503}
2504
2505fn resolve_api_key_in_section(
2506 vendor_label: &str,
2507 section: &str,
2508 env_var_name: &str,
2509 inline: Option<&str>,
2510) -> crate::error::Result<String> {
2511 if let Some(key) = optional_api_key(env_var_name, inline) {
2512 return Ok(key);
2513 }
2514 let valid_env_name = is_valid_env_var_name(env_var_name);
2515 let advice = if valid_env_name {
2516 "set an API key in a valid environment variable or set `api_key`"
2517 } else {
2518 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
2519 };
2520 Err(crate::error::AppError::Credentials(format!(
2521 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
2522 config_path_hint()
2523 )))
2524}
2525
2526pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
2527 let mut chars = name.chars();
2528 let Some(first) = chars.next() else {
2529 return false;
2530 };
2531 (first.is_ascii_alphabetic() || first == '_')
2532 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2533}
2534
2535impl Config {
2536 pub fn load() -> Result<Self> {
2539 let Some(path) = resolved_path() else {
2540 return Ok(Self::default());
2541 };
2542 Self::load_from(&path)
2543 }
2544
2545 pub fn load_from(path: &std::path::Path) -> Result<Self> {
2546 match std::fs::read_to_string(path) {
2547 Ok(s) => {
2548 let mut config: Self = toml::from_str(&s).map_err(|mut e| {
2549 let span = e.span();
2550 e.set_input(None);
2553 let summary = toml_error_summary(&s, span, &e.to_string());
2554 AppError::Other(format!("config.toml: {summary}"))
2555 })?;
2556 config.expand_paths();
2560 config.validate()?;
2561 #[cfg(unix)]
2562 config.protect_inline_secrets(path)?;
2563 crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
2567 crate::vendor::register_secret_env_vars(&config.provider_secret_env_vars());
2568 Ok(config)
2569 }
2570 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
2571 Err(e) => Err(AppError::io_at(path, e)),
2572 }
2573 }
2574
2575 fn expand_paths(&mut self) {
2576 expand_tilde_opt(&mut self.context.projects_path);
2577 expand_tilde_opt(&mut self.anthropic.credentials_path);
2578 expand_tilde_opt(&mut self.anthropic.accounts_dir);
2579 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
2580 expand_tilde_opt(&mut self.anthropic.desktop_prepare_binary);
2581 expand_tilde_opt(&mut self.openai.codex_auth_path);
2582 expand_tilde_opt(&mut self.antigravity.agy_binary);
2583 expand_tilde_opt(&mut self.cursor.db_path);
2584 expand_tilde_opt(&mut self.cursor.agent_auth_path);
2585 expand_tilde_opt(&mut self.kiro.db_path);
2586 expand_tilde_opt(&mut self.kimi.credentials_path);
2587 expand_tilde_opt(&mut self.grokbot.secrets_path);
2588 expand_tilde_opt(&mut self.modelstudio.config_dir);
2589 expand_tilde_opt(&mut self.devin.credentials_path);
2590 expand_tilde_opt(&mut self.muse.credentials_path);
2591 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
2592 expand_tilde_opt(&mut self.supergrok.auth_path);
2593 expand_tilde_opt(&mut self.supergrok.config_path);
2594 expand_tilde_opt(&mut self.copilot.gh_binary);
2595 if let Some(paths) = &mut self.cline.auth_paths {
2596 for path in paths {
2597 *path = expand_tilde(path);
2598 }
2599 }
2600 for account in &mut self.anthropic.accounts {
2601 account.credentials_path = expand_tilde(&account.credentials_path);
2602 }
2603 for account in &mut self.openai.accounts {
2604 account.codex_auth_path = expand_tilde(&account.codex_auth_path);
2605 }
2606 for account in &mut self.devin.accounts {
2607 account.credentials_path = expand_tilde(&account.credentials_path);
2608 }
2609 if let Some(paths) = &mut self.commandcode.auth_paths {
2610 for path in paths {
2611 *path = expand_tilde(path);
2612 }
2613 }
2614 if let Some(paths) = &mut self.opencode_go.auth_paths {
2615 for path in paths {
2616 *path = expand_tilde(path);
2617 }
2618 }
2619 expand_tilde_opt(&mut self.droid.cli_path);
2620 }
2621
2622 #[cfg(unix)]
2626 fn has_inline_secrets(&self) -> bool {
2627 [
2628 self.zai.api_key.as_deref(),
2629 self.openrouter.api_key.as_deref(),
2630 self.deepseek.api_key.as_deref(),
2631 self.deepinfra.api_key.as_deref(),
2632 self.kimi.api_key.as_deref(),
2633 self.kilo.api_key.as_deref(),
2634 self.novita.api_key.as_deref(),
2635 self.synthetic.api_key.as_deref(),
2636 self.minimax.api_key.as_deref(),
2637 self.moonshot.api_key.as_deref(),
2638 self.grok.api_key.as_deref(),
2639 self.anthropic_api.api_key.as_deref(),
2640 self.opencode_go.api_key.as_deref(),
2641 self.ollama.api_key.as_deref(),
2642 self.orcarouter.api_key.as_deref(),
2643 self.lyceum.api_key.as_deref(),
2644 self.antigravity.oauth_client_secret.as_deref(),
2645 self.cline.api_key.as_deref(),
2646 ]
2647 .into_iter()
2648 .chain(
2649 Self::API_KEY_ACCOUNT_VENDORS
2650 .into_iter()
2651 .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2652 .map(|account| account.api_key.as_deref()),
2653 )
2654 .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
2655 .any(|key| key.is_some_and(|key| !key.is_empty()))
2656 }
2657
2658 fn custom_secret_env_vars(&self) -> Vec<String> {
2659 self.custom
2660 .iter()
2661 .filter(|c| !c.api_key_env.is_empty())
2662 .map(|c| c.api_key_env.clone())
2663 .collect()
2664 }
2665
2666 fn provider_secret_env_vars(&self) -> Vec<String> {
2672 let renamed = VendorId::all().iter().map(|&id| self.api_key_env_for(id));
2673 let management = std::iter::once(self.openrouter.management_api_key_env.as_str());
2674 let accounts = Self::API_KEY_ACCOUNT_VENDORS
2675 .into_iter()
2676 .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2677 .flat_map(|account| [&account.api_key_env, &account.management_api_key_env])
2678 .filter_map(|name| name.as_deref());
2679 renamed
2680 .chain(management)
2681 .chain(accounts)
2682 .map(str::to_string)
2683 .collect()
2684 }
2685
2686 pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
2688 self.custom.iter().filter(|c| c.enabled)
2689 }
2690
2691 pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
2693 self.custom.iter().find(|c| c.id == id)
2694 }
2695
2696 #[cfg(unix)]
2697 fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
2698 if !self.has_inline_secrets() {
2699 return Ok(());
2700 }
2701
2702 let metadata = std::fs::metadata(path).map_err(|_| {
2703 AppError::Credentials(format!(
2704 "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
2705 path.display()
2706 ))
2707 })?;
2708 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
2709 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
2710 AppError::Credentials(format!(
2711 "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",
2712 path.display()
2713 ))
2714 })?;
2715 }
2716 Ok(())
2717 }
2718
2719 pub fn is_enabled(&self, id: VendorId) -> bool {
2720 match id {
2721 VendorId::Anthropic => self.anthropic.enabled,
2722 VendorId::AnthropicApi => self.anthropic_api.enabled,
2723 VendorId::Openai => self.openai.enabled,
2724 VendorId::Copilot => self.copilot.enabled,
2725 VendorId::Zai => self.zai.enabled,
2726 VendorId::Openrouter => self.openrouter.enabled,
2727 VendorId::Deepseek => self.deepseek.enabled,
2728 VendorId::Deepinfra => self.deepinfra.enabled,
2729 VendorId::Kimi => self.kimi.enabled,
2730 VendorId::Kilo => self.kilo.enabled,
2731 VendorId::Novita => self.novita.enabled,
2732 VendorId::Synthetic => self.synthetic.enabled,
2733 VendorId::Moonshot => self.moonshot.enabled,
2734 VendorId::Grok => self.grok.enabled,
2735 VendorId::Supergrok => self.supergrok.enabled,
2736 VendorId::Grokbot => self.grokbot.enabled,
2737 VendorId::Antigravity => self.antigravity.enabled,
2738 VendorId::Cursor => self.cursor.enabled,
2739 VendorId::Minimax => self.minimax.enabled,
2740 VendorId::Kiro => self.kiro.enabled,
2741 VendorId::NousResearch => self.nous.enabled,
2742 VendorId::Cline => self.cline.enabled,
2743 VendorId::OpenCodeGo => self.opencode_go.enabled,
2744 VendorId::CommandCode => self.commandcode.enabled,
2745 VendorId::Ollama => self.ollama.enabled,
2746 VendorId::OrcaRouter => self.orcarouter.enabled,
2747 VendorId::ModelStudio => self.modelstudio.enabled,
2748 VendorId::Lyceum => self.lyceum.enabled,
2749 VendorId::Devin => self.devin.enabled,
2750 VendorId::Droid => self.droid.enabled,
2751 VendorId::MuseCode => self.muse.enabled,
2752 }
2753 }
2754
2755 pub fn api_key_env_for(&self, id: VendorId) -> &str {
2762 match id {
2763 VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
2764 VendorId::Zai => &self.zai.api_key_env,
2765 VendorId::Openrouter => &self.openrouter.api_key_env,
2766 VendorId::Deepseek => &self.deepseek.api_key_env,
2767 VendorId::Deepinfra => &self.deepinfra.api_key_env,
2768 VendorId::Kimi => &self.kimi.api_key_env,
2769 VendorId::Kilo => &self.kilo.api_key_env,
2770 VendorId::Novita => &self.novita.api_key_env,
2771 VendorId::Synthetic => &self.synthetic.api_key_env,
2772 VendorId::Moonshot => &self.moonshot.api_key_env,
2773 VendorId::Grok => &self.grok.api_key_env,
2774 VendorId::Minimax => &self.minimax.api_key_env,
2775 VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
2776 VendorId::Ollama => &self.ollama.api_key_env,
2777 VendorId::OrcaRouter => &self.orcarouter.api_key_env,
2778 VendorId::Lyceum => &self.lyceum.api_key_env,
2779 VendorId::Cline => &self.cline.api_key_env,
2780 VendorId::Anthropic
2783 | VendorId::Openai
2784 | VendorId::Copilot
2785 | VendorId::Supergrok
2786 | VendorId::Grokbot
2787 | VendorId::Antigravity
2788 | VendorId::Cursor
2789 | VendorId::Kiro
2790 | VendorId::NousResearch
2791 | VendorId::CommandCode
2792 | VendorId::ModelStudio
2793 | VendorId::Devin
2794 | VendorId::MuseCode
2795 | VendorId::Droid => id.api_key_env(),
2796 }
2797 }
2798
2799 pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
2803 let raw = match id {
2804 VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
2805 VendorId::Zai => self.zai.api_key.as_deref(),
2806 VendorId::Openrouter => self.openrouter.api_key.as_deref(),
2807 VendorId::Deepseek => self.deepseek.api_key.as_deref(),
2808 VendorId::Deepinfra => self.deepinfra.api_key.as_deref(),
2809 VendorId::Kimi => self.kimi.api_key.as_deref(),
2810 VendorId::Kilo => self.kilo.api_key.as_deref(),
2811 VendorId::Novita => self.novita.api_key.as_deref(),
2812 VendorId::Synthetic => self.synthetic.api_key.as_deref(),
2813 VendorId::Moonshot => self.moonshot.api_key.as_deref(),
2814 VendorId::Grok => self.grok.api_key.as_deref(),
2815 VendorId::Minimax => self.minimax.api_key.as_deref(),
2816 VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
2817 VendorId::Ollama => self.ollama.api_key.as_deref(),
2818 VendorId::OrcaRouter => self.orcarouter.api_key.as_deref(),
2819 VendorId::Lyceum => self.lyceum.api_key.as_deref(),
2820 VendorId::Cline => self.cline.api_key.as_deref(),
2821 VendorId::Anthropic
2822 | VendorId::Openai
2823 | VendorId::Copilot
2824 | VendorId::Supergrok
2825 | VendorId::Grokbot
2826 | VendorId::Antigravity
2827 | VendorId::Cursor
2828 | VendorId::Kiro
2829 | VendorId::NousResearch
2830 | VendorId::CommandCode
2831 | VendorId::ModelStudio
2832 | VendorId::Devin
2833 | VendorId::MuseCode
2834 | VendorId::Droid => None,
2835 };
2836 raw.filter(|key| !key.is_empty())
2837 }
2838
2839 pub const API_KEY_ACCOUNT_VENDORS: [VendorId; 11] = [
2843 VendorId::Zai,
2844 VendorId::Openrouter,
2845 VendorId::Deepseek,
2846 VendorId::Deepinfra,
2847 VendorId::Kilo,
2848 VendorId::Novita,
2849 VendorId::Moonshot,
2850 VendorId::Grok,
2851 VendorId::Minimax,
2852 VendorId::OrcaRouter,
2853 VendorId::Lyceum,
2854 ];
2855
2856 pub fn api_key_accounts(&self, id: VendorId) -> Option<&[ApiKeyAccount]> {
2859 match id {
2860 VendorId::Zai => Some(&self.zai.accounts),
2861 VendorId::Openrouter => Some(&self.openrouter.accounts),
2862 VendorId::Deepseek => Some(&self.deepseek.accounts),
2863 VendorId::Deepinfra => Some(&self.deepinfra.accounts),
2864 VendorId::Kilo => Some(&self.kilo.accounts),
2865 VendorId::Novita => Some(&self.novita.accounts),
2866 VendorId::Moonshot => Some(&self.moonshot.accounts),
2867 VendorId::Grok => Some(&self.grok.accounts),
2868 VendorId::Minimax => Some(&self.minimax.accounts),
2869 VendorId::OrcaRouter => Some(&self.orcarouter.accounts),
2870 VendorId::Lyceum => Some(&self.lyceum.accounts),
2871 _ => None,
2872 }
2873 }
2874
2875 pub fn show_default_api_key_account(&self, id: VendorId) -> bool {
2878 match id {
2879 VendorId::Zai => self.zai.show_default_account,
2880 VendorId::Openrouter => self.openrouter.show_default_account,
2881 VendorId::Deepseek => self.deepseek.show_default_account,
2882 VendorId::Deepinfra => self.deepinfra.show_default_account,
2883 VendorId::Kilo => self.kilo.show_default_account,
2884 VendorId::Novita => self.novita.show_default_account,
2885 VendorId::Moonshot => self.moonshot.show_default_account,
2886 VendorId::Grok => self.grok.show_default_account,
2887 VendorId::Minimax => self.minimax.show_default_account,
2888 VendorId::OrcaRouter => self.orcarouter.show_default_account,
2889 VendorId::Lyceum => self.lyceum.show_default_account,
2890 _ => true,
2891 }
2892 }
2893
2894 pub fn resolve_account_api_key_for(&self, id: VendorId, label: Option<&str>) -> Result<String> {
2900 let name = match id {
2903 VendorId::Zai => "Zai",
2904 other => other.display_name(),
2905 };
2906 let Some(label) = label else {
2907 return resolve_api_key(name, self.api_key_env_for(id), self.inline_api_key(id));
2908 };
2909 let slug = id.config_section();
2910 let account = api_key_account(slug, self.api_key_accounts(id).unwrap_or(&[]), label)?;
2911 resolve_api_key_in_section(
2913 &format!("{name} account {label:?}"),
2914 &format!("[[{slug}.accounts]]"),
2915 account.api_key_env.as_deref().unwrap_or(""),
2916 account.api_key.as_deref(),
2917 )
2918 }
2919
2920 pub fn openrouter_management_key(&self, label: Option<&str>) -> Option<String> {
2927 let env_name = match label {
2928 Some(label) => api_key_account("openrouter", &self.openrouter.accounts, label)
2929 .ok()?
2930 .management_api_key_env
2931 .as_deref()?,
2932 None => self.openrouter.management_api_key_env.as_str(),
2933 };
2934 optional_api_key(env_name, None)
2935 }
2936
2937 pub fn display_prefs(&self, vendor: VendorId) -> DisplayPrefs {
2942 match vendor {
2943 VendorId::Deepseek => {
2944 DisplayPrefs::balance(self.deepseek.display_limit, self.deepseek.headline)
2945 }
2946 VendorId::Deepinfra => {
2947 DisplayPrefs::balance(self.deepinfra.display_limit, self.deepinfra.headline)
2948 }
2949 VendorId::Kilo => DisplayPrefs::balance(self.kilo.display_limit, self.kilo.headline),
2950 VendorId::Novita => {
2951 DisplayPrefs::balance(self.novita.display_limit, self.novita.headline)
2952 }
2953 VendorId::Moonshot => {
2954 DisplayPrefs::balance(self.moonshot.display_limit, self.moonshot.headline)
2955 }
2956 VendorId::Grok => DisplayPrefs::balance(self.grok.display_limit, self.grok.headline),
2957 VendorId::Openrouter => DisplayPrefs::balance(None, self.openrouter.headline),
2960 VendorId::NousResearch => DisplayPrefs::balance(None, self.nous.headline),
2963 _ => DisplayPrefs::default(),
2964 }
2965 }
2966
2967 pub fn enabled_vendors(&self) -> Vec<VendorId> {
2968 VendorId::all()
2969 .iter()
2970 .copied()
2971 .filter(|id| self.is_enabled(*id))
2972 .collect()
2973 }
2974
2975 pub fn validate(&self) -> Result<()> {
2979 if let Some(minutes) = self.tray.refresh_minutes
2980 && !TRAY_REFRESH_MINUTES.contains(&minutes)
2981 {
2982 return Err(AppError::Other(format!(
2983 "[tray] refresh_minutes must be one of 1, 5, 10, 15, 30 or 60, got {minutes}"
2984 )));
2985 }
2986 if !(1..=100).contains(&self.notifications.threshold) {
2987 return Err(AppError::Other(format!(
2988 "[notifications] threshold must be between 1 and 100, got {}",
2989 self.notifications.threshold
2990 )));
2991 }
2992 if self.context.context_window_tokens == Some(0) {
2993 return Err(AppError::Other(
2994 "[context] context_window_tokens must be greater than zero".into(),
2995 ));
2996 }
2997 for (model, tokens) in &self.context.model_context_window_tokens {
2998 if model.trim().is_empty() {
2999 return Err(AppError::Other(
3000 "[context] model_context_window_tokens keys must not be empty".into(),
3001 ));
3002 }
3003 if *tokens == 0 {
3004 return Err(AppError::Other(format!(
3005 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
3006 )));
3007 }
3008 }
3009 if let Some(limit) = self.anthropic_api.monthly_limit
3010 && (!limit.is_finite() || limit <= 0.0)
3011 {
3012 return Err(AppError::Other(
3013 "[anthropic_api] monthly_limit must be finite and greater than zero; \
3014 remove it to show spend without a limit"
3015 .into(),
3016 ));
3017 }
3018 for (section, limit) in [
3022 ("deepseek", self.deepseek.display_limit),
3023 ("deepinfra", self.deepinfra.display_limit),
3024 ("kilo", self.kilo.display_limit),
3025 ("novita", self.novita.display_limit),
3026 ("moonshot", self.moonshot.display_limit),
3027 ("grok", self.grok.display_limit),
3028 ] {
3029 if let Some(limit) = limit
3030 && (!limit.is_finite() || limit <= 0.0)
3031 {
3032 return Err(AppError::Other(format!(
3033 "[{section}] display_limit must be finite and greater than zero; \
3034 remove it to show the balance without a limit"
3035 )));
3036 }
3037 }
3038 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
3039 && !self.kimi.region.eq_ignore_ascii_case("auto")
3040 {
3041 return Err(AppError::Other(format!(
3042 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
3043 self.kimi.region
3044 )));
3045 }
3046 if !self.minimax.region.eq_ignore_ascii_case("global")
3047 && !self.minimax.region.eq_ignore_ascii_case("cn")
3048 {
3049 return Err(AppError::Other(format!(
3050 "[minimax] region must be \"global\" or \"cn\", got {:?}",
3051 self.minimax.region
3052 )));
3053 }
3054 if self.supergrok.grok_binary.as_os_str().is_empty() {
3055 return Err(AppError::Other(
3056 "[supergrok] grok_binary must not be empty".into(),
3057 ));
3058 }
3059 if self
3060 .antigravity
3061 .agy_binary
3062 .as_ref()
3063 .is_some_and(|path| path.as_os_str().is_empty())
3064 {
3065 return Err(AppError::Other(
3066 "[antigravity] agy_binary must not be empty".into(),
3067 ));
3068 }
3069 let mut labels = HashSet::new();
3070 for account in &self.anthropic.accounts {
3071 validate_account_label(&account.label)?;
3072 if !labels.insert(&account.label) {
3073 return Err(AppError::Credentials(format!(
3074 "duplicate anthropic account label {:?}",
3075 account.label
3076 )));
3077 }
3078 }
3079 let mut openai_labels = HashSet::new();
3080 for account in &self.openai.accounts {
3081 validate_account_label_for("openai", &account.label)?;
3082 if !openai_labels.insert(&account.label) {
3083 return Err(AppError::Credentials(format!(
3084 "duplicate openai account label {:?}",
3085 account.label
3086 )));
3087 }
3088 }
3089 let mut devin_labels = HashSet::new();
3090 for account in &self.devin.accounts {
3091 validate_account_label_for("devin", &account.label)?;
3092 if !devin_labels.insert(&account.label) {
3093 return Err(AppError::Credentials(format!(
3094 "duplicate devin account label {:?}",
3095 account.label
3096 )));
3097 }
3098 }
3099 for id in Self::API_KEY_ACCOUNT_VENDORS {
3103 let slug = id.config_section();
3104 let mut labels = HashSet::new();
3105 for account in self.api_key_accounts(id).unwrap_or(&[]) {
3106 validate_account_label_for(slug, &account.label)?;
3107 if !labels.insert(&account.label) {
3108 return Err(AppError::Credentials(format!(
3109 "duplicate {slug} account label {:?}",
3110 account.label
3111 )));
3112 }
3113 let has_env = account
3114 .api_key_env
3115 .as_deref()
3116 .is_some_and(|name| !name.is_empty());
3117 let has_inline = account
3118 .api_key
3119 .as_deref()
3120 .is_some_and(|key| !key.is_empty());
3121 if !has_env && !has_inline {
3122 return Err(AppError::Credentials(format!(
3123 "{slug} account {:?} must set api_key_env or api_key",
3124 account.label
3125 )));
3126 }
3127 }
3128 }
3129 self.validate_custom()
3130 }
3131
3132 fn validate_custom(&self) -> Result<()> {
3136 let mut ids = HashSet::new();
3137 let mut short_names: HashSet<&str> =
3138 VendorId::all().iter().map(|v| v.short_name()).collect();
3139 for (index, custom) in self.custom.iter().enumerate() {
3140 custom.validate(index)?;
3141 if !ids.insert(custom.id.as_str()) {
3142 return Err(AppError::Other(format!(
3143 "{}: duplicate id",
3144 custom.section_label()
3145 )));
3146 }
3147 if !short_names.insert(custom.short_name.as_str()) {
3148 return Err(AppError::Other(format!(
3149 "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
3150 custom.section_label(),
3151 custom.short_name
3152 )));
3153 }
3154 }
3155 Ok(())
3156 }
3157}
3158
3159#[cfg(unix)]
3160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3161enum InlineKeyPermissionDecision {
3162 Ok,
3163 Tighten,
3164}
3165
3166#[cfg(unix)]
3167fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
3168 if mode & 0o077 == 0 {
3169 InlineKeyPermissionDecision::Ok
3170 } else {
3171 InlineKeyPermissionDecision::Tighten
3172 }
3173}
3174
3175pub fn default_path() -> Option<PathBuf> {
3176 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
3177 Some(proj.config_dir().join("config.toml"))
3178}
3179
3180fn legacy_xdg_path() -> Option<PathBuf> {
3185 let home = crate::cache::home_dir().ok()?;
3186 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
3187}
3188
3189pub fn resolved_path() -> Option<PathBuf> {
3200 if let Some(path) = override_path() {
3201 return Some(path);
3202 }
3203 let canonical = default_path();
3204 if let Some(p) = &canonical
3205 && p.exists()
3206 {
3207 return canonical;
3208 }
3209 if let Some(legacy) = legacy_xdg_path()
3210 && legacy.exists()
3211 {
3212 return Some(legacy);
3213 }
3214 canonical
3215}
3216
3217static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
3218
3219pub fn set_override_path(path: &std::path::Path) {
3225 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
3226 *slot = Some(path.to_path_buf());
3227 }
3228}
3229
3230#[doc(hidden)]
3233pub fn clear_override_path() {
3234 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
3235 *slot = None;
3236 }
3237}
3238
3239fn override_path() -> Option<PathBuf> {
3240 PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
3241}
3242
3243#[doc(hidden)]
3249pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
3250 #[cfg(unix)]
3251 {
3252 use std::os::unix::ffi::{OsStrExt, OsStringExt};
3253 let rest = arg.as_bytes().strip_prefix(b"--config=")?;
3254 Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
3255 }
3256 #[cfg(windows)]
3257 {
3258 use std::os::windows::ffi::{OsStrExt, OsStringExt};
3259 const PREFIX: &[u16] = &[
3260 b'-' as u16,
3261 b'-' as u16,
3262 b'c' as u16,
3263 b'o' as u16,
3264 b'n' as u16,
3265 b'f' as u16,
3266 b'i' as u16,
3267 b'g' as u16,
3268 b'=' as u16,
3269 ];
3270 let wide: Vec<u16> = arg.encode_wide().collect();
3271 let rest = wide.strip_prefix(PREFIX)?;
3272 Some(std::ffi::OsString::from_wide(rest).into())
3273 }
3274 #[cfg(not(any(unix, windows)))]
3275 {
3276 Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
3277 }
3278}
3279
3280fn expand_tilde(p: &std::path::Path) -> PathBuf {
3283 let Some(s) = p.to_str() else {
3284 return p.to_path_buf();
3285 };
3286 let rest = if s == "~" {
3287 ""
3288 } else if let Some(r) = s.strip_prefix("~/") {
3289 r
3290 } else {
3291 return p.to_path_buf();
3292 };
3293 match crate::cache::home_dir() {
3294 Ok(home) if rest.is_empty() => home,
3295 Ok(home) => home.join(rest),
3296 Err(_) => p.to_path_buf(),
3297 }
3298}
3299
3300fn expand_tilde_opt(p: &mut Option<PathBuf>) {
3301 if let Some(inner) = p.as_ref() {
3302 *p = Some(expand_tilde(inner));
3303 }
3304}
3305
3306pub fn config_path_hint() -> String {
3311 resolved_path()
3312 .map(|p| p.display().to_string())
3313 .unwrap_or_else(|| "config.toml".to_string())
3314}
3315
3316#[cfg(test)]
3317mod tests {
3318 use super::*;
3319 use std::io::Write;
3320 use tempfile::NamedTempFile;
3321
3322 #[cfg(unix)]
3323 use std::os::unix::fs::{MetadataExt, PermissionsExt};
3324
3325 fn write_toml(s: &str) -> NamedTempFile {
3326 let mut f = NamedTempFile::new().unwrap();
3327 f.write_all(s.as_bytes()).unwrap();
3328 f.flush().unwrap();
3329 f
3330 }
3331
3332 #[test]
3336 fn openai_without_accounts_resolves_the_singular_path() {
3337 let explicit = OpenAiConfig {
3338 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
3339 ..OpenAiConfig::default()
3340 };
3341 assert_eq!(
3342 explicit.resolve_auth_path(None).unwrap(),
3343 PathBuf::from("/tmp/codex/auth.json")
3344 );
3345
3346 let bare = OpenAiConfig::default();
3347 assert_eq!(
3348 bare.resolve_auth_path(None).unwrap(),
3349 crate::openai::creds::default_path().unwrap(),
3350 "no codex_auth_path must still mean ~/.codex/auth.json"
3351 );
3352 }
3353
3354 #[test]
3357 fn openai_named_accounts_resolve_their_own_auth_file() {
3358 let config: Config = toml::from_str(
3359 r#"
3360 [openai]
3361 codex_auth_path = "/tmp/personal/auth.json"
3362 [[openai.accounts]]
3363 label = "work"
3364 codex_auth_path = "/tmp/work/auth.json"
3365 "#,
3366 )
3367 .unwrap();
3368
3369 assert_eq!(
3370 config.openai.resolve_auth_path(Some("work")).unwrap(),
3371 PathBuf::from("/tmp/work/auth.json")
3372 );
3373 assert_eq!(
3374 config.openai.resolve_auth_path(None).unwrap(),
3375 PathBuf::from("/tmp/personal/auth.json")
3376 );
3377 }
3378
3379 #[test]
3382 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
3383 let config = OpenAiConfig {
3384 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
3385 accounts: vec![OpenAiAccount {
3386 label: "work".into(),
3387 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
3388 }],
3389 ..OpenAiConfig::default()
3390 };
3391 let err = config
3392 .resolve_auth_path(Some("nope"))
3393 .unwrap_err()
3394 .to_string();
3395 assert!(err.contains("nope"), "{err}");
3396 assert!(err.contains("[[openai.accounts]]"), "{err}");
3397 }
3398
3399 fn two_codex_accounts() -> OpenAiConfig {
3400 OpenAiConfig {
3401 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
3402 accounts: vec![
3403 OpenAiAccount {
3404 label: "main".into(),
3405 codex_auth_path: PathBuf::from("/tmp/codex-main/auth.json"),
3406 },
3407 OpenAiAccount {
3408 label: "work".into(),
3409 codex_auth_path: PathBuf::from("/tmp/codex-work/auth.json"),
3410 },
3411 ],
3412 ..OpenAiConfig::default()
3413 }
3414 }
3415
3416 #[test]
3417 fn the_active_codex_account_is_read_from_the_default_slot() {
3418 let config = two_codex_accounts();
3419 assert_eq!(
3420 config
3421 .fetch_auth_path_probing("work", Some("work"), |_| false)
3422 .unwrap(),
3423 PathBuf::from("/tmp/codex/auth.json")
3424 );
3425 assert_eq!(
3426 config
3427 .fetch_auth_path_probing("main", Some("work"), |_| false)
3428 .unwrap(),
3429 PathBuf::from("/tmp/codex-main/auth.json")
3430 );
3431 }
3432
3433 #[test]
3434 fn an_active_codex_account_with_its_own_file_keeps_reading_it() {
3435 let config = two_codex_accounts();
3436 assert_eq!(
3437 config
3438 .fetch_auth_path_probing("work", Some("work"), |_| true)
3439 .unwrap(),
3440 PathBuf::from("/tmp/codex-work/auth.json")
3441 );
3442 }
3443
3444 #[test]
3445 fn adding_an_openai_account_keeps_the_rest_of_the_file() {
3446 let mut doc: toml_edit::DocumentMut = "# mine\n[zai]\nenabled = true\n".parse().unwrap();
3447 add_openai_account_to_doc(&mut doc, "work", "~/.codex-work/auth.json").unwrap();
3448 let text = doc.to_string();
3449 assert!(
3450 text.starts_with("# mine\n[zai]\nenabled = true\n"),
3451 "{text}"
3452 );
3453 let parsed: Config = toml::from_str(&text).unwrap();
3454 assert_eq!(parsed.openai.accounts[0].label, "work");
3455 assert!(add_openai_account_to_doc(&mut doc, "work", "x").is_err());
3456 assert!(add_openai_account_to_doc(&mut doc, "../x", "x").is_err());
3457 }
3458
3459 #[test]
3460 fn defaults_enable_only_the_four_core_vendors() {
3461 let c = Config::default();
3462 assert!(c.is_enabled(VendorId::Anthropic));
3463 assert!(c.is_enabled(VendorId::Openai));
3464 assert!(c.is_enabled(VendorId::Zai));
3465 assert!(c.is_enabled(VendorId::Openrouter));
3466 for opt_in in [
3467 VendorId::AnthropicApi,
3468 VendorId::Copilot,
3469 VendorId::Deepseek,
3470 VendorId::Kimi,
3471 VendorId::Kilo,
3472 VendorId::Novita,
3473 VendorId::Moonshot,
3474 VendorId::Grok,
3475 VendorId::Supergrok,
3476 VendorId::Grokbot,
3477 VendorId::Cursor,
3478 VendorId::Minimax,
3479 VendorId::Kiro,
3480 VendorId::CommandCode,
3481 VendorId::OrcaRouter,
3482 VendorId::ModelStudio,
3483 VendorId::Devin,
3484 VendorId::Droid,
3485 ] {
3486 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
3487 }
3488 assert_eq!(c.enabled_vendors().len(), 4);
3489 }
3490
3491 #[test]
3492 fn commandcode_is_opt_in_when_loading_existing_configs() {
3493 let absent: Config = toml::from_str("[openai]\nenabled = true\n").unwrap();
3494 assert!(!absent.is_enabled(VendorId::CommandCode));
3495
3496 let opted_in: Config = toml::from_str("[commandcode]\nenabled = true\n").unwrap();
3497 assert!(opted_in.is_enabled(VendorId::CommandCode));
3498
3499 let opted_out: Config = toml::from_str("[commandcode]\nenabled = false\n").unwrap();
3500 assert!(!opted_out.is_enabled(VendorId::CommandCode));
3501 }
3502
3503 #[test]
3504 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
3505 let config = Config::default();
3506 assert!(!config.is_enabled(VendorId::NousResearch));
3507 assert!(!config.is_enabled(VendorId::OpenCodeGo));
3508 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
3509 assert!(config.opencode_go.api_key.is_none());
3510 assert!(!config.is_enabled(VendorId::Copilot));
3511 }
3512
3513 #[cfg(unix)]
3514 #[test]
3515 fn inline_credentials_are_protected() {
3516 let mut config = Config::default();
3517 config.opencode_go.api_key = Some("<redacted>".to_string());
3518 assert!(config.has_inline_secrets());
3519 }
3520
3521 #[test]
3522 fn antigravity_oauth_client_overrides_parse() {
3523 let config: Config = toml::from_str(
3524 "[antigravity]
3525enabled = true
3526oauth_client_id = \"test-client\"
3527oauth_client_secret = \"test-client-secret\"
3528",
3529 )
3530 .unwrap();
3531 assert!(config.antigravity.enabled);
3532 assert_eq!(
3533 config.antigravity.oauth_client_id.as_deref(),
3534 Some("test-client")
3535 );
3536 assert_eq!(
3537 config.antigravity.oauth_client_secret.as_deref(),
3538 Some("test-client-secret")
3539 );
3540 let bare: Config = toml::from_str(
3541 "[antigravity]
3542enabled = true
3543",
3544 )
3545 .unwrap();
3546 assert!(bare.antigravity.oauth_client_id.is_none());
3547 assert!(bare.antigravity.oauth_client_secret.is_none());
3548 }
3549
3550 #[test]
3553 fn antigravity_ide_language_server_defaults_to_probing() {
3554 assert!(Config::default().antigravity.ide_language_server);
3555 let bare: Config = toml::from_str(
3556 "[antigravity]
3557enabled = true
3558",
3559 )
3560 .unwrap();
3561 assert!(bare.antigravity.ide_language_server);
3562 let opted_out: Config = toml::from_str(
3563 "[antigravity]
3564enabled = true
3565ide_language_server = false
3566",
3567 )
3568 .unwrap();
3569 assert!(!opted_out.antigravity.ide_language_server);
3570 }
3571
3572 #[test]
3573 fn antigravity_agy_binary_is_optional_and_tilde_expanded() {
3574 assert!(Config::default().antigravity.agy_binary.is_none());
3575 let file = write_toml(
3576 r#"
3577 [antigravity]
3578 enabled = true
3579 agy_binary = "~/bin/agy"
3580 "#,
3581 );
3582 let config = Config::load_from(file.path()).unwrap();
3583 assert_eq!(
3584 config.antigravity.agy_binary,
3585 Some(crate::cache::home_dir().unwrap().join("bin/agy"))
3586 );
3587 }
3588
3589 #[test]
3590 fn antigravity_agy_binary_must_not_be_empty() {
3591 let file = write_toml(
3592 r#"
3593 [antigravity]
3594 agy_binary = ""
3595 "#,
3596 );
3597 let error = Config::load_from(file.path()).unwrap_err().to_string();
3598 assert!(error.contains("[antigravity] agy_binary must not be empty"));
3599 }
3600
3601 #[cfg(unix)]
3602 #[test]
3603 fn antigravity_inline_oauth_secret_receives_config_file_protection() {
3604 let mut config = Config::default();
3605 config.antigravity.oauth_client_id = Some("test-client".into());
3606 assert!(!config.has_inline_secrets());
3607 config.antigravity.oauth_client_secret = Some("<redacted>".into());
3608 assert!(config.has_inline_secrets());
3609 }
3610
3611 #[cfg(unix)]
3612 #[test]
3613 fn openrouter_named_inline_keys_receive_config_file_protection() {
3614 let mut config = Config::default();
3615 config.openrouter.accounts.push(ApiKeyAccount {
3616 label: "work".into(),
3617 api_key_env: None,
3618 api_key: Some("<redacted>".into()),
3619 management_api_key_env: None,
3620 });
3621 assert!(config.has_inline_secrets());
3622 }
3623
3624 #[cfg(unix)]
3625 #[test]
3626 fn every_named_inline_api_key_receives_config_file_protection() {
3627 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
3628 let section = vendor.config_section();
3629 let config: Config = toml::from_str(&format!(
3630 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"<redacted>\"\n"
3631 ))
3632 .unwrap();
3633 assert!(config.has_inline_secrets(), "{vendor:?}");
3634 }
3635 }
3636
3637 #[cfg(unix)]
3641 #[test]
3642 fn every_inline_api_key_receives_config_file_protection() {
3643 for &vendor in VendorId::all() {
3644 let section = vendor.config_section();
3645 let config: Config =
3646 toml::from_str(&format!("[{section}]\napi_key = \"<redacted>\"\n")).unwrap();
3647 if config.inline_api_key(vendor).is_some() {
3648 assert!(config.has_inline_secrets(), "{vendor:?}");
3649 }
3650 }
3651 }
3652
3653 #[test]
3654 fn missing_file_uses_defaults() {
3655 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
3656 let c = Config::load_from(path).unwrap();
3657 assert!(c.is_enabled(VendorId::Anthropic));
3658 }
3659
3660 #[test]
3661 fn parses_full_config() {
3662 let f = write_toml(
3663 r#"
3664 [anthropic]
3665 enabled = true
3666
3667 [openai]
3668 enabled = false
3669 admin_key_env = "MY_ADMIN_KEY"
3670
3671 [zai]
3672 enabled = true
3673 api_key_env = "MY_ZAI"
3674 plan_tier = "pro"
3675
3676 [openrouter]
3677 enabled = false
3678 "#,
3679 );
3680 let c = Config::load_from(f.path()).unwrap();
3681 assert!(c.is_enabled(VendorId::Anthropic));
3682 assert!(!c.is_enabled(VendorId::Openai));
3683 assert!(c.is_enabled(VendorId::Zai));
3684 assert!(!c.is_enabled(VendorId::Openrouter));
3685 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
3686 assert_eq!(c.zai.api_key_env, "MY_ZAI");
3687 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
3688 assert!(c.openrouter.accounts.is_empty());
3689 assert!(c.openrouter.show_default_account);
3690 }
3691
3692 #[test]
3693 fn partial_config_falls_back_to_defaults() {
3694 let f = write_toml(
3695 r#"[openai]
3696enabled = false
3697"#,
3698 );
3699 let c = Config::load_from(f.path()).unwrap();
3700 assert!(!c.is_enabled(VendorId::Openai));
3701 assert!(c.is_enabled(VendorId::Anthropic));
3703 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
3704 }
3705
3706 #[test]
3707 fn malformed_toml_returns_error() {
3708 let f = write_toml("this is not = = valid");
3709 assert!(Config::load_from(f.path()).is_err());
3710 }
3711
3712 #[cfg(unix)]
3713 #[test]
3714 fn load_from_tightens_world_readable_config_with_inline_api_key() {
3715 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
3716 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3717
3718 Config::load_from(file.path()).unwrap();
3719
3720 assert_eq!(
3721 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3722 0o600
3723 );
3724 }
3725
3726 #[cfg(unix)]
3727 #[test]
3728 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
3729 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
3730 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3731
3732 Config::load_from(file.path()).unwrap();
3733
3734 assert_eq!(
3735 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3736 0o644
3737 );
3738 }
3739
3740 #[cfg(unix)]
3741 #[test]
3742 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
3743 assert_eq!(
3744 inline_key_permission_decision(0o600),
3745 InlineKeyPermissionDecision::Ok
3746 );
3747 assert_eq!(
3748 inline_key_permission_decision(0o640),
3749 InlineKeyPermissionDecision::Tighten
3750 );
3751 assert_eq!(
3752 inline_key_permission_decision(0o604),
3753 InlineKeyPermissionDecision::Tighten
3754 );
3755 }
3756
3757 #[test]
3758 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
3759 for value in ["0", "-1", "inf", "nan"] {
3760 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
3761 let error = Config::load_from(file.path()).unwrap_err().to_string();
3762 assert!(error.contains("monthly_limit"), "value {value}: {error}");
3763 }
3764
3765 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
3766 assert_eq!(
3767 Config::load_from(file.path())
3768 .unwrap()
3769 .anthropic_api
3770 .monthly_limit,
3771 Some(1000.0)
3772 );
3773 }
3774
3775 #[test]
3776 fn display_limit_must_be_positive_and_finite_on_every_balance_vendor() {
3777 for section in ["deepseek", "kilo", "novita", "moonshot", "grok"] {
3779 for value in ["0", "-1", "inf", "nan"] {
3780 let file = write_toml(&format!("[{section}]\ndisplay_limit = {value}\n"));
3781 let error = Config::load_from(file.path()).unwrap_err().to_string();
3782 assert!(
3783 error.contains(&format!("[{section}] display_limit")),
3784 "{section} = {value}: {error}"
3785 );
3786 }
3787 let file = write_toml(&format!("[{section}]\ndisplay_limit = 200\n"));
3788 let config = Config::load_from(file.path()).unwrap();
3789 assert_eq!(
3790 config.display_prefs(vendor_of(section)).display_limit,
3791 Some(200.0),
3792 "{section}"
3793 );
3794 }
3795 }
3796
3797 #[test]
3799 fn display_limit_is_absent_until_the_user_states_one() {
3800 let config = Config::default();
3801 for vendor in VendorId::all() {
3802 assert_eq!(
3803 config.display_prefs(*vendor).display_limit,
3804 None,
3805 "{vendor:?}"
3806 );
3807 }
3808 }
3809
3810 #[test]
3813 fn the_default_headline_follows_the_kind_of_vendor() {
3814 let config = Config::default();
3815 for vendor in [
3816 VendorId::Deepseek,
3817 VendorId::Kilo,
3818 VendorId::Novita,
3819 VendorId::Moonshot,
3820 VendorId::Grok,
3821 ] {
3822 assert_eq!(
3823 config.display_prefs(vendor).headline,
3824 Headline::Amount,
3825 "{vendor:?}"
3826 );
3827 }
3828 assert_eq!(
3829 config.display_prefs(VendorId::Openrouter).headline,
3830 Headline::Percent
3831 );
3832 assert_eq!(
3833 config.display_prefs(VendorId::Anthropic),
3834 DisplayPrefs::default()
3835 );
3836 }
3837
3838 #[test]
3839 fn the_headline_is_configurable_per_vendor_and_a_typo_is_loud() {
3840 let file = write_toml("[deepseek]\nheadline = \"percent\"\n");
3841 assert_eq!(
3842 Config::load_from(file.path())
3843 .unwrap()
3844 .display_prefs(VendorId::Deepseek)
3845 .headline,
3846 Headline::Percent
3847 );
3848
3849 let file = write_toml("[openrouter]\nheadline = \"amount\"\n");
3850 assert_eq!(
3851 Config::load_from(file.path())
3852 .unwrap()
3853 .display_prefs(VendorId::Openrouter)
3854 .headline,
3855 Headline::Amount
3856 );
3857
3858 let file = write_toml("[deepseek]\nheadline = \"dollars\"\n");
3859 let error = Config::load_from(file.path()).unwrap_err().to_string();
3860 assert!(error.contains("headline"), "{error}");
3861 }
3862
3863 #[test]
3869 fn openrouter_has_no_display_limit_to_be_ignored() {
3870 let file = write_toml("[openrouter]\ndisplay_limit = 200\nheadline = \"percent\"\n");
3871 let config = Config::load_from(file.path()).unwrap();
3872 let prefs = config.display_prefs(VendorId::Openrouter);
3873 assert_eq!(prefs.display_limit, None);
3874 assert_eq!(prefs.headline, Headline::Percent);
3875 }
3876
3877 #[test]
3880 fn a_display_limit_alone_leaves_the_headline_where_it_was() {
3881 let file = write_toml("[deepseek]\ndisplay_limit = 200\n");
3882 let prefs = Config::load_from(file.path())
3883 .unwrap()
3884 .display_prefs(VendorId::Deepseek);
3885 assert_eq!(prefs.display_limit, Some(200.0));
3886 assert_eq!(prefs.headline, Headline::Amount);
3887 }
3888
3889 fn vendor_of(section: &str) -> VendorId {
3890 VendorId::all()
3891 .iter()
3892 .copied()
3893 .find(|vendor| vendor.config_section() == section)
3894 .unwrap_or_else(|| panic!("no vendor for [{section}]"))
3895 }
3896
3897 #[test]
3898 fn minimax_region_accepts_only_known_instances() {
3899 for region in ["global", "GLOBAL", "cn", "CN"] {
3900 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3901 assert_eq!(
3902 Config::load_from(file.path()).unwrap().minimax.region,
3903 region
3904 );
3905 }
3906
3907 for region in ["", "china", "us"] {
3908 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3909 let error = Config::load_from(file.path()).unwrap_err().to_string();
3910 assert!(error.contains("[minimax] region"), "{error}");
3911 }
3912 }
3913
3914 #[test]
3915 fn kimi_region_accepts_auto_and_both_deployments() {
3916 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
3917 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3918 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
3919 }
3920
3921 for region in ["", "us", "oversea"] {
3922 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3923 let error = Config::load_from(file.path()).unwrap_err().to_string();
3924 assert!(error.contains("[kimi] region"), "{error}");
3925 }
3926 }
3927
3928 #[test]
3929 fn kimi_defaults_to_auto_region_and_no_credential_override() {
3930 let defaults = KimiConfig::default();
3931 assert_eq!(defaults.region, "auto");
3932 assert_eq!(defaults.credentials_path, None);
3933 assert!(!defaults.enabled);
3934 }
3935
3936 #[test]
3937 fn kimi_credentials_path_expands_a_tilde() {
3938 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
3939 let path = Config::load_from(file.path())
3940 .unwrap()
3941 .kimi
3942 .credentials_path
3943 .unwrap();
3944 assert!(!path.starts_with("~"), "{}", path.display());
3945 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
3946 }
3947
3948 #[test]
3949 fn grokbot_is_opt_in_and_takes_no_api_key() {
3950 let defaults = GrokbotConfig::default();
3951 assert!(!defaults.enabled);
3952 assert_eq!(defaults.secrets_path, None);
3953 let config = Config::default();
3955 assert_eq!(config.api_key_env_for(VendorId::Grokbot), "");
3956 assert_eq!(config.inline_api_key(VendorId::Grokbot), None);
3957
3958 let file = write_toml("[grokbot]\nenabled = true\n");
3959 let config = Config::load_from(file.path()).unwrap();
3960 assert!(config.is_enabled(VendorId::Grokbot));
3961 assert!(config.enabled_vendors().contains(&VendorId::Grokbot));
3962 }
3963
3964 #[test]
3965 fn grokbot_secrets_path_expands_a_tilde() {
3966 let file = write_toml("[grokbot]\nsecrets_path = \"~/gb/secrets.json\"\n");
3967 let path = Config::load_from(file.path())
3968 .unwrap()
3969 .grokbot
3970 .secrets_path
3971 .unwrap();
3972 assert!(!path.starts_with("~"), "{}", path.display());
3973 assert!(path.ends_with("gb/secrets.json"), "{}", path.display());
3974 }
3975
3976 #[test]
3977 fn modelstudio_is_opt_in_and_takes_no_api_key() {
3978 let defaults = ModelStudioConfig::default();
3979 assert!(!defaults.enabled);
3980 assert_eq!(defaults.config_dir, None);
3981 let config = Config::default();
3983 assert_eq!(config.api_key_env_for(VendorId::ModelStudio), "");
3984 assert_eq!(config.inline_api_key(VendorId::ModelStudio), None);
3985
3986 let file = write_toml("[modelstudio]\nenabled = true\n");
3987 let config = Config::load_from(file.path()).unwrap();
3988 assert!(config.is_enabled(VendorId::ModelStudio));
3989 assert!(config.enabled_vendors().contains(&VendorId::ModelStudio));
3990 }
3991
3992 #[test]
3993 fn modelstudio_config_dir_expands_a_tilde() {
3994 let file = write_toml("[modelstudio]\nconfig_dir = \"~/bl\"\n");
3995 let path = Config::load_from(file.path())
3996 .unwrap()
3997 .modelstudio
3998 .config_dir
3999 .unwrap();
4000 assert!(!path.starts_with("~"), "{}", path.display());
4001 assert!(path.ends_with("bl"), "{}", path.display());
4002 }
4003
4004 #[test]
4005 fn droid_is_opt_in_and_takes_no_api_key() {
4006 let defaults = DroidConfig::default();
4007 assert!(!defaults.enabled);
4008 assert!(!defaults.cli_renewal);
4009 assert_eq!(defaults.cli_path, None);
4010
4011 let config = Config::default();
4012 assert_eq!(config.api_key_env_for(VendorId::Droid), "");
4013 assert_eq!(config.inline_api_key(VendorId::Droid), None);
4014
4015 let file = write_toml("[droid]\nenabled = true\n");
4016 let config = Config::load_from(file.path()).unwrap();
4017 assert!(config.is_enabled(VendorId::Droid));
4018 assert!(config.enabled_vendors().contains(&VendorId::Droid));
4019 }
4020
4021 #[test]
4022 fn devin_is_opt_in_and_takes_no_api_key() {
4023 let defaults = DevinConfig::default();
4024 assert!(!defaults.enabled);
4025 assert_eq!(defaults.credentials_path, None);
4026
4027 let config = Config::default();
4028 assert_eq!(config.api_key_env_for(VendorId::Devin), "");
4029 assert_eq!(config.inline_api_key(VendorId::Devin), None);
4030
4031 let file = write_toml("[devin]\nenabled = true\n");
4032 let config = Config::load_from(file.path()).unwrap();
4033 assert!(config.is_enabled(VendorId::Devin));
4034 assert!(config.enabled_vendors().contains(&VendorId::Devin));
4035 }
4036
4037 #[test]
4038 fn devin_credentials_path_expands_a_tilde() {
4039 let file = write_toml("[devin]\ncredentials_path = \"~/devin/credentials.toml\"\n");
4040 let path = Config::load_from(file.path())
4041 .unwrap()
4042 .devin
4043 .credentials_path
4044 .unwrap();
4045 assert!(!path.starts_with("~"), "{}", path.display());
4046 assert!(
4047 path.ends_with("devin/credentials.toml"),
4048 "{}",
4049 path.display()
4050 );
4051 }
4052
4053 #[test]
4054 fn devin_without_accounts_resolves_the_singular_path() {
4055 let explicit = DevinConfig {
4056 credentials_path: Some(PathBuf::from("/tmp/devin/credentials.toml")),
4057 ..DevinConfig::default()
4058 };
4059 assert_eq!(
4060 explicit.resolve_credentials_path(None).unwrap(),
4061 PathBuf::from("/tmp/devin/credentials.toml")
4062 );
4063 }
4064
4065 #[test]
4066 fn devin_named_accounts_resolve_their_own_credentials_file() {
4067 let config: Config = toml::from_str(
4068 r#"
4069 [devin]
4070 credentials_path = "/tmp/personal/credentials.toml"
4071 [[devin.accounts]]
4072 label = "work"
4073 credentials_path = "/tmp/work/credentials.toml"
4074 "#,
4075 )
4076 .unwrap();
4077
4078 assert_eq!(
4079 config.devin.resolve_credentials_path(Some("work")).unwrap(),
4080 PathBuf::from("/tmp/work/credentials.toml")
4081 );
4082 assert_eq!(
4083 config.devin.resolve_credentials_path(None).unwrap(),
4084 PathBuf::from("/tmp/personal/credentials.toml")
4085 );
4086 }
4087
4088 #[test]
4089 fn devin_named_accounts_reject_an_unregistered_label() {
4090 let config = DevinConfig {
4091 accounts: vec![crate::config::DevinAccount {
4092 label: "work".into(),
4093 credentials_path: PathBuf::from("/tmp/work/credentials.toml"),
4094 }],
4095 ..DevinConfig::default()
4096 };
4097 let err = config
4098 .resolve_credentials_path(Some("nope"))
4099 .unwrap_err()
4100 .to_string();
4101 assert!(err.contains("nope"), "{err}");
4102 assert!(err.contains("[[devin.accounts]]"), "{err}");
4103 }
4104
4105 #[test]
4106 fn devin_account_credentials_paths_are_tilde_expanded_on_load() {
4107 let f = write_toml(
4108 r#"
4109 [[devin.accounts]]
4110 label = "work"
4111 credentials_path = "~/.devin-work/credentials.toml"
4112 "#,
4113 );
4114 let c = Config::load_from(f.path()).unwrap();
4115 let home = crate::cache::home_dir().unwrap();
4116 assert_eq!(
4117 c.devin.accounts[0].credentials_path,
4118 home.join(".devin-work/credentials.toml")
4119 );
4120 }
4121
4122 #[test]
4123 fn devin_rejects_duplicate_account_labels() {
4124 let f = write_toml(
4125 r#"
4126 [[devin.accounts]]
4127 label = "work"
4128 credentials_path = "/tmp/work1/credentials.toml"
4129 [[devin.accounts]]
4130 label = "work"
4131 credentials_path = "/tmp/work2/credentials.toml"
4132 "#,
4133 );
4134 let err = Config::load_from(f.path()).unwrap_err().to_string();
4135 assert!(
4136 err.contains("duplicate devin account label \"work\""),
4137 "{err}"
4138 );
4139 }
4140
4141 #[test]
4142 fn devin_rejects_invalid_account_labels() {
4143 let f = write_toml(
4144 r#"
4145 [[devin.accounts]]
4146 label = "../bad"
4147 credentials_path = "/tmp/bad/credentials.toml"
4148 "#,
4149 );
4150 assert!(Config::load_from(f.path()).is_err());
4151 }
4152
4153 #[test]
4154 fn lyceum_is_opt_in_and_uses_shared_api_key_account_resolution() {
4155 let defaults = LyceumConfig::default();
4156 assert!(!defaults.enabled);
4157 assert_eq!(defaults.api_key_env, "LYCEUM_API_KEY");
4158 let override_file = write_toml("[lyceum]\napi_key_env = \"LYCEUM_CUSTOM_TEST_KEY\"\n");
4159 let override_config = Config::load_from(override_file.path()).unwrap();
4160 assert_eq!(
4161 override_config.api_key_env_for(VendorId::Lyceum),
4162 "LYCEUM_CUSTOM_TEST_KEY"
4163 );
4164 let file = write_toml(
4165 r#"[lyceum]
4166enabled = true
4167api_key_env = ""
4168api_key = "synthetic-inline"
4169[[lyceum.accounts]]
4170label = "work"
4171api_key = "synthetic-account"
4172"#,
4173 );
4174 let config = Config::load_from(file.path()).unwrap();
4175 assert!(config.is_enabled(VendorId::Lyceum));
4176 assert_eq!(config.api_key_env_for(VendorId::Lyceum), "");
4177 assert_eq!(
4178 config
4179 .resolve_account_api_key_for(VendorId::Lyceum, None)
4180 .unwrap(),
4181 "synthetic-inline"
4182 );
4183 assert_eq!(
4184 config
4185 .resolve_account_api_key_for(VendorId::Lyceum, Some("work"))
4186 .unwrap(),
4187 "synthetic-account"
4188 );
4189 }
4190
4191 #[test]
4192 fn optional_api_key_reports_absence_instead_of_failing() {
4193 assert_eq!(
4194 optional_api_key("9INVALID", Some("inline")),
4195 Some("inline".to_string())
4196 );
4197 assert_eq!(optional_api_key("9INVALID", None), None);
4198 assert_eq!(optional_api_key("9INVALID", Some("")), None);
4199 }
4200
4201 #[test]
4202 fn optional_api_key_prefers_env_and_falls_through_to_inline() {
4203 let from_env = |name: &str| (name == "TEST_API_KEY").then(|| "from-env".to_string());
4204 assert_eq!(
4205 optional_api_key_with("TEST_API_KEY", Some("from-config"), from_env),
4206 Some("from-env".to_string())
4207 );
4208 assert_eq!(
4209 optional_api_key_with("TEST_API_KEY", Some("from-config"), |_| None),
4210 Some("from-config".to_string())
4211 );
4212 assert_eq!(
4213 optional_api_key_with("TEST_API_KEY", Some("from-config"), |_| {
4214 Some(String::new())
4215 }),
4216 Some("from-config".to_string())
4217 );
4218 assert_eq!(optional_api_key_with("TEST_API_KEY", None, |_| None), None);
4219 }
4220
4221 #[test]
4222 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
4223 let defaults = Config::default();
4224 assert!(!defaults.context.enabled);
4225 assert_eq!(
4226 defaults.context.window_tokens_for(Some("claude-test")),
4227 None
4228 );
4229
4230 let file = write_toml(
4231 r#"
4232 [context]
4233 enabled = true
4234 context_window_tokens = 200000
4235
4236 [context.model_context_window_tokens]
4237 claude-opus-1m = 1000000
4238 "claude exact id" = 300000
4239 "#,
4240 );
4241 let config = Config::load_from(file.path()).unwrap();
4242 assert!(config.context.enabled);
4243 assert_eq!(
4244 config.context.window_tokens_for(Some("claude-opus-1m")),
4245 Some(1_000_000)
4246 );
4247 assert_eq!(
4248 config.context.window_tokens_for(Some("claude exact id")),
4249 Some(300_000)
4250 );
4251 assert_eq!(
4252 config.context.window_tokens_for(Some("another-model")),
4253 Some(200_000)
4254 );
4255 }
4256
4257 #[test]
4258 fn context_layout_defaults_to_full_and_parses_each_variant() {
4259 assert_eq!(Config::default().context.layout, ContextLayout::Full);
4260 for (text, want) in [
4261 ("full", ContextLayout::Full),
4262 ("split", ContextLayout::Split),
4263 ("bottom", ContextLayout::Bottom),
4264 ] {
4265 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
4266 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
4267 }
4268 let file = write_toml("[context]\nlayout = \"floating\"\n");
4269 assert!(
4270 Config::load_from(file.path()).is_err(),
4271 "an unknown layout must be rejected, not silently defaulted"
4272 );
4273 }
4274
4275 #[test]
4276 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
4277 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
4278 for (text, want) in [
4279 ("sidebar", VendorBoxStyle::Sidebar),
4280 ("navbar", VendorBoxStyle::Navbar),
4281 ("none", VendorBoxStyle::None),
4282 ] {
4283 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
4284 assert_eq!(
4285 Config::load_from(file.path()).unwrap().ui.vendor_box(),
4286 want
4287 );
4288 }
4289 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
4290 assert!(
4291 Config::load_from(file.path()).is_err(),
4292 "an unknown vendor_box style must be rejected, not silently defaulted"
4293 );
4294 }
4295
4296 #[test]
4297 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
4298 for source in [
4299 "[context]\ncontext_window_tokens = 0\n",
4300 "[context.model_context_window_tokens]\nclaude = 0\n",
4301 "[context.model_context_window_tokens]\n\" \" = 200000\n",
4302 ] {
4303 let file = write_toml(source);
4304 let error = Config::load_from(file.path()).unwrap_err().to_string();
4305 assert!(error.contains("context"), "{error}");
4306 }
4307 }
4308
4309 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
4311 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
4312 M.lock().unwrap_or_else(|p| p.into_inner())
4313 }
4314
4315 #[test]
4316 fn resolve_api_key_prefers_env_over_inline() {
4317 let _g = env_guard();
4318 let var = "AI_USAGEBAR_TEST_ENV_WINS";
4320 unsafe { std::env::set_var(var, "from-env") };
4322 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
4323 unsafe { std::env::remove_var(var) };
4324 assert_eq!(got, "from-env");
4325 }
4326
4327 #[test]
4328 fn resolve_api_key_falls_back_to_inline() {
4329 let _g = env_guard();
4330 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
4331 unsafe { std::env::remove_var(var) };
4332 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
4333 assert_eq!(got, "inline-key");
4334 }
4335
4336 #[test]
4337 fn copilot_token_prefers_explicit_environment_over_gh_cli() {
4338 struct NeverRun;
4339 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
4340 fn run(
4341 &self,
4342 _: &crate::copilot::credentials::GhAuthTokenCommand,
4343 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
4344 panic!("environment override must not invoke gh")
4345 }
4346 }
4347
4348 let token = CopilotConfig::default()
4349 .resolve_token_with(
4350 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
4351 &NeverRun,
4352 None,
4353 )
4354 .unwrap();
4355 assert_eq!(token, "from-environment");
4356 }
4357
4358 #[test]
4362 fn copilot_rejects_an_environment_override_combined_with_a_named_account() {
4363 struct NeverRun;
4364 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
4365 fn run(
4366 &self,
4367 _: &crate::copilot::credentials::GhAuthTokenCommand,
4368 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
4369 panic!("the conflict must be refused before gh runs")
4370 }
4371 }
4372 let config = CopilotConfig {
4373 accounts: vec![CopilotAccount {
4374 label: "work".into(),
4375 user: "octocat".into(),
4376 }],
4377 ..CopilotConfig::default()
4378 };
4379 let error = config
4380 .resolve_token_with(
4381 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
4382 &NeverRun,
4383 Some("work"),
4384 )
4385 .unwrap_err()
4386 .to_string();
4387 assert!(error.contains("GITHUB_COPILOT_TOKEN is set"), "{error}");
4388 assert!(error.contains("work"), "{error}");
4389 }
4390
4391 #[test]
4394 fn copilot_unknown_account_label_errors_listing_the_known_ones() {
4395 let config = CopilotConfig {
4396 accounts: vec![CopilotAccount {
4397 label: "work".into(),
4398 user: "octocat".into(),
4399 }],
4400 ..CopilotConfig::default()
4401 };
4402 assert_eq!(config.resolve_user(None).unwrap(), None);
4403 assert_eq!(config.resolve_user(Some("work")).unwrap(), Some("octocat"));
4404 let error = config
4405 .resolve_user(Some("personal"))
4406 .unwrap_err()
4407 .to_string();
4408 assert!(error.contains("personal"), "{error}");
4409 assert!(error.contains("work"), "{error}");
4410
4411 let error = CopilotConfig::default()
4413 .resolve_user(Some("work"))
4414 .unwrap_err()
4415 .to_string();
4416 assert!(error.contains("no accounts array"), "{error}");
4417 }
4418
4419 #[test]
4422 fn copilot_defaults_keep_the_unnamed_account_visible() {
4423 let config = CopilotConfig::default();
4424 assert!(config.show_default_account);
4425 assert!(config.accounts.is_empty());
4426 assert!(!config.enabled, "Copilot stays opt-in");
4427 }
4428
4429 #[test]
4430 fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
4431 struct FailedGh;
4432 impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
4433 fn run(
4434 &self,
4435 _: &crate::copilot::credentials::GhAuthTokenCommand,
4436 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
4437 Ok(crate::copilot::credentials::GhAuthTokenOutput {
4438 success: false,
4439 stdout: b"never-echo-gh-output".to_vec(),
4440 })
4441 }
4442 }
4443 let error = CopilotConfig::default()
4444 .resolve_token_with(|_| None, &FailedGh, None)
4445 .unwrap_err()
4446 .to_string();
4447 assert!(error.contains("gh auth login --web"));
4448 assert!(!error.contains("never-echo-gh-output"));
4449 }
4450
4451 #[test]
4452 fn resolve_api_key_errors_when_both_missing() {
4453 let _g = env_guard();
4454 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
4455 unsafe { std::env::remove_var(var) };
4456 let err = resolve_api_key("Zai", var, None).unwrap_err();
4457 match err {
4458 crate::error::AppError::Credentials(msg) => {
4459 assert!(
4460 msg.contains("api_key"),
4461 "error should suggest config field: {msg}"
4462 );
4463 }
4464 other => panic!("expected Credentials error, got {other:?}"),
4465 }
4466 }
4467
4468 #[test]
4469 fn resolve_api_key_uses_exact_opencode_go_section_name() {
4470 let _g = env_guard();
4471 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
4472 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
4473 let message = err.to_string();
4474 assert!(
4475 message.contains("[opencode-go]"),
4476 "wrong section hint: {message}"
4477 );
4478 assert!(
4479 !message.contains("[opencode go]"),
4480 "wrong section hint: {message}"
4481 );
4482 }
4483
4484 fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
4485 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
4486 M.lock().unwrap_or_else(|p| p.into_inner())
4487 }
4488
4489 struct ScopedPathOverride {
4495 _serial: std::sync::MutexGuard<'static, ()>,
4496 }
4497
4498 impl Drop for ScopedPathOverride {
4499 fn drop(&mut self) {
4500 clear_override_path();
4501 }
4502 }
4503
4504 fn scoped_path_override() -> ScopedPathOverride {
4505 ScopedPathOverride {
4506 _serial: path_override_guard(),
4507 }
4508 }
4509
4510 #[test]
4511 fn override_path_wins_over_canonical_and_legacy() {
4512 let _scoped = scoped_path_override();
4513 let file = NamedTempFile::new().unwrap();
4514 set_override_path(file.path());
4515 assert_eq!(resolved_path().as_deref(), Some(file.path()));
4516 assert_eq!(config_path_hint(), file.path().display().to_string());
4517 clear_override_path();
4518 let p = resolved_path().expect("a config path must resolve");
4520 assert!(p.ends_with("config.toml"));
4521 }
4522
4523 #[test]
4524 fn scoped_override_guard_clears_the_override_on_panic() {
4525 let hook = std::panic::take_hook();
4528 std::panic::set_hook(Box::new(|_| {}));
4529 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4530 let _scoped = scoped_path_override();
4531 set_override_path(std::path::Path::new("panicked-override.toml"));
4532 panic!("simulated mid-test failure");
4533 }))
4534 .is_err();
4535 std::panic::set_hook(hook);
4536 assert!(panicked, "the simulated failure must run");
4537 let _serial = path_override_guard();
4538 assert!(
4539 override_path().is_none(),
4540 "a panicking test must not leak the override into siblings"
4541 );
4542 }
4543
4544 #[test]
4545 fn config_path_hint_ends_with_config_toml() {
4546 let _g = path_override_guard();
4547 assert!(config_path_hint().ends_with("config.toml"));
4550 }
4551
4552 #[test]
4553 fn config_flag_value_splits_the_equals_form() {
4554 use std::ffi::OsStr;
4555 assert_eq!(
4556 config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
4557 Some(std::path::Path::new("work.toml"))
4558 );
4559 assert_eq!(
4560 config_flag_value(OsStr::new("--config=")).as_deref(),
4561 Some(std::path::Path::new(""))
4562 );
4563 assert_eq!(config_flag_value(OsStr::new("--config")), None);
4564 assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
4565 assert_eq!(config_flag_value(OsStr::new("account")), None);
4566 }
4567
4568 #[cfg(unix)]
4572 #[test]
4573 fn config_flag_value_keeps_undecodable_bytes_intact() {
4574 use std::ffi::OsString;
4575 use std::os::unix::ffi::{OsStrExt, OsStringExt};
4576 let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
4577 let value = config_flag_value(&raw).expect("prefix matches");
4578 assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
4579 }
4580
4581 #[cfg(windows)]
4582 #[test]
4583 fn config_flag_value_keeps_lone_surrogates_intact() {
4584 use std::ffi::OsString;
4585 use std::os::windows::ffi::{OsStrExt, OsStringExt};
4586 let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
4587 wide.push(0xDC00); wide.extend("x.toml".encode_utf16());
4589 let raw = OsString::from_wide(&wide);
4590 let value = config_flag_value(&raw).expect("prefix matches");
4591 let mut expected = vec![0xDC00u16];
4592 expected.extend("x.toml".encode_utf16());
4593 assert_eq!(
4594 value.as_os_str().encode_wide().collect::<Vec<_>>(),
4595 expected
4596 );
4597 }
4598
4599 #[test]
4600 fn resolve_api_key_treats_empty_env_as_unset() {
4601 let _g = env_guard();
4602 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
4603 unsafe { std::env::set_var(var, "") };
4604 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
4605 unsafe { std::env::remove_var(var) };
4606 assert_eq!(got, "inline");
4607 }
4608
4609 #[test]
4610 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
4611 let _g = env_guard();
4612 let bad = "sk-kimi-very-real-looking-pasted-secret";
4614 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
4615 let msg = err.to_string();
4616 assert!(
4617 msg.contains("invalid") && msg.contains("api_key_env"),
4618 "error should explain misconfiguration: {msg}"
4619 );
4620 assert!(
4621 !msg.contains(bad),
4622 "error must not echo the misconfigured value: {msg}"
4623 );
4624 assert!(msg.contains("valid environment variable name"));
4625 assert!(
4626 msg.contains("[kimi]"),
4627 "error should point at the lowercase TOML section: {msg}"
4628 );
4629 }
4630
4631 #[test]
4632 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
4633 let _g = env_guard();
4634 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
4635 assert_eq!(got, "inline-key");
4636 }
4637
4638 #[test]
4639 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
4640 let _g = env_guard();
4641 let pasted_secret = "sk_pasted_secret";
4644 unsafe { std::env::remove_var(pasted_secret) };
4645 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
4646 assert!(
4647 !err.to_string().contains(pasted_secret),
4648 "error must not echo configured api_key_env values"
4649 );
4650 }
4651
4652 #[test]
4653 fn is_valid_env_var_name_rules() {
4654 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
4656 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
4657 }
4658 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
4660 assert!(
4661 !is_valid_env_var_name(invalid),
4662 "{invalid} should be invalid"
4663 );
4664 }
4665 }
4666
4667 #[test]
4668 fn config_parses_with_inline_api_key_and_primary() {
4669 let f = write_toml(
4670 r#"
4671 [ui]
4672 primary = "openrouter"
4673
4674 [zai]
4675 enabled = true
4676 api_key_env = "MY_ZAI"
4677 api_key = "sk-zai-inline"
4678
4679 [openrouter]
4680 enabled = true
4681 api_key = "sk-or-inline"
4682 "#,
4683 );
4684 let c = Config::load_from(f.path()).unwrap();
4685 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
4686 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
4687 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
4688 }
4689
4690 #[test]
4691 fn openrouter_named_accounts_preserve_the_default_contract() {
4692 let f = write_toml(
4693 r#"
4694 [openrouter]
4695 enabled = true
4696 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
4697 api_key = "default-inline"
4698 show_default_account = false
4699
4700 [[openrouter.accounts]]
4701 label = "work"
4702 api_key_env = "OPENROUTER_WORK_API_KEY"
4703
4704 [[openrouter.accounts]]
4705 label = "personal"
4706 api_key = "personal-inline"
4707 "#,
4708 );
4709 let _g = env_guard();
4710 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
4711 let config = Config::load_from(f.path()).unwrap();
4712 assert!(!config.openrouter.show_default_account);
4713 assert_eq!(config.openrouter.accounts.len(), 2);
4714 assert_eq!(
4715 config
4716 .resolve_account_api_key_for(VendorId::Openrouter, None)
4717 .unwrap(),
4718 "default-inline"
4719 );
4720 assert_eq!(
4721 config
4722 .resolve_account_api_key_for(VendorId::Openrouter, Some("personal"))
4723 .unwrap(),
4724 "personal-inline"
4725 );
4726 }
4727
4728 #[test]
4729 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
4730 for source in [
4731 r#"
4732 [[openrouter.accounts]]
4733 label = "work"
4734 api_key = "one"
4735 [[openrouter.accounts]]
4736 label = "work"
4737 api_key = "two"
4738 "#,
4739 r#"
4740 [[openrouter.accounts]]
4741 label = "../work"
4742 api_key = "one"
4743 "#,
4744 r#"
4745 [[openrouter.accounts]]
4746 label = "work"
4747 "#,
4748 ] {
4749 let f = write_toml(source);
4750 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
4751 }
4752 }
4753
4754 #[test]
4755 fn openrouter_unknown_account_never_falls_back_to_default_key() {
4756 let mut config = Config::default();
4757 config.openrouter.api_key = Some("default-secret".into());
4758 config.openrouter.accounts.push(ApiKeyAccount {
4759 label: "work".into(),
4760 api_key_env: None,
4761 api_key: Some("work-secret".into()),
4762 management_api_key_env: None,
4763 });
4764 let message = config
4765 .resolve_account_api_key_for(VendorId::Openrouter, Some("missing"))
4766 .unwrap_err()
4767 .to_string();
4768 assert!(message.contains("missing") && message.contains("work"));
4769 assert!(!message.contains("default-secret"));
4770 assert!(!message.contains("work-secret"));
4771 }
4772
4773 #[test]
4774 fn openrouter_account_key_errors_do_not_echo_configured_values() {
4775 let mut config = Config::default();
4776 config.openrouter.accounts.push(ApiKeyAccount {
4777 label: "work".into(),
4778 api_key_env: Some("sk_pasted_secret".into()),
4779 api_key: None,
4780 management_api_key_env: None,
4781 });
4782 let _g = env_guard();
4783 unsafe { std::env::remove_var("sk_pasted_secret") };
4784 let message = config
4785 .resolve_account_api_key_for(VendorId::Openrouter, Some("work"))
4786 .unwrap_err()
4787 .to_string();
4788 assert!(message.contains("[[openrouter.accounts]]"));
4789 assert!(!message.contains("sk_pasted_secret"));
4790 }
4791
4792 #[test]
4793 fn openrouter_management_key_resolves_per_account_and_never_inherits() {
4794 let mut config = Config::default();
4795 config.openrouter.accounts = vec![
4796 ApiKeyAccount {
4797 label: "work".into(),
4798 api_key_env: None,
4799 api_key: Some("work-key".into()),
4800 management_api_key_env: Some("AI_USAGEBAR_TEST_OR_MGMT_WORK".into()),
4801 },
4802 ApiKeyAccount {
4803 label: "plain".into(),
4804 api_key_env: None,
4805 api_key: Some("plain-key".into()),
4806 management_api_key_env: None,
4807 },
4808 ];
4809 let _g = env_guard();
4810 unsafe { std::env::remove_var("OPENROUTER_MANAGEMENT_API_KEY") };
4811 unsafe { std::env::set_var("AI_USAGEBAR_TEST_OR_MGMT_WORK", "mgmt-work") };
4812
4813 assert_eq!(config.openrouter_management_key(None), None);
4815 assert_eq!(
4817 config.openrouter_management_key(Some("work")).as_deref(),
4818 Some("mgmt-work")
4819 );
4820 unsafe { std::env::set_var("OPENROUTER_MANAGEMENT_API_KEY", "mgmt-default") };
4823 assert_eq!(
4824 config.openrouter_management_key(None).as_deref(),
4825 Some("mgmt-default")
4826 );
4827 assert_eq!(config.openrouter_management_key(Some("plain")), None);
4828 assert_eq!(config.openrouter_management_key(Some("typo")), None);
4830
4831 config.openrouter.management_api_key_env = "AI_USAGEBAR_TEST_OR_MGMT_DEFAULT".into();
4833 unsafe { std::env::set_var("AI_USAGEBAR_TEST_OR_MGMT_DEFAULT", "mgmt-renamed") };
4834 assert_eq!(
4835 config.openrouter_management_key(None).as_deref(),
4836 Some("mgmt-renamed")
4837 );
4838 unsafe { std::env::remove_var("OPENROUTER_MANAGEMENT_API_KEY") };
4839 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_MGMT_WORK") };
4840 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_MGMT_DEFAULT") };
4841 }
4842
4843 #[test]
4844 fn every_api_key_account_vendor_validates_its_array_like_openrouter() {
4845 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
4846 let section = vendor.config_section();
4847 let parse = |accounts: &str| -> Result<Config> {
4848 let config: Config =
4849 toml::from_str(&format!("[{section}]\nenabled = true\n{accounts}")).unwrap();
4850 config.validate().map(|()| config)
4851 };
4852 let config = parse(&format!(
4853 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k\"\n"
4854 ))
4855 .unwrap();
4856 assert_eq!(
4857 config.api_key_accounts(vendor).unwrap().len(),
4858 1,
4859 "{vendor:?}"
4860 );
4861 assert!(config.show_default_api_key_account(vendor), "{vendor:?}");
4862
4863 let duplicate = parse(&format!(
4864 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"a\"\n\
4865 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"b\"\n"
4866 ))
4867 .unwrap_err()
4868 .to_string();
4869 assert!(
4870 duplicate.contains(&format!("duplicate {section} account label")),
4871 "{duplicate}"
4872 );
4873
4874 let keyless = parse(&format!("[[{section}.accounts]]\nlabel = \"work\"\n"))
4875 .unwrap_err()
4876 .to_string();
4877 assert!(
4878 keyless.contains("must set api_key_env or api_key"),
4879 "{keyless}"
4880 );
4881
4882 assert!(
4884 parse(&format!(
4885 "[[{section}.accounts]]\nlabel = \"../x\"\napi_key = \"k\"\n"
4886 ))
4887 .is_err(),
4888 "{vendor:?}"
4889 );
4890 }
4891 }
4892
4893 #[test]
4894 fn named_api_key_accounts_resolve_their_own_key_and_never_the_default() {
4895 let mut config = Config::default();
4896 config.deepseek.api_key_env.clear();
4897 config.deepseek.api_key = Some("default-key".into());
4898 config.deepseek.accounts.push(ApiKeyAccount {
4899 label: "work".into(),
4900 api_key_env: Some("AI_USAGEBAR_TEST_DEEPSEEK_WORK".into()),
4901 api_key: Some("work-inline".into()),
4902 management_api_key_env: None,
4903 });
4904 let _g = env_guard();
4905 unsafe { std::env::set_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK", "work-env") };
4906 let resolve = |label| config.resolve_account_api_key_for(VendorId::Deepseek, label);
4907 assert_eq!(resolve(None).unwrap(), "default-key");
4908 assert_eq!(resolve(Some("work")).unwrap(), "work-env", "env wins");
4909 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK") };
4910 assert_eq!(resolve(Some("work")).unwrap(), "work-inline");
4911
4912 let unknown = resolve(Some("typo")).unwrap_err().to_string();
4913 assert!(unknown.contains("[[deepseek.accounts]]"), "{unknown}");
4914 assert!(
4915 unknown.contains("\"work\""),
4916 "lists known labels: {unknown}"
4917 );
4918 assert!(!unknown.contains("default-key"), "{unknown}");
4919 }
4920
4921 #[test]
4922 fn the_default_key_error_keeps_each_vendors_historic_wording() {
4923 let mut config = Config::default();
4924 config.zai.api_key_env.clear();
4925 config.minimax.api_key_env.clear();
4926 let zai = config
4927 .resolve_account_api_key_for(VendorId::Zai, None)
4928 .unwrap_err()
4929 .to_string();
4930 let expected = resolve_api_key("Zai", "", None).unwrap_err().to_string();
4931 assert_eq!(zai, expected);
4932 assert!(zai.contains("[zai]"), "{zai}");
4933 let minimax = config
4934 .resolve_account_api_key_for(VendorId::Minimax, None)
4935 .unwrap_err()
4936 .to_string();
4937 assert_eq!(
4938 minimax,
4939 resolve_api_key("MiniMax", "", None)
4940 .unwrap_err()
4941 .to_string()
4942 );
4943 }
4944
4945 #[test]
4946 fn enabled_vendors_preserves_canonical_order() {
4947 let c = Config::default();
4950 assert_eq!(
4951 c.enabled_vendors(),
4952 vec![
4953 VendorId::Anthropic,
4954 VendorId::Openai,
4955 VendorId::Zai,
4956 VendorId::Openrouter,
4957 ]
4958 );
4959 }
4960
4961 #[test]
4962 fn deepseek_appears_when_enabled() {
4963 let f = write_toml(
4964 r#"
4965 [deepseek]
4966 enabled = true
4967 api_key = "sk-test"
4968 "#,
4969 );
4970 let c = Config::load_from(f.path()).unwrap();
4971 assert!(c.is_enabled(VendorId::Deepseek));
4972 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
4973 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
4974 }
4975
4976 #[test]
4977 fn tilde_paths_are_expanded_on_load() {
4978 let f = write_toml(
4982 r#"
4983 [context]
4984 projects_path = "~/.claude/projects"
4985
4986 [anthropic]
4987 credentials_path = "~/.claude/.credentials.json"
4988
4989 [[anthropic.accounts]]
4990 label = "work"
4991 credentials_path = "~/work.json"
4992 "#,
4993 );
4994 let c = Config::load_from(f.path()).unwrap();
4995 let home = crate::cache::home_dir().unwrap();
4996
4997 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
4998 let got = c.anthropic.credentials_path.unwrap();
4999 assert_eq!(got, home.join(".claude/.credentials.json"));
5000 assert!(!got.to_string_lossy().contains('~'));
5001 assert_eq!(
5002 c.anthropic.accounts[0].credentials_path,
5003 home.join("work.json")
5004 );
5005 }
5006
5007 #[test]
5008 fn absolute_and_relative_paths_are_left_alone() {
5009 let f = write_toml(
5010 r#"
5011 [anthropic]
5012 credentials_path = "/etc/creds.json"
5013 "#,
5014 );
5015 let c = Config::load_from(f.path()).unwrap();
5016 assert_eq!(
5017 c.anthropic.credentials_path.unwrap(),
5018 std::path::Path::new("/etc/creds.json")
5019 );
5020
5021 let f2 = write_toml(
5023 r#"
5024 [anthropic]
5025 credentials_path = "~someone/creds.json"
5026 "#,
5027 );
5028 let c2 = Config::load_from(f2.path()).unwrap();
5029 assert_eq!(
5030 c2.anthropic.credentials_path.unwrap(),
5031 std::path::Path::new("~someone/creds.json")
5032 );
5033 }
5034
5035 #[test]
5036 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
5037 let _g = path_override_guard();
5038 let p = resolved_path().expect("a config path must resolve");
5041 assert!(p.ends_with("config.toml"));
5042 let canonical = default_path().unwrap();
5043 let legacy = legacy_xdg_path().unwrap();
5044 assert!(
5045 p == canonical || p == legacy,
5046 "resolved to an unexpected location: {}",
5047 p.display()
5048 );
5049 }
5050
5051 #[test]
5052 fn misspelled_section_is_rejected_not_ignored() {
5053 let f = write_toml(
5056 r#"
5057 [openrouer]
5058 enabled = true
5059 api_key = "sk-or-v1-typo"
5060 "#,
5061 );
5062 let err = Config::load_from(f.path()).unwrap_err().to_string();
5063 assert!(
5064 err.contains("openrouer"),
5065 "error should name the typo: {err}"
5066 );
5067 }
5068
5069 #[test]
5070 fn invalid_toml_is_an_error_not_silent_defaults() {
5071 let f = write_toml("[zai\nenabled = true\n");
5072 assert!(Config::load_from(f.path()).is_err());
5073 }
5074
5075 #[test]
5079 fn a_parse_error_names_the_line_without_quoting_it() {
5080 let f = write_toml("[openrouter]\nenabled = true\napi_key = sk-or-v1-unquoted\n");
5081 let err = Config::load_from(f.path()).unwrap_err().to_string();
5082 assert!(err.contains("line 3,"), "{err}");
5083 assert!(!err.contains("sk-or-v1-unquoted"), "{err}");
5084 }
5085
5086 #[test]
5089 fn a_writer_parse_error_names_the_line_without_quoting_it() {
5090 let (_dir, path) =
5091 crate::cache::closed_temp_file("config.toml", Some("[zai]\napi_key = zk-unquoted\n"));
5092 let err = read_config_document(&path).unwrap_err().to_string();
5093 assert!(err.contains("line 2,"), "{err}");
5094 assert!(!err.contains("zk-unquoted"), "{err}");
5095 }
5096
5097 #[test]
5098 fn toml_error_summary_keeps_the_position_and_the_message() {
5099 let input = "a = 1\nkey = sk-secret\n";
5100 assert_eq!(
5101 toml_error_summary(input, Some(12..21), "string values must be quoted\n"),
5102 "line 2, column 7: string values must be quoted"
5103 );
5104 assert_eq!(toml_error_summary(input, None, "bad"), "bad");
5105 }
5106
5107 #[test]
5108 fn a_missing_file_is_still_just_defaults() {
5109 let dir = tempfile::tempdir().unwrap();
5112 let missing = dir.path().join("nope").join("config.toml");
5113 let c = Config::load_from(&missing).unwrap();
5114 assert!(c.is_enabled(VendorId::Anthropic));
5115 }
5116
5117 #[test]
5118 fn kimi_appears_when_enabled() {
5119 let f = write_toml(
5120 r#"
5121 [kimi]
5122 enabled = true
5123 api_key = "sk-test"
5124 "#,
5125 );
5126 let c = Config::load_from(f.path()).unwrap();
5127 assert!(c.is_enabled(VendorId::Kimi));
5128 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
5129 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
5130 }
5131
5132 #[test]
5133 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
5134 let f = write_toml(
5135 r#"
5136 [deepseek]
5137 enabled = true
5138 api_key = "sk-ds"
5139
5140 [kimi]
5141 enabled = true
5142 api_key = "sk-kimi"
5143 "#,
5144 );
5145 let c = Config::load_from(f.path()).unwrap();
5146 assert_eq!(
5147 c.enabled_vendors(),
5148 vec![
5149 VendorId::Anthropic,
5150 VendorId::Openai,
5151 VendorId::Zai,
5152 VendorId::Openrouter,
5153 VendorId::Deepseek,
5154 VendorId::Kimi,
5155 ]
5156 );
5157 }
5158
5159 #[test]
5160 fn parses_anthropic_accounts_and_looks_them_up() {
5161 let f = write_toml(
5162 r#"
5163 [anthropic]
5164 enabled = true
5165
5166 [[anthropic.accounts]]
5167 label = "personal"
5168 credentials_path = "/creds/personal.json"
5169
5170 [[anthropic.accounts]]
5171 label = "work"
5172 credentials_path = "/creds/work.json"
5173 "#,
5174 );
5175 let c = Config::load_from(f.path()).unwrap();
5176 assert_eq!(c.anthropic.accounts.len(), 2);
5177 let work = c.anthropic.account("work").unwrap();
5178 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
5179 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
5181 assert!(err.contains("missing") && err.contains("work"), "{err}");
5182 }
5183
5184 #[test]
5185 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
5186 let f = write_toml(
5187 r#"
5188 [[anthropic.accounts]]
5189 label = "work"
5190 credentials_path = "/creds/work-one.json"
5191
5192 [[anthropic.accounts]]
5193 label = "work"
5194 credentials_path = "/creds/work-two.json"
5195 "#,
5196 );
5197 let err = Config::load_from(f.path()).unwrap_err().to_string();
5198 assert!(
5199 err.contains("duplicate anthropic account label \"work\""),
5200 "{err}"
5201 );
5202 }
5203
5204 #[test]
5205 fn account_label_rejects_path_like_names() {
5206 let cfg = AnthropicConfig::default();
5207 for bad in [
5208 "",
5209 ".",
5210 "..",
5211 "a/b",
5212 r"a\b",
5213 "C:work",
5214 "line\nbreak",
5215 "tab\tname",
5216 "usage.json",
5217 ".stale",
5218 ".last_error",
5219 ".fetch.lock",
5220 ] {
5221 let err = cfg.account(bad).unwrap_err();
5222 assert!(
5223 format!("{err:?}").contains("invalid anthropic account label"),
5224 "{bad:?} should be rejected as a label"
5225 );
5226 }
5227 }
5228
5229 #[test]
5230 fn anthropic_accounts_default_to_empty() {
5231 assert!(Config::default().anthropic.accounts.is_empty());
5234 assert!(Config::default().anthropic.accounts_dir.is_none());
5235 }
5236
5237 fn seed_account_dir(root: &std::path::Path, label: &str) {
5243 let dir = root.join(label);
5244 std::fs::create_dir_all(&dir).unwrap();
5245 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
5246 }
5247
5248 #[test]
5249 fn discovers_account_dirs_in_claude_config_dir_layout() {
5250 let td = tempfile::tempdir().unwrap();
5251 seed_account_dir(td.path(), "work");
5252 seed_account_dir(td.path(), "personal");
5253 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
5256 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
5258
5259 let cfg = AnthropicConfig {
5260 accounts_dir: Some(td.path().to_path_buf()),
5261 ..Default::default()
5262 };
5263 let all = cfg.all_accounts();
5264 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
5265 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
5266 assert_eq!(
5267 all[2].credentials_path,
5268 td.path().join("work").join(".credentials.json")
5269 );
5270 }
5271
5272 #[test]
5273 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
5274 let td = tempfile::tempdir().unwrap();
5275 seed_account_dir(td.path(), "work");
5276 let cfg = AnthropicConfig {
5277 accounts: vec![AnthropicAccount {
5278 label: "work".into(),
5279 credentials_path: "/explicit/work.json".into(),
5280 }],
5281 accounts_dir: Some(td.path().to_path_buf()),
5282 ..Default::default()
5283 };
5284 let all = cfg.all_accounts();
5285 assert_eq!(all.len(), 1, "no duplicate label");
5286 assert_eq!(
5287 all[0].credentials_path,
5288 std::path::Path::new("/explicit/work.json"),
5289 "explicit entry wins"
5290 );
5291 seed_account_dir(td.path(), "other");
5293 assert_eq!(cfg.account("other").unwrap().label, "other");
5294 }
5295
5296 #[test]
5297 fn missing_accounts_dir_is_silently_empty_not_an_error() {
5298 let cfg = AnthropicConfig {
5299 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
5300 ..Default::default()
5301 };
5302 assert!(cfg.all_accounts().is_empty());
5303 }
5304
5305 #[test]
5306 fn openai_account_auth_paths_are_tilde_expanded_on_load() {
5307 let f = write_toml(
5308 r#"
5309 [[openai.accounts]]
5310 label = "work"
5311 codex_auth_path = "~/.codex-work/auth.json"
5312 "#,
5313 );
5314 let c = Config::load_from(f.path()).unwrap();
5315 let home = crate::cache::home_dir().unwrap();
5316 assert_eq!(
5317 c.openai.accounts[0].codex_auth_path,
5318 home.join(".codex-work/auth.json")
5319 );
5320 }
5321
5322 #[test]
5323 fn accounts_dir_is_tilde_expanded_on_load() {
5324 let f = write_toml(
5325 r#"
5326 [anthropic]
5327 accounts_dir = "~/.config/ai-usagebar/accounts"
5328 "#,
5329 );
5330 let c = Config::load_from(f.path()).unwrap();
5331 let home = crate::cache::home_dir().unwrap();
5332 assert_eq!(
5333 c.anthropic.accounts_dir,
5334 Some(home.join(".config/ai-usagebar/accounts"))
5335 );
5336 }
5337
5338 #[test]
5339 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
5340 let f = write_toml(
5341 r#"
5342 [anthropic]
5343 desktop_profiles_dir = "~/.claude-acc/profiles"
5344 "#,
5345 );
5346 let c = Config::load_from(f.path()).unwrap();
5347 let home = crate::cache::home_dir().unwrap();
5348 assert_eq!(
5349 c.anthropic.desktop_profiles_dir,
5350 Some(home.join(".claude-acc/profiles"))
5351 );
5352 }
5353
5354 #[test]
5355 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
5356 let cfg = AnthropicConfig {
5357 accounts: vec![
5358 AnthropicAccount {
5359 label: "work".into(),
5360 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
5361 },
5362 AnthropicAccount {
5363 label: "personal".into(),
5364 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
5365 },
5366 ],
5367 ..Default::default()
5368 };
5369
5370 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
5371 assert!(
5372 matches!(&idle, CredsTarget::Named { config_dir, .. }
5373 if config_dir == std::path::Path::new("/tmp/accounts/work")),
5374 "{idle:?}"
5375 );
5376
5377 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
5379 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
5380
5381 assert_eq!(idle_cache.dir(), live_cache.dir());
5384 }
5385
5386 #[test]
5387 fn the_live_cli_account_keeps_its_own_slot_while_that_file_is_there() {
5388 let cfg = AnthropicConfig {
5393 accounts: vec![AnthropicAccount {
5394 label: "personal".into(),
5395 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
5396 }],
5397 ..Default::default()
5398 };
5399
5400 let (present, _) = cfg
5401 .account_target_probing("personal", Some("personal"), |_| true)
5402 .unwrap();
5403 assert!(
5404 matches!(&present, CredsTarget::Named { path, .. }
5405 if path == Path::new("/tmp/accounts/personal/.credentials.json")),
5406 "{present:?}"
5407 );
5408
5409 let (moved, _) = cfg
5411 .account_target_probing("personal", Some("personal"), |_| false)
5412 .unwrap();
5413 assert!(matches!(moved, CredsTarget::Default(_)), "{moved:?}");
5414 }
5415
5416 #[test]
5417 fn the_default_entry_reads_config_credentials_path_strictly() {
5418 let cfg = AnthropicConfig {
5419 credentials_path: Some("/tmp/claude-home/.credentials.json".into()),
5420 ..Default::default()
5421 };
5422
5423 assert!(
5424 matches!(&cfg.default_creds_target(), CredsTarget::Explicit(path)
5425 if path == Path::new("/tmp/claude-home/.credentials.json")),
5426 );
5427 assert!(matches!(
5429 AnthropicConfig::default().default_creds_target(),
5430 CredsTarget::Default(_)
5431 ));
5432 }
5433
5434 #[test]
5435 fn the_live_cli_accounts_own_file_is_probed_on_disk() {
5436 let creds = NamedTempFile::new().unwrap();
5440 let cfg = AnthropicConfig {
5441 accounts: vec![AnthropicAccount {
5442 label: "personal".into(),
5443 credentials_path: creds.path().to_path_buf(),
5444 }],
5445 ..Default::default()
5446 };
5447 let (target, _) = cfg
5448 .account_target_with("personal", Some("personal"))
5449 .unwrap();
5450 assert!(
5451 matches!(&target, CredsTarget::Named { path, .. } if path == creds.path()),
5452 "read {target:?} instead of the account's own file"
5453 );
5454 }
5455
5456 #[test]
5457 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
5458 let cfg = AnthropicConfig {
5459 accounts: vec![AnthropicAccount {
5460 label: "work".into(),
5461 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
5462 }],
5463 ..Default::default()
5464 };
5465 let (target, _) = cfg.account_target_with("work", None).unwrap();
5466 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
5467 }
5468
5469 fn config_example() -> PathBuf {
5473 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
5474 }
5475
5476 #[test]
5477 fn shipped_example_parses_as_a_real_config() {
5478 let c = Config::load_from(&config_example()).unwrap();
5483 assert!(!c.context.enabled);
5484 assert!(c.is_enabled(VendorId::Anthropic));
5485 assert!(c.is_enabled(VendorId::Openai));
5486 assert!(!c.is_enabled(VendorId::AnthropicApi));
5487 assert!(!c.is_enabled(VendorId::Deepseek));
5488 assert!(!c.is_enabled(VendorId::Kimi));
5489 assert!(!c.is_enabled(VendorId::Kilo));
5490 assert!(!c.is_enabled(VendorId::Novita));
5491 assert!(!c.is_enabled(VendorId::Moonshot));
5492 assert!(!c.is_enabled(VendorId::Grok));
5493 assert!(!c.is_enabled(VendorId::Cursor));
5494 assert!(!c.is_enabled(VendorId::Minimax));
5495 }
5496
5497 #[test]
5498 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
5499 let text = std::fs::read_to_string(config_example()).unwrap();
5504 let live: Vec<&str> = text
5505 .lines()
5506 .map(str::trim)
5507 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
5508 .collect();
5509 assert!(
5510 live.is_empty(),
5511 "admin_key_env must stay commented out while it is inert: {live:?}"
5512 );
5513 assert!(
5516 text.contains("admin_key_env") && text.contains("RESERVED"),
5517 "the example should keep describing admin_key_env as reserved"
5518 );
5519 }
5520
5521 #[test]
5522 fn admin_key_env_is_accepted_but_changes_nothing() {
5523 let f = write_toml(
5527 r#"
5528 [openai]
5529 admin_key_env = "SOME_ADMIN_KEY"
5530 "#,
5531 );
5532 let c = Config::load_from(f.path()).unwrap();
5533 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
5534 let default = OpenAiConfig::default();
5536 assert_eq!(c.openai.enabled, default.enabled);
5537 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
5538 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
5539 }
5540
5541 #[test]
5542 fn config_example_documents_every_vendor_without_secrets() {
5543 let raw = std::fs::read_to_string(config_example()).unwrap();
5544 let cfg = Config::load_from(&config_example()).unwrap();
5545 for id in VendorId::all() {
5548 let section = id.slug();
5549 assert!(
5550 raw.contains(&format!("[{section}]")),
5551 "config.example.toml has no [{section}] section"
5552 );
5553 }
5554
5555 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
5558 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
5559 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
5560 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
5561 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
5562 assert!(!cfg.supergrok.enabled);
5563 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
5564 assert_eq!(
5565 cfg.supergrok
5566 .grok_binary
5567 .file_name()
5568 .and_then(|p| p.to_str()),
5569 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
5570 );
5571 assert!(cfg.supergrok.auth_path.is_none());
5572 assert!(cfg.supergrok.config_path.is_none());
5573 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
5574 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
5575 }
5576
5577 #[test]
5578 fn supergrok_binary_must_not_be_empty() {
5579 let file = write_toml(
5580 r#"
5581 [supergrok]
5582 enabled = true
5583 grok_binary = ""
5584 "#,
5585 );
5586 let error = Config::load_from(file.path()).unwrap_err().to_string();
5587 assert!(error.contains("grok_binary must not be empty"));
5588 }
5589
5590 #[test]
5591 fn supergrok_paths_are_tilde_expanded() {
5592 let file = write_toml(
5593 r#"
5594 [supergrok]
5595 grok_binary = "~/bin/grok"
5596 auth_path = "~/.grok/auth.json"
5597 config_path = "~/.grok/config.toml"
5598 "#,
5599 );
5600 let config = Config::load_from(file.path()).unwrap();
5601 let home = crate::cache::home_dir().unwrap();
5602 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
5603 assert_eq!(
5604 config.supergrok.auth_path,
5605 Some(home.join(".grok/auth.json"))
5606 );
5607 assert_eq!(
5608 config.supergrok.config_path,
5609 Some(home.join(".grok/config.toml"))
5610 );
5611 }
5612
5613 #[test]
5616 fn commandcode_and_copilot_paths_are_tilde_expanded() {
5617 let file = write_toml(
5618 r#"
5619 [commandcode]
5620 auth_paths = ["~/.commandcode/auth.json", "/etc/commandcode/auth.json"]
5621
5622 [copilot]
5623 gh_binary = "~/bin/gh"
5624 "#,
5625 );
5626 let config = Config::load_from(file.path()).unwrap();
5627 let home = crate::cache::home_dir().unwrap();
5628 assert_eq!(
5629 config.commandcode.auth_paths,
5630 Some(vec![
5631 home.join(".commandcode/auth.json"),
5632 PathBuf::from("/etc/commandcode/auth.json"),
5633 ])
5634 );
5635 assert_eq!(config.copilot.gh_binary, Some(home.join("bin/gh")));
5636 }
5637
5638 #[test]
5639 fn opencode_go_and_droid_paths_are_tilde_expanded() {
5640 let file = write_toml(
5641 r#"
5642 [opencode-go]
5643 auth_paths = ["~/.local/share/opencode/opencode.db", "/etc/opencode/auth.json"]
5644
5645 [droid]
5646 cli_path = "~/.local/bin/droid"
5647 "#,
5648 );
5649 let config = Config::load_from(file.path()).unwrap();
5650 let home = crate::cache::home_dir().unwrap();
5651 assert_eq!(
5652 config.opencode_go.auth_paths,
5653 Some(vec![
5654 home.join(".local/share/opencode/opencode.db"),
5655 PathBuf::from("/etc/opencode/auth.json"),
5656 ])
5657 );
5658 assert_eq!(config.droid.cli_path, Some(home.join(".local/bin/droid")));
5659 }
5660
5661 #[test]
5662 fn kiro_db_path_is_tilde_expanded() {
5663 let f = write_toml(
5664 r#"
5665 [kiro]
5666 db_path = "~/kiro-data.sqlite3"
5667 "#,
5668 );
5669 let c = Config::load_from(f.path()).unwrap();
5670 let home = crate::cache::home_dir().unwrap();
5671 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
5672 }
5673
5674 #[test]
5675 fn kiro_appears_when_enabled() {
5676 let f = write_toml(
5677 r#"
5678 [kiro]
5679 enabled = true
5680 "#,
5681 );
5682 let c = Config::load_from(f.path()).unwrap();
5683 assert!(c.is_enabled(VendorId::Kiro));
5684 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
5685 }
5686
5687 #[test]
5688 fn cursor_db_path_is_tilde_expanded() {
5689 let f = write_toml(
5690 r#"
5691 [cursor]
5692 db_path = "~/cursor-state.vscdb"
5693 "#,
5694 );
5695 let c = Config::load_from(f.path()).unwrap();
5696 let home = crate::cache::home_dir().unwrap();
5697 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
5698 }
5699
5700 #[test]
5701 fn cursor_agent_auth_path_is_tilde_expanded() {
5702 let f = write_toml(
5703 r#"
5704 [cursor]
5705 agent_auth_path = "~/cursor-agent-auth.json"
5706 "#,
5707 );
5708 let c = Config::load_from(f.path()).unwrap();
5709 let home = crate::cache::home_dir().unwrap();
5710 assert_eq!(
5711 c.cursor.agent_auth_path,
5712 Some(home.join("cursor-agent-auth.json"))
5713 );
5714 }
5715
5716 #[test]
5717 fn cursor_appears_when_enabled() {
5718 let f = write_toml(
5719 r#"
5720 [cursor]
5721 enabled = true
5722 "#,
5723 );
5724 let c = Config::load_from(f.path()).unwrap();
5725 assert!(c.is_enabled(VendorId::Cursor));
5726 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
5727 }
5728
5729 #[test]
5730 fn add_account_appends_and_preserves_existing() {
5731 let mut doc: toml_edit::DocumentMut = r#"
5732# keep me
5733[anthropic]
5734enabled = true
5735
5736[[anthropic.accounts]]
5737label = "personal"
5738credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
5739"#
5740 .parse()
5741 .unwrap();
5742 add_anthropic_account_to_doc(
5743 &mut doc,
5744 "work",
5745 "~/.config/ai-usagebar/accounts/work/.credentials.json",
5746 )
5747 .unwrap();
5748 let rendered = doc.to_string();
5749 assert!(rendered.contains("# keep me"), "comment must survive");
5750 let f = write_toml(&rendered);
5752 let c = Config::load_from(f.path()).unwrap();
5753 let labels: Vec<&str> = c
5754 .anthropic
5755 .accounts
5756 .iter()
5757 .map(|a| a.label.as_str())
5758 .collect();
5759 assert_eq!(labels, vec!["personal", "work"]);
5760 }
5761
5762 #[test]
5763 fn add_account_to_empty_doc_is_loadable() {
5764 let mut doc = toml_edit::DocumentMut::new();
5765 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
5766 let f = write_toml(&doc.to_string());
5767 let c = Config::load_from(f.path()).unwrap();
5768 assert_eq!(c.anthropic.accounts.len(), 1);
5769 assert_eq!(c.anthropic.accounts[0].label, "solo");
5770 }
5771
5772 #[test]
5773 fn add_account_rejects_duplicate_label() {
5774 let mut doc: toml_edit::DocumentMut = r#"
5775[[anthropic.accounts]]
5776label = "work"
5777credentials_path = "~/w/.credentials.json"
5778"#
5779 .parse()
5780 .unwrap();
5781 assert!(
5782 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
5783 "a duplicate label must be rejected, not appended"
5784 );
5785 }
5786
5787 #[test]
5788 fn add_account_rejects_bad_label() {
5789 let mut doc = toml_edit::DocumentMut::new();
5790 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
5791 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
5792 }
5793
5794 #[test]
5795 fn tildify_collapses_home_only() {
5796 let home = Path::new("/Users/me");
5797 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
5798 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
5799 }
5800
5801 #[test]
5802 fn default_account_credentials_path_nests_under_config_dir() {
5803 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
5804 assert_eq!(
5805 default_account_credentials_path(cfg, "work"),
5806 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
5807 );
5808 }
5809 const CUSTOM_BLOCK: &str = r#"
5812[[custom]]
5813id = "mytool"
5814name = "My Tool"
5815short_name = "myt"
5816enabled = true
5817url = "https://api.example.test/v1/usage"
5818api_key_env = "MYTOOL_API_KEY"
5819auth_header = "Authorization"
5820auth_scheme = "Bearer"
5821plan = "Pro"
5822cache_ttl_secs = 120
5823[custom.headers]
5824X-Org = "org_1"
5825[[custom.metrics]]
5826label = "Requests"
5827used = "/requests/used"
5828limit = "/requests/limit"
5829resets_at = "/requests/reset"
5830window_secs = 3600
5831[[custom.texts]]
5832label = "Tier"
5833value = "/tier"
5834"#;
5835
5836 fn custom_with(from: &str, to: &str) -> String {
5837 assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
5838 CUSTOM_BLOCK.replace(from, to)
5839 }
5840
5841 fn custom_error(toml: &str) -> String {
5842 Config::load_from(write_toml(toml).path())
5843 .unwrap_err()
5844 .to_string()
5845 }
5846
5847 fn assert_custom_rejected(toml: &str, needle: &str) {
5848 let msg = custom_error(toml);
5849 assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
5850 assert!(
5851 msg.contains("[[custom]]"),
5852 "the error must locate the section: {msg}"
5853 );
5854 }
5855
5856 #[test]
5857 fn custom_block_parses_every_field() {
5858 let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
5859 assert_eq!(config.custom.len(), 1);
5860 let c = &config.custom[0];
5861 assert_eq!(c.id, "mytool");
5862 assert_eq!(c.name, "My Tool");
5863 assert_eq!(c.short_name, "myt");
5864 assert_eq!(
5865 c.brand, None,
5866 "a custom provider has no mark unless it asks"
5867 );
5868 assert!(c.enabled);
5869 assert_eq!(c.url, "https://api.example.test/v1/usage");
5870 assert!(!c.allow_http);
5871 assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
5872 assert_eq!(c.api_key, None);
5873 assert_eq!(c.auth_header, "Authorization");
5874 assert_eq!(c.auth_scheme, "Bearer");
5875 assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
5876 assert_eq!(c.plan.as_deref(), Some("Pro"));
5877 assert_eq!(c.plan_path, None);
5878 assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
5879 assert_eq!(c.metrics.len(), 1);
5880 assert_eq!(c.metrics[0].label, "Requests");
5881 assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
5882 assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
5883 assert_eq!(c.metrics[0].percent, None);
5884 assert_eq!(c.metrics[0].currency, None);
5885 assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
5886 assert_eq!(c.metrics[0].window_secs, Some(3600));
5887 assert_eq!(c.texts.len(), 1);
5888 assert_eq!(c.texts[0].label, "Tier");
5889 assert_eq!(c.texts[0].value, "/tier");
5890 assert_eq!(c.texts[0].currency, None);
5891 assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
5892 }
5893
5894 #[test]
5895 fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
5896 let config: Config = toml::from_str(
5897 r#"
5898 [[custom]]
5899 id = "bare"
5900 short_name = "bre"
5901 url = "https://example.test/u"
5902 [[custom.metrics]]
5903 label = "Q"
5904 percent = "/pct"
5905 "#,
5906 )
5907 .unwrap();
5908 let c = &config.custom[0];
5909 assert_eq!(c.name, "bare", "name must default to id on a plain parse");
5910 assert!(!c.enabled);
5911 assert!(!c.allow_http);
5912 assert_eq!(c.api_key_env, "");
5913 assert_eq!(c.auth_header, "Authorization");
5914 assert_eq!(c.auth_scheme, "Bearer");
5915 assert_eq!(c.cache_ttl_secs, 60);
5916 assert!(config.validate().is_ok());
5917 assert!(Config::default().custom.is_empty());
5918 }
5919
5920 #[test]
5921 fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
5922 let config = Config::load_from(
5923 write_toml(&custom_with(
5924 r#"short_name = "myt""#,
5925 "short_name = \"myt\"\nbrand = \"opencode-go\"",
5926 ))
5927 .path(),
5928 )
5929 .unwrap();
5930 assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
5931
5932 for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
5936 assert_custom_rejected(
5937 &custom_with(
5938 r#"short_name = "myt""#,
5939 &format!("short_name = \"myt\"\nbrand = {brand:?}"),
5940 ),
5941 "must name a built-in vendor",
5942 );
5943 }
5944 }
5945
5946 #[test]
5947 fn custom_rejects_a_malformed_id() {
5948 let long = "a".repeat(33);
5949 for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
5950 let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
5951 assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
5952 assert!(msg.contains("must match"), "{id:?}: {msg}");
5953 }
5954 }
5955
5956 #[test]
5957 fn custom_rejects_a_builtin_slug_as_id() {
5958 assert_custom_rejected(
5959 &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
5960 "is a built-in vendor",
5961 );
5962 assert_custom_rejected(
5963 &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
5964 "is a built-in vendor",
5965 );
5966 }
5967
5968 #[test]
5969 fn custom_rejects_duplicate_ids() {
5970 let twice = format!(
5971 "{}{}",
5972 CUSTOM_BLOCK,
5973 custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
5974 );
5975 assert_custom_rejected(&twice, "duplicate id");
5976 }
5977
5978 #[test]
5979 fn custom_rejects_a_name_over_48_chars() {
5980 let long = "n".repeat(49);
5981 assert_custom_rejected(
5982 &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
5983 "name must be 1 to 48 characters",
5984 );
5985 }
5986
5987 #[test]
5988 fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
5989 for short in ["my", "myto", "MYT", "m1t"] {
5990 assert_custom_rejected(
5991 &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
5992 "exactly 3 lowercase ASCII letters",
5993 );
5994 }
5995 }
5996
5997 #[test]
5998 fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
5999 assert_custom_rejected(
6000 &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
6001 "already used by a built-in vendor",
6002 );
6003 let twice = format!(
6004 "{}{}",
6005 CUSTOM_BLOCK,
6006 custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
6007 );
6008 assert_custom_rejected(&twice, "already used by a built-in vendor");
6009 }
6010
6011 #[test]
6012 fn custom_rejects_http_unless_allowed() {
6013 let plain = custom_with(
6014 r#"url = "https://api.example.test/v1/usage""#,
6015 r#"url = "http://localhost:8080/usage""#,
6016 );
6017 assert_custom_rejected(&plain, "url must use https://");
6018 let allowed = plain.replace(
6019 r#"url = "http://localhost:8080/usage""#,
6020 "url = \"http://localhost:8080/usage\"\nallow_http = true",
6021 );
6022 assert!(
6023 Config::load_from(write_toml(&allowed).path()).is_ok(),
6024 "allow_http must permit http://"
6025 );
6026 }
6027
6028 #[test]
6029 fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
6030 assert_custom_rejected(
6031 &custom_with(
6032 r#"url = "https://api.example.test/v1/usage""#,
6033 r#"url = "https://user:pw@api.example.test/v1/usage""#,
6034 ),
6035 "must not carry credentials",
6036 );
6037 assert_custom_rejected(
6038 &custom_with(
6039 r#"url = "https://api.example.test/v1/usage""#,
6040 r#"url = "not a url""#,
6041 ),
6042 "is not a valid URL",
6043 );
6044 assert_custom_rejected(
6045 &custom_with(
6046 r#"url = "https://api.example.test/v1/usage""#,
6047 r#"url = "ftp://api.example.test/v1/usage""#,
6048 ),
6049 "is not http or https",
6050 );
6051 }
6052
6053 #[test]
6054 fn custom_rejects_an_invalid_api_key_env() {
6055 assert_custom_rejected(
6056 &custom_with(
6057 r#"api_key_env = "MYTOOL_API_KEY""#,
6058 r#"api_key_env = "1BAD-NAME""#,
6059 ),
6060 "is not a valid environment variable name",
6061 );
6062 let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
6063 assert!(
6064 Config::load_from(write_toml(&none).path()).is_ok(),
6065 "an empty api_key_env means inline-only and is valid"
6066 );
6067 }
6068
6069 #[test]
6073 fn custom_invalid_api_key_env_is_not_repeated() {
6074 let msg = custom_error(&custom_with(
6075 r#"api_key_env = "MYTOOL_API_KEY""#,
6076 r#"api_key_env = "sk-live-pasted-secret""#,
6077 ));
6078 assert!(msg.contains("api_key_env"), "{msg}");
6079 assert!(!msg.contains("sk-live-pasted-secret"), "{msg}");
6080 }
6081
6082 #[test]
6083 fn custom_rejects_an_invalid_auth_header_name() {
6084 assert_custom_rejected(
6085 &custom_with(
6086 r#"auth_header = "Authorization""#,
6087 r#"auth_header = "X Api Key""#,
6088 ),
6089 "auth_header \"X Api Key\" is not a valid HTTP header name",
6090 );
6091 }
6092
6093 #[test]
6094 fn custom_rejects_a_control_char_in_auth_scheme() {
6095 assert_custom_rejected(
6096 &custom_with(
6097 r#"auth_scheme = "Bearer""#,
6098 "auth_scheme = \"Bearer\\u0007\"",
6099 ),
6100 "auth_scheme contains characters that are not valid",
6101 );
6102 let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
6103 assert!(
6104 Config::load_from(write_toml(&bare).path()).is_ok(),
6105 "an empty scheme (bare key) is valid"
6106 );
6107 }
6108
6109 #[test]
6110 fn custom_rejects_a_bad_extra_header() {
6111 assert_custom_rejected(
6112 &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
6113 "headers must not repeat auth_header",
6114 );
6115 assert_custom_rejected(
6116 &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
6117 "is not a valid HTTP header name",
6118 );
6119 assert_custom_rejected(
6120 &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
6121 "has a value that is not valid in an HTTP header",
6122 );
6123 }
6124
6125 #[test]
6126 fn custom_rejects_cache_ttl_outside_10_to_3600() {
6127 for ttl in ["9", "3601"] {
6128 assert_custom_rejected(
6129 &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
6130 "cache_ttl_secs must be between 10 and 3600",
6131 );
6132 }
6133 }
6134
6135 #[test]
6136 fn custom_rejects_an_entry_with_no_metrics_or_texts() {
6137 let toml = r#"
6138[[custom]]
6139id = "empty"
6140short_name = "emp"
6141url = "https://example.test/u"
6142"#;
6143 assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
6144 }
6145
6146 #[test]
6147 fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
6148 assert_custom_rejected(
6149 &custom_with(
6150 r#"limit = "/requests/limit""#,
6151 "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
6152 ),
6153 "must set `percent`, or both `used` and `limit`",
6154 );
6155 assert_custom_rejected(
6156 &custom_with("limit = \"/requests/limit\"\n", ""),
6157 "must set `percent`, or both `used` and `limit`",
6158 );
6159 }
6160
6161 #[test]
6162 fn custom_rejects_a_pointer_without_a_leading_slash() {
6163 assert_custom_rejected(
6164 &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
6165 "used \"requests.used\" must be an RFC 6901 JSON Pointer",
6166 );
6167 assert_custom_rejected(
6168 &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
6169 "value \"tier\" must be an RFC 6901 JSON Pointer",
6170 );
6171 assert_custom_rejected(
6172 &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
6173 "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
6174 );
6175 assert_custom_rejected(
6176 &custom_with(
6177 r#"resets_at = "/requests/reset""#,
6178 "resets_at = \"/re\\u001bset\"",
6179 ),
6180 "resets_at",
6181 );
6182 }
6183
6184 #[test]
6185 fn custom_rejects_a_label_outside_1_to_64_chars() {
6186 let long = "l".repeat(65);
6187 assert_custom_rejected(
6188 &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
6189 "metric label",
6190 );
6191 assert_custom_rejected(
6192 &custom_with(r#"label = "Tier""#, r#"label = """#),
6193 "text label \"\" must be 1 to 64 characters",
6194 );
6195 }
6196
6197 #[test]
6198 fn custom_rejects_window_secs_under_60() {
6199 assert_custom_rejected(
6200 &custom_with("window_secs = 3600", "window_secs = 59"),
6201 "window_secs must be at least 60",
6202 );
6203 }
6204
6205 #[test]
6206 fn custom_rejects_duplicate_metric_and_text_labels() {
6207 let metric_twice = custom_with(
6208 "window_secs = 3600\n",
6209 "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
6210 );
6211 assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
6212 let text_twice =
6213 format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
6214 assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
6215 }
6216
6217 #[test]
6218 fn custom_metric_and_text_parse_currency() {
6219 let toml = custom_with(
6220 "window_secs = 3600\n",
6221 "window_secs = 3600\ncurrency = \"USD\"\n",
6222 )
6223 .replace(
6224 "value = \"/tier\"\n",
6225 "value = \"/tier\"\ncurrency = \"EUR\"\n",
6226 );
6227 let config = Config::load_from(write_toml(&toml).path()).unwrap();
6228 assert_eq!(config.custom[0].metrics[0].currency.as_deref(), Some("USD"));
6229 assert_eq!(config.custom[0].texts[0].currency.as_deref(), Some("EUR"));
6230 }
6231
6232 #[test]
6233 fn custom_rejects_an_invalid_currency_code() {
6234 let toml = custom_with(
6235 "window_secs = 3600\n",
6236 "window_secs = 3600\ncurrency = \"usd\"\n",
6237 );
6238 assert_custom_rejected(
6239 &toml,
6240 "metric \"Requests\" currency \"usd\" must be a 3-letter uppercase code",
6241 );
6242
6243 let toml = custom_with(
6244 "value = \"/tier\"\n",
6245 "value = \"/tier\"\ncurrency = \"USDT\"\n",
6246 );
6247 assert_custom_rejected(
6248 &toml,
6249 "text \"Tier\" currency \"USDT\" must be a 3-letter uppercase code",
6250 );
6251 }
6252
6253 #[test]
6254 fn custom_rejects_a_metric_mixing_currency_with_percent() {
6255 let toml = r#"
6256 [[custom]]
6257 id = "bare"
6258 short_name = "bre"
6259 url = "https://example.test/u"
6260 [[custom.metrics]]
6261 label = "Quota"
6262 percent = "/pct"
6263 currency = "USD"
6264 "#;
6265 assert_custom_rejected(
6266 toml,
6267 "metric \"Quota\" cannot combine `currency` with `percent`",
6268 );
6269 }
6270
6271 #[test]
6272 fn enabled_custom_and_custom_by_id_select_entries() {
6273 let two = format!(
6274 "{}{}",
6275 CUSTOM_BLOCK,
6276 custom_with(r#"id = "mytool""#, r#"id = "off""#)
6277 .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
6278 .replace("enabled = true", "enabled = false")
6279 );
6280 let config = Config::load_from(write_toml(&two).path()).unwrap();
6281 let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
6282 assert_eq!(enabled, ["mytool"]);
6283 assert_eq!(
6284 config.custom_by_id("off").map(|c| c.name.as_str()),
6285 Some("My Tool")
6286 );
6287 assert!(config.custom_by_id("nope").is_none());
6288 }
6289
6290 #[cfg(unix)]
6291 #[test]
6292 fn has_inline_secrets_sees_a_custom_inline_key() {
6293 let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
6294 assert!(!without.has_inline_secrets());
6295 let with: Config = toml::from_str(&custom_with(
6296 r#"api_key_env = "MYTOOL_API_KEY""#,
6297 "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
6298 ))
6299 .unwrap();
6300 assert!(with.has_inline_secrets());
6301 }
6302
6303 #[test]
6304 fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
6305 let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
6306 let mut spec = CustomProviderConfig {
6307 id: "mytool".into(),
6308 api_key_env: var.into(),
6309 api_key: Some("sk-inline-secret".into()),
6310 ..CustomProviderConfig::default()
6311 };
6312 unsafe { std::env::set_var(var, "sk-env-secret") };
6313 let from_env = spec.resolve_api_key();
6314 unsafe { std::env::remove_var(var) };
6315 assert_eq!(from_env.unwrap(), "sk-env-secret");
6316
6317 assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
6318
6319 spec.api_key = Some(String::new());
6320 let err = spec.resolve_api_key().unwrap_err();
6321 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
6322 let msg = err.to_string();
6323 assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
6324 assert!(msg.contains(var), "{msg}");
6325 assert!(!msg.contains("secret"), "{msg}");
6326
6327 spec.api_key_env = String::new();
6328 let msg = spec.resolve_api_key().unwrap_err().to_string();
6329 assert!(msg.contains("set `api_key`"), "{msg}");
6330 }
6331
6332 #[test]
6333 fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
6334 let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
6335 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
6336 let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
6337 Config::load_from(file.path()).unwrap();
6338 assert!(
6339 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
6340 "a custom provider's env var must be scrubbed from subprocesses"
6341 );
6342 }
6343
6344 #[test]
6345 fn loading_a_config_registers_account_and_renamed_env_vars_for_scrubbing() {
6346 let account = "AI_USAGEBAR_ACCOUNT_SCRUB_TEST_4C2E";
6347 let renamed = "AI_USAGEBAR_RENAMED_SCRUB_TEST_7A3F";
6348 let before = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6349 assert!(!before.contains(&account));
6350 assert!(!before.contains(&renamed));
6351 let file = write_toml(&format!(
6352 "[zai]\napi_key_env = \"{renamed}\"\n\
6353 [[deepseek.accounts]]\nlabel = \"work\"\napi_key_env = \"{account}\"\n"
6354 ));
6355 Config::load_from(file.path()).unwrap();
6356 let after = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6357 assert!(after.contains(&account), "a named account's key variable");
6358 assert!(after.contains(&renamed), "a renamed api_key_env");
6359 }
6360
6361 #[test]
6362 fn loading_a_config_registers_openrouter_management_env_vars_for_scrubbing() {
6363 let renamed = "AI_USAGEBAR_MGMT_RENAMED_SCRUB_TEST_5D1B";
6364 let account = "AI_USAGEBAR_MGMT_ACCOUNT_SCRUB_TEST_8E6C";
6365 let before = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6366 assert!(!before.contains(&renamed));
6367 assert!(!before.contains(&account));
6368 let file = write_toml(&format!(
6369 "[openrouter]\nmanagement_api_key_env = \"{renamed}\"\n\
6370 [[openrouter.accounts]]\nlabel = \"work\"\napi_key_env = \"OR_WORK_KEY\"\n\
6371 management_api_key_env = \"{account}\"\n"
6372 ));
6373 Config::load_from(file.path()).unwrap();
6374 let after = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
6375 assert!(after.contains(&renamed), "a renamed management_api_key_env");
6376 assert!(
6377 after.contains(&account),
6378 "an account's management_api_key_env"
6379 );
6380 }
6381
6382 #[test]
6387 fn loading_a_config_resolves_and_scrubs_a_renamed_cline_env_var() {
6388 let renamed = "AI_USAGEBAR_CLINE_RENAMED_SCRUB_TEST_2F8A";
6389 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&renamed));
6390 let file = write_toml(&format!("[cline]\napi_key_env = \"{renamed}\"\n"));
6391 let config = Config::load_from(file.path()).unwrap();
6392 assert_eq!(config.api_key_env_for(VendorId::Cline), renamed);
6393 assert!(
6394 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&renamed),
6395 "a renamed Cline key variable must be scrubbed from subprocesses"
6396 );
6397 }
6398
6399 #[test]
6404 fn every_config_section_parses_to_its_vendors_enabled_switch() {
6405 for vendor in VendorId::all() {
6406 let text = format!(
6407 "[{}]
6408enabled = true
6409",
6410 vendor.config_section()
6411 );
6412 let config: Config = toml::from_str(&text)
6413 .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
6414 assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
6415 let others = VendorId::all()
6416 .iter()
6417 .filter(|other| *other != vendor && config.is_enabled(**other))
6418 .count();
6419 assert_eq!(
6420 others,
6421 Config::default().enabled_vendors().len()
6422 - usize::from(Config::default().is_enabled(*vendor)),
6423 "[{}] enabled a different vendor",
6424 vendor.config_section()
6425 );
6426 }
6427 }
6428
6429 #[test]
6430 fn tray_section_parses_and_defaults_to_notify() {
6431 let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
6432 let config = Config::load_from(file.path()).unwrap();
6433 assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
6434 assert_eq!(config.tray.updates(), UpdateMode::Auto);
6435
6436 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
6437 assert_eq!(empty.tray, TrayConfig::default());
6438 assert_eq!(empty.tray.updates(), UpdateMode::Notify);
6439 assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
6440 assert_eq!(UpdateMode::parse("weekly"), None);
6441 assert_eq!(UpdateMode::Auto.as_str(), "auto");
6442 }
6443
6444 #[test]
6445 fn tray_ignores_removed_menu_bar_keys_for_back_compatibility() {
6446 let legacy = write_toml(
6447 "[tray]\nmenu_bar_show_all = false\nmenu_bar_hide_value = true\nmenu_bar_names = \"short\"\nmenu_bar_provider = \"anthropic\"\nmenu_bar_window = \"weekly\"\n",
6448 );
6449 let config = Config::load_from(legacy.path()).unwrap();
6450 assert_eq!(config.tray, TrayConfig::default());
6451 }
6452
6453 #[test]
6454 fn tray_section_rejects_a_misspelled_mode() {
6455 let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
6456 assert!(Config::load_from(file.path()).is_err());
6457 }
6458
6459 #[test]
6460 fn tray_refresh_minutes_defaults_to_five_and_parses() {
6461 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
6462 assert_eq!(empty.tray.refresh_minutes, None);
6463 assert_eq!(empty.tray.refresh_minutes(), 5);
6464
6465 let file = write_toml("[tray]\nrefresh_minutes = 10\n");
6466 let config = Config::load_from(file.path()).unwrap();
6467 assert_eq!(config.tray.refresh_minutes(), 10);
6468 }
6469
6470 #[test]
6471 fn tray_refresh_minutes_rejects_values_outside_the_menu() {
6472 for minutes in ["3", "0", "120"] {
6473 let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
6474 let error = Config::load_from(file.path()).unwrap_err().to_string();
6475 assert!(error.contains("[tray] refresh_minutes"), "{error}");
6476 assert!(error.contains("1, 5, 10, 15, 30 or 60"), "{error}");
6477 }
6478 }
6479
6480 #[test]
6481 fn notifications_default_on_at_97_and_parse_overrides() {
6482 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
6483 assert!(empty.notifications.enabled);
6484 assert_eq!(empty.notifications.threshold, 97);
6485
6486 let file = write_toml("[notifications]\nenabled = false\nthreshold = 100\n");
6487 let config = Config::load_from(file.path()).unwrap();
6488 assert!(!config.notifications.enabled);
6489 assert_eq!(config.notifications.threshold, 100);
6490 }
6491
6492 #[test]
6493 fn notifications_threshold_rejects_values_outside_1_to_100() {
6494 for threshold in ["0", "101", "255"] {
6495 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
6496 let error = Config::load_from(file.path()).unwrap_err().to_string();
6497 assert!(
6498 error.contains("[notifications] threshold must be between 1 and 100"),
6499 "threshold {threshold}: {error}"
6500 );
6501 }
6502 for threshold in ["1", "50", "100"] {
6504 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
6505 let config = Config::load_from(file.path()).unwrap();
6506 assert_eq!(config.notifications.threshold.to_string(), threshold);
6507 }
6508 }
6509
6510 #[test]
6511 fn notification_preferences_round_trip_without_changing_other_sections() {
6512 let dir = tempfile::tempdir().unwrap();
6513 let path = dir.path().join("config.toml");
6514 std::fs::write(&path, "# keep this\n[tray]\nrefresh_minutes = 10\n").unwrap();
6515 set_notification_value(&path, "enabled", false.into()).unwrap();
6516 set_notification_value(&path, "threshold", 85i64.into()).unwrap();
6517 let text = std::fs::read_to_string(&path).unwrap();
6518 assert!(text.contains("# keep this\n[tray]\nrefresh_minutes = 10"));
6519 let config = Config::load_from(&path).unwrap();
6520 assert!(!config.notifications.enabled);
6521 assert_eq!(config.notifications.threshold, 85);
6522 assert!(set_notification_value(&path, "threshold", 101i64.into()).is_err());
6523 assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
6524 }
6525
6526 #[test]
6531 fn vendor_enabled_round_trips_and_rejects_unknown_slugs() {
6532 let dir = tempfile::tempdir().unwrap();
6533 let path = dir.path().join("config.toml");
6534 std::fs::write(&path, "# keep\n[zai]\nenabled = true # mine\n").unwrap();
6535
6536 set_vendor_enabled(&path, "zai", false).unwrap();
6537 let text = std::fs::read_to_string(&path).unwrap();
6538 assert!(text.starts_with("# keep\n"), "{text}");
6539 assert!(text.contains("enabled = false # mine"), "{text}");
6540 assert!(!config_enabled(&path, VendorId::Zai));
6541
6542 set_vendor_enabled(&path, "grok", true).unwrap();
6543 let config = Config::load_from(&path).unwrap();
6544 assert!(config.is_enabled(VendorId::Grok));
6545 assert!(
6546 !config.is_enabled(VendorId::Deepseek),
6547 "only the named vendor moves; untouched opt-in vendors stay off"
6548 );
6549 assert!(
6550 config.is_enabled(VendorId::Openai),
6551 "an untouched default-on vendor is not switched off either"
6552 );
6553
6554 for bad in ["", "custom", "not-a-vendor", "anthropic "] {
6555 let error = set_vendor_enabled(&path, bad, true)
6556 .unwrap_err()
6557 .to_string();
6558 assert!(error.contains("unknown provider"), "{bad}: {error}");
6559 }
6560 assert!(
6562 std::fs::read_to_string(&path)
6563 .unwrap()
6564 .contains("enabled = false # mine")
6565 );
6566 }
6567
6568 #[test]
6571 fn set_vendor_enabled_in_doc_targets_the_section_and_stays_idempotent() {
6572 let dir = tempfile::tempdir().unwrap();
6573 let path = dir.path().join("config.toml");
6574 std::fs::write(&path, "[grok]\nenabled = true\napi_key = \"k\"\n").unwrap();
6575 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
6576
6577 set_vendor_enabled(&path, "grok", true).unwrap();
6578
6579 assert_eq!(
6580 std::fs::read_to_string(&path).unwrap(),
6581 "[grok]\nenabled = true\napi_key = \"k\"\n"
6582 );
6583 assert_eq!(
6584 std::fs::metadata(&path).unwrap().modified().unwrap(),
6585 before
6586 );
6587
6588 let mut doc = toml_edit::DocumentMut::new();
6589 set_vendor_enabled_in_doc(&mut doc, "opencode-go", true).unwrap();
6590 assert_eq!(doc.to_string(), "[opencode-go]\nenabled = true\n");
6591 assert!(set_vendor_enabled_in_doc(&mut doc, "mytool", true).is_err());
6592 }
6593
6594 fn config_enabled(path: &std::path::Path, vendor: VendorId) -> bool {
6595 Config::load_from(path).unwrap().is_enabled(vendor)
6596 }
6597
6598 #[test]
6599 fn menu_bar_short_name_defaults_on_and_persists_as_a_bool() {
6600 let dir = tempfile::tempdir().unwrap();
6601 let path = dir.path().join("config.toml");
6602 std::fs::write(&path, "[tray]\n").unwrap();
6603 assert!(Config::load_from(&path).unwrap().tray.menu_bar_short_name());
6604
6605 set_tray_value(&path, "menu_bar_short_name", Some(false.into())).unwrap();
6606 let text = std::fs::read_to_string(&path).unwrap();
6607 assert_eq!(text, "[tray]\nmenu_bar_short_name = false\n");
6608 assert!(!Config::load_from(&path).unwrap().tray.menu_bar_short_name());
6609 }
6610
6611 #[test]
6612 fn set_tray_value_writes_refresh_minutes_as_an_integer() {
6613 let dir = tempfile::tempdir().unwrap();
6614 let path = dir.path().join("config.toml");
6615 std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
6616
6617 set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
6618 let text = std::fs::read_to_string(&path).unwrap();
6619 assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
6620 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
6621
6622 set_tray_value(&path, "refresh_minutes", None).unwrap();
6623 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
6624 }
6625
6626 #[test]
6627 fn set_tray_value_creates_replaces_and_removes_keys() {
6628 let dir = tempfile::tempdir().unwrap();
6629 let path = dir.path().join("config.toml");
6630 std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
6631
6632 set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
6633 let text = std::fs::read_to_string(&path).unwrap();
6634 assert!(text.contains("# primary = \"anthropic\""), "{text}");
6635 assert!(
6636 text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
6637 "{text}"
6638 );
6639
6640 set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
6641 set_tray_value(&path, "updates", Some("off".into())).unwrap();
6642 let config = Config::load_from(&path).unwrap();
6643 assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
6644 assert_eq!(config.tray.updates(), UpdateMode::Off);
6645
6646 set_tray_value(&path, "shortcut", None).unwrap();
6647 let text = std::fs::read_to_string(&path).unwrap();
6648 assert!(!text.contains("shortcut"), "{text}");
6649 assert!(text.contains("updates = \"off\""), "{text}");
6650
6651 let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
6653 set_tray_value(&path, "shortcut", None).unwrap();
6654 assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
6655 }
6656
6657 #[test]
6658 fn set_value_keeps_the_trailing_comment_when_replacing() {
6659 let mut doc: toml_edit::DocumentMut =
6660 "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
6661 set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
6662 assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
6663 }
6664
6665 #[test]
6666 fn set_value_updates_fields_in_a_toml_inline_table() {
6667 let mut doc: toml_edit::DocumentMut =
6668 "tray = { shortcut = \"Ctrl+U\" } # mine\n".parse().unwrap();
6669 set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
6670 assert_eq!(doc.to_string(), "tray = { shortcut = \"Alt+U\" } # mine\n");
6671 }
6672
6673 #[test]
6674 fn enable_vendors_in_creates_a_missing_config() {
6675 let dir = tempfile::TempDir::new().unwrap();
6676 let path = dir.path().join("sub").join("config.toml");
6677
6678 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6679
6680 assert_eq!(
6681 std::fs::read_to_string(&path).unwrap(),
6682 "[grok]
6683enabled = true
6684"
6685 );
6686 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
6687 }
6688
6689 #[test]
6690 fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
6691 let dir = tempfile::TempDir::new().unwrap();
6692 let path = dir.path().join("config.toml");
6693 let original = "# my settings
6694[zai]
6695api_key = \"x\" # keep
6696enabled = false
6697";
6698 std::fs::write(&path, original).unwrap();
6699
6700 enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
6701
6702 let text = std::fs::read_to_string(&path).unwrap();
6703 assert!(
6704 text.starts_with(
6705 "# my settings
6706"
6707 ),
6708 "{text}"
6709 );
6710 assert!(
6711 text.contains(
6712 "api_key = \"x\" # keep
6713"
6714 ),
6715 "{text}"
6716 );
6717 assert!(
6718 text.contains(
6719 "[grok]
6720enabled = true
6721"
6722 ),
6723 "{text}"
6724 );
6725 assert!(
6726 text.contains(
6727 "[opencode-go]
6728enabled = true
6729"
6730 ),
6731 "{text}"
6732 );
6733 let config = Config::load_from(&path).unwrap();
6734 assert!(
6735 !config.is_enabled(VendorId::Zai),
6736 "never widens to false, never flips others"
6737 );
6738 assert!(config.is_enabled(VendorId::Grok));
6739 assert!(config.is_enabled(VendorId::OpenCodeGo));
6740 }
6741
6742 #[test]
6743 fn enable_vendors_in_leaves_an_explicit_false_alone() {
6744 let dir = tempfile::TempDir::new().unwrap();
6745 let path = dir.path().join("config.toml");
6746 let original = "[grok]
6747enabled = false # off
6748api_key = \"k\"
6749";
6750 std::fs::write(&path, original).unwrap();
6751
6752 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6756
6757 assert!(written.is_empty(), "{written:?}");
6758 assert_eq!(
6759 std::fs::read_to_string(&path).unwrap(),
6760 original,
6761 "the file must not be rewritten at all"
6762 );
6763 }
6764
6765 #[test]
6766 fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
6767 let dir = tempfile::TempDir::new().unwrap();
6768 let path = dir.path().join("config.toml");
6769 std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
6770
6771 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6772
6773 assert_eq!(written, vec![VendorId::Grok]);
6774 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
6775 }
6776
6777 #[test]
6778 fn enable_vendors_in_enables_a_toml_inline_table() {
6779 let dir = tempfile::TempDir::new().unwrap();
6780 let path = dir.path().join("config.toml");
6781 std::fs::write(&path, "grok = { api_key = \"k\" }\n").unwrap();
6782
6783 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6784
6785 assert_eq!(written, vec![VendorId::Grok]);
6786 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
6787 let raw = std::fs::read_to_string(&path).unwrap();
6788 assert!(raw.contains("grok = {"));
6789 }
6790
6791 #[test]
6792 fn enable_vendors_in_is_textually_idempotent() {
6793 let dir = tempfile::TempDir::new().unwrap();
6794 let path = dir.path().join("config.toml");
6795 let original = "[grok]
6796enabled = true
6797
6798# trailing
6799";
6800 std::fs::write(&path, original).unwrap();
6801 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
6802
6803 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6804
6805 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
6806 assert_eq!(
6807 std::fs::metadata(&path).unwrap().modified().unwrap(),
6808 before,
6809 "an unchanged document must not be rewritten"
6810 );
6811 }
6812
6813 #[test]
6814 fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
6815 let dir = tempfile::TempDir::new().unwrap();
6816 let path = dir.path().join("config.toml");
6817
6818 enable_vendors_in(&path, &[]).unwrap();
6819
6820 assert!(!path.exists());
6821 }
6822
6823 #[test]
6828 fn edit_config_document_serializes_concurrent_increments_without_loss() {
6829 let dir = tempfile::TempDir::new().unwrap();
6830 let path = dir.path().join("config.toml");
6831 std::fs::write(&path, "[counter]\ncount = 0\n").unwrap();
6832
6833 let handles: Vec<_> = (0..2)
6834 .map(|_| {
6835 let path = path.clone();
6836 std::thread::spawn(move || -> Result<()> {
6837 for _ in 0..50 {
6838 edit_config_document(&path, |doc| {
6839 let count = doc
6840 .get("counter")
6841 .and_then(|table| table.get("count"))
6842 .and_then(|value| value.as_integer())
6843 .unwrap_or(0);
6844 set_value(
6845 doc,
6846 "counter",
6847 "count",
6848 Some(toml_edit::Value::from(count + 1)),
6849 )
6850 })?;
6851 }
6852 Ok(())
6853 })
6854 })
6855 .collect();
6856 for handle in handles {
6857 handle.join().unwrap().unwrap();
6858 }
6859
6860 let doc = read_config_document(&path).unwrap();
6861 let count = doc
6862 .get("counter")
6863 .and_then(|table| table.get("count"))
6864 .and_then(|value| value.as_integer())
6865 .unwrap();
6866 assert_eq!(count, 100, "no increment may be lost");
6867 }
6868
6869 #[test]
6874 fn edit_config_document_times_out_and_leaves_the_file_untouched() {
6875 let dir = tempfile::TempDir::new().unwrap();
6876 let path = dir.path().join("config.toml");
6877 let original = "[ui]\nprimary = \"zai\"\n";
6878 std::fs::write(&path, original).unwrap();
6879 let lock_path = config_lock_path(&path);
6880
6881 let held = crate::cache::acquire_lock(&lock_path, Duration::from_secs(30)).unwrap();
6882 let error = edit_config_document_within(&path, Duration::from_millis(100), |doc| {
6883 set_value(doc, "tray", "updates", Some(toml_edit::Value::from("auto")))
6884 })
6885 .expect_err("a lock held past the timeout must fail the edit");
6886 assert_eq!(
6887 error.user_message(),
6888 "config.toml is being saved by another process; try again."
6889 );
6890 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
6891 drop(held);
6892
6893 edit_config_document(&path, |doc| {
6894 set_value(doc, "tray", "updates", Some(toml_edit::Value::from("auto")))
6895 })
6896 .unwrap();
6897 assert!(
6898 std::fs::read_to_string(&path)
6899 .unwrap()
6900 .contains(r#"updates = "auto""#)
6901 );
6902 assert!(lock_path.exists(), "the lock file lives beside the config");
6903 }
6904
6905 #[test]
6908 fn edit_config_document_writes_nothing_when_the_edit_fails() {
6909 let dir = tempfile::TempDir::new().unwrap();
6910 let path = dir.path().join("config.toml");
6911 let original = "[ui]\nprimary = \"zai\"\n";
6912 std::fs::write(&path, original).unwrap();
6913
6914 let error = edit_config_document(&path, |_doc| -> Result<()> {
6915 Err(AppError::Other("the edit refused".into()))
6916 })
6917 .expect_err("a failed edit must fail the call");
6918 assert_eq!(error.user_message(), "the edit refused");
6919 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
6920
6921 set_vendor_enabled(&path, "deepseek", true).unwrap();
6923 assert!(
6924 std::fs::read_to_string(&path)
6925 .unwrap()
6926 .contains("enabled = true")
6927 );
6928 }
6929
6930 #[test]
6934 fn edit_config_document_removes_the_file_when_the_document_is_left_empty() {
6935 let dir = tempfile::TempDir::new().unwrap();
6936 let path = dir.path().join("config.toml");
6937 std::fs::write(&path, "[ui]\nprimary = \"zai\"\n").unwrap();
6938
6939 edit_config_document(&path, |doc| {
6940 doc.clear();
6941 Ok(())
6942 })
6943 .unwrap();
6944 assert!(
6945 !path.exists(),
6946 "an emptied config must not linger as an empty file"
6947 );
6948 }
6949
6950 #[test]
6954 fn edit_config_document_keeps_a_file_that_still_holds_comments() {
6955 let dir = tempfile::TempDir::new().unwrap();
6956 let path = dir.path().join("config.toml");
6957 let original = "# my notes\n# [zai]\n# enabled = true\n";
6958 std::fs::write(&path, original).unwrap();
6959
6960 edit_config_document(&path, |doc| {
6961 doc["zai"] = toml_edit::table();
6962 doc["zai"]["api_key"] = toml_edit::value("candidate");
6963 Ok(())
6964 })
6965 .unwrap();
6966 edit_config_document(&path, |doc| {
6967 doc.remove("zai");
6968 Ok(())
6969 })
6970 .unwrap();
6971 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
6972 }
6973
6974 #[test]
6978 fn edit_config_document_skips_the_write_when_the_document_is_unchanged() {
6979 let dir = tempfile::TempDir::new().unwrap();
6980 let path = dir.path().join("config.toml");
6981 std::fs::write(&path, "[deepseek]\nenabled = true\n").unwrap();
6982 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
6983
6984 set_vendor_enabled(&path, "deepseek", true).unwrap();
6985
6986 assert_eq!(
6987 std::fs::metadata(&path).unwrap().modified().unwrap(),
6988 before,
6989 "an unchanged document must not be rewritten"
6990 );
6991 }
6992}