1use std::collections::{BTreeMap, HashSet};
23use std::path::{Path, PathBuf};
24
25#[cfg(unix)]
26use std::os::unix::fs::{MetadataExt, PermissionsExt};
27
28use serde::{Deserialize, Serialize};
29
30use crate::anthropic::creds::CredsTarget;
31use crate::balance::{DisplayPrefs, Headline};
32use crate::cache::Cache;
33use crate::error::{AppError, Result};
34use crate::vendor::VendorId;
35
36#[derive(Debug, Clone, Default, Deserialize, Serialize)]
43#[serde(default, deny_unknown_fields)]
44pub struct Config {
45 pub ui: UiConfig,
46 pub tray: TrayConfig,
47 pub context: ContextConfig,
48 pub anthropic: AnthropicConfig,
49 pub anthropic_api: AnthropicApiConfig,
50 pub openai: OpenAiConfig,
51 pub copilot: CopilotConfig,
52 pub zai: ZaiConfig,
53 pub openrouter: OpenRouterConfig,
54 pub deepseek: DeepseekConfig,
55 pub deepinfra: DeepInfraConfig,
56 pub kimi: KimiConfig,
57 pub kilo: KiloConfig,
58 pub novita: NovitaConfig,
59 pub moonshot: MoonshotConfig,
60 pub grok: GrokConfig,
61 pub supergrok: SuperGrokConfig,
62 pub grokbot: GrokbotConfig,
63 pub antigravity: AntigravityConfig,
64 pub cursor: CursorConfig,
65 pub minimax: MinimaxConfig,
66 pub kiro: KiroConfig,
67 pub nous: NousConfig,
68 #[serde(rename = "opencode-go")]
69 pub opencode_go: OpenCodeGoConfig,
70 pub commandcode: CommandCodeConfig,
71 pub ollama: OllamaConfig,
72 pub orcarouter: OrcaRouterConfig,
73 pub modelstudio: ModelStudioConfig,
74 pub lyceum: LyceumConfig,
75 pub devin: DevinConfig,
76 pub notifications: NotificationsConfig,
78 pub custom: Vec<CustomProviderConfig>,
80 pub cline: ClineConfig,
81}
82
83#[derive(Debug, Clone, Default, Deserialize, Serialize)]
87#[serde(default)]
88pub struct UiConfig {
89 pub primary: Option<VendorId>,
91 pub overview_vendors: Option<Vec<VendorId>>,
96 pub vendor_box: Option<VendorBoxStyle>,
98}
99
100impl UiConfig {
101 pub fn vendor_box(&self) -> VendorBoxStyle {
102 self.vendor_box.unwrap_or_default()
103 }
104}
105
106#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
110#[serde(default)]
111pub struct TrayConfig {
112 pub shortcut: Option<String>,
115 pub refresh_minutes: Option<u64>,
118 pub updates: Option<UpdateMode>,
120 pub menu_bar_style: Option<String>,
123 pub menu_bar_short_name: Option<bool>,
126}
127
128pub const TRAY_REFRESH_MINUTES: [u64; 3] = [1, 5, 10];
131const DEFAULT_TRAY_REFRESH_MINUTES: u64 = 5;
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
137#[serde(default)]
138pub struct NotificationsConfig {
139 pub enabled: bool,
140 pub threshold: u8,
143}
144
145impl Default for NotificationsConfig {
146 fn default() -> Self {
147 Self {
148 enabled: true,
149 threshold: 97,
150 }
151 }
152}
153
154impl TrayConfig {
155 pub fn refresh_minutes(&self) -> u64 {
156 self.refresh_minutes.unwrap_or(DEFAULT_TRAY_REFRESH_MINUTES)
157 }
158
159 pub fn updates(&self) -> UpdateMode {
160 self.updates.unwrap_or_default()
161 }
162
163 pub fn menu_bar_short_name(&self) -> bool {
164 self.menu_bar_short_name.unwrap_or(true)
165 }
166}
167
168#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
171#[serde(rename_all = "lowercase")]
172pub enum UpdateMode {
173 Auto,
174 #[default]
175 Notify,
176 Off,
177}
178
179impl UpdateMode {
180 pub fn as_str(self) -> &'static str {
181 match self {
182 Self::Auto => "auto",
183 Self::Notify => "notify",
184 Self::Off => "off",
185 }
186 }
187
188 pub fn parse(text: &str) -> Option<Self> {
189 match text.trim().to_ascii_lowercase().as_str() {
190 "auto" => Some(Self::Auto),
191 "notify" => Some(Self::Notify),
192 "off" => Some(Self::Off),
193 _ => None,
194 }
195 }
196}
197
198#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
200#[serde(rename_all = "lowercase")]
201pub enum VendorBoxStyle {
202 #[default]
204 Sidebar,
205 Navbar,
207 None,
209}
210
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
214#[serde(rename_all = "lowercase")]
215pub enum ContextLayout {
216 #[default]
218 Full,
219 Split,
221 Bottom,
223}
224
225impl ContextLayout {
226 pub fn next(self) -> Self {
227 match self {
228 ContextLayout::Full => ContextLayout::Split,
229 ContextLayout::Split => ContextLayout::Bottom,
230 ContextLayout::Bottom => ContextLayout::Full,
231 }
232 }
233
234 pub fn label(self) -> &'static str {
235 match self {
236 ContextLayout::Full => "full",
237 ContextLayout::Split => "split",
238 ContextLayout::Bottom => "bottom",
239 }
240 }
241}
242
243#[derive(Debug, Clone, Default, Deserialize, Serialize)]
248#[serde(default)]
249pub struct ContextConfig {
250 pub enabled: bool,
253 pub projects_path: Option<PathBuf>,
255 pub context_window_tokens: Option<u64>,
258 pub model_context_window_tokens: BTreeMap<String, u64>,
261 pub layout: ContextLayout,
263}
264
265impl ContextConfig {
266 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
267 model
268 .and_then(|model| self.model_context_window_tokens.get(model).copied())
269 .filter(|tokens| *tokens > 0)
270 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
271 }
272}
273
274#[derive(Debug, Clone, Deserialize, Serialize)]
275#[serde(default)]
276pub struct AnthropicConfig {
277 pub enabled: bool,
278 pub credentials_path: Option<PathBuf>,
281 pub accounts: Vec<AnthropicAccount>,
285 pub accounts_dir: Option<PathBuf>,
293 pub show_default_account: bool,
299 pub desktop_profiles_dir: Option<PathBuf>,
305 pub desktop_prepare_binary: Option<PathBuf>,
308}
309
310impl Default for AnthropicConfig {
311 fn default() -> Self {
312 Self {
313 enabled: true,
314 credentials_path: None,
315 accounts: Vec::new(),
316 accounts_dir: None,
317 show_default_account: true,
318 desktop_profiles_dir: None,
319 desktop_prepare_binary: None,
320 }
321 }
322}
323
324#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
334pub struct AnthropicAccount {
335 pub label: String,
338 pub credentials_path: PathBuf,
342}
343
344impl AnthropicAccount {
345 pub fn config_dir(&self) -> PathBuf {
350 self.credentials_path
351 .parent()
352 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
353 }
354}
355
356impl AnthropicConfig {
357 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
363 let mut out = self.accounts.clone();
364 if let Some(dir) = &self.accounts_dir {
365 for acct in discover_accounts(dir) {
366 if !out.iter().any(|a| a.label == acct.label) {
367 out.push(acct);
368 }
369 }
370 }
371 out
372 }
373
374 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
379 validate_account_label(label)?;
380 let all = self.all_accounts();
381 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
382 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
383 AppError::Credentials(format!(
384 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
385 known labels: {known:?}"
386 ))
387 })
388 }
389
390 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
401 self.account_target_with(label, self.active_cli_label().as_deref())
402 }
403
404 pub fn active_cli_label(&self) -> Option<String> {
410 crate::anthropic::cli_account::home_claude_json()
411 .ok()
412 .and_then(|path| {
413 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
414 })
415 }
416
417 pub fn default_creds_target(&self) -> CredsTarget {
421 match self.credentials_path.clone() {
422 Some(path) => CredsTarget::Explicit(path),
423 None => {
424 CredsTarget::Default(crate::anthropic::creds::default_path().unwrap_or_default())
425 }
426 }
427 }
428
429 pub fn account_target_with(
437 &self,
438 label: &str,
439 cli_active: Option<&str>,
440 ) -> Result<(CredsTarget, Cache)> {
441 self.account_target_probing(label, cli_active, |path| path.exists())
442 }
443
444 pub fn account_target_probing(
464 &self,
465 label: &str,
466 cli_active: Option<&str>,
467 exists: impl Fn(&Path) -> bool,
468 ) -> Result<(CredsTarget, Cache)> {
469 let account = self.account(label)?;
470 let cache = Cache::for_vendor_account("anthropic", label)?;
471 if cli_active == Some(label) && !exists(&account.credentials_path) {
472 return Ok((
473 CredsTarget::Default(crate::anthropic::creds::default_path()?),
474 cache,
475 ));
476 }
477 Ok((
478 CredsTarget::Named {
479 config_dir: account.config_dir(),
480 path: account.credentials_path,
481 },
482 cache,
483 ))
484 }
485}
486
487pub fn validate_account_label(label: &str) -> Result<()> {
493 validate_account_label_for("anthropic", label)
494}
495
496fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
497 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
498 let bad = label.is_empty()
499 || label == "."
500 || label == ".."
501 || label.contains(['/', '\\'])
502 || label.contains(':')
503 || label.chars().any(char::is_control)
504 || RESERVED.contains(&label);
505 if bad {
506 return Err(AppError::Credentials(format!(
507 "invalid {vendor} account label {label:?}: must be a non-empty name \
508 without path separators, drive prefixes, control characters, or reserved cache names"
509 )));
510 }
511 Ok(())
512}
513
514fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
522 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
523 return Vec::new();
524 };
525 let mut found: Vec<AnthropicAccount> = entries
526 .flatten()
527 .filter_map(|entry| {
528 let path = entry.path();
529 if !path.is_dir() {
530 return None;
531 }
532 let label = path.file_name()?.to_str()?.to_string();
533 validate_account_label(&label).ok()?;
534 Some(AnthropicAccount {
535 label,
536 credentials_path: path.join(".credentials.json"),
537 })
538 })
539 .collect();
540 found.sort_by(|a, b| a.label.cmp(&b.label));
541 found
542}
543
544pub fn tildify(path: &Path, home: &Path) -> String {
548 path.strip_prefix(home)
549 .map(|rest| {
550 let rendered = rest.display().to_string();
551 #[cfg(windows)]
554 let rendered = rendered.replace('\\', "/");
555 format!("~/{rendered}")
556 })
557 .unwrap_or_else(|_| path.display().to_string())
558}
559
560pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
565 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
566 base.join("accounts").join(label).join(".credentials.json")
567}
568
569pub fn add_anthropic_account_to_doc(
575 doc: &mut toml_edit::DocumentMut,
576 label: &str,
577 credentials_path: &str,
578) -> Result<()> {
579 use toml_edit::{Item, Table, value};
580
581 validate_account_label(label)?;
582
583 let anthropic = doc
584 .entry("anthropic")
585 .or_insert_with(|| Item::Table(Table::new()));
586 let anthropic = anthropic
587 .as_table_mut()
588 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
589
590 let accounts = anthropic
591 .entry("accounts")
592 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
593 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
594 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
595 })?;
596
597 let exists = accounts
598 .iter()
599 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
600 if exists {
601 return Err(AppError::Credentials(format!(
602 "anthropic account {label:?} already exists in config.toml"
603 )));
604 }
605
606 let mut table = Table::new();
607 table["label"] = value(label);
608 table["credentials_path"] = value(credentials_path);
609 accounts.push(table);
610 Ok(())
611}
612
613pub fn default_codex_auth_path(home: &Path, label: &str) -> PathBuf {
617 home.join(format!(".codex-{label}")).join("auth.json")
618}
619
620pub fn add_openai_account_to_doc(
625 doc: &mut toml_edit::DocumentMut,
626 label: &str,
627 codex_auth_path: &str,
628) -> Result<()> {
629 use toml_edit::{Item, Table, value};
630
631 validate_account_label_for("openai", label)?;
632
633 let openai = doc
634 .entry("openai")
635 .or_insert_with(|| Item::Table(Table::new()));
636 let openai = openai
637 .as_table_mut()
638 .ok_or_else(|| AppError::Other("[openai] in config.toml is not a table".into()))?;
639
640 let accounts = openai
641 .entry("accounts")
642 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
643 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
644 AppError::Other("[[openai.accounts]] in config.toml is not an array of tables".into())
645 })?;
646
647 if accounts
648 .iter()
649 .any(|t| t.get("label").and_then(Item::as_str) == Some(label))
650 {
651 return Err(AppError::Credentials(format!(
652 "openai account {label:?} already exists in config.toml"
653 )));
654 }
655
656 let mut table = Table::new();
657 table["label"] = value(label);
658 table["codex_auth_path"] = value(codex_auth_path);
659 accounts.push(table);
660 Ok(())
661}
662
663pub(crate) fn set_bool(
667 doc: &mut toml_edit::DocumentMut,
668 section: &str,
669 key: &str,
670 new_value: bool,
671) -> Result<()> {
672 let table = doc
673 .entry(section)
674 .or_insert_with(toml_edit::table)
675 .as_table_mut()
676 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
677
678 if let Some(item) = table.get_mut(key)
679 && let Some(v) = item.as_value_mut()
680 {
681 let suffix = v.decor().suffix().cloned();
685 *v = toml_edit::Value::from(new_value);
686 v.decor_mut().set_prefix(" ");
687 if let Some(suffix) = suffix {
688 v.decor_mut().set_suffix(suffix);
689 }
690 return Ok(());
691 }
692 table.insert(key, toml_edit::value(new_value));
693 Ok(())
694}
695
696pub(crate) fn set_value(
702 doc: &mut toml_edit::DocumentMut,
703 section: &str,
704 key: &str,
705 new_value: Option<toml_edit::Value>,
706) -> Result<()> {
707 let table = doc
708 .entry(section)
709 .or_insert_with(toml_edit::table)
710 .as_table_mut()
711 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
712
713 let Some(mut new_value) = new_value else {
714 table.remove(key);
715 return Ok(());
716 };
717 if let Some(item) = table.get_mut(key)
718 && let Some(v) = item.as_value_mut()
719 {
720 let suffix = v.decor().suffix().cloned();
721 new_value.decor_mut().set_prefix(" ");
722 if let Some(suffix) = suffix {
723 new_value.decor_mut().set_suffix(suffix);
724 }
725 *v = new_value;
726 return Ok(());
727 }
728 table.insert(key, toml_edit::Item::Value(new_value));
729 Ok(())
730}
731
732pub fn set_tray_value(path: &Path, key: &str, value: Option<toml_edit::Value>) -> Result<()> {
738 let mut doc = read_config_document(path)?;
739 let before = doc.to_string();
740 set_value(&mut doc, "tray", key, value)?;
741 if doc.to_string() == before {
742 return Ok(());
743 }
744 write_config_document(path, &doc)
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 let mut doc = read_config_document(path)?;
760 let before = doc.to_string();
761 set_value(&mut doc, "notifications", key, Some(value))?;
762 if doc.to_string() == before {
763 return Ok(());
764 }
765 write_config_document(path, &doc)
766}
767
768pub fn set_vendor_enabled(path: &Path, slug: &str, enabled: bool) -> Result<()> {
776 let mut doc = read_config_document(path)?;
777 let before = doc.to_string();
778 set_vendor_enabled_in_doc(&mut doc, slug, enabled)?;
779 if doc.to_string() == before {
780 return Ok(());
781 }
782 write_config_document(path, &doc)
783}
784
785pub fn set_vendor_enabled_in_doc(
790 doc: &mut toml_edit::DocumentMut,
791 slug: &str,
792 enabled: bool,
793) -> Result<()> {
794 let Some(vendor) = VendorId::from_slug(slug) else {
795 return Err(AppError::Other(format!(
796 "unknown provider {slug:?}: not a built-in vendor"
797 )));
798 };
799 set_bool(doc, vendor.config_section(), "enabled", enabled)
800}
801
802pub(crate) fn toml_error_summary(
808 input: &str,
809 span: Option<std::ops::Range<usize>>,
810 message: &str,
811) -> String {
812 let message = message.trim_end();
813 match span.and_then(|range| input.get(..range.start)) {
814 Some(before) => {
815 let line = before.matches('\n').count() + 1;
816 let line_start = before.rfind('\n').map_or(0, |i| i + 1);
817 let column = before[line_start..].chars().count() + 1;
818 format!("line {line}, column {column}: {message}")
819 }
820 None => message.to_string(),
821 }
822}
823
824pub(crate) fn read_config_document(path: &Path) -> Result<toml_edit::DocumentMut> {
828 let original = match std::fs::read_to_string(path) {
829 Ok(contents) => contents,
830 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
831 Err(error) => return Err(AppError::io_at(path, error)),
832 };
833 if original.trim().is_empty() {
834 return Ok(toml_edit::DocumentMut::new());
835 }
836 original.parse().map_err(|e: toml_edit::TomlError| {
837 let summary = toml_error_summary(&original, e.span(), e.message());
838 AppError::Other(format!("config.toml not parseable: {summary}"))
839 })
840}
841
842pub(crate) fn write_config_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> {
846 let bytes = doc.to_string();
847 crate::cache::atomic_write(path, bytes.as_bytes())?;
848
849 #[cfg(unix)]
850 {
851 if let Ok(meta) = std::fs::metadata(path) {
852 let mut perms = meta.permissions();
853 perms.set_mode(0o600);
854 let _ = std::fs::set_permissions(path, perms);
855 }
856 }
857 Ok(())
858}
859
860pub fn enable_vendors_in(path: &Path, vendors: &[VendorId]) -> Result<Vec<VendorId>> {
868 let mut doc = read_config_document(path)?;
869 let before = doc.to_string();
870 let written: Vec<VendorId> = vendors
871 .iter()
872 .copied()
873 .filter(|vendor| !is_explicitly_disabled(&doc, *vendor))
874 .collect();
875 for vendor in &written {
876 set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
877 }
878 if doc.to_string() == before {
879 return Ok(written);
880 }
881 write_config_document(path, &doc)?;
882 Ok(written)
883}
884
885fn is_explicitly_disabled(doc: &toml_edit::DocumentMut, vendor: VendorId) -> bool {
896 doc.get(vendor.config_section())
897 .and_then(|section| section.get("enabled"))
898 .and_then(|enabled| enabled.as_bool())
899 == Some(false)
900}
901
902#[derive(Debug, Clone, Deserialize, Serialize)]
903#[serde(default)]
904pub struct OpenAiConfig {
905 pub enabled: bool,
906 pub codex_auth_path: Option<PathBuf>,
908 #[serde(default)]
913 pub accounts: Vec<OpenAiAccount>,
914 pub show_default_account: bool,
920 pub admin_key_env: String,
928}
929
930#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
941pub struct OpenAiAccount {
942 pub label: String,
945 pub codex_auth_path: PathBuf,
948}
949
950impl OpenAiConfig {
951 pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
955 let Some(label) = label else {
956 return match &self.codex_auth_path {
957 Some(path) => Ok(path.clone()),
958 None => crate::openai::creds::default_path(),
959 };
960 };
961 self.accounts
962 .iter()
963 .find(|account| account.label == label)
964 .map(|account| account.codex_auth_path.clone())
965 .ok_or_else(|| {
966 AppError::Credentials(format!(
967 "no OpenAI account named {label:?}. Add it under \
968 [[openai.accounts]], or drop --account to use the default login."
969 ))
970 })
971 }
972
973 pub fn fetch_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
980 let Some(label) = label else {
981 return self.resolve_auth_path(None);
982 };
983 let default = self.resolve_auth_path(None)?;
984 let active = crate::openai::account::resolve_active_label(&default, &self.accounts);
985 self.fetch_auth_path_probing(label, active.as_deref(), Path::exists)
986 }
987
988 pub fn fetch_auth_path_probing(
993 &self,
994 label: &str,
995 active: Option<&str>,
996 exists: impl Fn(&Path) -> bool,
997 ) -> Result<PathBuf> {
998 let own = self.resolve_auth_path(Some(label))?;
999 if active == Some(label) && !exists(&own) {
1000 return self.resolve_auth_path(None);
1001 }
1002 Ok(own)
1003 }
1004}
1005
1006impl Default for OpenAiConfig {
1007 fn default() -> Self {
1008 Self {
1009 enabled: true,
1010 codex_auth_path: None,
1011 accounts: Vec::new(),
1012 show_default_account: true,
1013 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
1014 }
1015 }
1016}
1017
1018#[derive(Debug, Clone, Deserialize, Serialize)]
1022#[serde(default)]
1023pub struct CopilotConfig {
1024 pub enabled: bool,
1025 pub gh_binary: Option<PathBuf>,
1028 pub accounts: Vec<CopilotAccount>,
1032 pub show_default_account: bool,
1038}
1039
1040impl Default for CopilotConfig {
1041 fn default() -> Self {
1042 Self {
1043 enabled: false,
1046 gh_binary: None,
1047 accounts: Vec::new(),
1048 show_default_account: true,
1049 }
1050 }
1051}
1052
1053#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1072pub struct CopilotAccount {
1073 pub label: String,
1076 pub user: String,
1079}
1080
1081impl CopilotConfig {
1082 pub fn resolve_user(&self, label: Option<&str>) -> Result<Option<&str>> {
1086 let Some(label) = label else {
1087 return Ok(None);
1088 };
1089 self.accounts
1090 .iter()
1091 .find(|account| account.label == label)
1092 .map(|account| Some(account.user.as_str()))
1093 .ok_or_else(|| {
1094 let known = self
1095 .accounts
1096 .iter()
1097 .map(|account| account.label.as_str())
1098 .collect::<Vec<_>>();
1099 AppError::Other(if known.is_empty() {
1100 format!(
1101 "copilot: no account {label:?} — [copilot] has no accounts array; \
1102 add [[copilot.accounts]] with label and user"
1103 )
1104 } else {
1105 format!(
1106 "copilot: no account {label:?}; known labels: {}",
1107 known.join(", ")
1108 )
1109 })
1110 })
1111 }
1112
1113 pub fn resolve_token(&self, label: Option<&str>) -> Result<String> {
1114 self.resolve_token_with(
1115 |name| std::env::var_os(name),
1116 &crate::copilot::credentials::SystemGhAuthTokenRunner,
1117 label,
1118 )
1119 }
1120
1121 fn resolve_token_with(
1122 &self,
1123 environment: impl Fn(&str) -> Option<std::ffi::OsString>,
1124 runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
1125 label: Option<&str>,
1126 ) -> Result<String> {
1127 let user = self.resolve_user(label)?;
1128 if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
1129 let token = value.into_string().map_err(|_| {
1130 AppError::Credentials(
1131 "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
1132 )
1133 })?;
1134 if !token.is_empty() {
1135 if let Some(label) = label {
1139 return Err(AppError::Credentials(format!(
1140 "GitHub Copilot: GITHUB_COPILOT_TOKEN is set, so it is unclear whether \
1141 --account {label:?} should use that token or {user:?}'s `gh` login. \
1142 Unset the variable to use named accounts."
1143 )));
1144 }
1145 return Ok(token);
1146 }
1147 }
1148 crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref(), user)
1149 }
1150}
1151
1152#[derive(Debug, Clone, Deserialize, Serialize)]
1153#[serde(default)]
1154pub struct NousConfig {
1155 pub enabled: bool,
1156 pub headline: Headline,
1160}
1161
1162impl Default for NousConfig {
1163 fn default() -> Self {
1164 Self {
1165 enabled: false,
1166 headline: Headline::Percent,
1169 }
1170 }
1171}
1172
1173#[derive(Debug, Clone, Deserialize, Serialize)]
1174#[serde(default)]
1175pub struct OpenCodeGoConfig {
1176 pub enabled: bool,
1177 pub api_key_env: String,
1178 pub api_key: Option<String>,
1179 pub auth_paths: Option<Vec<PathBuf>>,
1183}
1184
1185#[derive(Debug, Clone, Deserialize, Serialize)]
1191#[serde(default)]
1192pub struct ClineConfig {
1193 pub enabled: bool,
1194 pub auth_paths: Option<Vec<PathBuf>>,
1195 pub api_key_env: String,
1196 pub api_key: Option<String>,
1197}
1198
1199impl Default for ClineConfig {
1200 fn default() -> Self {
1201 Self {
1202 enabled: false,
1203 auth_paths: None,
1204 api_key_env: crate::cline::creds::API_KEY_ENV.to_string(),
1205 api_key: None,
1206 }
1207 }
1208}
1209
1210#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1215#[serde(default)]
1216pub struct CommandCodeConfig {
1217 pub enabled: bool,
1218 pub auth_paths: Option<Vec<PathBuf>>,
1219}
1220
1221#[derive(Debug, Clone, Deserialize, Serialize)]
1226#[serde(default)]
1227pub struct OllamaConfig {
1228 pub enabled: bool,
1229 pub api_key_env: String,
1230 pub api_key: Option<String>,
1231 pub plan: String,
1234}
1235
1236impl Default for OllamaConfig {
1237 fn default() -> Self {
1238 Self {
1239 enabled: false,
1240 api_key_env: "OLLAMA_API_KEY".to_string(),
1241 api_key: None,
1242 plan: "pro".to_string(),
1243 }
1244 }
1245}
1246
1247#[derive(Debug, Clone, Deserialize, Serialize)]
1250#[serde(default)]
1251pub struct OrcaRouterConfig {
1252 pub enabled: bool,
1253 pub accounts: Vec<ApiKeyAccount>,
1256 pub show_default_account: bool,
1260 pub api_key_env: String,
1261 pub api_key: Option<String>,
1262}
1263
1264impl Default for OrcaRouterConfig {
1265 fn default() -> Self {
1266 Self {
1267 enabled: false,
1268 accounts: Vec::new(),
1269 show_default_account: true,
1270 api_key_env: "ORCAROUTER_API_KEY".to_string(),
1271 api_key: None,
1272 }
1273 }
1274}
1275
1276#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1283#[serde(default)]
1284pub struct ModelStudioConfig {
1285 pub enabled: bool,
1288 pub config_dir: Option<PathBuf>,
1291}
1292
1293#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1296#[serde(default)]
1297pub struct DevinConfig {
1298 pub enabled: bool,
1299 pub credentials_path: Option<PathBuf>,
1303}
1304
1305impl Default for OpenCodeGoConfig {
1306 fn default() -> Self {
1307 Self {
1308 enabled: false,
1309 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
1310 api_key: None,
1311 auth_paths: None,
1312 }
1313 }
1314}
1315
1316#[derive(Debug, Clone, Deserialize, Serialize)]
1317#[serde(default)]
1318pub struct ZaiConfig {
1319 pub enabled: bool,
1320 pub accounts: Vec<ApiKeyAccount>,
1323 pub show_default_account: bool,
1327 pub api_key_env: String,
1329 pub api_key: Option<String>,
1332 pub plan_tier: Option<String>,
1334}
1335
1336impl Default for ZaiConfig {
1337 fn default() -> Self {
1338 Self {
1339 enabled: true,
1340 accounts: Vec::new(),
1341 show_default_account: true,
1342 api_key_env: "ZAI_API_KEY".to_string(),
1343 api_key: None,
1344 plan_tier: None,
1345 }
1346 }
1347}
1348
1349#[derive(Debug, Clone, Deserialize, Serialize)]
1350#[serde(default)]
1351pub struct OpenRouterConfig {
1352 pub enabled: bool,
1353 pub accounts: Vec<ApiKeyAccount>,
1356 pub show_default_account: bool,
1360 pub api_key_env: String,
1361 pub api_key: Option<String>,
1362 pub management_api_key_env: String,
1367 pub headline: Headline,
1379}
1380
1381impl Default for OpenRouterConfig {
1382 fn default() -> Self {
1383 Self {
1384 enabled: true,
1385 accounts: Vec::new(),
1386 show_default_account: true,
1387 api_key_env: "OPENROUTER_API_KEY".to_string(),
1388 api_key: None,
1389 management_api_key_env: "OPENROUTER_MANAGEMENT_API_KEY".to_string(),
1390 headline: Headline::Percent,
1391 }
1392 }
1393}
1394
1395#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1400pub struct ApiKeyAccount {
1401 pub label: String,
1403 #[serde(default)]
1405 pub api_key_env: Option<String>,
1406 #[serde(default)]
1408 pub api_key: Option<String>,
1409 #[serde(default)]
1413 pub management_api_key_env: Option<String>,
1414}
1415
1416fn api_key_account<'a>(
1420 slug: &str,
1421 accounts: &'a [ApiKeyAccount],
1422 label: &str,
1423) -> Result<&'a ApiKeyAccount> {
1424 validate_account_label_for(slug, label)?;
1425 accounts
1426 .iter()
1427 .find(|account| account.label == label)
1428 .ok_or_else(|| {
1429 let known: Vec<&str> = accounts
1430 .iter()
1431 .map(|account| account.label.as_str())
1432 .collect();
1433 AppError::Credentials(format!(
1434 "{slug} account {label:?} not found in [[{slug}.accounts]]; \
1435 known labels: {known:?}"
1436 ))
1437 })
1438}
1439
1440#[derive(Debug, Clone, Deserialize, Serialize)]
1441#[serde(default)]
1442pub struct DeepseekConfig {
1443 pub enabled: bool,
1444 pub accounts: Vec<ApiKeyAccount>,
1447 pub show_default_account: bool,
1451 pub api_key_env: String,
1452 pub api_key: Option<String>,
1453 pub display_limit: Option<f64>,
1456 pub headline: Headline,
1458}
1459
1460impl Default for DeepseekConfig {
1461 fn default() -> Self {
1462 Self {
1463 enabled: false,
1464 accounts: Vec::new(),
1465 show_default_account: true,
1466 api_key_env: "DEEPSEEK_API_KEY".to_string(),
1467 api_key: None,
1468 display_limit: None,
1469 headline: Headline::Amount,
1470 }
1471 }
1472}
1473
1474#[derive(Debug, Clone, Deserialize, Serialize)]
1475#[serde(default)]
1476pub struct DeepInfraConfig {
1477 pub enabled: bool,
1478 pub accounts: Vec<ApiKeyAccount>,
1479 pub show_default_account: bool,
1480 pub api_key_env: String,
1481 pub api_key: Option<String>,
1482 pub display_limit: Option<f64>,
1484 pub headline: Headline,
1485}
1486
1487impl Default for DeepInfraConfig {
1488 fn default() -> Self {
1489 Self {
1490 enabled: false,
1491 accounts: Vec::new(),
1492 show_default_account: true,
1493 api_key_env: "DEEPINFRA_API_KEY".to_string(),
1494 api_key: None,
1495 display_limit: None,
1496 headline: Headline::Amount,
1497 }
1498 }
1499}
1500
1501#[derive(Debug, Clone, Deserialize, Serialize)]
1502#[serde(default)]
1503pub struct KimiConfig {
1504 pub enabled: bool,
1505 pub api_key_env: String,
1506 pub api_key: Option<String>,
1509 pub credentials_path: Option<PathBuf>,
1513 pub region: String,
1518}
1519
1520impl Default for KimiConfig {
1521 fn default() -> Self {
1522 Self {
1523 enabled: false,
1524 api_key_env: "KIMI_API_KEY".to_string(),
1525 api_key: None,
1526 credentials_path: None,
1527 region: "auto".to_string(),
1528 }
1529 }
1530}
1531
1532#[derive(Debug, Clone, Deserialize, Serialize)]
1533#[serde(default)]
1534pub struct KiloConfig {
1535 pub enabled: bool,
1536 pub accounts: Vec<ApiKeyAccount>,
1539 pub show_default_account: bool,
1543 pub api_key_env: String,
1544 pub api_key: Option<String>,
1545 pub organization_id: Option<String>,
1548 pub display_limit: Option<f64>,
1551 pub headline: Headline,
1553}
1554
1555impl Default for KiloConfig {
1556 fn default() -> Self {
1557 Self {
1560 enabled: false,
1561 accounts: Vec::new(),
1562 show_default_account: true,
1563 api_key_env: "KILO_API_KEY".to_string(),
1564 api_key: None,
1565 organization_id: None,
1566 display_limit: None,
1567 headline: Headline::Amount,
1568 }
1569 }
1570}
1571
1572#[derive(Debug, Clone, Deserialize, Serialize)]
1573#[serde(default)]
1574pub struct NovitaConfig {
1575 pub enabled: bool,
1576 pub accounts: Vec<ApiKeyAccount>,
1579 pub show_default_account: bool,
1583 pub api_key_env: String,
1584 pub api_key: Option<String>,
1585 pub display_limit: Option<f64>,
1589 pub headline: Headline,
1591}
1592
1593impl Default for NovitaConfig {
1594 fn default() -> Self {
1595 Self {
1597 enabled: false,
1598 accounts: Vec::new(),
1599 show_default_account: true,
1600 api_key_env: "NOVITA_API_KEY".to_string(),
1601 api_key: None,
1602 display_limit: None,
1603 headline: Headline::Amount,
1604 }
1605 }
1606}
1607
1608#[derive(Debug, Clone, Deserialize, Serialize)]
1609#[serde(default)]
1610pub struct LyceumConfig {
1611 pub enabled: bool,
1612 pub accounts: Vec<ApiKeyAccount>,
1613 pub show_default_account: bool,
1614 pub api_key_env: String,
1615 pub api_key: Option<String>,
1616}
1617impl Default for LyceumConfig {
1618 fn default() -> Self {
1619 Self {
1620 enabled: false,
1621 accounts: Vec::new(),
1622 show_default_account: true,
1623 api_key_env: "LYCEUM_API_KEY".into(),
1624 api_key: None,
1625 }
1626 }
1627}
1628
1629#[derive(Debug, Clone, Deserialize, Serialize)]
1630#[serde(default)]
1631pub struct MinimaxConfig {
1632 pub enabled: bool,
1633 pub accounts: Vec<ApiKeyAccount>,
1636 pub show_default_account: bool,
1640 pub api_key_env: String,
1641 pub api_key: Option<String>,
1642 pub region: String,
1648}
1649
1650impl Default for MinimaxConfig {
1651 fn default() -> Self {
1652 Self {
1654 enabled: false,
1655 accounts: Vec::new(),
1656 show_default_account: true,
1657 api_key_env: "MINIMAX_API_KEY".to_string(),
1658 api_key: None,
1659 region: "global".to_string(),
1660 }
1661 }
1662}
1663
1664#[derive(Debug, Clone, Deserialize, Serialize)]
1665#[serde(default)]
1666pub struct MoonshotConfig {
1667 pub enabled: bool,
1668 pub accounts: Vec<ApiKeyAccount>,
1671 pub show_default_account: bool,
1675 pub api_key_env: String,
1676 pub api_key: Option<String>,
1677 pub region: String,
1679 pub display_limit: Option<f64>,
1682 pub headline: Headline,
1684}
1685
1686impl Default for MoonshotConfig {
1687 fn default() -> Self {
1688 Self {
1690 enabled: false,
1691 accounts: Vec::new(),
1692 show_default_account: true,
1693 api_key_env: "MOONSHOT_API_KEY".to_string(),
1694 api_key: None,
1695 region: "global".to_string(),
1696 display_limit: None,
1697 headline: Headline::Amount,
1698 }
1699 }
1700}
1701
1702#[derive(Debug, Clone, Deserialize, Serialize)]
1703#[serde(default)]
1704pub struct GrokConfig {
1705 pub enabled: bool,
1706 pub accounts: Vec<ApiKeyAccount>,
1709 pub show_default_account: bool,
1713 pub api_key_env: String,
1715 pub api_key: Option<String>,
1716 pub team_id: Option<String>,
1719 pub display_limit: Option<f64>,
1721 pub headline: Headline,
1723}
1724
1725impl Default for GrokConfig {
1726 fn default() -> Self {
1727 Self {
1729 enabled: false,
1730 accounts: Vec::new(),
1731 show_default_account: true,
1732 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1733 api_key: None,
1734 team_id: None,
1735 display_limit: None,
1736 headline: Headline::Amount,
1737 }
1738 }
1739}
1740
1741#[derive(Debug, Clone, Deserialize, Serialize)]
1749#[serde(default)]
1750pub struct SuperGrokConfig {
1751 pub enabled: bool,
1752 pub grok_binary: PathBuf,
1756 pub auth_path: Option<PathBuf>,
1759 pub config_path: Option<PathBuf>,
1760}
1761
1762impl Default for SuperGrokConfig {
1763 fn default() -> Self {
1764 Self {
1765 enabled: false,
1766 grok_binary: default_grok_binary(),
1767 auth_path: None,
1768 config_path: None,
1769 }
1770 }
1771}
1772
1773#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1779#[serde(default)]
1780pub struct GrokbotConfig {
1781 pub enabled: bool,
1784 pub secrets_path: Option<PathBuf>,
1789}
1790
1791fn default_grok_binary() -> PathBuf {
1792 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1793 let grok_home = std::env::var_os("GROK_HOME")
1794 .filter(|value| !value.is_empty())
1795 .map(PathBuf::from)
1796 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1797 grok_home
1798 .map(|home| home.join("bin").join(executable))
1799 .unwrap_or_else(|| PathBuf::from(executable))
1800}
1801
1802#[derive(Debug, Clone, Deserialize, Serialize)]
1810#[serde(default)]
1811pub struct AntigravityConfig {
1812 pub enabled: bool,
1813 pub oauth_client_id: Option<String>,
1815 pub oauth_client_secret: Option<String>,
1819 pub ide_language_server: bool,
1824}
1825
1826impl Default for AntigravityConfig {
1827 fn default() -> Self {
1828 Self {
1829 enabled: false,
1830 oauth_client_id: None,
1831 oauth_client_secret: None,
1832 ide_language_server: true,
1833 }
1834 }
1835}
1836
1837#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1847#[serde(default)]
1848pub struct CursorConfig {
1849 pub enabled: bool,
1850 pub db_path: Option<PathBuf>,
1854 pub agent_auth_path: Option<PathBuf>,
1859}
1860
1861#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1870#[serde(default)]
1871pub struct KiroConfig {
1872 pub enabled: bool,
1873 pub db_path: Option<PathBuf>,
1877}
1878
1879#[derive(Debug, Clone, Deserialize, Serialize)]
1880#[serde(default)]
1881pub struct AnthropicApiConfig {
1882 pub enabled: bool,
1883 pub api_key_env: String,
1886 pub api_key: Option<String>,
1887 pub monthly_limit: Option<f64>,
1890}
1891
1892impl Default for AnthropicApiConfig {
1893 fn default() -> Self {
1894 Self {
1896 enabled: false,
1897 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
1898 api_key: None,
1899 monthly_limit: None,
1900 }
1901 }
1902}
1903
1904#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1914#[serde(default, remote = "Self")]
1915pub struct CustomProviderConfig {
1916 pub id: String,
1918 pub name: String,
1920 pub short_name: String,
1923 pub brand: Option<String>,
1926 pub enabled: bool,
1927 pub url: String,
1929 pub allow_http: bool,
1930 pub api_key_env: String,
1932 pub api_key: Option<String>,
1933 pub auth_header: String,
1935 pub auth_scheme: String,
1937 pub headers: BTreeMap<String, String>,
1939 pub plan: Option<String>,
1941 pub plan_path: Option<String>,
1943 pub cache_ttl_secs: u64,
1945 pub metrics: Vec<CustomMetricSpec>,
1946 pub texts: Vec<CustomTextSpec>,
1947}
1948
1949impl Default for CustomProviderConfig {
1950 fn default() -> Self {
1951 Self {
1952 id: String::new(),
1953 name: String::new(),
1954 short_name: String::new(),
1955 brand: None,
1956 enabled: false,
1957 url: String::new(),
1958 allow_http: false,
1959 api_key_env: String::new(),
1960 api_key: None,
1961 auth_header: "Authorization".to_string(),
1962 auth_scheme: "Bearer".to_string(),
1963 headers: BTreeMap::new(),
1964 plan: None,
1965 plan_path: None,
1966 cache_ttl_secs: 60,
1967 metrics: Vec::new(),
1968 texts: Vec::new(),
1969 }
1970 }
1971}
1972
1973impl<'de> Deserialize<'de> for CustomProviderConfig {
1978 fn deserialize<D: serde::Deserializer<'de>>(
1979 deserializer: D,
1980 ) -> std::result::Result<Self, D::Error> {
1981 let mut this = Self::deserialize(deserializer)?;
1982 if this.name.is_empty() {
1983 this.name = this.id.clone();
1984 }
1985 Ok(this)
1986 }
1987}
1988
1989impl Serialize for CustomProviderConfig {
1990 fn serialize<S: serde::Serializer>(
1991 &self,
1992 serializer: S,
1993 ) -> std::result::Result<S::Ok, S::Error> {
1994 Self::serialize(self, serializer)
1995 }
1996}
1997
1998#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
2002#[serde(default)]
2003pub struct CustomMetricSpec {
2004 pub label: String,
2005 pub used: Option<String>,
2006 pub limit: Option<String>,
2007 pub percent: Option<String>,
2008 pub resets_at: Option<String>,
2010 pub window_secs: Option<u64>,
2012}
2013
2014#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
2016#[serde(default)]
2017pub struct CustomTextSpec {
2018 pub label: String,
2019 pub value: String,
2020}
2021
2022impl CustomProviderConfig {
2023 pub fn section_label(&self) -> String {
2025 format!("[[custom]] id = {:?}", self.id)
2026 }
2027
2028 pub fn resolve_api_key(&self) -> Result<String> {
2031 if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
2032 return Ok(key);
2033 }
2034 let advice = if self.api_key_env.is_empty() {
2035 "set `api_key`, or name an environment variable in `api_key_env`".to_string()
2036 } else {
2037 format!("export {} or set `api_key`", self.api_key_env)
2038 };
2039 Err(AppError::Credentials(format!(
2040 "custom {}: no API key. Either {advice} under {} in {}.",
2041 self.id,
2042 self.section_label(),
2043 config_path_hint()
2044 )))
2045 }
2046
2047 pub fn cache_ttl(&self) -> std::time::Duration {
2048 std::time::Duration::from_secs(self.cache_ttl_secs)
2049 }
2050
2051 fn validate(&self, index: usize) -> Result<()> {
2055 if !is_valid_custom_id(&self.id) {
2056 return Err(AppError::Other(format!(
2057 "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
2058 index + 1,
2059 self.id
2060 )));
2061 }
2062 let section = self.section_label();
2063 let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
2064
2065 if VendorId::all().iter().any(|v| v.slug() == self.id) {
2066 return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
2067 }
2068 let name_len = self.name.chars().count();
2069 if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
2070 return Err(bad(
2071 "name must be 1 to 48 characters without control characters".into(),
2072 ));
2073 }
2074 if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
2075 return Err(bad(format!(
2076 "short_name {:?} must be exactly 3 lowercase ASCII letters",
2077 self.short_name
2078 )));
2079 }
2080 if let Some(brand) = &self.brand
2081 && !VendorId::all().iter().any(|v| v.slug() == brand)
2082 {
2083 return Err(bad(format!(
2084 "brand {brand:?} must name a built-in vendor (it borrows that \
2085 vendor's mark); leave it unset to keep the short_name tag"
2086 )));
2087 }
2088 let url = reqwest::Url::parse(&self.url)
2089 .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
2090 match url.scheme() {
2091 "https" => {}
2092 "http" if self.allow_http => {}
2093 "http" => {
2094 return Err(bad(
2095 "url must use https:// (set allow_http = true to permit http://)".into(),
2096 ));
2097 }
2098 other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
2099 }
2100 if !url.username().is_empty() || url.password().is_some() {
2101 return Err(bad("url must not carry credentials (user:pass@)".into()));
2102 }
2103 if url.host_str().is_none() {
2104 return Err(bad("url has no host".into()));
2105 }
2106 if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
2107 return Err(bad(
2111 "api_key_env is not a valid environment variable name".into()
2112 ));
2113 }
2114 validate_header_name(§ion, "auth_header", &self.auth_header)?;
2115 if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
2116 return Err(bad(
2117 "auth_scheme contains characters that are not valid in an HTTP header".into(),
2118 ));
2119 }
2120 for (name, value) in &self.headers {
2121 validate_header_name(§ion, "headers", name)?;
2122 if name.eq_ignore_ascii_case(&self.auth_header) {
2123 return Err(bad(format!(
2124 "headers must not repeat auth_header {:?}",
2125 self.auth_header
2126 )));
2127 }
2128 if reqwest::header::HeaderValue::from_str(value).is_err() {
2129 return Err(bad(format!(
2130 "header {name:?} has a value that is not valid in an HTTP header"
2131 )));
2132 }
2133 }
2134 if let Some(plan) = &self.plan {
2135 validate_custom_label(§ion, "plan", plan)?;
2136 }
2137 if let Some(pointer) = &self.plan_path {
2138 validate_pointer(§ion, "plan_path", pointer)?;
2139 }
2140 if !(10..=3600).contains(&self.cache_ttl_secs) {
2141 return Err(bad(format!(
2142 "cache_ttl_secs must be between 10 and 3600, got {}",
2143 self.cache_ttl_secs
2144 )));
2145 }
2146 if self.metrics.is_empty() && self.texts.is_empty() {
2147 return Err(bad(
2148 "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
2149 ));
2150 }
2151 let mut metric_labels = HashSet::new();
2152 for metric in &self.metrics {
2153 validate_custom_label(§ion, "metric label", &metric.label)?;
2154 if !metric_labels.insert(metric.label.as_str()) {
2155 return Err(bad(format!("duplicate metric label {:?}", metric.label)));
2156 }
2157 let pair = (metric.used.is_some(), metric.limit.is_some());
2158 let well_formed = if metric.percent.is_some() {
2159 pair == (false, false)
2160 } else {
2161 pair == (true, true)
2162 };
2163 if !well_formed {
2164 return Err(bad(format!(
2165 "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
2166 metric.label
2167 )));
2168 }
2169 for (field, pointer) in [
2170 ("used", &metric.used),
2171 ("limit", &metric.limit),
2172 ("percent", &metric.percent),
2173 ("resets_at", &metric.resets_at),
2174 ] {
2175 if let Some(pointer) = pointer {
2176 validate_pointer(§ion, field, pointer)?;
2177 }
2178 }
2179 if let Some(secs) = metric.window_secs
2180 && secs < 60
2181 {
2182 return Err(bad(format!(
2183 "metric {:?} window_secs must be at least 60, got {secs}",
2184 metric.label
2185 )));
2186 }
2187 }
2188 let mut text_labels = HashSet::new();
2189 for text in &self.texts {
2190 validate_custom_label(§ion, "text label", &text.label)?;
2191 if !text_labels.insert(text.label.as_str()) {
2192 return Err(bad(format!("duplicate text label {:?}", text.label)));
2193 }
2194 validate_pointer(§ion, "value", &text.value)?;
2195 }
2196 Ok(())
2197 }
2198}
2199
2200fn is_valid_custom_id(id: &str) -> bool {
2201 let bytes = id.as_bytes();
2202 let Some(&first) = bytes.first() else {
2203 return false;
2204 };
2205 bytes.len() <= 32
2206 && (first.is_ascii_lowercase() || first.is_ascii_digit())
2207 && bytes
2208 .iter()
2209 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
2210}
2211
2212fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
2213 if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
2214 return Err(AppError::Other(format!(
2215 "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
2216 )));
2217 }
2218 Ok(())
2219}
2220
2221fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
2222 let len = label.chars().count();
2223 if len == 0 || len > 64 || label.chars().any(char::is_control) {
2224 return Err(AppError::Other(format!(
2225 "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
2226 )));
2227 }
2228 Ok(())
2229}
2230
2231fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
2232 if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
2233 return Err(AppError::Other(format!(
2234 "{section}: {field} {name:?} is not a valid HTTP header name"
2235 )));
2236 }
2237 Ok(())
2238}
2239
2240pub fn resolve_api_key(
2243 vendor_label: &str,
2244 env_var_name: &str,
2245 inline: Option<&str>,
2246) -> crate::error::Result<String> {
2247 let section = match vendor_label {
2248 "OpenCode Go" => "[opencode-go]".to_string(),
2249 _ => format!("[{}]", vendor_label.to_lowercase()),
2250 };
2251 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
2252}
2253
2254pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
2258 if is_valid_env_var_name(env_var_name)
2259 && let Ok(v) = std::env::var(env_var_name)
2260 && !v.is_empty()
2261 {
2262 return Some(v);
2263 }
2264 inline.filter(|v| !v.is_empty()).map(str::to_string)
2265}
2266
2267fn resolve_api_key_in_section(
2268 vendor_label: &str,
2269 section: &str,
2270 env_var_name: &str,
2271 inline: Option<&str>,
2272) -> crate::error::Result<String> {
2273 if let Some(key) = optional_api_key(env_var_name, inline) {
2274 return Ok(key);
2275 }
2276 let valid_env_name = is_valid_env_var_name(env_var_name);
2277 let advice = if valid_env_name {
2278 "set an API key in a valid environment variable or set `api_key`"
2279 } else {
2280 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
2281 };
2282 Err(crate::error::AppError::Credentials(format!(
2283 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
2284 config_path_hint()
2285 )))
2286}
2287
2288pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
2289 let mut chars = name.chars();
2290 let Some(first) = chars.next() else {
2291 return false;
2292 };
2293 (first.is_ascii_alphabetic() || first == '_')
2294 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
2295}
2296
2297impl Config {
2298 pub fn load() -> Result<Self> {
2301 let Some(path) = resolved_path() else {
2302 return Ok(Self::default());
2303 };
2304 Self::load_from(&path)
2305 }
2306
2307 pub fn load_from(path: &std::path::Path) -> Result<Self> {
2308 match std::fs::read_to_string(path) {
2309 Ok(s) => {
2310 let mut config: Self = toml::from_str(&s).map_err(|mut e| {
2311 let span = e.span();
2312 e.set_input(None);
2315 let summary = toml_error_summary(&s, span, &e.to_string());
2316 AppError::Other(format!("config.toml: {summary}"))
2317 })?;
2318 config.expand_paths();
2322 config.validate()?;
2323 #[cfg(unix)]
2324 config.protect_inline_secrets(path)?;
2325 crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
2329 crate::vendor::register_secret_env_vars(&config.provider_secret_env_vars());
2330 Ok(config)
2331 }
2332 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
2333 Err(e) => Err(AppError::io_at(path, e)),
2334 }
2335 }
2336
2337 fn expand_paths(&mut self) {
2338 expand_tilde_opt(&mut self.context.projects_path);
2339 expand_tilde_opt(&mut self.anthropic.credentials_path);
2340 expand_tilde_opt(&mut self.anthropic.accounts_dir);
2341 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
2342 expand_tilde_opt(&mut self.anthropic.desktop_prepare_binary);
2343 expand_tilde_opt(&mut self.openai.codex_auth_path);
2344 expand_tilde_opt(&mut self.cursor.db_path);
2345 expand_tilde_opt(&mut self.cursor.agent_auth_path);
2346 expand_tilde_opt(&mut self.kiro.db_path);
2347 expand_tilde_opt(&mut self.kimi.credentials_path);
2348 expand_tilde_opt(&mut self.grokbot.secrets_path);
2349 expand_tilde_opt(&mut self.modelstudio.config_dir);
2350 expand_tilde_opt(&mut self.devin.credentials_path);
2351 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
2352 expand_tilde_opt(&mut self.supergrok.auth_path);
2353 expand_tilde_opt(&mut self.supergrok.config_path);
2354 expand_tilde_opt(&mut self.copilot.gh_binary);
2355 if let Some(paths) = &mut self.cline.auth_paths {
2356 for path in paths {
2357 *path = expand_tilde(path);
2358 }
2359 }
2360 for account in &mut self.anthropic.accounts {
2361 account.credentials_path = expand_tilde(&account.credentials_path);
2362 }
2363 for account in &mut self.openai.accounts {
2364 account.codex_auth_path = expand_tilde(&account.codex_auth_path);
2365 }
2366 if let Some(paths) = &mut self.commandcode.auth_paths {
2367 for path in paths {
2368 *path = expand_tilde(path);
2369 }
2370 }
2371 }
2372
2373 #[cfg(unix)]
2377 fn has_inline_secrets(&self) -> bool {
2378 [
2379 self.zai.api_key.as_deref(),
2380 self.openrouter.api_key.as_deref(),
2381 self.deepseek.api_key.as_deref(),
2382 self.deepinfra.api_key.as_deref(),
2383 self.kimi.api_key.as_deref(),
2384 self.kilo.api_key.as_deref(),
2385 self.novita.api_key.as_deref(),
2386 self.minimax.api_key.as_deref(),
2387 self.moonshot.api_key.as_deref(),
2388 self.grok.api_key.as_deref(),
2389 self.anthropic_api.api_key.as_deref(),
2390 self.opencode_go.api_key.as_deref(),
2391 self.ollama.api_key.as_deref(),
2392 self.orcarouter.api_key.as_deref(),
2393 self.lyceum.api_key.as_deref(),
2394 self.antigravity.oauth_client_secret.as_deref(),
2395 self.cline.api_key.as_deref(),
2396 ]
2397 .into_iter()
2398 .chain(
2399 Self::API_KEY_ACCOUNT_VENDORS
2400 .into_iter()
2401 .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2402 .map(|account| account.api_key.as_deref()),
2403 )
2404 .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
2405 .any(|key| key.is_some_and(|key| !key.is_empty()))
2406 }
2407
2408 fn custom_secret_env_vars(&self) -> Vec<String> {
2409 self.custom
2410 .iter()
2411 .filter(|c| !c.api_key_env.is_empty())
2412 .map(|c| c.api_key_env.clone())
2413 .collect()
2414 }
2415
2416 fn provider_secret_env_vars(&self) -> Vec<String> {
2422 let renamed = VendorId::all().iter().map(|&id| self.api_key_env_for(id));
2423 let management = std::iter::once(self.openrouter.management_api_key_env.as_str());
2424 let accounts = Self::API_KEY_ACCOUNT_VENDORS
2425 .into_iter()
2426 .flat_map(|id| self.api_key_accounts(id).unwrap_or(&[]))
2427 .flat_map(|account| [&account.api_key_env, &account.management_api_key_env])
2428 .filter_map(|name| name.as_deref());
2429 renamed
2430 .chain(management)
2431 .chain(accounts)
2432 .map(str::to_string)
2433 .collect()
2434 }
2435
2436 pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
2438 self.custom.iter().filter(|c| c.enabled)
2439 }
2440
2441 pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
2443 self.custom.iter().find(|c| c.id == id)
2444 }
2445
2446 #[cfg(unix)]
2447 fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
2448 if !self.has_inline_secrets() {
2449 return Ok(());
2450 }
2451
2452 let metadata = std::fs::metadata(path).map_err(|_| {
2453 AppError::Credentials(format!(
2454 "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
2455 path.display()
2456 ))
2457 })?;
2458 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
2459 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
2460 AppError::Credentials(format!(
2461 "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",
2462 path.display()
2463 ))
2464 })?;
2465 }
2466 Ok(())
2467 }
2468
2469 pub fn is_enabled(&self, id: VendorId) -> bool {
2470 match id {
2471 VendorId::Anthropic => self.anthropic.enabled,
2472 VendorId::AnthropicApi => self.anthropic_api.enabled,
2473 VendorId::Openai => self.openai.enabled,
2474 VendorId::Copilot => self.copilot.enabled,
2475 VendorId::Zai => self.zai.enabled,
2476 VendorId::Openrouter => self.openrouter.enabled,
2477 VendorId::Deepseek => self.deepseek.enabled,
2478 VendorId::Deepinfra => self.deepinfra.enabled,
2479 VendorId::Kimi => self.kimi.enabled,
2480 VendorId::Kilo => self.kilo.enabled,
2481 VendorId::Novita => self.novita.enabled,
2482 VendorId::Moonshot => self.moonshot.enabled,
2483 VendorId::Grok => self.grok.enabled,
2484 VendorId::Supergrok => self.supergrok.enabled,
2485 VendorId::Grokbot => self.grokbot.enabled,
2486 VendorId::Antigravity => self.antigravity.enabled,
2487 VendorId::Cursor => self.cursor.enabled,
2488 VendorId::Minimax => self.minimax.enabled,
2489 VendorId::Kiro => self.kiro.enabled,
2490 VendorId::NousResearch => self.nous.enabled,
2491 VendorId::Cline => self.cline.enabled,
2492 VendorId::OpenCodeGo => self.opencode_go.enabled,
2493 VendorId::CommandCode => self.commandcode.enabled,
2494 VendorId::Ollama => self.ollama.enabled,
2495 VendorId::OrcaRouter => self.orcarouter.enabled,
2496 VendorId::ModelStudio => self.modelstudio.enabled,
2497 VendorId::Lyceum => self.lyceum.enabled,
2498 VendorId::Devin => self.devin.enabled,
2499 }
2500 }
2501
2502 pub fn api_key_env_for(&self, id: VendorId) -> &str {
2509 match id {
2510 VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
2511 VendorId::Zai => &self.zai.api_key_env,
2512 VendorId::Openrouter => &self.openrouter.api_key_env,
2513 VendorId::Deepseek => &self.deepseek.api_key_env,
2514 VendorId::Deepinfra => &self.deepinfra.api_key_env,
2515 VendorId::Kimi => &self.kimi.api_key_env,
2516 VendorId::Kilo => &self.kilo.api_key_env,
2517 VendorId::Novita => &self.novita.api_key_env,
2518 VendorId::Moonshot => &self.moonshot.api_key_env,
2519 VendorId::Grok => &self.grok.api_key_env,
2520 VendorId::Minimax => &self.minimax.api_key_env,
2521 VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
2522 VendorId::Ollama => &self.ollama.api_key_env,
2523 VendorId::OrcaRouter => &self.orcarouter.api_key_env,
2524 VendorId::Lyceum => &self.lyceum.api_key_env,
2525 VendorId::Anthropic
2528 | VendorId::Openai
2529 | VendorId::Copilot
2530 | VendorId::Supergrok
2531 | VendorId::Grokbot
2532 | VendorId::Antigravity
2533 | VendorId::Cursor
2534 | VendorId::Kiro
2535 | VendorId::NousResearch
2536 | VendorId::CommandCode
2537 | VendorId::Cline
2538 | VendorId::ModelStudio
2539 | VendorId::Devin => id.api_key_env(),
2540 }
2541 }
2542
2543 pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
2547 let raw = match id {
2548 VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
2549 VendorId::Zai => self.zai.api_key.as_deref(),
2550 VendorId::Openrouter => self.openrouter.api_key.as_deref(),
2551 VendorId::Deepseek => self.deepseek.api_key.as_deref(),
2552 VendorId::Deepinfra => self.deepinfra.api_key.as_deref(),
2553 VendorId::Kimi => self.kimi.api_key.as_deref(),
2554 VendorId::Kilo => self.kilo.api_key.as_deref(),
2555 VendorId::Novita => self.novita.api_key.as_deref(),
2556 VendorId::Moonshot => self.moonshot.api_key.as_deref(),
2557 VendorId::Grok => self.grok.api_key.as_deref(),
2558 VendorId::Minimax => self.minimax.api_key.as_deref(),
2559 VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
2560 VendorId::Ollama => self.ollama.api_key.as_deref(),
2561 VendorId::OrcaRouter => self.orcarouter.api_key.as_deref(),
2562 VendorId::Lyceum => self.lyceum.api_key.as_deref(),
2563 VendorId::Cline => self.cline.api_key.as_deref(),
2564 VendorId::Anthropic
2565 | VendorId::Openai
2566 | VendorId::Copilot
2567 | VendorId::Supergrok
2568 | VendorId::Grokbot
2569 | VendorId::Antigravity
2570 | VendorId::Cursor
2571 | VendorId::Kiro
2572 | VendorId::NousResearch
2573 | VendorId::CommandCode
2574 | VendorId::ModelStudio
2575 | VendorId::Devin => None,
2576 };
2577 raw.filter(|key| !key.is_empty())
2578 }
2579
2580 pub const API_KEY_ACCOUNT_VENDORS: [VendorId; 11] = [
2584 VendorId::Zai,
2585 VendorId::Openrouter,
2586 VendorId::Deepseek,
2587 VendorId::Deepinfra,
2588 VendorId::Kilo,
2589 VendorId::Novita,
2590 VendorId::Moonshot,
2591 VendorId::Grok,
2592 VendorId::Minimax,
2593 VendorId::OrcaRouter,
2594 VendorId::Lyceum,
2595 ];
2596
2597 pub fn api_key_accounts(&self, id: VendorId) -> Option<&[ApiKeyAccount]> {
2600 match id {
2601 VendorId::Zai => Some(&self.zai.accounts),
2602 VendorId::Openrouter => Some(&self.openrouter.accounts),
2603 VendorId::Deepseek => Some(&self.deepseek.accounts),
2604 VendorId::Deepinfra => Some(&self.deepinfra.accounts),
2605 VendorId::Kilo => Some(&self.kilo.accounts),
2606 VendorId::Novita => Some(&self.novita.accounts),
2607 VendorId::Moonshot => Some(&self.moonshot.accounts),
2608 VendorId::Grok => Some(&self.grok.accounts),
2609 VendorId::Minimax => Some(&self.minimax.accounts),
2610 VendorId::OrcaRouter => Some(&self.orcarouter.accounts),
2611 VendorId::Lyceum => Some(&self.lyceum.accounts),
2612 _ => None,
2613 }
2614 }
2615
2616 pub fn show_default_api_key_account(&self, id: VendorId) -> bool {
2619 match id {
2620 VendorId::Zai => self.zai.show_default_account,
2621 VendorId::Openrouter => self.openrouter.show_default_account,
2622 VendorId::Deepseek => self.deepseek.show_default_account,
2623 VendorId::Deepinfra => self.deepinfra.show_default_account,
2624 VendorId::Kilo => self.kilo.show_default_account,
2625 VendorId::Novita => self.novita.show_default_account,
2626 VendorId::Moonshot => self.moonshot.show_default_account,
2627 VendorId::Grok => self.grok.show_default_account,
2628 VendorId::Minimax => self.minimax.show_default_account,
2629 VendorId::OrcaRouter => self.orcarouter.show_default_account,
2630 VendorId::Lyceum => self.lyceum.show_default_account,
2631 _ => true,
2632 }
2633 }
2634
2635 pub fn resolve_account_api_key_for(&self, id: VendorId, label: Option<&str>) -> Result<String> {
2641 let name = match id {
2644 VendorId::Zai => "Zai",
2645 other => other.display_name(),
2646 };
2647 let Some(label) = label else {
2648 return resolve_api_key(name, self.api_key_env_for(id), self.inline_api_key(id));
2649 };
2650 let slug = id.config_section();
2651 let account = api_key_account(slug, self.api_key_accounts(id).unwrap_or(&[]), label)?;
2652 resolve_api_key_in_section(
2654 &format!("{name} account {label:?}"),
2655 &format!("[[{slug}.accounts]]"),
2656 account.api_key_env.as_deref().unwrap_or(""),
2657 account.api_key.as_deref(),
2658 )
2659 }
2660
2661 pub fn openrouter_management_key(&self, label: Option<&str>) -> Option<String> {
2668 let env_name = match label {
2669 Some(label) => api_key_account("openrouter", &self.openrouter.accounts, label)
2670 .ok()?
2671 .management_api_key_env
2672 .as_deref()?,
2673 None => self.openrouter.management_api_key_env.as_str(),
2674 };
2675 optional_api_key(env_name, None)
2676 }
2677
2678 pub fn display_prefs(&self, vendor: VendorId) -> DisplayPrefs {
2683 match vendor {
2684 VendorId::Deepseek => {
2685 DisplayPrefs::balance(self.deepseek.display_limit, self.deepseek.headline)
2686 }
2687 VendorId::Deepinfra => {
2688 DisplayPrefs::balance(self.deepinfra.display_limit, self.deepinfra.headline)
2689 }
2690 VendorId::Kilo => DisplayPrefs::balance(self.kilo.display_limit, self.kilo.headline),
2691 VendorId::Novita => {
2692 DisplayPrefs::balance(self.novita.display_limit, self.novita.headline)
2693 }
2694 VendorId::Moonshot => {
2695 DisplayPrefs::balance(self.moonshot.display_limit, self.moonshot.headline)
2696 }
2697 VendorId::Grok => DisplayPrefs::balance(self.grok.display_limit, self.grok.headline),
2698 VendorId::Openrouter => DisplayPrefs::balance(None, self.openrouter.headline),
2701 VendorId::NousResearch => DisplayPrefs::balance(None, self.nous.headline),
2704 _ => DisplayPrefs::default(),
2705 }
2706 }
2707
2708 pub fn enabled_vendors(&self) -> Vec<VendorId> {
2709 VendorId::all()
2710 .iter()
2711 .copied()
2712 .filter(|id| self.is_enabled(*id))
2713 .collect()
2714 }
2715
2716 pub fn validate(&self) -> Result<()> {
2720 if let Some(minutes) = self.tray.refresh_minutes
2721 && !TRAY_REFRESH_MINUTES.contains(&minutes)
2722 {
2723 return Err(AppError::Other(format!(
2724 "[tray] refresh_minutes must be one of 1, 5 or 10, got {minutes}"
2725 )));
2726 }
2727 if !(1..=100).contains(&self.notifications.threshold) {
2728 return Err(AppError::Other(format!(
2729 "[notifications] threshold must be between 1 and 100, got {}",
2730 self.notifications.threshold
2731 )));
2732 }
2733 if self.context.context_window_tokens == Some(0) {
2734 return Err(AppError::Other(
2735 "[context] context_window_tokens must be greater than zero".into(),
2736 ));
2737 }
2738 for (model, tokens) in &self.context.model_context_window_tokens {
2739 if model.trim().is_empty() {
2740 return Err(AppError::Other(
2741 "[context] model_context_window_tokens keys must not be empty".into(),
2742 ));
2743 }
2744 if *tokens == 0 {
2745 return Err(AppError::Other(format!(
2746 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
2747 )));
2748 }
2749 }
2750 if let Some(limit) = self.anthropic_api.monthly_limit
2751 && (!limit.is_finite() || limit <= 0.0)
2752 {
2753 return Err(AppError::Other(
2754 "[anthropic_api] monthly_limit must be finite and greater than zero; \
2755 remove it to show spend without a limit"
2756 .into(),
2757 ));
2758 }
2759 for (section, limit) in [
2763 ("deepseek", self.deepseek.display_limit),
2764 ("deepinfra", self.deepinfra.display_limit),
2765 ("kilo", self.kilo.display_limit),
2766 ("novita", self.novita.display_limit),
2767 ("moonshot", self.moonshot.display_limit),
2768 ("grok", self.grok.display_limit),
2769 ] {
2770 if let Some(limit) = limit
2771 && (!limit.is_finite() || limit <= 0.0)
2772 {
2773 return Err(AppError::Other(format!(
2774 "[{section}] display_limit must be finite and greater than zero; \
2775 remove it to show the balance without a limit"
2776 )));
2777 }
2778 }
2779 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
2780 && !self.kimi.region.eq_ignore_ascii_case("auto")
2781 {
2782 return Err(AppError::Other(format!(
2783 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
2784 self.kimi.region
2785 )));
2786 }
2787 if !self.minimax.region.eq_ignore_ascii_case("global")
2788 && !self.minimax.region.eq_ignore_ascii_case("cn")
2789 {
2790 return Err(AppError::Other(format!(
2791 "[minimax] region must be \"global\" or \"cn\", got {:?}",
2792 self.minimax.region
2793 )));
2794 }
2795 if self.supergrok.grok_binary.as_os_str().is_empty() {
2796 return Err(AppError::Other(
2797 "[supergrok] grok_binary must not be empty".into(),
2798 ));
2799 }
2800 let mut labels = HashSet::new();
2801 for account in &self.anthropic.accounts {
2802 validate_account_label(&account.label)?;
2803 if !labels.insert(&account.label) {
2804 return Err(AppError::Credentials(format!(
2805 "duplicate anthropic account label {:?}",
2806 account.label
2807 )));
2808 }
2809 }
2810 let mut openai_labels = HashSet::new();
2811 for account in &self.openai.accounts {
2812 validate_account_label_for("openai", &account.label)?;
2813 if !openai_labels.insert(&account.label) {
2814 return Err(AppError::Credentials(format!(
2815 "duplicate openai account label {:?}",
2816 account.label
2817 )));
2818 }
2819 }
2820 for id in Self::API_KEY_ACCOUNT_VENDORS {
2824 let slug = id.config_section();
2825 let mut labels = HashSet::new();
2826 for account in self.api_key_accounts(id).unwrap_or(&[]) {
2827 validate_account_label_for(slug, &account.label)?;
2828 if !labels.insert(&account.label) {
2829 return Err(AppError::Credentials(format!(
2830 "duplicate {slug} account label {:?}",
2831 account.label
2832 )));
2833 }
2834 let has_env = account
2835 .api_key_env
2836 .as_deref()
2837 .is_some_and(|name| !name.is_empty());
2838 let has_inline = account
2839 .api_key
2840 .as_deref()
2841 .is_some_and(|key| !key.is_empty());
2842 if !has_env && !has_inline {
2843 return Err(AppError::Credentials(format!(
2844 "{slug} account {:?} must set api_key_env or api_key",
2845 account.label
2846 )));
2847 }
2848 }
2849 }
2850 self.validate_custom()
2851 }
2852
2853 fn validate_custom(&self) -> Result<()> {
2857 let mut ids = HashSet::new();
2858 let mut short_names: HashSet<&str> =
2859 VendorId::all().iter().map(|v| v.short_name()).collect();
2860 for (index, custom) in self.custom.iter().enumerate() {
2861 custom.validate(index)?;
2862 if !ids.insert(custom.id.as_str()) {
2863 return Err(AppError::Other(format!(
2864 "{}: duplicate id",
2865 custom.section_label()
2866 )));
2867 }
2868 if !short_names.insert(custom.short_name.as_str()) {
2869 return Err(AppError::Other(format!(
2870 "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
2871 custom.section_label(),
2872 custom.short_name
2873 )));
2874 }
2875 }
2876 Ok(())
2877 }
2878}
2879
2880#[cfg(unix)]
2881#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2882enum InlineKeyPermissionDecision {
2883 Ok,
2884 Tighten,
2885}
2886
2887#[cfg(unix)]
2888fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
2889 if mode & 0o077 == 0 {
2890 InlineKeyPermissionDecision::Ok
2891 } else {
2892 InlineKeyPermissionDecision::Tighten
2893 }
2894}
2895
2896pub fn default_path() -> Option<PathBuf> {
2897 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
2898 Some(proj.config_dir().join("config.toml"))
2899}
2900
2901fn legacy_xdg_path() -> Option<PathBuf> {
2906 let home = crate::cache::home_dir().ok()?;
2907 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
2908}
2909
2910pub fn resolved_path() -> Option<PathBuf> {
2921 if let Some(path) = override_path() {
2922 return Some(path);
2923 }
2924 let canonical = default_path();
2925 if let Some(p) = &canonical
2926 && p.exists()
2927 {
2928 return canonical;
2929 }
2930 if let Some(legacy) = legacy_xdg_path()
2931 && legacy.exists()
2932 {
2933 return Some(legacy);
2934 }
2935 canonical
2936}
2937
2938static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
2939
2940pub fn set_override_path(path: &std::path::Path) {
2946 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2947 *slot = Some(path.to_path_buf());
2948 }
2949}
2950
2951#[doc(hidden)]
2954pub fn clear_override_path() {
2955 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2956 *slot = None;
2957 }
2958}
2959
2960fn override_path() -> Option<PathBuf> {
2961 PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
2962}
2963
2964#[doc(hidden)]
2970pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
2971 #[cfg(unix)]
2972 {
2973 use std::os::unix::ffi::{OsStrExt, OsStringExt};
2974 let rest = arg.as_bytes().strip_prefix(b"--config=")?;
2975 Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
2976 }
2977 #[cfg(windows)]
2978 {
2979 use std::os::windows::ffi::{OsStrExt, OsStringExt};
2980 const PREFIX: &[u16] = &[
2981 b'-' as u16,
2982 b'-' as u16,
2983 b'c' as u16,
2984 b'o' as u16,
2985 b'n' as u16,
2986 b'f' as u16,
2987 b'i' as u16,
2988 b'g' as u16,
2989 b'=' as u16,
2990 ];
2991 let wide: Vec<u16> = arg.encode_wide().collect();
2992 let rest = wide.strip_prefix(PREFIX)?;
2993 Some(std::ffi::OsString::from_wide(rest).into())
2994 }
2995 #[cfg(not(any(unix, windows)))]
2996 {
2997 Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
2998 }
2999}
3000
3001fn expand_tilde(p: &std::path::Path) -> PathBuf {
3004 let Some(s) = p.to_str() else {
3005 return p.to_path_buf();
3006 };
3007 let rest = if s == "~" {
3008 ""
3009 } else if let Some(r) = s.strip_prefix("~/") {
3010 r
3011 } else {
3012 return p.to_path_buf();
3013 };
3014 match crate::cache::home_dir() {
3015 Ok(home) if rest.is_empty() => home,
3016 Ok(home) => home.join(rest),
3017 Err(_) => p.to_path_buf(),
3018 }
3019}
3020
3021fn expand_tilde_opt(p: &mut Option<PathBuf>) {
3022 if let Some(inner) = p.as_ref() {
3023 *p = Some(expand_tilde(inner));
3024 }
3025}
3026
3027pub fn config_path_hint() -> String {
3032 resolved_path()
3033 .map(|p| p.display().to_string())
3034 .unwrap_or_else(|| "config.toml".to_string())
3035}
3036
3037#[cfg(test)]
3038mod tests {
3039 use super::*;
3040 use std::io::Write;
3041 use tempfile::NamedTempFile;
3042
3043 #[cfg(unix)]
3044 use std::os::unix::fs::{MetadataExt, PermissionsExt};
3045
3046 fn write_toml(s: &str) -> NamedTempFile {
3047 let mut f = NamedTempFile::new().unwrap();
3048 f.write_all(s.as_bytes()).unwrap();
3049 f.flush().unwrap();
3050 f
3051 }
3052
3053 #[test]
3057 fn openai_without_accounts_resolves_the_singular_path() {
3058 let explicit = OpenAiConfig {
3059 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
3060 ..OpenAiConfig::default()
3061 };
3062 assert_eq!(
3063 explicit.resolve_auth_path(None).unwrap(),
3064 PathBuf::from("/tmp/codex/auth.json")
3065 );
3066
3067 let bare = OpenAiConfig::default();
3068 assert_eq!(
3069 bare.resolve_auth_path(None).unwrap(),
3070 crate::openai::creds::default_path().unwrap(),
3071 "no codex_auth_path must still mean ~/.codex/auth.json"
3072 );
3073 }
3074
3075 #[test]
3078 fn openai_named_accounts_resolve_their_own_auth_file() {
3079 let config: Config = toml::from_str(
3080 r#"
3081 [openai]
3082 codex_auth_path = "/tmp/personal/auth.json"
3083 [[openai.accounts]]
3084 label = "work"
3085 codex_auth_path = "/tmp/work/auth.json"
3086 "#,
3087 )
3088 .unwrap();
3089
3090 assert_eq!(
3091 config.openai.resolve_auth_path(Some("work")).unwrap(),
3092 PathBuf::from("/tmp/work/auth.json")
3093 );
3094 assert_eq!(
3095 config.openai.resolve_auth_path(None).unwrap(),
3096 PathBuf::from("/tmp/personal/auth.json")
3097 );
3098 }
3099
3100 #[test]
3103 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
3104 let config = OpenAiConfig {
3105 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
3106 accounts: vec![OpenAiAccount {
3107 label: "work".into(),
3108 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
3109 }],
3110 ..OpenAiConfig::default()
3111 };
3112 let err = config
3113 .resolve_auth_path(Some("nope"))
3114 .unwrap_err()
3115 .to_string();
3116 assert!(err.contains("nope"), "{err}");
3117 assert!(err.contains("[[openai.accounts]]"), "{err}");
3118 }
3119
3120 fn two_codex_accounts() -> OpenAiConfig {
3121 OpenAiConfig {
3122 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
3123 accounts: vec![
3124 OpenAiAccount {
3125 label: "main".into(),
3126 codex_auth_path: PathBuf::from("/tmp/codex-main/auth.json"),
3127 },
3128 OpenAiAccount {
3129 label: "work".into(),
3130 codex_auth_path: PathBuf::from("/tmp/codex-work/auth.json"),
3131 },
3132 ],
3133 ..OpenAiConfig::default()
3134 }
3135 }
3136
3137 #[test]
3138 fn the_active_codex_account_is_read_from_the_default_slot() {
3139 let config = two_codex_accounts();
3140 assert_eq!(
3141 config
3142 .fetch_auth_path_probing("work", Some("work"), |_| false)
3143 .unwrap(),
3144 PathBuf::from("/tmp/codex/auth.json")
3145 );
3146 assert_eq!(
3147 config
3148 .fetch_auth_path_probing("main", Some("work"), |_| false)
3149 .unwrap(),
3150 PathBuf::from("/tmp/codex-main/auth.json")
3151 );
3152 }
3153
3154 #[test]
3155 fn an_active_codex_account_with_its_own_file_keeps_reading_it() {
3156 let config = two_codex_accounts();
3157 assert_eq!(
3158 config
3159 .fetch_auth_path_probing("work", Some("work"), |_| true)
3160 .unwrap(),
3161 PathBuf::from("/tmp/codex-work/auth.json")
3162 );
3163 }
3164
3165 #[test]
3166 fn adding_an_openai_account_keeps_the_rest_of_the_file() {
3167 let mut doc: toml_edit::DocumentMut = "# mine\n[zai]\nenabled = true\n".parse().unwrap();
3168 add_openai_account_to_doc(&mut doc, "work", "~/.codex-work/auth.json").unwrap();
3169 let text = doc.to_string();
3170 assert!(
3171 text.starts_with("# mine\n[zai]\nenabled = true\n"),
3172 "{text}"
3173 );
3174 let parsed: Config = toml::from_str(&text).unwrap();
3175 assert_eq!(parsed.openai.accounts[0].label, "work");
3176 assert!(add_openai_account_to_doc(&mut doc, "work", "x").is_err());
3177 assert!(add_openai_account_to_doc(&mut doc, "../x", "x").is_err());
3178 }
3179
3180 #[test]
3181 fn defaults_enable_only_the_four_core_vendors() {
3182 let c = Config::default();
3183 assert!(c.is_enabled(VendorId::Anthropic));
3184 assert!(c.is_enabled(VendorId::Openai));
3185 assert!(c.is_enabled(VendorId::Zai));
3186 assert!(c.is_enabled(VendorId::Openrouter));
3187 for opt_in in [
3188 VendorId::AnthropicApi,
3189 VendorId::Copilot,
3190 VendorId::Deepseek,
3191 VendorId::Kimi,
3192 VendorId::Kilo,
3193 VendorId::Novita,
3194 VendorId::Moonshot,
3195 VendorId::Grok,
3196 VendorId::Supergrok,
3197 VendorId::Grokbot,
3198 VendorId::Cursor,
3199 VendorId::Minimax,
3200 VendorId::Kiro,
3201 VendorId::CommandCode,
3202 VendorId::OrcaRouter,
3203 VendorId::ModelStudio,
3204 VendorId::Devin,
3205 ] {
3206 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
3207 }
3208 assert_eq!(c.enabled_vendors().len(), 4);
3209 }
3210
3211 #[test]
3212 fn commandcode_is_opt_in_when_loading_existing_configs() {
3213 let absent: Config = toml::from_str("[openai]\nenabled = true\n").unwrap();
3214 assert!(!absent.is_enabled(VendorId::CommandCode));
3215
3216 let opted_in: Config = toml::from_str("[commandcode]\nenabled = true\n").unwrap();
3217 assert!(opted_in.is_enabled(VendorId::CommandCode));
3218
3219 let opted_out: Config = toml::from_str("[commandcode]\nenabled = false\n").unwrap();
3220 assert!(!opted_out.is_enabled(VendorId::CommandCode));
3221 }
3222
3223 #[test]
3224 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
3225 let config = Config::default();
3226 assert!(!config.is_enabled(VendorId::NousResearch));
3227 assert!(!config.is_enabled(VendorId::OpenCodeGo));
3228 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
3229 assert!(config.opencode_go.api_key.is_none());
3230 assert!(!config.is_enabled(VendorId::Copilot));
3231 }
3232
3233 #[cfg(unix)]
3234 #[test]
3235 fn inline_credentials_are_protected() {
3236 let mut config = Config::default();
3237 config.opencode_go.api_key = Some("<redacted>".to_string());
3238 assert!(config.has_inline_secrets());
3239 }
3240
3241 #[test]
3242 fn antigravity_oauth_client_overrides_parse() {
3243 let config: Config = toml::from_str(
3244 "[antigravity]
3245enabled = true
3246oauth_client_id = \"test-client\"
3247oauth_client_secret = \"test-client-secret\"
3248",
3249 )
3250 .unwrap();
3251 assert!(config.antigravity.enabled);
3252 assert_eq!(
3253 config.antigravity.oauth_client_id.as_deref(),
3254 Some("test-client")
3255 );
3256 assert_eq!(
3257 config.antigravity.oauth_client_secret.as_deref(),
3258 Some("test-client-secret")
3259 );
3260 let bare: Config = toml::from_str(
3261 "[antigravity]
3262enabled = true
3263",
3264 )
3265 .unwrap();
3266 assert!(bare.antigravity.oauth_client_id.is_none());
3267 assert!(bare.antigravity.oauth_client_secret.is_none());
3268 }
3269
3270 #[test]
3273 fn antigravity_ide_language_server_defaults_to_probing() {
3274 assert!(Config::default().antigravity.ide_language_server);
3275 let bare: Config = toml::from_str(
3276 "[antigravity]
3277enabled = true
3278",
3279 )
3280 .unwrap();
3281 assert!(bare.antigravity.ide_language_server);
3282 let opted_out: Config = toml::from_str(
3283 "[antigravity]
3284enabled = true
3285ide_language_server = false
3286",
3287 )
3288 .unwrap();
3289 assert!(!opted_out.antigravity.ide_language_server);
3290 }
3291
3292 #[cfg(unix)]
3293 #[test]
3294 fn antigravity_inline_oauth_secret_receives_config_file_protection() {
3295 let mut config = Config::default();
3296 config.antigravity.oauth_client_id = Some("test-client".into());
3297 assert!(!config.has_inline_secrets());
3298 config.antigravity.oauth_client_secret = Some("<redacted>".into());
3299 assert!(config.has_inline_secrets());
3300 }
3301
3302 #[cfg(unix)]
3303 #[test]
3304 fn openrouter_named_inline_keys_receive_config_file_protection() {
3305 let mut config = Config::default();
3306 config.openrouter.accounts.push(ApiKeyAccount {
3307 label: "work".into(),
3308 api_key_env: None,
3309 api_key: Some("<redacted>".into()),
3310 management_api_key_env: None,
3311 });
3312 assert!(config.has_inline_secrets());
3313 }
3314
3315 #[cfg(unix)]
3316 #[test]
3317 fn every_named_inline_api_key_receives_config_file_protection() {
3318 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
3319 let section = vendor.config_section();
3320 let config: Config = toml::from_str(&format!(
3321 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"<redacted>\"\n"
3322 ))
3323 .unwrap();
3324 assert!(config.has_inline_secrets(), "{vendor:?}");
3325 }
3326 }
3327
3328 #[cfg(unix)]
3332 #[test]
3333 fn every_inline_api_key_receives_config_file_protection() {
3334 for &vendor in VendorId::all() {
3335 let section = vendor.config_section();
3336 let config: Config =
3337 toml::from_str(&format!("[{section}]\napi_key = \"<redacted>\"\n")).unwrap();
3338 if config.inline_api_key(vendor).is_some() {
3339 assert!(config.has_inline_secrets(), "{vendor:?}");
3340 }
3341 }
3342 }
3343
3344 #[test]
3345 fn missing_file_uses_defaults() {
3346 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
3347 let c = Config::load_from(path).unwrap();
3348 assert!(c.is_enabled(VendorId::Anthropic));
3349 }
3350
3351 #[test]
3352 fn parses_full_config() {
3353 let f = write_toml(
3354 r#"
3355 [anthropic]
3356 enabled = true
3357
3358 [openai]
3359 enabled = false
3360 admin_key_env = "MY_ADMIN_KEY"
3361
3362 [zai]
3363 enabled = true
3364 api_key_env = "MY_ZAI"
3365 plan_tier = "pro"
3366
3367 [openrouter]
3368 enabled = false
3369 "#,
3370 );
3371 let c = Config::load_from(f.path()).unwrap();
3372 assert!(c.is_enabled(VendorId::Anthropic));
3373 assert!(!c.is_enabled(VendorId::Openai));
3374 assert!(c.is_enabled(VendorId::Zai));
3375 assert!(!c.is_enabled(VendorId::Openrouter));
3376 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
3377 assert_eq!(c.zai.api_key_env, "MY_ZAI");
3378 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
3379 assert!(c.openrouter.accounts.is_empty());
3380 assert!(c.openrouter.show_default_account);
3381 }
3382
3383 #[test]
3384 fn partial_config_falls_back_to_defaults() {
3385 let f = write_toml(
3386 r#"[openai]
3387enabled = false
3388"#,
3389 );
3390 let c = Config::load_from(f.path()).unwrap();
3391 assert!(!c.is_enabled(VendorId::Openai));
3392 assert!(c.is_enabled(VendorId::Anthropic));
3394 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
3395 }
3396
3397 #[test]
3398 fn malformed_toml_returns_error() {
3399 let f = write_toml("this is not = = valid");
3400 assert!(Config::load_from(f.path()).is_err());
3401 }
3402
3403 #[cfg(unix)]
3404 #[test]
3405 fn load_from_tightens_world_readable_config_with_inline_api_key() {
3406 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
3407 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3408
3409 Config::load_from(file.path()).unwrap();
3410
3411 assert_eq!(
3412 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3413 0o600
3414 );
3415 }
3416
3417 #[cfg(unix)]
3418 #[test]
3419 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
3420 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
3421 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
3422
3423 Config::load_from(file.path()).unwrap();
3424
3425 assert_eq!(
3426 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
3427 0o644
3428 );
3429 }
3430
3431 #[cfg(unix)]
3432 #[test]
3433 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
3434 assert_eq!(
3435 inline_key_permission_decision(0o600),
3436 InlineKeyPermissionDecision::Ok
3437 );
3438 assert_eq!(
3439 inline_key_permission_decision(0o640),
3440 InlineKeyPermissionDecision::Tighten
3441 );
3442 assert_eq!(
3443 inline_key_permission_decision(0o604),
3444 InlineKeyPermissionDecision::Tighten
3445 );
3446 }
3447
3448 #[test]
3449 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
3450 for value in ["0", "-1", "inf", "nan"] {
3451 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
3452 let error = Config::load_from(file.path()).unwrap_err().to_string();
3453 assert!(error.contains("monthly_limit"), "value {value}: {error}");
3454 }
3455
3456 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
3457 assert_eq!(
3458 Config::load_from(file.path())
3459 .unwrap()
3460 .anthropic_api
3461 .monthly_limit,
3462 Some(1000.0)
3463 );
3464 }
3465
3466 #[test]
3467 fn display_limit_must_be_positive_and_finite_on_every_balance_vendor() {
3468 for section in ["deepseek", "kilo", "novita", "moonshot", "grok"] {
3470 for value in ["0", "-1", "inf", "nan"] {
3471 let file = write_toml(&format!("[{section}]\ndisplay_limit = {value}\n"));
3472 let error = Config::load_from(file.path()).unwrap_err().to_string();
3473 assert!(
3474 error.contains(&format!("[{section}] display_limit")),
3475 "{section} = {value}: {error}"
3476 );
3477 }
3478 let file = write_toml(&format!("[{section}]\ndisplay_limit = 200\n"));
3479 let config = Config::load_from(file.path()).unwrap();
3480 assert_eq!(
3481 config.display_prefs(vendor_of(section)).display_limit,
3482 Some(200.0),
3483 "{section}"
3484 );
3485 }
3486 }
3487
3488 #[test]
3490 fn display_limit_is_absent_until_the_user_states_one() {
3491 let config = Config::default();
3492 for vendor in VendorId::all() {
3493 assert_eq!(
3494 config.display_prefs(*vendor).display_limit,
3495 None,
3496 "{vendor:?}"
3497 );
3498 }
3499 }
3500
3501 #[test]
3504 fn the_default_headline_follows_the_kind_of_vendor() {
3505 let config = Config::default();
3506 for vendor in [
3507 VendorId::Deepseek,
3508 VendorId::Kilo,
3509 VendorId::Novita,
3510 VendorId::Moonshot,
3511 VendorId::Grok,
3512 ] {
3513 assert_eq!(
3514 config.display_prefs(vendor).headline,
3515 Headline::Amount,
3516 "{vendor:?}"
3517 );
3518 }
3519 assert_eq!(
3520 config.display_prefs(VendorId::Openrouter).headline,
3521 Headline::Percent
3522 );
3523 assert_eq!(
3524 config.display_prefs(VendorId::Anthropic),
3525 DisplayPrefs::default()
3526 );
3527 }
3528
3529 #[test]
3530 fn the_headline_is_configurable_per_vendor_and_a_typo_is_loud() {
3531 let file = write_toml("[deepseek]\nheadline = \"percent\"\n");
3532 assert_eq!(
3533 Config::load_from(file.path())
3534 .unwrap()
3535 .display_prefs(VendorId::Deepseek)
3536 .headline,
3537 Headline::Percent
3538 );
3539
3540 let file = write_toml("[openrouter]\nheadline = \"amount\"\n");
3541 assert_eq!(
3542 Config::load_from(file.path())
3543 .unwrap()
3544 .display_prefs(VendorId::Openrouter)
3545 .headline,
3546 Headline::Amount
3547 );
3548
3549 let file = write_toml("[deepseek]\nheadline = \"dollars\"\n");
3550 let error = Config::load_from(file.path()).unwrap_err().to_string();
3551 assert!(error.contains("headline"), "{error}");
3552 }
3553
3554 #[test]
3560 fn openrouter_has_no_display_limit_to_be_ignored() {
3561 let file = write_toml("[openrouter]\ndisplay_limit = 200\nheadline = \"percent\"\n");
3562 let config = Config::load_from(file.path()).unwrap();
3563 let prefs = config.display_prefs(VendorId::Openrouter);
3564 assert_eq!(prefs.display_limit, None);
3565 assert_eq!(prefs.headline, Headline::Percent);
3566 }
3567
3568 #[test]
3571 fn a_display_limit_alone_leaves_the_headline_where_it_was() {
3572 let file = write_toml("[deepseek]\ndisplay_limit = 200\n");
3573 let prefs = Config::load_from(file.path())
3574 .unwrap()
3575 .display_prefs(VendorId::Deepseek);
3576 assert_eq!(prefs.display_limit, Some(200.0));
3577 assert_eq!(prefs.headline, Headline::Amount);
3578 }
3579
3580 fn vendor_of(section: &str) -> VendorId {
3581 VendorId::all()
3582 .iter()
3583 .copied()
3584 .find(|vendor| vendor.config_section() == section)
3585 .unwrap_or_else(|| panic!("no vendor for [{section}]"))
3586 }
3587
3588 #[test]
3589 fn minimax_region_accepts_only_known_instances() {
3590 for region in ["global", "GLOBAL", "cn", "CN"] {
3591 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3592 assert_eq!(
3593 Config::load_from(file.path()).unwrap().minimax.region,
3594 region
3595 );
3596 }
3597
3598 for region in ["", "china", "us"] {
3599 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
3600 let error = Config::load_from(file.path()).unwrap_err().to_string();
3601 assert!(error.contains("[minimax] region"), "{error}");
3602 }
3603 }
3604
3605 #[test]
3606 fn kimi_region_accepts_auto_and_both_deployments() {
3607 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
3608 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3609 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
3610 }
3611
3612 for region in ["", "us", "oversea"] {
3613 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
3614 let error = Config::load_from(file.path()).unwrap_err().to_string();
3615 assert!(error.contains("[kimi] region"), "{error}");
3616 }
3617 }
3618
3619 #[test]
3620 fn kimi_defaults_to_auto_region_and_no_credential_override() {
3621 let defaults = KimiConfig::default();
3622 assert_eq!(defaults.region, "auto");
3623 assert_eq!(defaults.credentials_path, None);
3624 assert!(!defaults.enabled);
3625 }
3626
3627 #[test]
3628 fn kimi_credentials_path_expands_a_tilde() {
3629 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
3630 let path = Config::load_from(file.path())
3631 .unwrap()
3632 .kimi
3633 .credentials_path
3634 .unwrap();
3635 assert!(!path.starts_with("~"), "{}", path.display());
3636 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
3637 }
3638
3639 #[test]
3640 fn grokbot_is_opt_in_and_takes_no_api_key() {
3641 let defaults = GrokbotConfig::default();
3642 assert!(!defaults.enabled);
3643 assert_eq!(defaults.secrets_path, None);
3644 let config = Config::default();
3646 assert_eq!(config.api_key_env_for(VendorId::Grokbot), "");
3647 assert_eq!(config.inline_api_key(VendorId::Grokbot), None);
3648
3649 let file = write_toml("[grokbot]\nenabled = true\n");
3650 let config = Config::load_from(file.path()).unwrap();
3651 assert!(config.is_enabled(VendorId::Grokbot));
3652 assert!(config.enabled_vendors().contains(&VendorId::Grokbot));
3653 }
3654
3655 #[test]
3656 fn grokbot_secrets_path_expands_a_tilde() {
3657 let file = write_toml("[grokbot]\nsecrets_path = \"~/gb/secrets.json\"\n");
3658 let path = Config::load_from(file.path())
3659 .unwrap()
3660 .grokbot
3661 .secrets_path
3662 .unwrap();
3663 assert!(!path.starts_with("~"), "{}", path.display());
3664 assert!(path.ends_with("gb/secrets.json"), "{}", path.display());
3665 }
3666
3667 #[test]
3668 fn modelstudio_is_opt_in_and_takes_no_api_key() {
3669 let defaults = ModelStudioConfig::default();
3670 assert!(!defaults.enabled);
3671 assert_eq!(defaults.config_dir, None);
3672 let config = Config::default();
3674 assert_eq!(config.api_key_env_for(VendorId::ModelStudio), "");
3675 assert_eq!(config.inline_api_key(VendorId::ModelStudio), None);
3676
3677 let file = write_toml("[modelstudio]\nenabled = true\n");
3678 let config = Config::load_from(file.path()).unwrap();
3679 assert!(config.is_enabled(VendorId::ModelStudio));
3680 assert!(config.enabled_vendors().contains(&VendorId::ModelStudio));
3681 }
3682
3683 #[test]
3684 fn modelstudio_config_dir_expands_a_tilde() {
3685 let file = write_toml("[modelstudio]\nconfig_dir = \"~/bl\"\n");
3686 let path = Config::load_from(file.path())
3687 .unwrap()
3688 .modelstudio
3689 .config_dir
3690 .unwrap();
3691 assert!(!path.starts_with("~"), "{}", path.display());
3692 assert!(path.ends_with("bl"), "{}", path.display());
3693 }
3694
3695 #[test]
3696 fn devin_is_opt_in_and_takes_no_api_key() {
3697 let defaults = DevinConfig::default();
3698 assert!(!defaults.enabled);
3699 assert_eq!(defaults.credentials_path, None);
3700
3701 let config = Config::default();
3702 assert_eq!(config.api_key_env_for(VendorId::Devin), "");
3703 assert_eq!(config.inline_api_key(VendorId::Devin), None);
3704
3705 let file = write_toml("[devin]\nenabled = true\n");
3706 let config = Config::load_from(file.path()).unwrap();
3707 assert!(config.is_enabled(VendorId::Devin));
3708 assert!(config.enabled_vendors().contains(&VendorId::Devin));
3709 }
3710
3711 #[test]
3712 fn devin_credentials_path_expands_a_tilde() {
3713 let file = write_toml("[devin]\ncredentials_path = \"~/devin/credentials.toml\"\n");
3714 let path = Config::load_from(file.path())
3715 .unwrap()
3716 .devin
3717 .credentials_path
3718 .unwrap();
3719 assert!(!path.starts_with("~"), "{}", path.display());
3720 assert!(
3721 path.ends_with("devin/credentials.toml"),
3722 "{}",
3723 path.display()
3724 );
3725 }
3726
3727 #[test]
3728 fn lyceum_is_opt_in_and_uses_shared_api_key_account_resolution() {
3729 let defaults = LyceumConfig::default();
3730 assert!(!defaults.enabled);
3731 assert_eq!(defaults.api_key_env, "LYCEUM_API_KEY");
3732 let override_file = write_toml("[lyceum]\napi_key_env = \"LYCEUM_CUSTOM_TEST_KEY\"\n");
3733 let override_config = Config::load_from(override_file.path()).unwrap();
3734 assert_eq!(
3735 override_config.api_key_env_for(VendorId::Lyceum),
3736 "LYCEUM_CUSTOM_TEST_KEY"
3737 );
3738 let file = write_toml(
3739 r#"[lyceum]
3740enabled = true
3741api_key_env = ""
3742api_key = "synthetic-inline"
3743[[lyceum.accounts]]
3744label = "work"
3745api_key = "synthetic-account"
3746"#,
3747 );
3748 let config = Config::load_from(file.path()).unwrap();
3749 assert!(config.is_enabled(VendorId::Lyceum));
3750 assert_eq!(config.api_key_env_for(VendorId::Lyceum), "");
3751 assert_eq!(
3752 config
3753 .resolve_account_api_key_for(VendorId::Lyceum, None)
3754 .unwrap(),
3755 "synthetic-inline"
3756 );
3757 assert_eq!(
3758 config
3759 .resolve_account_api_key_for(VendorId::Lyceum, Some("work"))
3760 .unwrap(),
3761 "synthetic-account"
3762 );
3763 }
3764
3765 #[test]
3766 fn optional_api_key_reports_absence_instead_of_failing() {
3767 assert_eq!(
3768 optional_api_key("9INVALID", Some("inline")),
3769 Some("inline".to_string())
3770 );
3771 assert_eq!(optional_api_key("9INVALID", None), None);
3772 assert_eq!(optional_api_key("9INVALID", Some("")), None);
3773 assert_eq!(
3776 optional_api_key("9INVALID", Some("inline")),
3777 Some("inline".to_string())
3778 );
3779 }
3780
3781 #[test]
3782 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
3783 let defaults = Config::default();
3784 assert!(!defaults.context.enabled);
3785 assert_eq!(
3786 defaults.context.window_tokens_for(Some("claude-test")),
3787 None
3788 );
3789
3790 let file = write_toml(
3791 r#"
3792 [context]
3793 enabled = true
3794 context_window_tokens = 200000
3795
3796 [context.model_context_window_tokens]
3797 claude-opus-1m = 1000000
3798 "claude exact id" = 300000
3799 "#,
3800 );
3801 let config = Config::load_from(file.path()).unwrap();
3802 assert!(config.context.enabled);
3803 assert_eq!(
3804 config.context.window_tokens_for(Some("claude-opus-1m")),
3805 Some(1_000_000)
3806 );
3807 assert_eq!(
3808 config.context.window_tokens_for(Some("claude exact id")),
3809 Some(300_000)
3810 );
3811 assert_eq!(
3812 config.context.window_tokens_for(Some("another-model")),
3813 Some(200_000)
3814 );
3815 }
3816
3817 #[test]
3818 fn context_layout_defaults_to_full_and_parses_each_variant() {
3819 assert_eq!(Config::default().context.layout, ContextLayout::Full);
3820 for (text, want) in [
3821 ("full", ContextLayout::Full),
3822 ("split", ContextLayout::Split),
3823 ("bottom", ContextLayout::Bottom),
3824 ] {
3825 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
3826 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
3827 }
3828 let file = write_toml("[context]\nlayout = \"floating\"\n");
3829 assert!(
3830 Config::load_from(file.path()).is_err(),
3831 "an unknown layout must be rejected, not silently defaulted"
3832 );
3833 }
3834
3835 #[test]
3836 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
3837 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
3838 for (text, want) in [
3839 ("sidebar", VendorBoxStyle::Sidebar),
3840 ("navbar", VendorBoxStyle::Navbar),
3841 ("none", VendorBoxStyle::None),
3842 ] {
3843 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
3844 assert_eq!(
3845 Config::load_from(file.path()).unwrap().ui.vendor_box(),
3846 want
3847 );
3848 }
3849 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
3850 assert!(
3851 Config::load_from(file.path()).is_err(),
3852 "an unknown vendor_box style must be rejected, not silently defaulted"
3853 );
3854 }
3855
3856 #[test]
3857 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
3858 for source in [
3859 "[context]\ncontext_window_tokens = 0\n",
3860 "[context.model_context_window_tokens]\nclaude = 0\n",
3861 "[context.model_context_window_tokens]\n\" \" = 200000\n",
3862 ] {
3863 let file = write_toml(source);
3864 let error = Config::load_from(file.path()).unwrap_err().to_string();
3865 assert!(error.contains("context"), "{error}");
3866 }
3867 }
3868
3869 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
3871 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
3872 M.lock().unwrap_or_else(|p| p.into_inner())
3873 }
3874
3875 #[test]
3876 fn resolve_api_key_prefers_env_over_inline() {
3877 let _g = env_guard();
3878 let var = "AI_USAGEBAR_TEST_ENV_WINS";
3880 unsafe { std::env::set_var(var, "from-env") };
3882 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
3883 unsafe { std::env::remove_var(var) };
3884 assert_eq!(got, "from-env");
3885 }
3886
3887 #[test]
3888 fn resolve_api_key_falls_back_to_inline() {
3889 let _g = env_guard();
3890 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
3891 unsafe { std::env::remove_var(var) };
3892 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
3893 assert_eq!(got, "inline-key");
3894 }
3895
3896 #[test]
3897 fn copilot_token_prefers_explicit_environment_over_gh_cli() {
3898 struct NeverRun;
3899 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
3900 fn run(
3901 &self,
3902 _: &crate::copilot::credentials::GhAuthTokenCommand,
3903 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3904 panic!("environment override must not invoke gh")
3905 }
3906 }
3907
3908 let token = CopilotConfig::default()
3909 .resolve_token_with(
3910 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
3911 &NeverRun,
3912 None,
3913 )
3914 .unwrap();
3915 assert_eq!(token, "from-environment");
3916 }
3917
3918 #[test]
3922 fn copilot_rejects_an_environment_override_combined_with_a_named_account() {
3923 struct NeverRun;
3924 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
3925 fn run(
3926 &self,
3927 _: &crate::copilot::credentials::GhAuthTokenCommand,
3928 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3929 panic!("the conflict must be refused before gh runs")
3930 }
3931 }
3932 let config = CopilotConfig {
3933 accounts: vec![CopilotAccount {
3934 label: "work".into(),
3935 user: "octocat".into(),
3936 }],
3937 ..CopilotConfig::default()
3938 };
3939 let error = config
3940 .resolve_token_with(
3941 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
3942 &NeverRun,
3943 Some("work"),
3944 )
3945 .unwrap_err()
3946 .to_string();
3947 assert!(error.contains("GITHUB_COPILOT_TOKEN is set"), "{error}");
3948 assert!(error.contains("work"), "{error}");
3949 }
3950
3951 #[test]
3954 fn copilot_unknown_account_label_errors_listing_the_known_ones() {
3955 let config = CopilotConfig {
3956 accounts: vec![CopilotAccount {
3957 label: "work".into(),
3958 user: "octocat".into(),
3959 }],
3960 ..CopilotConfig::default()
3961 };
3962 assert_eq!(config.resolve_user(None).unwrap(), None);
3963 assert_eq!(config.resolve_user(Some("work")).unwrap(), Some("octocat"));
3964 let error = config
3965 .resolve_user(Some("personal"))
3966 .unwrap_err()
3967 .to_string();
3968 assert!(error.contains("personal"), "{error}");
3969 assert!(error.contains("work"), "{error}");
3970
3971 let error = CopilotConfig::default()
3973 .resolve_user(Some("work"))
3974 .unwrap_err()
3975 .to_string();
3976 assert!(error.contains("no accounts array"), "{error}");
3977 }
3978
3979 #[test]
3982 fn copilot_defaults_keep_the_unnamed_account_visible() {
3983 let config = CopilotConfig::default();
3984 assert!(config.show_default_account);
3985 assert!(config.accounts.is_empty());
3986 assert!(!config.enabled, "Copilot stays opt-in");
3987 }
3988
3989 #[test]
3990 fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
3991 struct FailedGh;
3992 impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
3993 fn run(
3994 &self,
3995 _: &crate::copilot::credentials::GhAuthTokenCommand,
3996 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3997 Ok(crate::copilot::credentials::GhAuthTokenOutput {
3998 success: false,
3999 stdout: b"never-echo-gh-output".to_vec(),
4000 })
4001 }
4002 }
4003 let error = CopilotConfig::default()
4004 .resolve_token_with(|_| None, &FailedGh, None)
4005 .unwrap_err()
4006 .to_string();
4007 assert!(error.contains("gh auth login --web"));
4008 assert!(!error.contains("never-echo-gh-output"));
4009 }
4010
4011 #[test]
4012 fn resolve_api_key_errors_when_both_missing() {
4013 let _g = env_guard();
4014 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
4015 unsafe { std::env::remove_var(var) };
4016 let err = resolve_api_key("Zai", var, None).unwrap_err();
4017 match err {
4018 crate::error::AppError::Credentials(msg) => {
4019 assert!(
4020 msg.contains("api_key"),
4021 "error should suggest config field: {msg}"
4022 );
4023 }
4024 other => panic!("expected Credentials error, got {other:?}"),
4025 }
4026 }
4027
4028 #[test]
4029 fn resolve_api_key_uses_exact_opencode_go_section_name() {
4030 let _g = env_guard();
4031 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
4032 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
4033 let message = err.to_string();
4034 assert!(
4035 message.contains("[opencode-go]"),
4036 "wrong section hint: {message}"
4037 );
4038 assert!(
4039 !message.contains("[opencode go]"),
4040 "wrong section hint: {message}"
4041 );
4042 }
4043
4044 fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
4045 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
4046 M.lock().unwrap_or_else(|p| p.into_inner())
4047 }
4048
4049 struct ScopedPathOverride {
4055 _serial: std::sync::MutexGuard<'static, ()>,
4056 }
4057
4058 impl Drop for ScopedPathOverride {
4059 fn drop(&mut self) {
4060 clear_override_path();
4061 }
4062 }
4063
4064 fn scoped_path_override() -> ScopedPathOverride {
4065 ScopedPathOverride {
4066 _serial: path_override_guard(),
4067 }
4068 }
4069
4070 #[test]
4071 fn override_path_wins_over_canonical_and_legacy() {
4072 let _scoped = scoped_path_override();
4073 let file = NamedTempFile::new().unwrap();
4074 set_override_path(file.path());
4075 assert_eq!(resolved_path().as_deref(), Some(file.path()));
4076 assert_eq!(config_path_hint(), file.path().display().to_string());
4077 clear_override_path();
4078 let p = resolved_path().expect("a config path must resolve");
4080 assert!(p.ends_with("config.toml"));
4081 }
4082
4083 #[test]
4084 fn scoped_override_guard_clears_the_override_on_panic() {
4085 let hook = std::panic::take_hook();
4088 std::panic::set_hook(Box::new(|_| {}));
4089 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4090 let _scoped = scoped_path_override();
4091 set_override_path(std::path::Path::new("panicked-override.toml"));
4092 panic!("simulated mid-test failure");
4093 }))
4094 .is_err();
4095 std::panic::set_hook(hook);
4096 assert!(panicked, "the simulated failure must run");
4097 let _serial = path_override_guard();
4098 assert!(
4099 override_path().is_none(),
4100 "a panicking test must not leak the override into siblings"
4101 );
4102 }
4103
4104 #[test]
4105 fn config_path_hint_ends_with_config_toml() {
4106 let _g = path_override_guard();
4107 assert!(config_path_hint().ends_with("config.toml"));
4110 }
4111
4112 #[test]
4113 fn config_flag_value_splits_the_equals_form() {
4114 use std::ffi::OsStr;
4115 assert_eq!(
4116 config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
4117 Some(std::path::Path::new("work.toml"))
4118 );
4119 assert_eq!(
4120 config_flag_value(OsStr::new("--config=")).as_deref(),
4121 Some(std::path::Path::new(""))
4122 );
4123 assert_eq!(config_flag_value(OsStr::new("--config")), None);
4124 assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
4125 assert_eq!(config_flag_value(OsStr::new("account")), None);
4126 }
4127
4128 #[cfg(unix)]
4132 #[test]
4133 fn config_flag_value_keeps_undecodable_bytes_intact() {
4134 use std::ffi::OsString;
4135 use std::os::unix::ffi::{OsStrExt, OsStringExt};
4136 let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
4137 let value = config_flag_value(&raw).expect("prefix matches");
4138 assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
4139 }
4140
4141 #[cfg(windows)]
4142 #[test]
4143 fn config_flag_value_keeps_lone_surrogates_intact() {
4144 use std::ffi::OsString;
4145 use std::os::windows::ffi::{OsStrExt, OsStringExt};
4146 let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
4147 wide.push(0xDC00); wide.extend("x.toml".encode_utf16());
4149 let raw = OsString::from_wide(&wide);
4150 let value = config_flag_value(&raw).expect("prefix matches");
4151 let mut expected = vec![0xDC00u16];
4152 expected.extend("x.toml".encode_utf16());
4153 assert_eq!(
4154 value.as_os_str().encode_wide().collect::<Vec<_>>(),
4155 expected
4156 );
4157 }
4158
4159 #[test]
4160 fn resolve_api_key_treats_empty_env_as_unset() {
4161 let _g = env_guard();
4162 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
4163 unsafe { std::env::set_var(var, "") };
4164 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
4165 unsafe { std::env::remove_var(var) };
4166 assert_eq!(got, "inline");
4167 }
4168
4169 #[test]
4170 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
4171 let _g = env_guard();
4172 let bad = "sk-kimi-very-real-looking-pasted-secret";
4174 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
4175 let msg = err.to_string();
4176 assert!(
4177 msg.contains("invalid") && msg.contains("api_key_env"),
4178 "error should explain misconfiguration: {msg}"
4179 );
4180 assert!(
4181 !msg.contains(bad),
4182 "error must not echo the misconfigured value: {msg}"
4183 );
4184 assert!(msg.contains("valid environment variable name"));
4185 assert!(
4186 msg.contains("[kimi]"),
4187 "error should point at the lowercase TOML section: {msg}"
4188 );
4189 }
4190
4191 #[test]
4192 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
4193 let _g = env_guard();
4194 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
4195 assert_eq!(got, "inline-key");
4196 }
4197
4198 #[test]
4199 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
4200 let _g = env_guard();
4201 let pasted_secret = "sk_pasted_secret";
4204 unsafe { std::env::remove_var(pasted_secret) };
4205 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
4206 assert!(
4207 !err.to_string().contains(pasted_secret),
4208 "error must not echo configured api_key_env values"
4209 );
4210 }
4211
4212 #[test]
4213 fn is_valid_env_var_name_rules() {
4214 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
4216 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
4217 }
4218 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
4220 assert!(
4221 !is_valid_env_var_name(invalid),
4222 "{invalid} should be invalid"
4223 );
4224 }
4225 }
4226
4227 #[test]
4228 fn config_parses_with_inline_api_key_and_primary() {
4229 let f = write_toml(
4230 r#"
4231 [ui]
4232 primary = "openrouter"
4233
4234 [zai]
4235 enabled = true
4236 api_key_env = "MY_ZAI"
4237 api_key = "sk-zai-inline"
4238
4239 [openrouter]
4240 enabled = true
4241 api_key = "sk-or-inline"
4242 "#,
4243 );
4244 let c = Config::load_from(f.path()).unwrap();
4245 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
4246 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
4247 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
4248 }
4249
4250 #[test]
4251 fn openrouter_named_accounts_preserve_the_default_contract() {
4252 let f = write_toml(
4253 r#"
4254 [openrouter]
4255 enabled = true
4256 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
4257 api_key = "default-inline"
4258 show_default_account = false
4259
4260 [[openrouter.accounts]]
4261 label = "work"
4262 api_key_env = "OPENROUTER_WORK_API_KEY"
4263
4264 [[openrouter.accounts]]
4265 label = "personal"
4266 api_key = "personal-inline"
4267 "#,
4268 );
4269 let _g = env_guard();
4270 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
4271 let config = Config::load_from(f.path()).unwrap();
4272 assert!(!config.openrouter.show_default_account);
4273 assert_eq!(config.openrouter.accounts.len(), 2);
4274 assert_eq!(
4275 config
4276 .resolve_account_api_key_for(VendorId::Openrouter, None)
4277 .unwrap(),
4278 "default-inline"
4279 );
4280 assert_eq!(
4281 config
4282 .resolve_account_api_key_for(VendorId::Openrouter, Some("personal"))
4283 .unwrap(),
4284 "personal-inline"
4285 );
4286 }
4287
4288 #[test]
4289 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
4290 for source in [
4291 r#"
4292 [[openrouter.accounts]]
4293 label = "work"
4294 api_key = "one"
4295 [[openrouter.accounts]]
4296 label = "work"
4297 api_key = "two"
4298 "#,
4299 r#"
4300 [[openrouter.accounts]]
4301 label = "../work"
4302 api_key = "one"
4303 "#,
4304 r#"
4305 [[openrouter.accounts]]
4306 label = "work"
4307 "#,
4308 ] {
4309 let f = write_toml(source);
4310 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
4311 }
4312 }
4313
4314 #[test]
4315 fn openrouter_unknown_account_never_falls_back_to_default_key() {
4316 let mut config = Config::default();
4317 config.openrouter.api_key = Some("default-secret".into());
4318 config.openrouter.accounts.push(ApiKeyAccount {
4319 label: "work".into(),
4320 api_key_env: None,
4321 api_key: Some("work-secret".into()),
4322 management_api_key_env: None,
4323 });
4324 let message = config
4325 .resolve_account_api_key_for(VendorId::Openrouter, Some("missing"))
4326 .unwrap_err()
4327 .to_string();
4328 assert!(message.contains("missing") && message.contains("work"));
4329 assert!(!message.contains("default-secret"));
4330 assert!(!message.contains("work-secret"));
4331 }
4332
4333 #[test]
4334 fn openrouter_account_key_errors_do_not_echo_configured_values() {
4335 let mut config = Config::default();
4336 config.openrouter.accounts.push(ApiKeyAccount {
4337 label: "work".into(),
4338 api_key_env: Some("sk_pasted_secret".into()),
4339 api_key: None,
4340 management_api_key_env: None,
4341 });
4342 let _g = env_guard();
4343 unsafe { std::env::remove_var("sk_pasted_secret") };
4344 let message = config
4345 .resolve_account_api_key_for(VendorId::Openrouter, Some("work"))
4346 .unwrap_err()
4347 .to_string();
4348 assert!(message.contains("[[openrouter.accounts]]"));
4349 assert!(!message.contains("sk_pasted_secret"));
4350 }
4351
4352 #[test]
4353 fn openrouter_management_key_resolves_per_account_and_never_inherits() {
4354 let mut config = Config::default();
4355 config.openrouter.accounts = vec![
4356 ApiKeyAccount {
4357 label: "work".into(),
4358 api_key_env: None,
4359 api_key: Some("work-key".into()),
4360 management_api_key_env: Some("AI_USAGEBAR_TEST_OR_MGMT_WORK".into()),
4361 },
4362 ApiKeyAccount {
4363 label: "plain".into(),
4364 api_key_env: None,
4365 api_key: Some("plain-key".into()),
4366 management_api_key_env: None,
4367 },
4368 ];
4369 let _g = env_guard();
4370 unsafe { std::env::remove_var("OPENROUTER_MANAGEMENT_API_KEY") };
4371 unsafe { std::env::set_var("AI_USAGEBAR_TEST_OR_MGMT_WORK", "mgmt-work") };
4372
4373 assert_eq!(config.openrouter_management_key(None), None);
4375 assert_eq!(
4377 config.openrouter_management_key(Some("work")).as_deref(),
4378 Some("mgmt-work")
4379 );
4380 unsafe { std::env::set_var("OPENROUTER_MANAGEMENT_API_KEY", "mgmt-default") };
4383 assert_eq!(
4384 config.openrouter_management_key(None).as_deref(),
4385 Some("mgmt-default")
4386 );
4387 assert_eq!(config.openrouter_management_key(Some("plain")), None);
4388 assert_eq!(config.openrouter_management_key(Some("typo")), None);
4390
4391 config.openrouter.management_api_key_env = "AI_USAGEBAR_TEST_OR_MGMT_DEFAULT".into();
4393 unsafe { std::env::set_var("AI_USAGEBAR_TEST_OR_MGMT_DEFAULT", "mgmt-renamed") };
4394 assert_eq!(
4395 config.openrouter_management_key(None).as_deref(),
4396 Some("mgmt-renamed")
4397 );
4398 unsafe { std::env::remove_var("OPENROUTER_MANAGEMENT_API_KEY") };
4399 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_MGMT_WORK") };
4400 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_MGMT_DEFAULT") };
4401 }
4402
4403 #[test]
4404 fn every_api_key_account_vendor_validates_its_array_like_openrouter() {
4405 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
4406 let section = vendor.config_section();
4407 let parse = |accounts: &str| -> Result<Config> {
4408 let config: Config =
4409 toml::from_str(&format!("[{section}]\nenabled = true\n{accounts}")).unwrap();
4410 config.validate().map(|()| config)
4411 };
4412 let config = parse(&format!(
4413 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k\"\n"
4414 ))
4415 .unwrap();
4416 assert_eq!(
4417 config.api_key_accounts(vendor).unwrap().len(),
4418 1,
4419 "{vendor:?}"
4420 );
4421 assert!(config.show_default_api_key_account(vendor), "{vendor:?}");
4422
4423 let duplicate = parse(&format!(
4424 "[[{section}.accounts]]\nlabel = \"work\"\napi_key = \"a\"\n\
4425 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"b\"\n"
4426 ))
4427 .unwrap_err()
4428 .to_string();
4429 assert!(
4430 duplicate.contains(&format!("duplicate {section} account label")),
4431 "{duplicate}"
4432 );
4433
4434 let keyless = parse(&format!("[[{section}.accounts]]\nlabel = \"work\"\n"))
4435 .unwrap_err()
4436 .to_string();
4437 assert!(
4438 keyless.contains("must set api_key_env or api_key"),
4439 "{keyless}"
4440 );
4441
4442 assert!(
4444 parse(&format!(
4445 "[[{section}.accounts]]\nlabel = \"../x\"\napi_key = \"k\"\n"
4446 ))
4447 .is_err(),
4448 "{vendor:?}"
4449 );
4450 }
4451 }
4452
4453 #[test]
4454 fn named_api_key_accounts_resolve_their_own_key_and_never_the_default() {
4455 let mut config = Config::default();
4456 config.deepseek.api_key_env.clear();
4457 config.deepseek.api_key = Some("default-key".into());
4458 config.deepseek.accounts.push(ApiKeyAccount {
4459 label: "work".into(),
4460 api_key_env: Some("AI_USAGEBAR_TEST_DEEPSEEK_WORK".into()),
4461 api_key: Some("work-inline".into()),
4462 management_api_key_env: None,
4463 });
4464 let _g = env_guard();
4465 unsafe { std::env::set_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK", "work-env") };
4466 let resolve = |label| config.resolve_account_api_key_for(VendorId::Deepseek, label);
4467 assert_eq!(resolve(None).unwrap(), "default-key");
4468 assert_eq!(resolve(Some("work")).unwrap(), "work-env", "env wins");
4469 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_DEEPSEEK_WORK") };
4470 assert_eq!(resolve(Some("work")).unwrap(), "work-inline");
4471
4472 let unknown = resolve(Some("typo")).unwrap_err().to_string();
4473 assert!(unknown.contains("[[deepseek.accounts]]"), "{unknown}");
4474 assert!(
4475 unknown.contains("\"work\""),
4476 "lists known labels: {unknown}"
4477 );
4478 assert!(!unknown.contains("default-key"), "{unknown}");
4479 }
4480
4481 #[test]
4482 fn the_default_key_error_keeps_each_vendors_historic_wording() {
4483 let mut config = Config::default();
4484 config.zai.api_key_env.clear();
4485 config.minimax.api_key_env.clear();
4486 let zai = config
4487 .resolve_account_api_key_for(VendorId::Zai, None)
4488 .unwrap_err()
4489 .to_string();
4490 let expected = resolve_api_key("Zai", "", None).unwrap_err().to_string();
4491 assert_eq!(zai, expected);
4492 assert!(zai.contains("[zai]"), "{zai}");
4493 let minimax = config
4494 .resolve_account_api_key_for(VendorId::Minimax, None)
4495 .unwrap_err()
4496 .to_string();
4497 assert_eq!(
4498 minimax,
4499 resolve_api_key("MiniMax", "", None)
4500 .unwrap_err()
4501 .to_string()
4502 );
4503 }
4504
4505 #[test]
4506 fn enabled_vendors_preserves_canonical_order() {
4507 let c = Config::default();
4510 assert_eq!(
4511 c.enabled_vendors(),
4512 vec![
4513 VendorId::Anthropic,
4514 VendorId::Openai,
4515 VendorId::Zai,
4516 VendorId::Openrouter,
4517 ]
4518 );
4519 }
4520
4521 #[test]
4522 fn deepseek_appears_when_enabled() {
4523 let f = write_toml(
4524 r#"
4525 [deepseek]
4526 enabled = true
4527 api_key = "sk-test"
4528 "#,
4529 );
4530 let c = Config::load_from(f.path()).unwrap();
4531 assert!(c.is_enabled(VendorId::Deepseek));
4532 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
4533 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
4534 }
4535
4536 #[test]
4537 fn tilde_paths_are_expanded_on_load() {
4538 let f = write_toml(
4542 r#"
4543 [context]
4544 projects_path = "~/.claude/projects"
4545
4546 [anthropic]
4547 credentials_path = "~/.claude/.credentials.json"
4548
4549 [[anthropic.accounts]]
4550 label = "work"
4551 credentials_path = "~/work.json"
4552 "#,
4553 );
4554 let c = Config::load_from(f.path()).unwrap();
4555 let home = crate::cache::home_dir().unwrap();
4556
4557 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
4558 let got = c.anthropic.credentials_path.unwrap();
4559 assert_eq!(got, home.join(".claude/.credentials.json"));
4560 assert!(!got.to_string_lossy().contains('~'));
4561 assert_eq!(
4562 c.anthropic.accounts[0].credentials_path,
4563 home.join("work.json")
4564 );
4565 }
4566
4567 #[test]
4568 fn absolute_and_relative_paths_are_left_alone() {
4569 let f = write_toml(
4570 r#"
4571 [anthropic]
4572 credentials_path = "/etc/creds.json"
4573 "#,
4574 );
4575 let c = Config::load_from(f.path()).unwrap();
4576 assert_eq!(
4577 c.anthropic.credentials_path.unwrap(),
4578 std::path::Path::new("/etc/creds.json")
4579 );
4580
4581 let f2 = write_toml(
4583 r#"
4584 [anthropic]
4585 credentials_path = "~someone/creds.json"
4586 "#,
4587 );
4588 let c2 = Config::load_from(f2.path()).unwrap();
4589 assert_eq!(
4590 c2.anthropic.credentials_path.unwrap(),
4591 std::path::Path::new("~someone/creds.json")
4592 );
4593 }
4594
4595 #[test]
4596 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
4597 let _g = path_override_guard();
4598 let p = resolved_path().expect("a config path must resolve");
4601 assert!(p.ends_with("config.toml"));
4602 let canonical = default_path().unwrap();
4603 let legacy = legacy_xdg_path().unwrap();
4604 assert!(
4605 p == canonical || p == legacy,
4606 "resolved to an unexpected location: {}",
4607 p.display()
4608 );
4609 }
4610
4611 #[test]
4612 fn misspelled_section_is_rejected_not_ignored() {
4613 let f = write_toml(
4616 r#"
4617 [openrouer]
4618 enabled = true
4619 api_key = "sk-or-v1-typo"
4620 "#,
4621 );
4622 let err = Config::load_from(f.path()).unwrap_err().to_string();
4623 assert!(
4624 err.contains("openrouer"),
4625 "error should name the typo: {err}"
4626 );
4627 }
4628
4629 #[test]
4630 fn invalid_toml_is_an_error_not_silent_defaults() {
4631 let f = write_toml("[zai\nenabled = true\n");
4632 assert!(Config::load_from(f.path()).is_err());
4633 }
4634
4635 #[test]
4639 fn a_parse_error_names_the_line_without_quoting_it() {
4640 let f = write_toml("[openrouter]\nenabled = true\napi_key = sk-or-v1-unquoted\n");
4641 let err = Config::load_from(f.path()).unwrap_err().to_string();
4642 assert!(err.contains("line 3,"), "{err}");
4643 assert!(!err.contains("sk-or-v1-unquoted"), "{err}");
4644 }
4645
4646 #[test]
4649 fn a_writer_parse_error_names_the_line_without_quoting_it() {
4650 let (_dir, path) =
4651 crate::cache::closed_temp_file("config.toml", Some("[zai]\napi_key = zk-unquoted\n"));
4652 let err = read_config_document(&path).unwrap_err().to_string();
4653 assert!(err.contains("line 2,"), "{err}");
4654 assert!(!err.contains("zk-unquoted"), "{err}");
4655 }
4656
4657 #[test]
4658 fn toml_error_summary_keeps_the_position_and_the_message() {
4659 let input = "a = 1\nkey = sk-secret\n";
4660 assert_eq!(
4661 toml_error_summary(input, Some(12..21), "string values must be quoted\n"),
4662 "line 2, column 7: string values must be quoted"
4663 );
4664 assert_eq!(toml_error_summary(input, None, "bad"), "bad");
4665 }
4666
4667 #[test]
4668 fn a_missing_file_is_still_just_defaults() {
4669 let dir = tempfile::tempdir().unwrap();
4672 let missing = dir.path().join("nope").join("config.toml");
4673 let c = Config::load_from(&missing).unwrap();
4674 assert!(c.is_enabled(VendorId::Anthropic));
4675 }
4676
4677 #[test]
4678 fn kimi_appears_when_enabled() {
4679 let f = write_toml(
4680 r#"
4681 [kimi]
4682 enabled = true
4683 api_key = "sk-test"
4684 "#,
4685 );
4686 let c = Config::load_from(f.path()).unwrap();
4687 assert!(c.is_enabled(VendorId::Kimi));
4688 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
4689 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
4690 }
4691
4692 #[test]
4693 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
4694 let f = write_toml(
4695 r#"
4696 [deepseek]
4697 enabled = true
4698 api_key = "sk-ds"
4699
4700 [kimi]
4701 enabled = true
4702 api_key = "sk-kimi"
4703 "#,
4704 );
4705 let c = Config::load_from(f.path()).unwrap();
4706 assert_eq!(
4707 c.enabled_vendors(),
4708 vec![
4709 VendorId::Anthropic,
4710 VendorId::Openai,
4711 VendorId::Zai,
4712 VendorId::Openrouter,
4713 VendorId::Deepseek,
4714 VendorId::Kimi,
4715 ]
4716 );
4717 }
4718
4719 #[test]
4720 fn parses_anthropic_accounts_and_looks_them_up() {
4721 let f = write_toml(
4722 r#"
4723 [anthropic]
4724 enabled = true
4725
4726 [[anthropic.accounts]]
4727 label = "personal"
4728 credentials_path = "/creds/personal.json"
4729
4730 [[anthropic.accounts]]
4731 label = "work"
4732 credentials_path = "/creds/work.json"
4733 "#,
4734 );
4735 let c = Config::load_from(f.path()).unwrap();
4736 assert_eq!(c.anthropic.accounts.len(), 2);
4737 let work = c.anthropic.account("work").unwrap();
4738 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
4739 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
4741 assert!(err.contains("missing") && err.contains("work"), "{err}");
4742 }
4743
4744 #[test]
4745 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
4746 let f = write_toml(
4747 r#"
4748 [[anthropic.accounts]]
4749 label = "work"
4750 credentials_path = "/creds/work-one.json"
4751
4752 [[anthropic.accounts]]
4753 label = "work"
4754 credentials_path = "/creds/work-two.json"
4755 "#,
4756 );
4757 let err = Config::load_from(f.path()).unwrap_err().to_string();
4758 assert!(
4759 err.contains("duplicate anthropic account label \"work\""),
4760 "{err}"
4761 );
4762 }
4763
4764 #[test]
4765 fn account_label_rejects_path_like_names() {
4766 let cfg = AnthropicConfig::default();
4767 for bad in [
4768 "",
4769 ".",
4770 "..",
4771 "a/b",
4772 r"a\b",
4773 "C:work",
4774 "line\nbreak",
4775 "tab\tname",
4776 "usage.json",
4777 ".stale",
4778 ".last_error",
4779 ".fetch.lock",
4780 ] {
4781 let err = cfg.account(bad).unwrap_err();
4782 assert!(
4783 format!("{err:?}").contains("invalid anthropic account label"),
4784 "{bad:?} should be rejected as a label"
4785 );
4786 }
4787 }
4788
4789 #[test]
4790 fn anthropic_accounts_default_to_empty() {
4791 assert!(Config::default().anthropic.accounts.is_empty());
4794 assert!(Config::default().anthropic.accounts_dir.is_none());
4795 }
4796
4797 fn seed_account_dir(root: &std::path::Path, label: &str) {
4803 let dir = root.join(label);
4804 std::fs::create_dir_all(&dir).unwrap();
4805 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
4806 }
4807
4808 #[test]
4809 fn discovers_account_dirs_in_claude_config_dir_layout() {
4810 let td = tempfile::tempdir().unwrap();
4811 seed_account_dir(td.path(), "work");
4812 seed_account_dir(td.path(), "personal");
4813 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
4816 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
4818
4819 let cfg = AnthropicConfig {
4820 accounts_dir: Some(td.path().to_path_buf()),
4821 ..Default::default()
4822 };
4823 let all = cfg.all_accounts();
4824 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
4825 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
4826 assert_eq!(
4827 all[2].credentials_path,
4828 td.path().join("work").join(".credentials.json")
4829 );
4830 }
4831
4832 #[test]
4833 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
4834 let td = tempfile::tempdir().unwrap();
4835 seed_account_dir(td.path(), "work");
4836 let cfg = AnthropicConfig {
4837 accounts: vec![AnthropicAccount {
4838 label: "work".into(),
4839 credentials_path: "/explicit/work.json".into(),
4840 }],
4841 accounts_dir: Some(td.path().to_path_buf()),
4842 ..Default::default()
4843 };
4844 let all = cfg.all_accounts();
4845 assert_eq!(all.len(), 1, "no duplicate label");
4846 assert_eq!(
4847 all[0].credentials_path,
4848 std::path::Path::new("/explicit/work.json"),
4849 "explicit entry wins"
4850 );
4851 seed_account_dir(td.path(), "other");
4853 assert_eq!(cfg.account("other").unwrap().label, "other");
4854 }
4855
4856 #[test]
4857 fn missing_accounts_dir_is_silently_empty_not_an_error() {
4858 let cfg = AnthropicConfig {
4859 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
4860 ..Default::default()
4861 };
4862 assert!(cfg.all_accounts().is_empty());
4863 }
4864
4865 #[test]
4866 fn openai_account_auth_paths_are_tilde_expanded_on_load() {
4867 let f = write_toml(
4868 r#"
4869 [[openai.accounts]]
4870 label = "work"
4871 codex_auth_path = "~/.codex-work/auth.json"
4872 "#,
4873 );
4874 let c = Config::load_from(f.path()).unwrap();
4875 let home = crate::cache::home_dir().unwrap();
4876 assert_eq!(
4877 c.openai.accounts[0].codex_auth_path,
4878 home.join(".codex-work/auth.json")
4879 );
4880 }
4881
4882 #[test]
4883 fn accounts_dir_is_tilde_expanded_on_load() {
4884 let f = write_toml(
4885 r#"
4886 [anthropic]
4887 accounts_dir = "~/.config/ai-usagebar/accounts"
4888 "#,
4889 );
4890 let c = Config::load_from(f.path()).unwrap();
4891 let home = crate::cache::home_dir().unwrap();
4892 assert_eq!(
4893 c.anthropic.accounts_dir,
4894 Some(home.join(".config/ai-usagebar/accounts"))
4895 );
4896 }
4897
4898 #[test]
4899 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
4900 let f = write_toml(
4901 r#"
4902 [anthropic]
4903 desktop_profiles_dir = "~/.claude-acc/profiles"
4904 "#,
4905 );
4906 let c = Config::load_from(f.path()).unwrap();
4907 let home = crate::cache::home_dir().unwrap();
4908 assert_eq!(
4909 c.anthropic.desktop_profiles_dir,
4910 Some(home.join(".claude-acc/profiles"))
4911 );
4912 }
4913
4914 #[test]
4915 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
4916 let cfg = AnthropicConfig {
4917 accounts: vec![
4918 AnthropicAccount {
4919 label: "work".into(),
4920 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
4921 },
4922 AnthropicAccount {
4923 label: "personal".into(),
4924 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
4925 },
4926 ],
4927 ..Default::default()
4928 };
4929
4930 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
4931 assert!(
4932 matches!(&idle, CredsTarget::Named { config_dir, .. }
4933 if config_dir == std::path::Path::new("/tmp/accounts/work")),
4934 "{idle:?}"
4935 );
4936
4937 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
4939 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
4940
4941 assert_eq!(idle_cache.dir(), live_cache.dir());
4944 }
4945
4946 #[test]
4947 fn the_live_cli_account_keeps_its_own_slot_while_that_file_is_there() {
4948 let cfg = AnthropicConfig {
4953 accounts: vec![AnthropicAccount {
4954 label: "personal".into(),
4955 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
4956 }],
4957 ..Default::default()
4958 };
4959
4960 let (present, _) = cfg
4961 .account_target_probing("personal", Some("personal"), |_| true)
4962 .unwrap();
4963 assert!(
4964 matches!(&present, CredsTarget::Named { path, .. }
4965 if path == Path::new("/tmp/accounts/personal/.credentials.json")),
4966 "{present:?}"
4967 );
4968
4969 let (moved, _) = cfg
4971 .account_target_probing("personal", Some("personal"), |_| false)
4972 .unwrap();
4973 assert!(matches!(moved, CredsTarget::Default(_)), "{moved:?}");
4974 }
4975
4976 #[test]
4977 fn the_default_entry_reads_config_credentials_path_strictly() {
4978 let cfg = AnthropicConfig {
4979 credentials_path: Some("/tmp/claude-home/.credentials.json".into()),
4980 ..Default::default()
4981 };
4982
4983 assert!(
4984 matches!(&cfg.default_creds_target(), CredsTarget::Explicit(path)
4985 if path == Path::new("/tmp/claude-home/.credentials.json")),
4986 );
4987 assert!(matches!(
4989 AnthropicConfig::default().default_creds_target(),
4990 CredsTarget::Default(_)
4991 ));
4992 }
4993
4994 #[test]
4995 fn the_live_cli_accounts_own_file_is_probed_on_disk() {
4996 let creds = NamedTempFile::new().unwrap();
5000 let cfg = AnthropicConfig {
5001 accounts: vec![AnthropicAccount {
5002 label: "personal".into(),
5003 credentials_path: creds.path().to_path_buf(),
5004 }],
5005 ..Default::default()
5006 };
5007 let (target, _) = cfg
5008 .account_target_with("personal", Some("personal"))
5009 .unwrap();
5010 assert!(
5011 matches!(&target, CredsTarget::Named { path, .. } if path == creds.path()),
5012 "read {target:?} instead of the account's own file"
5013 );
5014 }
5015
5016 #[test]
5017 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
5018 let cfg = AnthropicConfig {
5019 accounts: vec![AnthropicAccount {
5020 label: "work".into(),
5021 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
5022 }],
5023 ..Default::default()
5024 };
5025 let (target, _) = cfg.account_target_with("work", None).unwrap();
5026 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
5027 }
5028
5029 fn config_example() -> PathBuf {
5033 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
5034 }
5035
5036 #[test]
5037 fn shipped_example_parses_as_a_real_config() {
5038 let c = Config::load_from(&config_example()).unwrap();
5043 assert!(!c.context.enabled);
5044 assert!(c.is_enabled(VendorId::Anthropic));
5045 assert!(c.is_enabled(VendorId::Openai));
5046 assert!(!c.is_enabled(VendorId::AnthropicApi));
5047 assert!(!c.is_enabled(VendorId::Deepseek));
5048 assert!(!c.is_enabled(VendorId::Kimi));
5049 assert!(!c.is_enabled(VendorId::Kilo));
5050 assert!(!c.is_enabled(VendorId::Novita));
5051 assert!(!c.is_enabled(VendorId::Moonshot));
5052 assert!(!c.is_enabled(VendorId::Grok));
5053 assert!(!c.is_enabled(VendorId::Cursor));
5054 assert!(!c.is_enabled(VendorId::Minimax));
5055 }
5056
5057 #[test]
5058 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
5059 let text = std::fs::read_to_string(config_example()).unwrap();
5064 let live: Vec<&str> = text
5065 .lines()
5066 .map(str::trim)
5067 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
5068 .collect();
5069 assert!(
5070 live.is_empty(),
5071 "admin_key_env must stay commented out while it is inert: {live:?}"
5072 );
5073 assert!(
5076 text.contains("admin_key_env") && text.contains("RESERVED"),
5077 "the example should keep describing admin_key_env as reserved"
5078 );
5079 }
5080
5081 #[test]
5082 fn admin_key_env_is_accepted_but_changes_nothing() {
5083 let f = write_toml(
5087 r#"
5088 [openai]
5089 admin_key_env = "SOME_ADMIN_KEY"
5090 "#,
5091 );
5092 let c = Config::load_from(f.path()).unwrap();
5093 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
5094 let default = OpenAiConfig::default();
5096 assert_eq!(c.openai.enabled, default.enabled);
5097 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
5098 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
5099 }
5100
5101 #[test]
5102 fn config_example_documents_every_vendor_without_secrets() {
5103 let raw = std::fs::read_to_string(config_example()).unwrap();
5104 let cfg = Config::load_from(&config_example()).unwrap();
5105 for id in VendorId::all() {
5108 let section = id.slug();
5109 assert!(
5110 raw.contains(&format!("[{section}]")),
5111 "config.example.toml has no [{section}] section"
5112 );
5113 }
5114
5115 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
5118 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
5119 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
5120 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
5121 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
5122 assert!(!cfg.supergrok.enabled);
5123 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
5124 assert_eq!(
5125 cfg.supergrok
5126 .grok_binary
5127 .file_name()
5128 .and_then(|p| p.to_str()),
5129 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
5130 );
5131 assert!(cfg.supergrok.auth_path.is_none());
5132 assert!(cfg.supergrok.config_path.is_none());
5133 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
5134 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
5135 }
5136
5137 #[test]
5138 fn supergrok_binary_must_not_be_empty() {
5139 let file = write_toml(
5140 r#"
5141 [supergrok]
5142 enabled = true
5143 grok_binary = ""
5144 "#,
5145 );
5146 let error = Config::load_from(file.path()).unwrap_err().to_string();
5147 assert!(error.contains("grok_binary must not be empty"));
5148 }
5149
5150 #[test]
5151 fn supergrok_paths_are_tilde_expanded() {
5152 let file = write_toml(
5153 r#"
5154 [supergrok]
5155 grok_binary = "~/bin/grok"
5156 auth_path = "~/.grok/auth.json"
5157 config_path = "~/.grok/config.toml"
5158 "#,
5159 );
5160 let config = Config::load_from(file.path()).unwrap();
5161 let home = crate::cache::home_dir().unwrap();
5162 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
5163 assert_eq!(
5164 config.supergrok.auth_path,
5165 Some(home.join(".grok/auth.json"))
5166 );
5167 assert_eq!(
5168 config.supergrok.config_path,
5169 Some(home.join(".grok/config.toml"))
5170 );
5171 }
5172
5173 #[test]
5176 fn commandcode_and_copilot_paths_are_tilde_expanded() {
5177 let file = write_toml(
5178 r#"
5179 [commandcode]
5180 auth_paths = ["~/.commandcode/auth.json", "/etc/commandcode/auth.json"]
5181
5182 [copilot]
5183 gh_binary = "~/bin/gh"
5184 "#,
5185 );
5186 let config = Config::load_from(file.path()).unwrap();
5187 let home = crate::cache::home_dir().unwrap();
5188 assert_eq!(
5189 config.commandcode.auth_paths,
5190 Some(vec![
5191 home.join(".commandcode/auth.json"),
5192 PathBuf::from("/etc/commandcode/auth.json"),
5193 ])
5194 );
5195 assert_eq!(config.copilot.gh_binary, Some(home.join("bin/gh")));
5196 }
5197
5198 #[test]
5199 fn kiro_db_path_is_tilde_expanded() {
5200 let f = write_toml(
5201 r#"
5202 [kiro]
5203 db_path = "~/kiro-data.sqlite3"
5204 "#,
5205 );
5206 let c = Config::load_from(f.path()).unwrap();
5207 let home = crate::cache::home_dir().unwrap();
5208 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
5209 }
5210
5211 #[test]
5212 fn kiro_appears_when_enabled() {
5213 let f = write_toml(
5214 r#"
5215 [kiro]
5216 enabled = true
5217 "#,
5218 );
5219 let c = Config::load_from(f.path()).unwrap();
5220 assert!(c.is_enabled(VendorId::Kiro));
5221 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
5222 }
5223
5224 #[test]
5225 fn cursor_db_path_is_tilde_expanded() {
5226 let f = write_toml(
5227 r#"
5228 [cursor]
5229 db_path = "~/cursor-state.vscdb"
5230 "#,
5231 );
5232 let c = Config::load_from(f.path()).unwrap();
5233 let home = crate::cache::home_dir().unwrap();
5234 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
5235 }
5236
5237 #[test]
5238 fn cursor_agent_auth_path_is_tilde_expanded() {
5239 let f = write_toml(
5240 r#"
5241 [cursor]
5242 agent_auth_path = "~/cursor-agent-auth.json"
5243 "#,
5244 );
5245 let c = Config::load_from(f.path()).unwrap();
5246 let home = crate::cache::home_dir().unwrap();
5247 assert_eq!(
5248 c.cursor.agent_auth_path,
5249 Some(home.join("cursor-agent-auth.json"))
5250 );
5251 }
5252
5253 #[test]
5254 fn cursor_appears_when_enabled() {
5255 let f = write_toml(
5256 r#"
5257 [cursor]
5258 enabled = true
5259 "#,
5260 );
5261 let c = Config::load_from(f.path()).unwrap();
5262 assert!(c.is_enabled(VendorId::Cursor));
5263 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
5264 }
5265
5266 #[test]
5267 fn add_account_appends_and_preserves_existing() {
5268 let mut doc: toml_edit::DocumentMut = r#"
5269# keep me
5270[anthropic]
5271enabled = true
5272
5273[[anthropic.accounts]]
5274label = "personal"
5275credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
5276"#
5277 .parse()
5278 .unwrap();
5279 add_anthropic_account_to_doc(
5280 &mut doc,
5281 "work",
5282 "~/.config/ai-usagebar/accounts/work/.credentials.json",
5283 )
5284 .unwrap();
5285 let rendered = doc.to_string();
5286 assert!(rendered.contains("# keep me"), "comment must survive");
5287 let f = write_toml(&rendered);
5289 let c = Config::load_from(f.path()).unwrap();
5290 let labels: Vec<&str> = c
5291 .anthropic
5292 .accounts
5293 .iter()
5294 .map(|a| a.label.as_str())
5295 .collect();
5296 assert_eq!(labels, vec!["personal", "work"]);
5297 }
5298
5299 #[test]
5300 fn add_account_to_empty_doc_is_loadable() {
5301 let mut doc = toml_edit::DocumentMut::new();
5302 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
5303 let f = write_toml(&doc.to_string());
5304 let c = Config::load_from(f.path()).unwrap();
5305 assert_eq!(c.anthropic.accounts.len(), 1);
5306 assert_eq!(c.anthropic.accounts[0].label, "solo");
5307 }
5308
5309 #[test]
5310 fn add_account_rejects_duplicate_label() {
5311 let mut doc: toml_edit::DocumentMut = r#"
5312[[anthropic.accounts]]
5313label = "work"
5314credentials_path = "~/w/.credentials.json"
5315"#
5316 .parse()
5317 .unwrap();
5318 assert!(
5319 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
5320 "a duplicate label must be rejected, not appended"
5321 );
5322 }
5323
5324 #[test]
5325 fn add_account_rejects_bad_label() {
5326 let mut doc = toml_edit::DocumentMut::new();
5327 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
5328 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
5329 }
5330
5331 #[test]
5332 fn tildify_collapses_home_only() {
5333 let home = Path::new("/Users/me");
5334 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
5335 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
5336 }
5337
5338 #[test]
5339 fn default_account_credentials_path_nests_under_config_dir() {
5340 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
5341 assert_eq!(
5342 default_account_credentials_path(cfg, "work"),
5343 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
5344 );
5345 }
5346 const CUSTOM_BLOCK: &str = r#"
5349[[custom]]
5350id = "mytool"
5351name = "My Tool"
5352short_name = "myt"
5353enabled = true
5354url = "https://api.example.test/v1/usage"
5355api_key_env = "MYTOOL_API_KEY"
5356auth_header = "Authorization"
5357auth_scheme = "Bearer"
5358plan = "Pro"
5359cache_ttl_secs = 120
5360[custom.headers]
5361X-Org = "org_1"
5362[[custom.metrics]]
5363label = "Requests"
5364used = "/requests/used"
5365limit = "/requests/limit"
5366resets_at = "/requests/reset"
5367window_secs = 3600
5368[[custom.texts]]
5369label = "Tier"
5370value = "/tier"
5371"#;
5372
5373 fn custom_with(from: &str, to: &str) -> String {
5374 assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
5375 CUSTOM_BLOCK.replace(from, to)
5376 }
5377
5378 fn custom_error(toml: &str) -> String {
5379 Config::load_from(write_toml(toml).path())
5380 .unwrap_err()
5381 .to_string()
5382 }
5383
5384 fn assert_custom_rejected(toml: &str, needle: &str) {
5385 let msg = custom_error(toml);
5386 assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
5387 assert!(
5388 msg.contains("[[custom]]"),
5389 "the error must locate the section: {msg}"
5390 );
5391 }
5392
5393 #[test]
5394 fn custom_block_parses_every_field() {
5395 let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
5396 assert_eq!(config.custom.len(), 1);
5397 let c = &config.custom[0];
5398 assert_eq!(c.id, "mytool");
5399 assert_eq!(c.name, "My Tool");
5400 assert_eq!(c.short_name, "myt");
5401 assert_eq!(
5402 c.brand, None,
5403 "a custom provider has no mark unless it asks"
5404 );
5405 assert!(c.enabled);
5406 assert_eq!(c.url, "https://api.example.test/v1/usage");
5407 assert!(!c.allow_http);
5408 assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
5409 assert_eq!(c.api_key, None);
5410 assert_eq!(c.auth_header, "Authorization");
5411 assert_eq!(c.auth_scheme, "Bearer");
5412 assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
5413 assert_eq!(c.plan.as_deref(), Some("Pro"));
5414 assert_eq!(c.plan_path, None);
5415 assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
5416 assert_eq!(c.metrics.len(), 1);
5417 assert_eq!(c.metrics[0].label, "Requests");
5418 assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
5419 assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
5420 assert_eq!(c.metrics[0].percent, None);
5421 assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
5422 assert_eq!(c.metrics[0].window_secs, Some(3600));
5423 assert_eq!(c.texts.len(), 1);
5424 assert_eq!(c.texts[0].label, "Tier");
5425 assert_eq!(c.texts[0].value, "/tier");
5426 assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
5427 }
5428
5429 #[test]
5430 fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
5431 let config: Config = toml::from_str(
5432 r#"
5433 [[custom]]
5434 id = "bare"
5435 short_name = "bre"
5436 url = "https://example.test/u"
5437 [[custom.metrics]]
5438 label = "Q"
5439 percent = "/pct"
5440 "#,
5441 )
5442 .unwrap();
5443 let c = &config.custom[0];
5444 assert_eq!(c.name, "bare", "name must default to id on a plain parse");
5445 assert!(!c.enabled);
5446 assert!(!c.allow_http);
5447 assert_eq!(c.api_key_env, "");
5448 assert_eq!(c.auth_header, "Authorization");
5449 assert_eq!(c.auth_scheme, "Bearer");
5450 assert_eq!(c.cache_ttl_secs, 60);
5451 assert!(config.validate().is_ok());
5452 assert!(Config::default().custom.is_empty());
5453 }
5454
5455 #[test]
5456 fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
5457 let config = Config::load_from(
5458 write_toml(&custom_with(
5459 r#"short_name = "myt""#,
5460 "short_name = \"myt\"\nbrand = \"opencode-go\"",
5461 ))
5462 .path(),
5463 )
5464 .unwrap();
5465 assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
5466
5467 for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
5471 assert_custom_rejected(
5472 &custom_with(
5473 r#"short_name = "myt""#,
5474 &format!("short_name = \"myt\"\nbrand = {brand:?}"),
5475 ),
5476 "must name a built-in vendor",
5477 );
5478 }
5479 }
5480
5481 #[test]
5482 fn custom_rejects_a_malformed_id() {
5483 let long = "a".repeat(33);
5484 for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
5485 let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
5486 assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
5487 assert!(msg.contains("must match"), "{id:?}: {msg}");
5488 }
5489 }
5490
5491 #[test]
5492 fn custom_rejects_a_builtin_slug_as_id() {
5493 assert_custom_rejected(
5494 &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
5495 "is a built-in vendor",
5496 );
5497 assert_custom_rejected(
5498 &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
5499 "is a built-in vendor",
5500 );
5501 }
5502
5503 #[test]
5504 fn custom_rejects_duplicate_ids() {
5505 let twice = format!(
5506 "{}{}",
5507 CUSTOM_BLOCK,
5508 custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
5509 );
5510 assert_custom_rejected(&twice, "duplicate id");
5511 }
5512
5513 #[test]
5514 fn custom_rejects_a_name_over_48_chars() {
5515 let long = "n".repeat(49);
5516 assert_custom_rejected(
5517 &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
5518 "name must be 1 to 48 characters",
5519 );
5520 }
5521
5522 #[test]
5523 fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
5524 for short in ["my", "myto", "MYT", "m1t"] {
5525 assert_custom_rejected(
5526 &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
5527 "exactly 3 lowercase ASCII letters",
5528 );
5529 }
5530 }
5531
5532 #[test]
5533 fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
5534 assert_custom_rejected(
5535 &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
5536 "already used by a built-in vendor",
5537 );
5538 let twice = format!(
5539 "{}{}",
5540 CUSTOM_BLOCK,
5541 custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
5542 );
5543 assert_custom_rejected(&twice, "already used by a built-in vendor");
5544 }
5545
5546 #[test]
5547 fn custom_rejects_http_unless_allowed() {
5548 let plain = custom_with(
5549 r#"url = "https://api.example.test/v1/usage""#,
5550 r#"url = "http://localhost:8080/usage""#,
5551 );
5552 assert_custom_rejected(&plain, "url must use https://");
5553 let allowed = plain.replace(
5554 r#"url = "http://localhost:8080/usage""#,
5555 "url = \"http://localhost:8080/usage\"\nallow_http = true",
5556 );
5557 assert!(
5558 Config::load_from(write_toml(&allowed).path()).is_ok(),
5559 "allow_http must permit http://"
5560 );
5561 }
5562
5563 #[test]
5564 fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
5565 assert_custom_rejected(
5566 &custom_with(
5567 r#"url = "https://api.example.test/v1/usage""#,
5568 r#"url = "https://user:pw@api.example.test/v1/usage""#,
5569 ),
5570 "must not carry credentials",
5571 );
5572 assert_custom_rejected(
5573 &custom_with(
5574 r#"url = "https://api.example.test/v1/usage""#,
5575 r#"url = "not a url""#,
5576 ),
5577 "is not a valid URL",
5578 );
5579 assert_custom_rejected(
5580 &custom_with(
5581 r#"url = "https://api.example.test/v1/usage""#,
5582 r#"url = "ftp://api.example.test/v1/usage""#,
5583 ),
5584 "is not http or https",
5585 );
5586 }
5587
5588 #[test]
5589 fn custom_rejects_an_invalid_api_key_env() {
5590 assert_custom_rejected(
5591 &custom_with(
5592 r#"api_key_env = "MYTOOL_API_KEY""#,
5593 r#"api_key_env = "1BAD-NAME""#,
5594 ),
5595 "is not a valid environment variable name",
5596 );
5597 let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
5598 assert!(
5599 Config::load_from(write_toml(&none).path()).is_ok(),
5600 "an empty api_key_env means inline-only and is valid"
5601 );
5602 }
5603
5604 #[test]
5608 fn custom_invalid_api_key_env_is_not_repeated() {
5609 let msg = custom_error(&custom_with(
5610 r#"api_key_env = "MYTOOL_API_KEY""#,
5611 r#"api_key_env = "sk-live-pasted-secret""#,
5612 ));
5613 assert!(msg.contains("api_key_env"), "{msg}");
5614 assert!(!msg.contains("sk-live-pasted-secret"), "{msg}");
5615 }
5616
5617 #[test]
5618 fn custom_rejects_an_invalid_auth_header_name() {
5619 assert_custom_rejected(
5620 &custom_with(
5621 r#"auth_header = "Authorization""#,
5622 r#"auth_header = "X Api Key""#,
5623 ),
5624 "auth_header \"X Api Key\" is not a valid HTTP header name",
5625 );
5626 }
5627
5628 #[test]
5629 fn custom_rejects_a_control_char_in_auth_scheme() {
5630 assert_custom_rejected(
5631 &custom_with(
5632 r#"auth_scheme = "Bearer""#,
5633 "auth_scheme = \"Bearer\\u0007\"",
5634 ),
5635 "auth_scheme contains characters that are not valid",
5636 );
5637 let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
5638 assert!(
5639 Config::load_from(write_toml(&bare).path()).is_ok(),
5640 "an empty scheme (bare key) is valid"
5641 );
5642 }
5643
5644 #[test]
5645 fn custom_rejects_a_bad_extra_header() {
5646 assert_custom_rejected(
5647 &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
5648 "headers must not repeat auth_header",
5649 );
5650 assert_custom_rejected(
5651 &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
5652 "is not a valid HTTP header name",
5653 );
5654 assert_custom_rejected(
5655 &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
5656 "has a value that is not valid in an HTTP header",
5657 );
5658 }
5659
5660 #[test]
5661 fn custom_rejects_cache_ttl_outside_10_to_3600() {
5662 for ttl in ["9", "3601"] {
5663 assert_custom_rejected(
5664 &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
5665 "cache_ttl_secs must be between 10 and 3600",
5666 );
5667 }
5668 }
5669
5670 #[test]
5671 fn custom_rejects_an_entry_with_no_metrics_or_texts() {
5672 let toml = r#"
5673[[custom]]
5674id = "empty"
5675short_name = "emp"
5676url = "https://example.test/u"
5677"#;
5678 assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
5679 }
5680
5681 #[test]
5682 fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
5683 assert_custom_rejected(
5684 &custom_with(
5685 r#"limit = "/requests/limit""#,
5686 "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
5687 ),
5688 "must set `percent`, or both `used` and `limit`",
5689 );
5690 assert_custom_rejected(
5691 &custom_with("limit = \"/requests/limit\"\n", ""),
5692 "must set `percent`, or both `used` and `limit`",
5693 );
5694 }
5695
5696 #[test]
5697 fn custom_rejects_a_pointer_without_a_leading_slash() {
5698 assert_custom_rejected(
5699 &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
5700 "used \"requests.used\" must be an RFC 6901 JSON Pointer",
5701 );
5702 assert_custom_rejected(
5703 &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
5704 "value \"tier\" must be an RFC 6901 JSON Pointer",
5705 );
5706 assert_custom_rejected(
5707 &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
5708 "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
5709 );
5710 assert_custom_rejected(
5711 &custom_with(
5712 r#"resets_at = "/requests/reset""#,
5713 "resets_at = \"/re\\u001bset\"",
5714 ),
5715 "resets_at",
5716 );
5717 }
5718
5719 #[test]
5720 fn custom_rejects_a_label_outside_1_to_64_chars() {
5721 let long = "l".repeat(65);
5722 assert_custom_rejected(
5723 &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
5724 "metric label",
5725 );
5726 assert_custom_rejected(
5727 &custom_with(r#"label = "Tier""#, r#"label = """#),
5728 "text label \"\" must be 1 to 64 characters",
5729 );
5730 }
5731
5732 #[test]
5733 fn custom_rejects_window_secs_under_60() {
5734 assert_custom_rejected(
5735 &custom_with("window_secs = 3600", "window_secs = 59"),
5736 "window_secs must be at least 60",
5737 );
5738 }
5739
5740 #[test]
5741 fn custom_rejects_duplicate_metric_and_text_labels() {
5742 let metric_twice = custom_with(
5743 "window_secs = 3600\n",
5744 "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
5745 );
5746 assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
5747 let text_twice =
5748 format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
5749 assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
5750 }
5751
5752 #[test]
5753 fn enabled_custom_and_custom_by_id_select_entries() {
5754 let two = format!(
5755 "{}{}",
5756 CUSTOM_BLOCK,
5757 custom_with(r#"id = "mytool""#, r#"id = "off""#)
5758 .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
5759 .replace("enabled = true", "enabled = false")
5760 );
5761 let config = Config::load_from(write_toml(&two).path()).unwrap();
5762 let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
5763 assert_eq!(enabled, ["mytool"]);
5764 assert_eq!(
5765 config.custom_by_id("off").map(|c| c.name.as_str()),
5766 Some("My Tool")
5767 );
5768 assert!(config.custom_by_id("nope").is_none());
5769 }
5770
5771 #[cfg(unix)]
5772 #[test]
5773 fn has_inline_secrets_sees_a_custom_inline_key() {
5774 let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
5775 assert!(!without.has_inline_secrets());
5776 let with: Config = toml::from_str(&custom_with(
5777 r#"api_key_env = "MYTOOL_API_KEY""#,
5778 "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
5779 ))
5780 .unwrap();
5781 assert!(with.has_inline_secrets());
5782 }
5783
5784 #[test]
5785 fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
5786 let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
5787 let mut spec = CustomProviderConfig {
5788 id: "mytool".into(),
5789 api_key_env: var.into(),
5790 api_key: Some("sk-inline-secret".into()),
5791 ..CustomProviderConfig::default()
5792 };
5793 unsafe { std::env::set_var(var, "sk-env-secret") };
5794 let from_env = spec.resolve_api_key();
5795 unsafe { std::env::remove_var(var) };
5796 assert_eq!(from_env.unwrap(), "sk-env-secret");
5797
5798 assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
5799
5800 spec.api_key = Some(String::new());
5801 let err = spec.resolve_api_key().unwrap_err();
5802 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
5803 let msg = err.to_string();
5804 assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
5805 assert!(msg.contains(var), "{msg}");
5806 assert!(!msg.contains("secret"), "{msg}");
5807
5808 spec.api_key_env = String::new();
5809 let msg = spec.resolve_api_key().unwrap_err().to_string();
5810 assert!(msg.contains("set `api_key`"), "{msg}");
5811 }
5812
5813 #[test]
5814 fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
5815 let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
5816 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
5817 let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
5818 Config::load_from(file.path()).unwrap();
5819 assert!(
5820 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
5821 "a custom provider's env var must be scrubbed from subprocesses"
5822 );
5823 }
5824
5825 #[test]
5826 fn loading_a_config_registers_account_and_renamed_env_vars_for_scrubbing() {
5827 let account = "AI_USAGEBAR_ACCOUNT_SCRUB_TEST_4C2E";
5828 let renamed = "AI_USAGEBAR_RENAMED_SCRUB_TEST_7A3F";
5829 let before = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
5830 assert!(!before.contains(&account));
5831 assert!(!before.contains(&renamed));
5832 let file = write_toml(&format!(
5833 "[zai]\napi_key_env = \"{renamed}\"\n\
5834 [[deepseek.accounts]]\nlabel = \"work\"\napi_key_env = \"{account}\"\n"
5835 ));
5836 Config::load_from(file.path()).unwrap();
5837 let after = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
5838 assert!(after.contains(&account), "a named account's key variable");
5839 assert!(after.contains(&renamed), "a renamed api_key_env");
5840 }
5841
5842 #[test]
5843 fn loading_a_config_registers_openrouter_management_env_vars_for_scrubbing() {
5844 let renamed = "AI_USAGEBAR_MGMT_RENAMED_SCRUB_TEST_5D1B";
5845 let account = "AI_USAGEBAR_MGMT_ACCOUNT_SCRUB_TEST_8E6C";
5846 let before = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
5847 assert!(!before.contains(&renamed));
5848 assert!(!before.contains(&account));
5849 let file = write_toml(&format!(
5850 "[openrouter]\nmanagement_api_key_env = \"{renamed}\"\n\
5851 [[openrouter.accounts]]\nlabel = \"work\"\napi_key_env = \"OR_WORK_KEY\"\n\
5852 management_api_key_env = \"{account}\"\n"
5853 ));
5854 Config::load_from(file.path()).unwrap();
5855 let after = crate::vendor::vendor_secret_env_vars_to_remove(&[]);
5856 assert!(after.contains(&renamed), "a renamed management_api_key_env");
5857 assert!(
5858 after.contains(&account),
5859 "an account's management_api_key_env"
5860 );
5861 }
5862
5863 #[test]
5868 fn every_config_section_parses_to_its_vendors_enabled_switch() {
5869 for vendor in VendorId::all() {
5870 let text = format!(
5871 "[{}]
5872enabled = true
5873",
5874 vendor.config_section()
5875 );
5876 let config: Config = toml::from_str(&text)
5877 .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
5878 assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
5879 let others = VendorId::all()
5880 .iter()
5881 .filter(|other| *other != vendor && config.is_enabled(**other))
5882 .count();
5883 assert_eq!(
5884 others,
5885 Config::default().enabled_vendors().len()
5886 - usize::from(Config::default().is_enabled(*vendor)),
5887 "[{}] enabled a different vendor",
5888 vendor.config_section()
5889 );
5890 }
5891 }
5892
5893 #[test]
5894 fn tray_section_parses_and_defaults_to_notify() {
5895 let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
5896 let config = Config::load_from(file.path()).unwrap();
5897 assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
5898 assert_eq!(config.tray.updates(), UpdateMode::Auto);
5899
5900 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
5901 assert_eq!(empty.tray, TrayConfig::default());
5902 assert_eq!(empty.tray.updates(), UpdateMode::Notify);
5903 assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
5904 assert_eq!(UpdateMode::parse("weekly"), None);
5905 assert_eq!(UpdateMode::Auto.as_str(), "auto");
5906 }
5907
5908 #[test]
5909 fn tray_ignores_removed_menu_bar_keys_for_back_compatibility() {
5910 let legacy = write_toml(
5911 "[tray]\nmenu_bar_show_all = false\nmenu_bar_hide_value = true\nmenu_bar_names = \"short\"\nmenu_bar_provider = \"anthropic\"\nmenu_bar_window = \"weekly\"\n",
5912 );
5913 let config = Config::load_from(legacy.path()).unwrap();
5914 assert_eq!(config.tray, TrayConfig::default());
5915 }
5916
5917 #[test]
5918 fn tray_section_rejects_a_misspelled_mode() {
5919 let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
5920 assert!(Config::load_from(file.path()).is_err());
5921 }
5922
5923 #[test]
5924 fn tray_refresh_minutes_defaults_to_five_and_parses() {
5925 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
5926 assert_eq!(empty.tray.refresh_minutes, None);
5927 assert_eq!(empty.tray.refresh_minutes(), 5);
5928
5929 let file = write_toml("[tray]\nrefresh_minutes = 10\n");
5930 let config = Config::load_from(file.path()).unwrap();
5931 assert_eq!(config.tray.refresh_minutes(), 10);
5932 }
5933
5934 #[test]
5935 fn tray_refresh_minutes_rejects_values_outside_the_menu() {
5936 for minutes in ["3", "0"] {
5937 let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
5938 let error = Config::load_from(file.path()).unwrap_err().to_string();
5939 assert!(error.contains("[tray] refresh_minutes"), "{error}");
5940 assert!(error.contains("1, 5 or 10"), "{error}");
5941 }
5942 }
5943
5944 #[test]
5945 fn notifications_default_on_at_97_and_parse_overrides() {
5946 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
5947 assert!(empty.notifications.enabled);
5948 assert_eq!(empty.notifications.threshold, 97);
5949
5950 let file = write_toml("[notifications]\nenabled = false\nthreshold = 100\n");
5951 let config = Config::load_from(file.path()).unwrap();
5952 assert!(!config.notifications.enabled);
5953 assert_eq!(config.notifications.threshold, 100);
5954 }
5955
5956 #[test]
5957 fn notifications_threshold_rejects_values_outside_1_to_100() {
5958 for threshold in ["0", "101", "255"] {
5959 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
5960 let error = Config::load_from(file.path()).unwrap_err().to_string();
5961 assert!(
5962 error.contains("[notifications] threshold must be between 1 and 100"),
5963 "threshold {threshold}: {error}"
5964 );
5965 }
5966 for threshold in ["1", "50", "100"] {
5968 let file = write_toml(&format!("[notifications]\nthreshold = {threshold}\n"));
5969 let config = Config::load_from(file.path()).unwrap();
5970 assert_eq!(config.notifications.threshold.to_string(), threshold);
5971 }
5972 }
5973
5974 #[test]
5975 fn notification_preferences_round_trip_without_changing_other_sections() {
5976 let dir = tempfile::tempdir().unwrap();
5977 let path = dir.path().join("config.toml");
5978 std::fs::write(&path, "# keep this\n[tray]\nrefresh_minutes = 10\n").unwrap();
5979 set_notification_value(&path, "enabled", false.into()).unwrap();
5980 set_notification_value(&path, "threshold", 85i64.into()).unwrap();
5981 let text = std::fs::read_to_string(&path).unwrap();
5982 assert!(text.contains("# keep this\n[tray]\nrefresh_minutes = 10"));
5983 let config = Config::load_from(&path).unwrap();
5984 assert!(!config.notifications.enabled);
5985 assert_eq!(config.notifications.threshold, 85);
5986 assert!(set_notification_value(&path, "threshold", 101i64.into()).is_err());
5987 assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
5988 }
5989
5990 #[test]
5995 fn vendor_enabled_round_trips_and_rejects_unknown_slugs() {
5996 let dir = tempfile::tempdir().unwrap();
5997 let path = dir.path().join("config.toml");
5998 std::fs::write(&path, "# keep\n[zai]\nenabled = true # mine\n").unwrap();
5999
6000 set_vendor_enabled(&path, "zai", false).unwrap();
6001 let text = std::fs::read_to_string(&path).unwrap();
6002 assert!(text.starts_with("# keep\n"), "{text}");
6003 assert!(text.contains("enabled = false # mine"), "{text}");
6004 assert!(!config_enabled(&path, VendorId::Zai));
6005
6006 set_vendor_enabled(&path, "grok", true).unwrap();
6007 let config = Config::load_from(&path).unwrap();
6008 assert!(config.is_enabled(VendorId::Grok));
6009 assert!(
6010 !config.is_enabled(VendorId::Deepseek),
6011 "only the named vendor moves; untouched opt-in vendors stay off"
6012 );
6013 assert!(
6014 config.is_enabled(VendorId::Openai),
6015 "an untouched default-on vendor is not switched off either"
6016 );
6017
6018 for bad in ["", "custom", "not-a-vendor", "anthropic "] {
6019 let error = set_vendor_enabled(&path, bad, true)
6020 .unwrap_err()
6021 .to_string();
6022 assert!(error.contains("unknown provider"), "{bad}: {error}");
6023 }
6024 assert!(
6026 std::fs::read_to_string(&path)
6027 .unwrap()
6028 .contains("enabled = false # mine")
6029 );
6030 }
6031
6032 #[test]
6035 fn set_vendor_enabled_in_doc_targets_the_section_and_stays_idempotent() {
6036 let dir = tempfile::tempdir().unwrap();
6037 let path = dir.path().join("config.toml");
6038 std::fs::write(&path, "[grok]\nenabled = true\napi_key = \"k\"\n").unwrap();
6039 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
6040
6041 set_vendor_enabled(&path, "grok", true).unwrap();
6042
6043 assert_eq!(
6044 std::fs::read_to_string(&path).unwrap(),
6045 "[grok]\nenabled = true\napi_key = \"k\"\n"
6046 );
6047 assert_eq!(
6048 std::fs::metadata(&path).unwrap().modified().unwrap(),
6049 before
6050 );
6051
6052 let mut doc = toml_edit::DocumentMut::new();
6053 set_vendor_enabled_in_doc(&mut doc, "opencode-go", true).unwrap();
6054 assert_eq!(doc.to_string(), "[opencode-go]\nenabled = true\n");
6055 assert!(set_vendor_enabled_in_doc(&mut doc, "mytool", true).is_err());
6056 }
6057
6058 fn config_enabled(path: &std::path::Path, vendor: VendorId) -> bool {
6059 Config::load_from(path).unwrap().is_enabled(vendor)
6060 }
6061
6062 #[test]
6063 fn menu_bar_short_name_defaults_on_and_persists_as_a_bool() {
6064 let dir = tempfile::tempdir().unwrap();
6065 let path = dir.path().join("config.toml");
6066 std::fs::write(&path, "[tray]\n").unwrap();
6067 assert!(Config::load_from(&path).unwrap().tray.menu_bar_short_name());
6068
6069 set_tray_value(&path, "menu_bar_short_name", Some(false.into())).unwrap();
6070 let text = std::fs::read_to_string(&path).unwrap();
6071 assert_eq!(text, "[tray]\nmenu_bar_short_name = false\n");
6072 assert!(!Config::load_from(&path).unwrap().tray.menu_bar_short_name());
6073 }
6074
6075 #[test]
6076 fn set_tray_value_writes_refresh_minutes_as_an_integer() {
6077 let dir = tempfile::tempdir().unwrap();
6078 let path = dir.path().join("config.toml");
6079 std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
6080
6081 set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
6082 let text = std::fs::read_to_string(&path).unwrap();
6083 assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
6084 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
6085
6086 set_tray_value(&path, "refresh_minutes", None).unwrap();
6087 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
6088 }
6089
6090 #[test]
6091 fn set_tray_value_creates_replaces_and_removes_keys() {
6092 let dir = tempfile::tempdir().unwrap();
6093 let path = dir.path().join("config.toml");
6094 std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
6095
6096 set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
6097 let text = std::fs::read_to_string(&path).unwrap();
6098 assert!(text.contains("# primary = \"anthropic\""), "{text}");
6099 assert!(
6100 text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
6101 "{text}"
6102 );
6103
6104 set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
6105 set_tray_value(&path, "updates", Some("off".into())).unwrap();
6106 let config = Config::load_from(&path).unwrap();
6107 assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
6108 assert_eq!(config.tray.updates(), UpdateMode::Off);
6109
6110 set_tray_value(&path, "shortcut", None).unwrap();
6111 let text = std::fs::read_to_string(&path).unwrap();
6112 assert!(!text.contains("shortcut"), "{text}");
6113 assert!(text.contains("updates = \"off\""), "{text}");
6114
6115 let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
6117 set_tray_value(&path, "shortcut", None).unwrap();
6118 assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
6119 }
6120
6121 #[test]
6122 fn set_value_keeps_the_trailing_comment_when_replacing() {
6123 let mut doc: toml_edit::DocumentMut =
6124 "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
6125 set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
6126 assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
6127 }
6128
6129 #[test]
6130 fn enable_vendors_in_creates_a_missing_config() {
6131 let dir = tempfile::TempDir::new().unwrap();
6132 let path = dir.path().join("sub").join("config.toml");
6133
6134 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6135
6136 assert_eq!(
6137 std::fs::read_to_string(&path).unwrap(),
6138 "[grok]
6139enabled = true
6140"
6141 );
6142 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
6143 }
6144
6145 #[test]
6146 fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
6147 let dir = tempfile::TempDir::new().unwrap();
6148 let path = dir.path().join("config.toml");
6149 let original = "# my settings
6150[zai]
6151api_key = \"x\" # keep
6152enabled = false
6153";
6154 std::fs::write(&path, original).unwrap();
6155
6156 enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
6157
6158 let text = std::fs::read_to_string(&path).unwrap();
6159 assert!(
6160 text.starts_with(
6161 "# my settings
6162"
6163 ),
6164 "{text}"
6165 );
6166 assert!(
6167 text.contains(
6168 "api_key = \"x\" # keep
6169"
6170 ),
6171 "{text}"
6172 );
6173 assert!(
6174 text.contains(
6175 "[grok]
6176enabled = true
6177"
6178 ),
6179 "{text}"
6180 );
6181 assert!(
6182 text.contains(
6183 "[opencode-go]
6184enabled = true
6185"
6186 ),
6187 "{text}"
6188 );
6189 let config = Config::load_from(&path).unwrap();
6190 assert!(
6191 !config.is_enabled(VendorId::Zai),
6192 "never widens to false, never flips others"
6193 );
6194 assert!(config.is_enabled(VendorId::Grok));
6195 assert!(config.is_enabled(VendorId::OpenCodeGo));
6196 }
6197
6198 #[test]
6199 fn enable_vendors_in_leaves_an_explicit_false_alone() {
6200 let dir = tempfile::TempDir::new().unwrap();
6201 let path = dir.path().join("config.toml");
6202 let original = "[grok]
6203enabled = false # off
6204api_key = \"k\"
6205";
6206 std::fs::write(&path, original).unwrap();
6207
6208 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6212
6213 assert!(written.is_empty(), "{written:?}");
6214 assert_eq!(
6215 std::fs::read_to_string(&path).unwrap(),
6216 original,
6217 "the file must not be rewritten at all"
6218 );
6219 }
6220
6221 #[test]
6222 fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
6223 let dir = tempfile::TempDir::new().unwrap();
6224 let path = dir.path().join("config.toml");
6225 std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
6226
6227 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6228
6229 assert_eq!(written, vec![VendorId::Grok]);
6230 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
6231 }
6232
6233 #[test]
6234 fn enable_vendors_in_is_textually_idempotent() {
6235 let dir = tempfile::TempDir::new().unwrap();
6236 let path = dir.path().join("config.toml");
6237 let original = "[grok]
6238enabled = true
6239
6240# trailing
6241";
6242 std::fs::write(&path, original).unwrap();
6243 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
6244
6245 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
6246
6247 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
6248 assert_eq!(
6249 std::fs::metadata(&path).unwrap().modified().unwrap(),
6250 before,
6251 "an unchanged document must not be rewritten"
6252 );
6253 }
6254
6255 #[test]
6256 fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
6257 let dir = tempfile::TempDir::new().unwrap();
6258 let path = dir.path().join("config.toml");
6259
6260 enable_vendors_in(&path, &[]).unwrap();
6261
6262 assert!(!path.exists());
6263 }
6264}