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