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